airbyte-cdk 6.55.0__py3-none-any.whl → 6.55.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,16 +3,11 @@
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
- import abc
7
6
  import importlib
8
- import inspect
9
7
  import os
10
- import sys
11
- from collections.abc import Callable
12
8
  from pathlib import Path
13
- from typing import cast
9
+ from typing import TYPE_CHECKING, cast
14
10
 
15
- import yaml
16
11
  from boltons.typeutils import classproperty
17
12
 
18
13
  from airbyte_cdk.models import (
@@ -24,14 +19,20 @@ from airbyte_cdk.test.models import (
24
19
  ConnectorTestScenario,
25
20
  )
26
21
  from airbyte_cdk.test.standard_tests._job_runner import IConnector, run_test_job
22
+ from airbyte_cdk.test.standard_tests.docker_base import DockerConnectorTestSuite
27
23
  from airbyte_cdk.utils.connector_paths import (
28
24
  ACCEPTANCE_TEST_CONFIG,
29
25
  find_connector_root,
30
26
  )
31
27
 
28
+ if TYPE_CHECKING:
29
+ from collections.abc import Callable
32
30
 
33
- class ConnectorTestSuiteBase(abc.ABC):
34
- """Base class for connector test suites."""
31
+ from airbyte_cdk.test import entrypoint_wrapper
32
+
33
+
34
+ class ConnectorTestSuiteBase(DockerConnectorTestSuite):
35
+ """Base class for Python connector test suites."""
35
36
 
36
37
  connector: type[IConnector] | Callable[[], IConnector] | None # type: ignore [reportRedeclaration]
37
38
  """The connector class or a factory function that returns an scenario of IConnector."""
@@ -79,13 +80,6 @@ class ConnectorTestSuiteBase(abc.ABC):
79
80
  ) from e
80
81
  return cast(type[IConnector], getattr(module, matching_class_name))
81
82
 
82
- @classmethod
83
- def get_test_class_dir(cls) -> Path:
84
- """Get the file path that contains the class."""
85
- module = sys.modules[cls.__module__]
86
- # Get the directory containing the test file
87
- return Path(inspect.getfile(module)).parent
88
-
89
83
  @classmethod
90
84
  def create_connector(
91
85
  cls,
@@ -118,69 +112,7 @@ class ConnectorTestSuiteBase(abc.ABC):
118
112
  test_scenario=scenario,
119
113
  connector_root=self.get_connector_root_dir(),
120
114
  )
