airbyte-cdk 6.45.4.post49.dev14495925594__py3-none-any.whl → 6.45.4.post51.dev14501768171__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.
- airbyte_cdk/test/entrypoint_wrapper.py +4 -0
- airbyte_cdk/test/standard_tests/__init__.py +46 -0
- airbyte_cdk/test/standard_tests/_job_runner.py +159 -0
- airbyte_cdk/test/standard_tests/connector_base.py +148 -0
- airbyte_cdk/test/standard_tests/declarative_sources.py +92 -0
- airbyte_cdk/test/standard_tests/destination_base.py +16 -0
- airbyte_cdk/test/standard_tests/models/__init__.py +7 -0
- airbyte_cdk/test/standard_tests/models/scenario.py +74 -0
- airbyte_cdk/test/standard_tests/pytest_hooks.py +61 -0
- airbyte_cdk/test/standard_tests/source_base.py +140 -0
- {airbyte_cdk-6.45.4.post49.dev14495925594.dist-info → airbyte_cdk-6.45.4.post51.dev14501768171.dist-info}/METADATA +2 -1
- {airbyte_cdk-6.45.4.post49.dev14495925594.dist-info → airbyte_cdk-6.45.4.post51.dev14501768171.dist-info}/RECORD +16 -7
- {airbyte_cdk-6.45.4.post49.dev14495925594.dist-info → airbyte_cdk-6.45.4.post51.dev14501768171.dist-info}/LICENSE.txt +0 -0
- {airbyte_cdk-6.45.4.post49.dev14495925594.dist-info → airbyte_cdk-6.45.4.post51.dev14501768171.dist-info}/LICENSE_SHORT +0 -0
- {airbyte_cdk-6.45.4.post49.dev14495925594.dist-info → airbyte_cdk-6.45.4.post51.dev14501768171.dist-info}/WHEEL +0 -0
- {airbyte_cdk-6.45.4.post49.dev14495925594.dist-info → airbyte_cdk-6.45.4.post51.dev14501768171.dist-info}/entry_points.txt +0 -0
@@ -82,6 +82,10 @@ class EntrypointOutput:
|
|
82
82
|
def state_messages(self) -> List[AirbyteMessage]:
|
83
83
|
return self._get_message_by_types([Type.STATE])
|
84
84
|
|
85
|
+
@property
|
86
|
+
def connection_status_messages(self) -> List[AirbyteMessage]:
|
87
|
+
return self._get_message_by_types([Type.CONNECTION_STATUS])
|
88
|
+
|
85
89
|
@property
|
86
90
|
def most_recent_state(self) -> Any:
|
87
91
|
state_messages = self._get_message_by_types([Type.STATE])
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# Copyright (c) 2024 Airbyte, Inc., all rights reserved.
|
2
|
+
'''FAST Airbyte Standard Tests
|
3
|
+
|
4
|
+
This module provides a set of base classes for declarative connector test suites.
|
5
|
+
The goal of this module is to provide a robust and extensible framework for testing Airbyte
|
6
|
+
connectors.
|
7
|
+
|
8
|
+
Example usage:
|
9
|
+
|
10
|
+
```python
|
11
|
+
# `test_airbyte_standards.py`
|
12
|
+
from airbyte_cdk.test import standard_tests
|
13
|
+
|
14
|
+
pytest_plugins = [
|
15
|
+
"airbyte_cdk.test.standard_tests.pytest_hooks",
|
16
|
+
]
|
17
|
+
|
18
|
+
|
19
|
+
class TestSuiteSourcePokeAPI(standard_tests.DeclarativeSourceTestSuite):
|
20
|
+
"""Test suite for the source."""
|
21
|
+
```
|
22
|
+
|
23
|
+
Available test suites base classes:
|
24
|
+
- `DeclarativeSourceTestSuite`: A test suite for declarative sources.
|
25
|
+
- `SourceTestSuiteBase`: A test suite for sources.
|
26
|
+
- `DestinationTestSuiteBase`: A test suite for destinations.
|
27
|
+
|
28
|
+
'''
|
29
|
+
|
30
|
+
from airbyte_cdk.test.standard_tests.connector_base import (
|
31
|
+
ConnectorTestScenario,
|
32
|
+
ConnectorTestSuiteBase,
|
33
|
+
)
|
34
|
+
from airbyte_cdk.test.standard_tests.declarative_sources import (
|
35
|
+
DeclarativeSourceTestSuite,
|
36
|
+
)
|
37
|
+
from airbyte_cdk.test.standard_tests.destination_base import DestinationTestSuiteBase
|
38
|
+
from airbyte_cdk.test.standard_tests.source_base import SourceTestSuiteBase
|
39
|
+
|
40
|
+
__all__ = [
|
41
|
+
"ConnectorTestScenario",
|
42
|
+
"ConnectorTestSuiteBase",
|
43
|
+
"DeclarativeSourceTestSuite",
|
44
|
+
"DestinationTestSuiteBase",
|
45
|
+
"SourceTestSuiteBase",
|
46
|
+
]
|
@@ -0,0 +1,159 @@
|
|
1
|
+
# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
|
2
|
+
"""Job runner for Airbyte Standard Tests."""
|
3
|
+
|
4
|
+
import logging
|
5
|
+
import tempfile
|
6
|
+
import uuid
|
7
|
+
from dataclasses import asdict
|
8
|
+
from pathlib import Path
|
9
|
+
from typing import Any, Callable, Literal
|
10
|
+
|
11
|
+
import orjson
|
12
|
+
from typing_extensions import Protocol, runtime_checkable
|
13
|
+
|
14
|
+
from airbyte_cdk.models import (
|
15
|
+
ConfiguredAirbyteCatalog,
|
16
|
+
Status,
|
17
|
+
)
|
18
|
+
from airbyte_cdk.test import entrypoint_wrapper
|
19
|
+
from airbyte_cdk.test.standard_tests.models import (
|
20
|
+
ConnectorTestScenario,
|
21
|
+
)
|
22
|
+
|
23
|
+
|
24
|
+
def _errors_to_str(
|
25
|
+
entrypoint_output: entrypoint_wrapper.EntrypointOutput,
|
26
|
+
) -> str:
|
27
|
+
"""Convert errors from entrypoint output to a string."""
|
28
|
+
if not entrypoint_output.errors:
|
29
|
+
# If there are no errors, return an empty string.
|
30
|
+
return ""
|
31
|
+
|
32
|
+
return "\n" + "\n".join(
|
33
|
+
[
|
34
|
+
str(error.trace.error).replace(
|
35
|
+
"\\n",
|
36
|
+
"\n",
|
37
|
+
)
|
38
|
+
for error in entrypoint_output.errors
|
39
|
+
if error.trace
|
40
|
+
],
|
41
|
+
)
|
42
|
+
|
43
|
+
|
44
|
+
@runtime_checkable
|
45
|
+
class IConnector(Protocol):
|
46
|
+
"""A connector that can be run in a test scenario.
|
47
|
+
|
48
|
+
Note: We currently use 'spec' to determine if we have a connector object.
|
49
|
+
In the future, it would be preferred to leverage a 'launch' method instead,
|
50
|
+
directly on the connector (which doesn't yet exist).
|
51
|
+
"""
|
52
|
+
|
53
|
+
def spec(self, logger: logging.Logger) -> Any:
|
54
|
+
"""Connectors should have a `spec` method."""
|
55
|
+
|
56
|
+
|
57
|
+
def run_test_job(
|
58
|
+
connector: IConnector | type[IConnector] | Callable[[], IConnector],
|
59
|
+
verb: Literal["read", "check", "discover"],
|
60
|
+
test_scenario: ConnectorTestScenario,
|
61
|
+
*,
|
62
|
+
catalog: ConfiguredAirbyteCatalog | dict[str, Any] | None = None,
|
63
|
+
) -> entrypoint_wrapper.EntrypointOutput:
|
64
|
+
"""Run a test scenario from provided CLI args and return the result."""
|
65
|
+
if not connector:
|
66
|
+
raise ValueError("Connector is required")
|
67
|
+
|
68
|
+
if catalog and isinstance(catalog, ConfiguredAirbyteCatalog):
|
69
|
+
# Convert the catalog to a dict if it's already a ConfiguredAirbyteCatalog.
|
70
|
+
catalog = asdict(catalog)
|
71
|
+
|
72
|
+
connector_obj: IConnector
|
73
|
+
if isinstance(connector, type) or callable(connector):
|
74
|
+
# If the connector is a class or a factory lambda, instantiate it.
|
75
|
+
connector_obj = connector()
|
76
|
+
elif isinstance(connector, IConnector):
|
77
|
+
connector_obj = connector
|
78
|
+
else:
|
79
|
+
raise ValueError(
|
80
|
+
f"Invalid connector input: {type(connector)}",
|
81
|
+
)
|
82
|
+
|
83
|
+
args: list[str] = [verb]
|
84
|
+
if test_scenario.config_path:
|
85
|
+
args += ["--config", str(test_scenario.config_path)]
|
86
|
+
elif test_scenario.config_dict:
|
87
|
+
config_path = (
|
88
|
+
Path(tempfile.gettempdir()) / "airbyte-test" / f"temp_config_{uuid.uuid4().hex}.json"
|
89
|
+
)
|
90
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
91
|
+
config_path.write_text(orjson.dumps(test_scenario.config_dict).decode())
|
92
|
+
args += ["--config", str(config_path)]
|
93
|
+
|
94
|
+
catalog_path: Path | None = None
|
95
|
+
if verb not in ["discover", "check"]:
|
96
|
+
# We need a catalog for read.
|
97
|
+
if catalog:
|
98
|
+
# Write the catalog to a temp json file and pass the path to the file as an argument.
|
99
|
+
catalog_path = (
|
100
|
+
Path(tempfile.gettempdir())
|
101
|
+
/ "airbyte-test"
|
102
|
+
/ f"temp_catalog_{uuid.uuid4().hex}.json"
|
103
|
+
)
|
104
|
+
catalog_path.parent.mkdir(parents=True, exist_ok=True)
|
105
|
+
catalog_path.write_text(orjson.dumps(catalog).decode())
|
106
|
+
elif test_scenario.configured_catalog_path:
|
107
|
+
catalog_path = Path(test_scenario.configured_catalog_path)
|
108
|
+
|
109
|
+
if catalog_path:
|
110
|
+
args += ["--catalog", str(catalog_path)]
|
111
|
+
|
112
|
+
# This is a bit of a hack because the source needs the catalog early.
|
113
|
+
# Because it *also* can fail, we have to redundantly wrap it in a try/except block.
|
114
|
+
|
115
|
+
result: entrypoint_wrapper.EntrypointOutput = entrypoint_wrapper._run_command( # noqa: SLF001 # Non-public API
|
116
|
+
source=connector_obj, # type: ignore [arg-type]
|
117
|
+
args=args,
|
118
|
+
expecting_exception=test_scenario.expect_exception,
|
119
|
+
)
|
120
|
+
if result.errors and not test_scenario.expect_exception:
|
121
|
+
raise AssertionError(
|
122
|
+
f"Expected no errors but got {len(result.errors)}: \n" + _errors_to_str(result)
|
123
|
+
)
|
124
|
+
|
125
|
+
if verb == "check":
|
126
|
+
# Check is expected to fail gracefully without an exception.
|
127
|
+
# Instead, we assert that we have a CONNECTION_STATUS message with
|
128
|
+
# a failure status.
|
129
|
+
assert len(result.connection_status_messages) == 1, (
|
130
|
+
"Expected exactly one CONNECTION_STATUS message. Got "
|
131
|
+
f"{len(result.connection_status_messages)}:\n"
|
132
|
+
+ "\n".join([str(msg) for msg in result.connection_status_messages])
|
133
|
+
+ _errors_to_str(result)
|
134
|
+
)
|
135
|
+
if test_scenario.expect_exception:
|
136
|
+
conn_status = result.connection_status_messages[0].connectionStatus
|
137
|
+
assert conn_status, (
|
138
|
+
"Expected CONNECTION_STATUS message to be present. Got: \n"
|
139
|
+
+ "\n".join([str(msg) for msg in result.connection_status_messages])
|
140
|
+
)
|
141
|
+
assert conn_status.status == Status.FAILED, (
|
142
|
+
"Expected CONNECTION_STATUS message to be FAILED. Got: \n"
|
143
|
+
+ "\n".join([str(msg) for msg in result.connection_status_messages])
|
144
|
+
)
|
145
|
+
|
146
|
+
return result
|
147
|
+
|
148
|
+
# For all other verbs, we assert check that an exception is raised (or not).
|
149
|
+
if test_scenario.expect_exception:
|
150
|
+
if not result.errors:
|
151
|
+
raise AssertionError("Expected exception but got none.")
|
152
|
+
|
153
|
+
return result
|
154
|
+
|
155
|
+
assert not result.errors, (
|
156
|
+
f"Expected no errors but got {len(result.errors)}: \n" + _errors_to_str(result)
|
157
|
+
)
|
158
|
+
|
159
|
+
return result
|
@@ -0,0 +1,148 @@
|
|
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 abc
|
7
|
+
import inspect
|
8
|
+
import sys
|
9
|
+
from collections.abc import Callable
|
10
|
+
from pathlib import Path
|
11
|
+
from typing import cast
|
12
|
+
|
13
|
+
import yaml
|
14
|
+
from boltons.typeutils import classproperty
|
15
|
+
|
16
|
+
from airbyte_cdk.models import (
|
17
|
+
AirbyteMessage,
|
18
|
+
Type,
|
19
|
+
)
|
20
|
+
from airbyte_cdk.test import entrypoint_wrapper
|
21
|
+
from airbyte_cdk.test.standard_tests._job_runner import IConnector, run_test_job
|
22
|
+
from airbyte_cdk.test.standard_tests.models import (
|
23
|
+
ConnectorTestScenario,
|
24
|
+
)
|
25
|
+
|
26
|
+
ACCEPTANCE_TEST_CONFIG = "acceptance-test-config.yml"
|
27
|
+
MANIFEST_YAML = "manifest.yaml"
|
28
|
+
|
29
|
+
|
30
|
+
class ConnectorTestSuiteBase(abc.ABC):
|
31
|
+
"""Base class for connector test suites."""
|
32
|
+
|
33
|
+
connector: type[IConnector] | Callable[[], IConnector] | None = None
|
34
|
+
"""The connector class or a factory function that returns an scenario of IConnector."""
|
35
|
+
|
36
|
+
@classmethod
|
37
|
+
def get_test_class_dir(cls) -> Path:
|
38
|
+
"""Get the file path that contains the class."""
|
39
|
+
module = sys.modules[cls.__module__]
|
40
|
+
# Get the directory containing the test file
|
41
|
+
return Path(inspect.getfile(module)).parent
|
42
|
+
|
43
|
+
@classmethod
|
44
|
+
def create_connector(
|
45
|
+
cls,
|
46
|
+
scenario: ConnectorTestScenario,
|
47
|
+
) -> IConnector:
|
48
|
+
"""Instantiate the connector class."""
|
49
|
+
connector = cls.connector # type: ignore
|
50
|
+
if connector:
|
51
|
+
if callable(connector) or isinstance(connector, type):
|
52
|
+
# If the connector is a class or factory function, instantiate it:
|
53
|
+
return cast(IConnector, connector()) # type: ignore [redundant-cast]
|
54
|
+
|
55
|
+
# Otherwise, we can't instantiate the connector. Fail with a clear error message.
|
56
|
+
raise NotImplementedError(
|
57
|
+
"No connector class or connector factory function provided. "
|
58
|
+
"Please provide a class or factory function in `cls.connector`, or "
|
59
|
+
"override `cls.create_connector()` to define a custom initialization process."
|
60
|
+
)
|
61
|
+
|
62
|
+
# Test Definitions
|
63
|
+
|
64
|
+
def test_check(
|
65
|
+
self,
|
66
|
+
scenario: ConnectorTestScenario,
|
67
|
+
) -> None:
|
68
|
+
"""Run `connection` acceptance tests."""
|
69
|
+
result: entrypoint_wrapper.EntrypointOutput = run_test_job(
|
70
|
+
self.create_connector(scenario),
|
71
|
+
"check",
|
72
|
+
test_scenario=scenario,
|
73
|
+
)
|
74
|
+
conn_status_messages: list[AirbyteMessage] = [
|
75
|
+
msg for msg in result._messages if msg.type == Type.CONNECTION_STATUS
|
76
|
+
] # noqa: SLF001 # Non-public API
|
77
|
+
assert len(conn_status_messages) == 1, (
|
78
|
+
f"Expected exactly one CONNECTION_STATUS message. Got: {result._messages}"
|
79
|
+
)
|
80
|
+
|
81
|
+
@classmethod
|
82
|
+
def get_connector_root_dir(cls) -> Path:
|
83
|
+
"""Get the root directory of the connector."""
|
84
|
+
for parent in cls.get_test_class_dir().parents:
|
85
|
+
if (parent / MANIFEST_YAML).exists():
|
86
|
+
return parent
|
87
|
+
if (parent / ACCEPTANCE_TEST_CONFIG).exists():
|
88
|
+
return parent
|
89
|
+
if parent.name == "airbyte_cdk":
|
90
|
+
break
|
91
|
+
# If we reach here, we didn't find the manifest file in any parent directory
|
92
|
+
# Check if the manifest file exists in the current directory
|
93
|
+
for parent in Path.cwd().parents:
|
94
|
+
if (parent / MANIFEST_YAML).exists():
|
95
|
+
return parent
|
96
|
+
if (parent / ACCEPTANCE_TEST_CONFIG).exists():
|
97
|
+
return parent
|
98
|
+
if parent.name == "airbyte_cdk":
|
99
|
+
break
|
100
|
+
|
101
|
+
raise FileNotFoundError(
|
102
|
+
"Could not find connector root directory relative to "
|
103
|
+
f"'{str(cls.get_test_class_dir())}' or '{str(Path.cwd())}'."
|
104
|
+
)
|
105
|
+
|
106
|
+
@classproperty
|
107
|
+
def acceptance_test_config_path(cls) -> Path:
|
108
|
+
"""Get the path to the acceptance test config file."""
|
109
|
+
result = cls.get_connector_root_dir() / ACCEPTANCE_TEST_CONFIG
|
110
|
+
if result.exists():
|
111
|
+
return result
|
112
|
+
|
113
|
+
raise FileNotFoundError(f"Acceptance test config file not found at: {str(result)}")
|
114
|
+
|
115
|
+
@classmethod
|
116
|
+
def get_scenarios(
|
117
|
+
cls,
|
118
|
+
) -> list[ConnectorTestScenario]:
|
119
|
+
"""Get acceptance tests for a given category.
|
120
|
+
|
121
|
+
This has to be a separate function because pytest does not allow
|
122
|
+
parametrization of fixtures with arguments from the test class itself.
|
123
|
+
"""
|
124
|
+
category = "connection"
|
125
|
+
all_tests_config = yaml.safe_load(cls.acceptance_test_config_path.read_text())
|
126
|
+
if "acceptance_tests" not in all_tests_config:
|
127
|
+
raise ValueError(
|
128
|
+
f"Acceptance tests config not found in {cls.acceptance_test_config_path}."
|
129
|
+
f" Found only: {str(all_tests_config)}."
|
130
|
+
)
|
131
|
+
if category not in all_tests_config["acceptance_tests"]:
|
132
|
+
return []
|
133
|
+
if "tests" not in all_tests_config["acceptance_tests"][category]:
|
134
|
+
raise ValueError(f"No tests found for category {category}")
|
135
|
+
|
136
|
+
tests_scenarios = [
|
137
|
+
ConnectorTestScenario.model_validate(test)
|
138
|
+
for test in all_tests_config["acceptance_tests"][category]["tests"]
|
139
|
+
if "iam_role" not in test["config_path"]
|
140
|
+
]
|
141
|
+
connector_root = cls.get_connector_root_dir().absolute()
|
142
|
+
for test in tests_scenarios:
|
143
|
+
if test.config_path:
|
144
|
+
test.config_path = connector_root / test.config_path
|
145
|
+
if test.configured_catalog_path:
|
146
|
+
test.configured_catalog_path = connector_root / test.configured_catalog_path
|
147
|
+
|
148
|
+
return tests_scenarios
|
@@ -0,0 +1,92 @@
|
|
1
|
+
import os
|
2
|
+
from hashlib import md5
|
3
|
+
from pathlib import Path
|
4
|
+
from typing import Any, cast
|
5
|
+
|
6
|
+
import yaml
|
7
|
+
from boltons.typeutils import classproperty
|
8
|
+
|
9
|
+
from airbyte_cdk.sources.declarative.concurrent_declarative_source import (
|
10
|
+
ConcurrentDeclarativeSource,
|
11
|
+
)
|
12
|
+
from airbyte_cdk.test.standard_tests._job_runner import IConnector
|
13
|
+
from airbyte_cdk.test.standard_tests.connector_base import MANIFEST_YAML
|
14
|
+
from airbyte_cdk.test.standard_tests.models import ConnectorTestScenario
|
15
|
+
from airbyte_cdk.test.standard_tests.source_base import SourceTestSuiteBase
|
16
|
+
|
17
|
+
|
18
|
+
def md5_checksum(file_path: Path) -> str:
|
19
|
+
"""Helper function to calculate the MD5 checksum of a file.
|
20
|
+
|
21
|
+
This is used to calculate the checksum of the `components.py` file, if it exists.
|
22
|
+
"""
|
23
|
+
with open(file_path, "rb") as file:
|
24
|
+
return md5(file.read()).hexdigest()
|
25
|
+
|
26
|
+
|
27
|
+
class DeclarativeSourceTestSuite(SourceTestSuiteBase):
|
28
|
+
"""Declarative source test suite.
|
29
|
+
|
30
|
+
This inherits from the Python-based source test suite and implements the
|
31
|
+
`create_connector` method to create a declarative source object instead of
|
32
|
+
requiring a custom Python source object.
|
33
|
+
|
34
|
+
The class also automatically locates the `manifest.yaml` file and the
|
35
|
+
`components.py` file (if it exists) in the connector's directory.
|
36
|
+
"""
|
37
|
+
|
38
|
+
@classproperty
|
39
|
+
def manifest_yaml_path(cls) -> Path:
|
40
|
+
"""Get the path to the manifest.yaml file."""
|
41
|
+
result = cls.get_connector_root_dir() / MANIFEST_YAML
|
42
|
+
if result.exists():
|
43
|
+
return result
|
44
|
+
|
45
|
+
raise FileNotFoundError(
|
46
|
+
f"Manifest YAML file not found at {result}. "
|
47
|
+
"Please ensure that the test suite is run in the correct directory.",
|
48
|
+
)
|
49
|
+
|
50
|
+
@classproperty
|
51
|
+
def components_py_path(cls) -> Path | None:
|
52
|
+
"""Get the path to the `components.py` file, if one exists.
|
53
|
+
|
54
|
+
If not `components.py` file exists, return None.
|
55
|
+
"""
|
56
|
+
result = cls.get_connector_root_dir() / "components.py"
|
57
|
+
if result.exists():
|
58
|
+
return result
|
59
|
+
|
60
|
+
return None
|
61
|
+
|
62
|
+
@classmethod
|
63
|
+
def create_connector(
|
64
|
+
cls,
|
65
|
+
scenario: ConnectorTestScenario,
|
66
|
+
) -> IConnector:
|
67
|
+
"""Create a connector scenario for the test suite.
|
68
|
+
|
69
|
+
This overrides `create_connector` from the create a declarative source object
|
70
|
+
instead of requiring a custom python source object.
|
71
|
+
|
72
|
+
Subclasses should not need to override this method.
|
73
|
+
"""
|
74
|
+
config: dict[str, Any] = scenario.get_config_dict()
|
75
|
+
|
76
|
+
manifest_dict = yaml.safe_load(cls.manifest_yaml_path.read_text())
|
77
|
+
if cls.components_py_path and cls.components_py_path.exists():
|
78
|
+
os.environ["AIRBYTE_ENABLE_UNSAFE_CODE"] = "true"
|
79
|
+
config["__injected_components_py"] = cls.components_py_path.read_text()
|
80
|
+
config["__injected_components_py_checksums"] = {
|
81
|
+
"md5": md5_checksum(cls.components_py_path),
|
82
|
+
}
|
83
|
+
|
84
|
+
return cast(
|
85
|
+
IConnector,
|
86
|
+
ConcurrentDeclarativeSource(
|
87
|
+
config=config,
|
88
|
+
catalog=None,
|
89
|
+
state=None,
|
90
|
+
source_config=manifest_dict,
|
91
|
+
),
|
92
|
+
)
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# Copyright (c) 2024 Airbyte, Inc., all rights reserved.
|
2
|
+
"""Base class for destination test suites."""
|
3
|
+
|
4
|
+
from airbyte_cdk.test.standard_tests.connector_base import ConnectorTestSuiteBase
|
5
|
+
|
6
|
+
|
7
|
+
class DestinationTestSuiteBase(ConnectorTestSuiteBase):
|
8
|
+
"""Base class for destination test suites.
|
9
|
+
|
10
|
+
This class provides a base set of functionality for testing destination connectors, and it
|
11
|
+
inherits all generic connector tests from the `ConnectorTestSuiteBase` class.
|
12
|
+
|
13
|
+
TODO: As of now, this class does not add any additional functionality or tests specific to
|
14
|
+
destination connectors. However, it serves as a placeholder for future enhancements and
|
15
|
+
customizations that may be needed for destination connectors.
|
16
|
+
"""
|
@@ -0,0 +1,74 @@
|
|
1
|
+
# Copyright (c) 2024 Airbyte, Inc., all rights reserved.
|
2
|
+
"""Run acceptance tests in PyTest.
|
3
|
+
|
4
|
+
These tests leverage the same `acceptance-test-config.yml` configuration files as the
|
5
|
+
acceptance tests in CAT, but they run in PyTest instead of CAT. This allows us to run
|
6
|
+
the acceptance tests in the same local environment as we are developing in, speeding
|
7
|
+
up iteration cycles.
|
8
|
+
"""
|
9
|
+
|
10
|
+
from __future__ import annotations
|
11
|
+
|
12
|
+
from pathlib import Path
|
13
|
+
from typing import Any, Literal, cast
|
14
|
+
|
15
|
+
import yaml
|
16
|
+
from pydantic import BaseModel
|
17
|
+
|
18
|
+
|
19
|
+
class ConnectorTestScenario(BaseModel):
|
20
|
+
"""Acceptance test scenario, as a Pydantic model.
|
21
|
+
|
22
|
+
This class represents an acceptance test scenario, which is a single test case
|
23
|
+
that can be run against a connector. It is used to deserialize and validate the
|
24
|
+
acceptance test configuration file.
|
25
|
+
"""
|
26
|
+
|
27
|
+
class AcceptanceTestExpectRecords(BaseModel):
|
28
|
+
path: Path
|
29
|
+
exact_order: bool = False
|
30
|
+
|
31
|
+
class AcceptanceTestFileTypes(BaseModel):
|
32
|
+
skip_test: bool
|
33
|
+
bypass_reason: str
|
34
|
+
|
35
|
+
config_path: Path | None = None
|
36
|
+
config_dict: dict[str, Any] | None = None
|
37
|
+
|
38
|
+
id: str | None = None
|
39
|
+
|
40
|
+
configured_catalog_path: Path | None = None
|
41
|
+
timeout_seconds: int | None = None
|
42
|
+
expect_records: AcceptanceTestExpectRecords | None = None
|
43
|
+
file_types: AcceptanceTestFileTypes | None = None
|
44
|
+
status: Literal["succeed", "failed"] | None = None
|
45
|
+
|
46
|
+
def get_config_dict(self) -> dict[str, Any]:
|
47
|
+
"""Return the config dictionary.
|
48
|
+
|
49
|
+
If a config dictionary has already been loaded, return it. Otherwise, load
|
50
|
+
the config file and return the dictionary.
|
51
|
+
"""
|
52
|
+
if self.config_dict:
|
53
|
+
return self.config_dict
|
54
|
+
|
55
|
+
if self.config_path:
|
56
|
+
return cast(dict[str, Any], yaml.safe_load(self.config_path.read_text()))
|
57
|
+
|
58
|
+
raise ValueError("No config dictionary or path provided.")
|
59
|
+
|
60
|
+
@property
|
61
|
+
def expect_exception(self) -> bool:
|
62
|
+
return self.status and self.status == "failed" or False
|
63
|
+
|
64
|
+
@property
|
65
|
+
def instance_name(self) -> str:
|
66
|
+
return self.config_path.stem if self.config_path else "Unnamed Scenario"
|
67
|
+
|
68
|
+
def __str__(self) -> str:
|
69
|
+
if self.id:
|
70
|
+
return f"'{self.id}' Test Scenario"
|
71
|
+
if self.config_path:
|
72
|
+
return f"'{self.config_path.name}' Test Scenario"
|
73
|
+
|
74
|
+
return f"'{hash(self)}' Test Scenario"
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
|
2
|
+
"""Pytest hooks for Airbyte CDK tests.
|
3
|
+
|
4
|
+
These hooks are used to customize the behavior of pytest during test discovery and execution.
|
5
|
+
|
6
|
+
To use these hooks within a connector, add the following lines to the connector's `conftest.py`
|
7
|
+
file, or to another file that is imported during test discovery:
|
8
|
+
|
9
|
+
```python
|
10
|
+
pytest_plugins = [
|
11
|
+
"airbyte_cdk.test.standard_tests.pytest_hooks",
|
12
|
+
]
|
13
|
+
```
|
14
|
+
"""
|
15
|
+
|
16
|
+
import pytest
|
17
|
+
|
18
|
+
|
19
|
+
def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
|
20
|
+
"""
|
21
|
+
A helper for pytest_generate_tests hook.
|
22
|
+
|
23
|
+
If a test method (in a class subclassed from our base class)
|
24
|
+
declares an argument 'scenario', this function retrieves the
|
25
|
+
'scenarios' attribute from the test class and parametrizes that
|
26
|
+
test with the values from 'scenarios'.
|
27
|
+
|
28
|
+
## Usage
|
29
|
+
|
30
|
+
```python
|
31
|
+
from airbyte_cdk.test.standard_tests.connector_base import (
|
32
|
+
generate_tests,
|
33
|
+
ConnectorTestSuiteBase,
|
34
|
+
)
|
35
|
+
|
36
|
+
def pytest_generate_tests(metafunc):
|
37
|
+
generate_tests(metafunc)
|
38
|
+
|
39
|
+
class TestMyConnector(ConnectorTestSuiteBase):
|
40
|
+
...
|
41
|
+
|
42
|
+
```
|
43
|
+
"""
|
44
|
+
# Check if the test function requires an 'scenario' argument
|
45
|
+
if "scenario" in metafunc.fixturenames:
|
46
|
+
# Retrieve the test class
|
47
|
+
test_class = metafunc.cls
|
48
|
+
if test_class is None:
|
49
|
+
return
|
50
|
+
|
51
|
+
# Get the 'scenarios' attribute from the class
|
52
|
+
scenarios_attr = getattr(test_class, "get_scenarios", None)
|
53
|
+
if scenarios_attr is None:
|
54
|
+
raise ValueError(
|
55
|
+
f"Test class {test_class} does not have a 'scenarios' attribute. "
|
56
|
+
"Please define the 'scenarios' attribute in the test class."
|
57
|
+
)
|
58
|
+
|
59
|
+
scenarios = test_class.get_scenarios()
|
60
|
+
ids = [str(scenario) for scenario in scenarios]
|
61
|
+
metafunc.parametrize("scenario", scenarios, ids=ids)
|
@@ -0,0 +1,140 @@
|
|
1
|
+
# Copyright (c) 2024 Airbyte, Inc., all rights reserved.
|
2
|
+
"""Base class for source test suites."""
|
3
|
+
|
4
|
+
from dataclasses import asdict
|
5
|
+
|
6
|
+
from airbyte_cdk.models import (
|
7
|
+
AirbyteMessage,
|
8
|
+
AirbyteStream,
|
9
|
+
ConfiguredAirbyteCatalog,
|
10
|
+
ConfiguredAirbyteStream,
|
11
|
+
DestinationSyncMode,
|
12
|
+
SyncMode,
|
13
|
+
Type,
|
14
|
+
)
|
15
|
+
from airbyte_cdk.test import entrypoint_wrapper
|
16
|
+
from airbyte_cdk.test.standard_tests._job_runner import run_test_job
|
17
|
+
from airbyte_cdk.test.standard_tests.connector_base import (
|
18
|
+
ConnectorTestSuiteBase,
|
19
|
+
)
|
20
|
+
from airbyte_cdk.test.standard_tests.models import (
|
21
|
+
ConnectorTestScenario,
|
22
|
+
)
|
23
|
+
|
24
|
+
|
25
|
+
class SourceTestSuiteBase(ConnectorTestSuiteBase):
|
26
|
+
"""Base class for source test suites.
|
27
|
+
|
28
|
+
This class provides a base set of functionality for testing source connectors, and it
|
29
|
+
inherits all generic connector tests from the `ConnectorTestSuiteBase` class.
|
30
|
+
"""
|
31
|
+
|
32
|
+
def test_check(
|
33
|
+
self,
|
34
|
+
scenario: ConnectorTestScenario,
|
35
|
+
) -> None:
|
36
|
+
"""Run standard `check` tests on the connector.
|
37
|
+
|
38
|
+
Assert that the connector returns a single CONNECTION_STATUS message.
|
39
|
+
This test is designed to validate the connector's ability to establish a connection
|
40
|
+
and return its status with the expected message type.
|
41
|
+
"""
|
42
|
+
result: entrypoint_wrapper.EntrypointOutput = run_test_job(
|
43
|
+
self.create_connector(scenario),
|
44
|
+
"check",
|
45
|
+
test_scenario=scenario,
|
46
|
+
)
|
47
|
+
conn_status_messages: list[AirbyteMessage] = [
|
48
|
+
msg for msg in result._messages if msg.type == Type.CONNECTION_STATUS
|
49
|
+
] # noqa: SLF001 # Non-public API
|
50
|
+
num_status_messages = len(conn_status_messages)
|
51
|
+
assert num_status_messages == 1, (
|
52
|
+
f"Expected exactly one CONNECTION_STATUS message. Got {num_status_messages}: \n"
|
53
|
+
+ "\n".join([str(m) for m in result._messages])
|
54
|
+
)
|
55
|
+
|
56
|
+
def test_discover(
|
57
|
+
self,
|
58
|
+
scenario: ConnectorTestScenario,
|
59
|
+
) -> None:
|
60
|
+
"""Standard test for `discover`."""
|
61
|
+
run_test_job(
|
62
|
+
self.create_connector(scenario),
|
63
|
+
"discover",
|
64
|
+
test_scenario=scenario,
|
65
|
+
)
|
66
|
+
|
67
|
+
def test_basic_read(
|
68
|
+
self,
|
69
|
+
scenario: ConnectorTestScenario,
|
70
|
+
) -> None:
|
71
|
+
"""Run standard `read` test on the connector.
|
72
|
+
|
73
|
+
This test is designed to validate the connector's ability to read data
|
74
|
+
from the source and return records. It first runs a `discover` job to
|
75
|
+
obtain the catalog of streams, and then it runs a `read` job to fetch
|
76
|
+
records from those streams.
|
77
|
+
"""
|
78
|
+
discover_result = run_test_job(
|
79
|
+
self.create_connector(scenario),
|
80
|
+
"discover",
|
81
|
+
test_scenario=scenario,
|
82
|
+
)
|
83
|
+
if scenario.expect_exception:
|
84
|
+
assert discover_result.errors, "Expected exception but got none."
|
85
|
+
return
|
86
|
+
|
87
|
+
configured_catalog = ConfiguredAirbyteCatalog(
|
88
|
+
streams=[
|
89
|
+
ConfiguredAirbyteStream(
|
90
|
+
stream=stream,
|
91
|
+
sync_mode=SyncMode.full_refresh,
|
92
|
+
destination_sync_mode=DestinationSyncMode.append_dedup,
|
93
|
+
)
|
94
|
+
for stream in discover_result.catalog.catalog.streams # type: ignore [reportOptionalMemberAccess, union-attr]
|
95
|
+
]
|
96
|
+
)
|
97
|
+
result = run_test_job(
|
98
|
+
self.create_connector(scenario),
|
99
|
+
"read",
|
100
|
+
test_scenario=scenario,
|
101
|
+
catalog=configured_catalog,
|
102
|
+
)
|
103
|
+
|
104
|
+
if not result.records:
|
105
|
+
raise AssertionError("Expected records but got none.") # noqa: TRY003
|
106
|
+
|
107
|
+
def test_fail_read_with_bad_catalog(
|
108
|
+
self,
|
109
|
+
scenario: ConnectorTestScenario,
|
110
|
+
) -> None:
|
111
|
+
"""Standard test for `read` when passed a bad catalog file."""
|
112
|
+
invalid_configured_catalog = ConfiguredAirbyteCatalog(
|
113
|
+
streams=[
|
114
|
+
# Create ConfiguredAirbyteStream which is deliberately invalid
|
115
|
+
# with regard to the Airbyte Protocol.
|
116
|
+
# This should cause the connector to fail.
|
117
|
+
ConfiguredAirbyteStream(
|
118
|
+
stream=AirbyteStream(
|
119
|
+
name="__AIRBYTE__stream_that_does_not_exist",
|
120
|
+
json_schema={
|
121
|
+
"type": "object",
|
122
|
+
"properties": {"f1": {"type": "string"}},
|
123
|
+
},
|
124
|
+
supported_sync_modes=[SyncMode.full_refresh],
|
125
|
+
),
|
126
|
+
sync_mode="INVALID", # type: ignore [reportArgumentType]
|
127
|
+
destination_sync_mode="INVALID", # type: ignore [reportArgumentType]
|
128
|
+
)
|
129
|
+
]
|
130
|
+
)
|
131
|
+
# Set expected status to "failed" to ensure the test fails if the connector.
|
132
|
+
scenario.status = "failed"
|
133
|
+
result: entrypoint_wrapper.EntrypointOutput = run_test_job(
|
134
|
+
self.create_connector(scenario),
|
135
|
+
"read",
|
136
|
+
test_scenario=scenario,
|
137
|
+
catalog=asdict(invalid_configured_catalog),
|
138
|
+
)
|
139
|
+
assert result.errors, "Expected errors but got none."
|
140
|
+
assert result.trace_messages, "Expected trace messages but got none."
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: airbyte-cdk
|
3
|
-
Version: 6.45.4.
|
3
|
+
Version: 6.45.4.post51.dev14501768171
|
4
4
|
Summary: A framework for writing Airbyte Connectors.
|
5
5
|
Home-page: https://airbyte.com
|
6
6
|
License: MIT
|
@@ -26,6 +26,7 @@ Requires-Dist: airbyte-protocol-models-dataclasses (>=0.15,<0.16)
|
|
26
26
|
Requires-Dist: anyascii (>=0.3.2,<0.4.0)
|
27
27
|
Requires-Dist: avro (>=1.11.2,<1.13.0) ; extra == "file-based"
|
28
28
|
Requires-Dist: backoff
|
29
|
+
Requires-Dist: boltons (>=25.0.0,<26.0.0)
|
29
30
|
Requires-Dist: cachetools
|
30
31
|
Requires-Dist: cohere (==4.21) ; extra == "vector-db-based"
|
31
32
|
Requires-Dist: cryptography (>=44.0.0,<45.0.0)
|
@@ -338,13 +338,22 @@ airbyte_cdk/sql/shared/sql_processor.py,sha256=1CwfC3fp9dWnHBpKtly7vGduf9ho_Mahi
|
|
338
338
|
airbyte_cdk/sql/types.py,sha256=XEIhRAo_ASd0kVLBkdLf5bHiRhNple-IJrC9TibcDdY,5880
|
339
339
|
airbyte_cdk/test/__init__.py,sha256=f_XdkOg4_63QT2k3BbKY34209lppwgw-svzfZstQEq4,199
|
340
340
|
airbyte_cdk/test/catalog_builder.py,sha256=-y05Cz1x0Dlk6oE9LSKhCozssV2gYBNtMdV5YYOPOtk,3015
|
341
|
-
airbyte_cdk/test/entrypoint_wrapper.py,sha256=
|
341
|
+
airbyte_cdk/test/entrypoint_wrapper.py,sha256=TyUmVJyIuGelAv6y8Wy_BnwqIRw_drjfZWKlroljCuQ,9951
|
342
342
|
airbyte_cdk/test/mock_http/__init__.py,sha256=jE5kC6CQ0OXkTqKhciDnNVZHesBFVIA2YvkdFGwva7k,322
|
343
343
|
airbyte_cdk/test/mock_http/matcher.py,sha256=4Qj8UnJKZIs-eodshryce3SN1Ayc8GZpBETmP6hTEyc,1446
|
344
344
|
airbyte_cdk/test/mock_http/mocker.py,sha256=XgsjMtVoeMpRELPyALgrkHFauH9H5irxrz1Kcxh2yFY,8013
|
345
345
|
airbyte_cdk/test/mock_http/request.py,sha256=tdB8cqk2vLgCDTOKffBKsM06llYs4ZecgtH6DKyx6yY,4112
|
346
346
|
airbyte_cdk/test/mock_http/response.py,sha256=s4-cQQqTtmeej0pQDWqmG0vUWpHS-93lIWMpW3zSVyU,662
|
347
347
|
airbyte_cdk/test/mock_http/response_builder.py,sha256=F-v7ebftqGj7YVIMLKdodmU9U8Dq8aIyllWGo2NGwHc,8331
|
348
|
+
airbyte_cdk/test/standard_tests/__init__.py,sha256=YS2bghoGmQ-4GNIbe6RuEmvV-V1kpM1OyxTpebrs0Ig,1338
|
349
|
+
airbyte_cdk/test/standard_tests/_job_runner.py,sha256=d2JkwxJilYIJNmyVH946YMn8x1pnP3JaNT865V8vZzQ,5820
|
350
|
+
airbyte_cdk/test/standard_tests/connector_base.py,sha256=HGdDqLq8cCdBJ5T2s92PdN5miD2Vs_HczWOUbojAebY,5618
|
351
|
+
airbyte_cdk/test/standard_tests/declarative_sources.py,sha256=2L0PMBC7U7BxkwAAjlgvdqr_4K1PIwNxN7xNoSpoZg4,3176
|
352
|
+
airbyte_cdk/test/standard_tests/destination_base.py,sha256=MARZip2mdo_PzGvzf2VBTAfrP4tbjrJYgeJUApnAArA,731
|
353
|
+
airbyte_cdk/test/standard_tests/models/__init__.py,sha256=bS25WlzQwPNxpU5DHtUDZo1DuXd0LkEv9qesNhY1jkY,135
|
354
|
+
airbyte_cdk/test/standard_tests/models/scenario.py,sha256=loOfIeKWbzhqyY3JiLU-sE7RKDAsvz3SLpI9APeTPiw,2378
|
355
|
+
airbyte_cdk/test/standard_tests/pytest_hooks.py,sha256=4OMy2jNQThS8y7Tyj8MiMy2-SWjoefD4lGo-zQmCUfU,1886
|
356
|
+
airbyte_cdk/test/standard_tests/source_base.py,sha256=B30Jduz5n7Z8oM3y9RF6B5upTZN3am6rFod6mqgIizo,5235
|
348
357
|
airbyte_cdk/test/state_builder.py,sha256=kLPql9lNzUJaBg5YYRLJlY_Hy5JLHJDVyKPMZMoYM44,946
|
349
358
|
airbyte_cdk/test/utils/__init__.py,sha256=Hu-1XT2KDoYjDF7-_ziDwv5bY3PueGjANOCbzeOegDg,57
|
350
359
|
airbyte_cdk/test/utils/data.py,sha256=CkCR1_-rujWNmPXFR1IXTMwx1rAl06wAyIKWpDcN02w,820
|
@@ -368,9 +377,9 @@ airbyte_cdk/utils/slice_hasher.py,sha256=EDxgROHDbfG-QKQb59m7h_7crN1tRiawdf5uU7G
|
|
368
377
|
airbyte_cdk/utils/spec_schema_transformations.py,sha256=-5HTuNsnDBAhj-oLeQXwpTGA0HdcjFOf2zTEMUTTg_Y,816
|
369
378
|
airbyte_cdk/utils/stream_status_utils.py,sha256=ZmBoiy5HVbUEHAMrUONxZvxnvfV9CesmQJLDTAIWnWw,1171
|
370
379
|
airbyte_cdk/utils/traced_exception.py,sha256=C8uIBuCL_E4WnBAOPSxBicD06JAldoN9fGsQDp463OY,6292
|
371
|
-
airbyte_cdk-6.45.4.
|
372
|
-
airbyte_cdk-6.45.4.
|
373
|
-
airbyte_cdk-6.45.4.
|
374
|
-
airbyte_cdk-6.45.4.
|
375
|
-
airbyte_cdk-6.45.4.
|
376
|
-
airbyte_cdk-6.45.4.
|
380
|
+
airbyte_cdk-6.45.4.post51.dev14501768171.dist-info/LICENSE.txt,sha256=Wfe61S4BaGPj404v8lrAbvhjYR68SHlkzeYrg3_bbuM,1051
|
381
|
+
airbyte_cdk-6.45.4.post51.dev14501768171.dist-info/LICENSE_SHORT,sha256=aqF6D1NcESmpn-cqsxBtszTEnHKnlsp8L4x9wAh3Nxg,55
|
382
|
+
airbyte_cdk-6.45.4.post51.dev14501768171.dist-info/METADATA,sha256=87ZM59VLuUoL7G-eda_IX1KjibxE2LSuc6BRkbXqT0w,6135
|
383
|
+
airbyte_cdk-6.45.4.post51.dev14501768171.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
|
384
|
+
airbyte_cdk-6.45.4.post51.dev14501768171.dist-info/entry_points.txt,sha256=fj-e3PAQvsxsQzyyq8UkG1k8spunWnD4BAH2AwlR6NM,95
|
385
|
+
airbyte_cdk-6.45.4.post51.dev14501768171.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|