121
- conn_status_messages: list[AirbyteMessage] = [
122
- msg for msg in result._messages if msg.type == Type.CONNECTION_STATUS
123
- ] # noqa: SLF001 # Non-public API
124
- assert len(conn_status_messages) == 1, (
125
- f"Expected exactly one CONNECTION_STATUS message. Got: {result._messages}"
115
+ assert len(result.connection_status_messages) == 1, (
116
+ f"Expected exactly one CONNECTION_STATUS message. "
117
+ "Got: {result.connection_status_messages!s}"
126
118
  )
127
-
128
- @classmethod
129
- def get_connector_root_dir(cls) -> Path:
130
- """Get the root directory of the connector."""
131
- return find_connector_root([cls.get_test_class_dir(), Path.cwd()])
132
-
133
- @classproperty
134
- def acceptance_test_config_path(cls) -> Path:
135
- """Get the path to the acceptance test config file."""
136
- result = cls.get_connector_root_dir() / ACCEPTANCE_TEST_CONFIG
137
- if result.exists():
138
- return result
139
-
140
- raise FileNotFoundError(f"Acceptance test config file not found at: {str(result)}")
141
-
142
- @classmethod
143
- def get_scenarios(
144
- cls,
145
- ) -> list[ConnectorTestScenario]:
146
- """Get acceptance tests for a given category.
147
-
148
- This has to be a separate function because pytest does not allow
149
- parametrization of fixtures with arguments from the test class itself.
150
- """
151
- categories = ["connection", "spec"]
152
- all_tests_config = yaml.safe_load(cls.acceptance_test_config_path.read_text())
153
- if "acceptance_tests" not in all_tests_config:
154
- raise ValueError(
155
- f"Acceptance tests config not found in {cls.acceptance_test_config_path}."
156
- f" Found only: {str(all_tests_config)}."
157
- )
158
-
159
- test_scenarios: list[ConnectorTestScenario] = []
160
- for category in categories:
161
- if (
162
- category not in all_tests_config["acceptance_tests"]
163
- or "tests" not in all_tests_config["acceptance_tests"][category]
164
- ):
165
- continue
166
-
167
- for test in all_tests_config["acceptance_tests"][category]["tests"]:
168
- if "config_path" not in test:
169
- # Skip tests without a config_path
170
- continue
171
-
172
- if "iam_role" in test["config_path"]:
173
- # We skip iam_role tests for now, as they are not supported in the test suite.
174
- continue
175
-
176
- scenario = ConnectorTestScenario.model_validate(test)
177
-
178
- if scenario.config_path and scenario.config_path in [
179
- s.config_path for s in test_scenarios
180
- ]:
181
- # Skip duplicate scenarios based on config_path
182
- continue
183
-
184
- test_scenarios.append(scenario)
185
-
186
- return test_scenarios
@@ -0,0 +1,388 @@
1
+ # Copyright (c) 2024 Airbyte, Inc., all rights reserved.
2
+ """Base class for connector test suites."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import inspect
7
+ import shutil
8
+ import sys
9
+ import tempfile
10
+ import warnings
11
+ from dataclasses import asdict
12
+ from pathlib import Path
13
+ from subprocess import CompletedProcess, SubprocessError
14
+ from typing import Literal
15
+
16
+ import orjson
17
+ import pytest
18
+ import yaml
19
+ from boltons.typeutils import classproperty
20
+
21
+ from airbyte_cdk.models import (
22
+ AirbyteCatalog,
23
+ ConfiguredAirbyteCatalog,
24
+ ConfiguredAirbyteStream,
25
+ DestinationSyncMode,
26
+ SyncMode,
27
+ )
28
+ from airbyte_cdk.models.airbyte_protocol_serializers import (
29
+ AirbyteCatalogSerializer,
30
+ AirbyteStreamSerializer,
31
+ )
32
+ from airbyte_cdk.models.connector_metadata import MetadataFile
33
+ from airbyte_cdk.test.entrypoint_wrapper import EntrypointOutput
34
+ from airbyte_cdk.test.models import ConnectorTestScenario
35
+ from airbyte_cdk.test.utils.reading import catalog
36
+ from airbyte_cdk.utils.connector_paths import (
37
+ ACCEPTANCE_TEST_CONFIG,
38
+ find_connector_root,
39
+ )
40
+ from airbyte_cdk.utils.docker import build_connector_image, run_docker_command
41
+
42
+
43
+ class DockerConnectorTestSuite:
44
+ """Base class for connector test suites."""
45
+
46
+ @classmethod
47
+ def get_test_class_dir(cls) -> Path:
48
+ """Get the file path that contains the class."""
49
+ module = sys.modules[cls.__module__]
50
+ # Get the directory containing the test file
51
+ return Path(inspect.getfile(module)).parent
52
+
53
+ @classmethod
54
+ def get_connector_root_dir(cls) -> Path:
55
+ """Get the root directory of the connector."""
56
+ return find_connector_root([cls.get_test_class_dir(), Path.cwd()])
57
+
58
+ @classproperty
59
+ def acceptance_test_config_path(cls) -> Path:
60
+ """Get the path to the acceptance test config file."""
61
+ result = cls.get_connector_root_dir() / ACCEPTANCE_TEST_CONFIG
62
+ if result.exists():
63
+ return result
64
+
65
+ raise FileNotFoundError(f"Acceptance test config file not found at: {str(result)}")
66
+
67
+ @classmethod
68
+ def get_scenarios(
69
+ cls,
70
+ ) -> list[ConnectorTestScenario]:
71
+ """Get acceptance tests for a given category.
72
+
73
+ This has to be a separate function because pytest does not allow
74
+ parametrization of fixtures with arguments from the test class itself.
75
+ """
76
+ categories = ["connection", "spec"]
77
+ try:
78
+ acceptance_test_config_path = cls.acceptance_test_config_path
79
+ except FileNotFoundError as e:
80
+ # Destinations sometimes do not have an acceptance tests file.
81
+ warnings.warn(
82
+ f"Acceptance test config file not found: {e!s}. No scenarios will be loaded.",
83
+ category=UserWarning,
84
+ stacklevel=1,
85
+ )
86
+ return []
87
+
88
+ all_tests_config = yaml.safe_load(cls.acceptance_test_config_path.read_text())
89
+ if "acceptance_tests" not in all_tests_config:
90
+ raise ValueError(
91
+ f"Acceptance tests config not found in {cls.acceptance_test_config_path}."
92
+ f" Found only: {str(all_tests_config)}."
93
+ )
94
+
95
+ test_scenarios: list[ConnectorTestScenario] = []
96
+ for category in categories:
97
+ if (
98
+ category not in all_tests_config["acceptance_tests"]
99
+ or "tests" not in all_tests_config["acceptance_tests"][category]
100
+ ):
101
+ continue
102
+
103
+ for test in all_tests_config["acceptance_tests"][category]["tests"]:
104
+ if "config_path" not in test:
105
+ # Skip tests without a config_path
106
+ continue
107
+
108
+ if "iam_role" in test["config_path"]:
109
+ # We skip iam_role tests for now, as they are not supported in the test suite.
110
+ continue
111
+
112
+ scenario = ConnectorTestScenario.model_validate(test)
113
+
114
+ if scenario.config_path and scenario.config_path in [
115
+ s.config_path for s in test_scenarios
116
+ ]:
117
+ # Skip duplicate scenarios based on config_path
118
+ continue
119
+
120
+ test_scenarios.append(scenario)
121
+
122
+ return test_scenarios
123
+
124
+ @pytest.mark.skipif(
125
+ shutil.which("docker") is None,
126
+ reason="docker CLI not found in PATH, skipping docker image tests",
127
+ )
128
+ @pytest.mark.image_tests
129
+ def test_docker_image_build_and_spec(
130
+ self,
131
+ connector_image_override: str | None,
132
+ ) -> None:
133
+ """Run `docker_image` acceptance tests."""
134
+ connector_root = self.get_connector_root_dir().absolute()
135
+ metadata = MetadataFile.from_file(connector_root / "metadata.yaml")
136
+
137
+ connector_image: str | None = connector_image_override
138
+ if not connector_image:
139
+ tag = "dev-latest"
140
+ connector_image = build_connector_image(
141
+ connector_name=connector_root.absolute().name,
142
+ connector_directory=connector_root,
143
+ metadata=metadata,
144
+ tag=tag,
145
+ no_verify=False,
146
+ )
147
+
148
+ try:
149
+ result: CompletedProcess[str] = run_docker_command(
150
+ [
151
+ "docker",
152
+ "run",
153
+ "--rm",
154
+ connector_image,
155
+ "spec",
156
+ ],
157
+ check=True, # Raise an error if the command fails
158
+ capture_stderr=True,
159
+ capture_stdout=True,
160
+ )
161
+ except SubprocessError as ex:
162
+ raise AssertionError(
163
+ f"Failed to run `spec` command in docker image {connector_image!r}. Error: {ex!s}"
164
+ ) from None
165
+
166
+ @pytest.mark.skipif(
167
+ shutil.which("docker") is None,
168
+ reason="docker CLI not found in PATH, skipping docker image tests",
169
+ )
170
+ @pytest.mark.image_tests
171
+ def test_docker_image_build_and_check(
172
+ self,
173
+ scenario: ConnectorTestScenario,
174
+ connector_image_override: str | None,
175
+ ) -> None:
176
+ """Run `docker_image` acceptance tests.
177
+
178
+ This test builds the connector image and runs the `check` command inside the container.
179
+
180
+ Note:
181
+ - It is expected for docker image caches to be reused between test runs.
182
+ - In the rare case that image caches need to be cleared, please clear
183
+ the local docker image cache using `docker image prune -a` command.
184
+ """
185
+ if scenario.expected_outcome.expect_exception():
186
+ pytest.skip("Skipping test_docker_image_build_and_check (expected to fail).")
187
+
188
+ tag = "dev-latest"
189
+ connector_root = self.get_connector_root_dir()
190
+ metadata = MetadataFile.from_file(connector_root / "metadata.yaml")
191
+ connector_image: str | None = connector_image_override
192
+ if not connector_image:
193
+ tag = "dev-latest"
194
+ connector_image = build_connector_image(
195
+ connector_name=connector_root.absolute().name,
196
+ connector_directory=connector_root,
197
+ metadata=metadata,
198
+ tag=tag,
199
+ no_verify=False,
200
+ )
201
+
202
+ container_config_path = "/secrets/config.json"
203
+ with scenario.with_temp_config_file(
204
+ connector_root=connector_root,
205
+ ) as temp_config_file:
206
+ _ = run_docker_command(
207
+ [
208
+ "docker",
209
+ "run",
210
+ "--rm",
211
+ "-v",
212
+ f"{temp_config_file}:{container_config_path}",
213
+ connector_image,
214
+ "check",
215
+ "--config",
216
+ container_config_path,
217
+ ],
218
+ check=True, # Raise an error if the command fails
219
+ capture_stderr=True,
220
+ capture_stdout=True,
221
+ )
222
+
223
+ @pytest.mark.skipif(
224
+ shutil.which("docker") is None,
225
+ reason="docker CLI not found in PATH, skipping docker image tests",
226
+ )
227
+ @pytest.mark.image_tests
228
+ def test_docker_image_build_and_read(
229
+ self,
230
+ scenario: ConnectorTestScenario,
231
+ connector_image_override: str | None,
232
+ read_from_streams: Literal["all", "none", "default"] | list[str],
233
+ read_scenarios: Literal["all", "none", "default"] | list[str],
234
+ ) -> None:
235
+ """Read from the connector's Docker image.
236
+
237
+ This test builds the connector image and runs the `read` command inside the container.
238
+
239
+ Note:
240
+ - It is expected for docker image caches to be reused between test runs.
241
+ - In the rare case that image caches need to be cleared, please clear
242
+ the local docker image cache using `docker image prune -a` command.
243
+ - If the --connector-image arg is provided, it will be used instead of building the image.
244
+ """
245
+ if scenario.expected_outcome.expect_exception():
246
+ pytest.skip("Skipping (expected to fail).")
247
+
248
+ if read_from_streams == "none":
249
+ pytest.skip("Skipping read test (`--read-from-streams=false`).")
250
+
251
+ if read_scenarios == "none":
252
+ pytest.skip("Skipping (`--read-scenarios=none`).")
253
+
254
+ default_scenario_ids = ["config", "valid_config", "default"]
255
+ if read_scenarios == "all":
256
+ pass
257
+ elif read_scenarios == "default":
258
+ if scenario.id not in default_scenario_ids:
259
+ pytest.skip(
260
+ f"Skipping read test for scenario '{scenario.id}' "
261
+ f"(not in default scenarios list '{default_scenario_ids}')."
262
+ )
263
+ elif scenario.id not in read_scenarios:
264
+ # pytest.skip(
265
+ raise ValueError(
266
+ f"Skipping read test for scenario '{scenario.id}' "
267
+ f"(not in --read-scenarios={read_scenarios})."
268
+ )
269
+
270
+ tag = "dev-latest"
271
+ connector_root = self.get_connector_root_dir()
272
+ connector_name = connector_root.absolute().name
273
+ metadata = MetadataFile.from_file(connector_root / "metadata.yaml")
274
+ connector_image: str | None = connector_image_override
275
+ if not connector_image:
276
+ tag = "dev-latest"
277
+ connector_image = build_connector_image(
278
+ connector_name=connector_name,
279
+ connector_directory=connector_root,
280
+ metadata=metadata,
281
+ tag=tag,
282
+ no_verify=False,
283
+ )
284
+
285
+ container_config_path = "/secrets/config.json"
286
+ container_catalog_path = "/secrets/catalog.json"
287
+
288
+ with (
289
+ scenario.with_temp_config_file(
290
+ connector_root=connector_root,
291
+ ) as temp_config_file,
292
+ tempfile.TemporaryDirectory(
293
+ prefix=f"{connector_name}-test",
294
+ ignore_cleanup_errors=True,
295
+ ) as temp_dir_str,
296
+ ):
297
+ temp_dir = Path(temp_dir_str)
298
+ discover_result = run_docker_command(
299
+ [
300
+ "docker",
301
+ "run",
302
+ "--rm",
303
+ "-v",
304
+ f"{temp_config_file}:{container_config_path}",
305
+ connector_image,
306
+ "discover",
307
+ "--config",
308
+ container_config_path,
309
+ ],
310
+ check=True, # Raise an error if the command fails
311
+ capture_stderr=True,
312
+ capture_stdout=True,
313
+ )
314
+ parsed_output = EntrypointOutput(messages=discover_result.stdout.splitlines())
315
+ try:
316
+ catalog_message = parsed_output.catalog # Get catalog message
317
+ assert catalog_message.catalog is not None, "Catalog message missing catalog."
318
+ discovered_catalog: AirbyteCatalog = parsed_output.catalog.catalog
319
+ except Exception as ex:
320
+ raise AssertionError(
321
+ f"Failed to load discovered catalog from {discover_result.stdout}. "
322
+ f"Error: {ex!s}"
323
+ ) from None
324
+ if not discovered_catalog.streams:
325
+ raise ValueError(
326
+ f"Discovered catalog for connector '{connector_name}' is empty. "
327
+ "Please check the connector's discover implementation."
328
+ )
329
+
330
+ streams_list = [stream.name for stream in discovered_catalog.streams]
331
+ if read_from_streams == "default" and metadata.data.suggestedStreams:
332
+ # set `streams_list` to be the intersection of discovered and suggested streams.
333
+ streams_list = list(set(streams_list) & set(metadata.data.suggestedStreams.streams))
334
+
335
+ if isinstance(read_from_streams, list):
336
+ # If `read_from_streams` is a list, we filter the discovered streams.
337
+ streams_list = list(set(streams_list) & set(read_from_streams))
338
+
339
+ configured_catalog: ConfiguredAirbyteCatalog = ConfiguredAirbyteCatalog(
340
+ streams=[
341
+ ConfiguredAirbyteStream(
342
+ stream=stream,
343
+ sync_mode=(
344
+ stream.supported_sync_modes[0]
345
+ if stream.supported_sync_modes
346
+ else SyncMode.full_refresh
347
+ ),
348
+ destination_sync_mode=DestinationSyncMode.append,
349
+ )
350
+ for stream in discovered_catalog.streams
351
+ if stream.name in streams_list
352
+ ]
353
+ )
354
+ configured_catalog_path = temp_dir / "catalog.json"
355
+ configured_catalog_path.write_text(
356
+ orjson.dumps(asdict(configured_catalog)).decode("utf-8")
357
+ )
358
+ read_result: CompletedProcess[str] = run_docker_command(
359
+ [
360
+ "docker",
361
+ "run",
362
+ "--rm",
363
+ "-v",
364
+ f"{temp_config_file}:{container_config_path}",
365
+ "-v",
366
+ f"{configured_catalog_path}:{container_catalog_path}",
367
+ connector_image,
368
+ "read",
369
+ "--config",
370
+ container_config_path,
371
+ "--catalog",
372
+ container_catalog_path,
373
+ ],
374
+ check=False,
375
+ capture_stderr=True,
376
+ capture_stdout=True,
377
+ )
378
+ if read_result.returncode != 0:
379
+ raise AssertionError(
380
+ f"Failed to run `read` command in docker image {connector_image!r}. "
381
+ "\n-----------------"
382
+ f"EXIT CODE: {read_result.returncode}\n"
383
+ "STDERR:\n"
384
+ f"{read_result.stderr}\n"
385
+ f"STDOUT:\n"
386
+ f"{read_result.stdout}\n"
387
+ "\n-----------------"
388
+ ) from None
@@ -13,12 +13,125 @@ pytest_plugins = [
13
13
  ```
14
14
  """
15
15
 
16
+ from typing import Literal, cast
17
+
16
18
  import pytest
17
19
 
18
20
 
19
- def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
21
+ @pytest.fixture
22
+ def connector_image_override(request: pytest.FixtureRequest) -> str | None:
23
+ """Return the value of --connector-image, or None if not set."""
24
+ return cast(str | None, request.config.getoption("--connector-image"))
25
+
26
+
27
+ @pytest.fixture
28
+ def read_from_streams(
29
+ request: pytest.FixtureRequest,
30
+ ) -> Literal["all", "none", "default"] | list[str]:
31
+ """Specify if the test should read from streams.
32
+
33
+ The input can be one of the following:
34
+ - [Omitted] - Default to False, meaning no streams will be read.
35
+ - `--read-from-streams`: Read from all suggested streams.
36
+ - `--read-from-streams=true`: Read from all suggested streams.
37
+ - `--read-from-streams=suggested`: Read from all suggested streams.
38
+ - `--read-from-streams=default`: Read from all suggested streams.
39
+ - `--read-from-streams=all`: Read from all streams.
40
+ - `--read-from-streams=stream1,stream2`: Read from the specified streams only.
41
+ - `--read-from-streams=false`: Do not read from any streams.
42
+ - `--read-from-streams=none`: Do not read from any streams.
43
+ """
44
+ input_val: str = request.config.getoption(
45
+ "--read-from-streams",
46
+ default="default", # type: ignore
47
+ ) # type: ignore
48
+
49
+ if isinstance(input_val, str):
50
+ if input_val.lower() == "false":
51
+ return "none"
52
+ if input_val.lower() in ["true", "suggested", "default"]:
53
+ # Default to 'default' (suggested) streams if the input is 'true', 'suggested', or
54
+ # 'default'.
55
+ # This is the default behavior if the option is not set.
56
+ return "default"
57
+ if input_val.lower() == "all":
58
+ # This will sometimes fail if the account doesn't have permissions
59
+ # to premium or restricted stream data.
60
+ return "all"
61
+
62
+ # If the input is a comma-separated list, split it into a list.
63
+ # This will return a one-element list if the input is a single stream name.
64
+ return input_val.split(",")
65
+
66
+ # Else, probably a bool; return it as is.
67
+ return input_val or "none"
68
+
69
+
70
+ @pytest.fixture
71
+ def read_scenarios(
72
+ request: pytest.FixtureRequest,
73
+ ) -> list[str] | Literal["all", "default"]:
74
+ """Return the value of `--read-scenarios`.
75
+
76
+ This argument is ignored if `--read-from-streams` is False or not set.
77
+
78
+ The input can be one of the following:
79
+ - [Omitted] - Default to 'config.json', meaning the default scenario will be read.
80
+ - `--read-scenarios=all`: Read all scenarios.
81
+ - `--read-scenarios=none`: Read no scenarios. (Overrides `--read-from-streams`, if set.)
82
+ - `--read-scenarios=scenario1,scenario2`: Read the specified scenarios only.
83
+
20
84
  """
21
- A helper for pytest_generate_tests hook.
85
+ input_val = cast(
86
+ str,
87
+ request.config.getoption(
88
+ "--read-scenarios",
89
+ default="default", # type: ignore
90
+ ),
91
+ )
92
+
93
+ if input_val.lower() == "default":
94
+ # Default config scenario is always 'config.json'.
95
+ return "default"
96
+
97
+ if input_val.lower() == "none":
98
+ # Default config scenario is always 'config.json'.
99
+ return []
100
+
101
+ return (
102
+ [
103
+ scenario_name.strip().lower().removesuffix(".json")
104
+ for scenario_name in input_val.split(",")
105
+ ]
106
+ if input_val
107
+ else []
108
+ )
109
+
110
+
111
+ def pytest_addoption(parser: pytest.Parser) -> None:
112
+ """Add --connector-image to pytest's CLI."""
113
+ parser.addoption(
114
+ "--connector-image",
115
+ action="store",
116
+ default=None,
117
+ help="Use this pre-built connector Docker image instead of building one.",
118
+ )
119
+ parser.addoption(
120
+ "--read-from-streams",
121
+ action="store",
122
+ default=None,
123
+ help=read_from_streams.__doc__,
124
+ )
125
+ parser.addoption(
126
+ "--read-scenarios",
127
+ action="store",
128
+ default="default",
129
+ help=read_scenarios.__doc__,
130
+ )
131
+
132
+
133
+ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
134
+ """A helper for pytest_generate_tests hook.
22
135
 
23
136
  If a test method (in a class subclassed from our base class)
24
137
  declares an argument 'scenario', this function retrieves the
@@ -4,6 +4,8 @@
4
4
  from dataclasses import asdict
5
5
  from typing import TYPE_CHECKING
6
6
 
7
+ import pytest
8
+
7
9
  from airbyte_cdk.models import (
8
10
  AirbyteMessage,
9
11
  AirbyteStream,
@@ -48,13 +50,10 @@ class SourceTestSuiteBase(ConnectorTestSuiteBase):
48
50
  test_scenario=scenario,
49
51
  connector_root=self.get_connector_root_dir(),
50
52
  )
51
- conn_status_messages: list[AirbyteMessage] = [
52
- msg for msg in result._messages if msg.type == Type.CONNECTION_STATUS
53
- ] # noqa: SLF001 # Non-public API
54
- num_status_messages = len(conn_status_messages)
53
+ num_status_messages = len(result.connection_status_messages)
55
54
  assert num_status_messages == 1, (
56
55
  f"Expected exactly one CONNECTION_STATUS message. Got {num_status_messages}: \n"
57
- + "\n".join([str(m) for m in result._messages])
56
+ + "\n".join([str(m) for m in result.get_message_iterator()])
58
57
  )
59
58
 
60
59
  def test_discover(
@@ -62,6 +61,13 @@ class SourceTestSuiteBase(ConnectorTestSuiteBase):
62
61
  scenario: ConnectorTestScenario,
63
62
  ) -> None:
64
63
  """Standard test for `discover`."""
64
+ if scenario.expected_outcome.expect_exception():
65
+ # If the scenario expects an exception, we can't ensure it specifically would fail
66
+ # in discover, because some discover implementations do not need to make a connection.
67
+ # We skip this test in that case.
68
+ pytest.skip("Skipping discover test for scenario that expects an exception.")
69
+ return
70
+
65
71
  run_test_job(
66
72
  self.create_connector(scenario),
67
73
  "discover",
@@ -133,8 +139,8 @@ class SourceTestSuiteBase(ConnectorTestSuiteBase):
133
139
  catalog=configured_catalog,
134
140
  )
135
141
 
136
- if not result.records:
137
- raise AssertionError("Expected records but got none.") # noqa: TRY003
142
+ if scenario.expected_outcome.expect_success() and not result.records:
143
+ raise AssertionError("Expected records but got none.")
138
144
 
139
145
  def test_fail_read_with_bad_catalog(
140
146
  self,