airbyte-cdk 6.45.4.post49.dev14495925594__py3-none-any.whl → 6.45.4.post72.dev14497997772__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/models/__init__.py +0 -1
- airbyte_cdk/models/airbyte_protocol.py +3 -1
- airbyte_cdk/models/file_transfer_record_message.py +13 -0
- airbyte_cdk/sources/concurrent_source/concurrent_read_processor.py +1 -1
- airbyte_cdk/sources/declarative/concurrent_declarative_source.py +0 -8
- airbyte_cdk/sources/declarative/declarative_component_schema.yaml +0 -36
- airbyte_cdk/sources/declarative/extractors/record_selector.py +1 -6
- airbyte_cdk/sources/declarative/models/declarative_component_schema.py +0 -31
- airbyte_cdk/sources/declarative/parsers/model_to_component_factory.py +1 -39
- airbyte_cdk/sources/declarative/stream_slicers/declarative_partition_generator.py +4 -9
- airbyte_cdk/sources/file_based/file_based_stream_reader.py +16 -38
- airbyte_cdk/sources/file_based/file_types/file_transfer.py +15 -8
- airbyte_cdk/sources/file_based/schema_helpers.py +1 -11
- airbyte_cdk/sources/file_based/stream/concurrent/adapters.py +12 -3
- airbyte_cdk/sources/file_based/stream/default_file_based_stream.py +38 -15
- airbyte_cdk/sources/file_based/stream/permissions_file_based_stream.py +3 -1
- airbyte_cdk/sources/streams/concurrent/default_stream.py +0 -3
- airbyte_cdk/sources/types.py +2 -11
- airbyte_cdk/sources/utils/record_helper.py +8 -8
- airbyte_cdk/test/declarative/__init__.py +6 -0
- airbyte_cdk/test/declarative/models/__init__.py +7 -0
- airbyte_cdk/test/declarative/models/scenario.py +74 -0
- airbyte_cdk/test/declarative/utils/__init__.py +0 -0
- airbyte_cdk/test/declarative/utils/job_runner.py +159 -0
- airbyte_cdk/test/entrypoint_wrapper.py +4 -0
- airbyte_cdk/test/mock_http/response_builder.py +0 -8
- airbyte_cdk/test/standard_tests/__init__.py +46 -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/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.post72.dev14497997772.dist-info}/METADATA +3 -2
- {airbyte_cdk-6.45.4.post49.dev14495925594.dist-info → airbyte_cdk-6.45.4.post72.dev14497997772.dist-info}/RECORD +38 -29
- airbyte_cdk/sources/declarative/retrievers/file_uploader.py +0 -89
- airbyte_cdk/sources/file_based/file_record_data.py +0 -23
- airbyte_cdk/sources/utils/files_directory.py +0 -15
- {airbyte_cdk-6.45.4.post49.dev14495925594.dist-info → airbyte_cdk-6.45.4.post72.dev14497997772.dist-info}/LICENSE.txt +0 -0
- {airbyte_cdk-6.45.4.post49.dev14495925594.dist-info → airbyte_cdk-6.45.4.post72.dev14497997772.dist-info}/LICENSE_SHORT +0 -0
- {airbyte_cdk-6.45.4.post49.dev14495925594.dist-info → airbyte_cdk-6.45.4.post72.dev14497997772.dist-info}/WHEEL +0 -0
- {airbyte_cdk-6.45.4.post49.dev14495925594.dist-info → airbyte_cdk-6.45.4.post72.dev14497997772.dist-info}/entry_points.txt +0 -0
@@ -11,7 +11,7 @@ from functools import cache
|
|
11
11
|
from os import path
|
12
12
|
from typing import Any, Dict, Iterable, List, Mapping, MutableMapping, Optional, Set, Tuple, Union
|
13
13
|
|
14
|
-
from airbyte_cdk.models import AirbyteLogMessage, AirbyteMessage,
|
14
|
+
from airbyte_cdk.models import AirbyteLogMessage, AirbyteMessage, FailureType, Level
|
15
15
|
from airbyte_cdk.models import Type as MessageType
|
16
16
|
from airbyte_cdk.sources.file_based.config.file_based_stream_config import PrimaryKeyType
|
17
17
|
from airbyte_cdk.sources.file_based.exceptions import (
|
@@ -56,7 +56,6 @@ class DefaultFileBasedStream(AbstractFileBasedStream, IncrementalMixin):
|
|
56
56
|
airbyte_columns = [ab_last_mod_col, ab_file_name_col]
|
57
57
|
use_file_transfer = False
|
58
58
|
preserve_directory_structure = True
|
59
|
-
_file_transfer = FileTransfer()
|
60
59
|
|
61
60
|
def __init__(self, **kwargs: Any):
|
62
61
|
if self.FILE_TRANSFER_KW in kwargs:
|
@@ -94,6 +93,21 @@ class DefaultFileBasedStream(AbstractFileBasedStream, IncrementalMixin):
|
|
94
93
|
self.config
|
95
94
|
)
|
96
95
|
|
96
|
+
def _filter_schema_invalid_properties(
|
97
|
+
self, configured_catalog_json_schema: Dict[str, Any]
|
98
|
+
) -> Dict[str, Any]:
|
99
|
+
if self.use_file_transfer:
|
100
|
+
return {
|
101
|
+
"type": "object",
|
102
|
+
"properties": {
|
103
|
+
"file_path": {"type": "string"},
|
104
|
+
"file_size": {"type": "string"},
|
105
|
+
self.ab_file_name_col: {"type": "string"},
|
106
|
+
},
|
107
|
+
}
|
108
|
+
else:
|
109
|
+
return super()._filter_schema_invalid_properties(configured_catalog_json_schema)
|
110
|
+
|
97
111
|
def _duplicated_files_names(
|
98
112
|
self, slices: List[dict[str, List[RemoteFile]]]
|
99
113
|
) -> List[dict[str, List[str]]]:
|
@@ -131,6 +145,14 @@ class DefaultFileBasedStream(AbstractFileBasedStream, IncrementalMixin):
|
|
131
145
|
record[self.ab_file_name_col] = file.uri
|
132
146
|
return record
|
133
147
|
|
148
|
+
def transform_record_for_file_transfer(
|
149
|
+
self, record: dict[str, Any], file: RemoteFile
|
150
|
+
) -> dict[str, Any]:
|
151
|
+
# timstamp() returns a float representing the number of seconds since the unix epoch
|
152
|
+
record[self.modified] = int(file.last_modified.timestamp()) * 1000
|
153
|
+
record[self.source_file_url] = file.uri
|
154
|
+
return record
|
155
|
+
|
134
156
|
def read_records_from_slice(self, stream_slice: StreamSlice) -> Iterable[AirbyteMessage]:
|
135
157
|
"""
|
136
158
|
Yield all records from all remote files in `list_files_for_this_sync`.
|
@@ -151,13 +173,19 @@ class DefaultFileBasedStream(AbstractFileBasedStream, IncrementalMixin):
|
|
151
173
|
|
152
174
|
try:
|
153
175
|
if self.use_file_transfer:
|
154
|
-
|
155
|
-
|
176
|
+
self.logger.info(f"{self.name}: {file} file-based syncing")
|
177
|
+
# todo: complete here the code to not rely on local parser
|
178
|
+
file_transfer = FileTransfer()
|
179
|
+
for record in file_transfer.get_file(
|
180
|
+
self.config, file, self.stream_reader, self.logger
|
156
181
|
):
|
182
|
+
line_no += 1
|
183
|
+
if not self.record_passes_validation_policy(record):
|
184
|
+
n_skipped += 1
|
185
|
+
continue
|
186
|
+
record = self.transform_record_for_file_transfer(record, file)
|
157
187
|
yield stream_data_to_airbyte_message(
|
158
|
-
self.name,
|
159
|
-
file_record_data.dict(exclude_none=True),
|
160
|
-
file_reference=file_reference,
|
188
|
+
self.name, record, is_file_transfer_message=True
|
161
189
|
)
|
162
190
|
else:
|
163
191
|
for record in parser.parse_records(
|
@@ -231,8 +259,6 @@ class DefaultFileBasedStream(AbstractFileBasedStream, IncrementalMixin):
|
|
231
259
|
|
232
260
|
@cache
|
233
261
|
def get_json_schema(self) -> JsonSchema:
|
234
|
-
if self.use_file_transfer:
|
235
|
-
return file_transfer_schema
|
236
262
|
extra_fields = {
|
237
263
|
self.ab_last_mod_col: {"type": "string"},
|
238
264
|
self.ab_file_name_col: {"type": "string"},
|
@@ -256,7 +282,9 @@ class DefaultFileBasedStream(AbstractFileBasedStream, IncrementalMixin):
|
|
256
282
|
return {"type": "object", "properties": {**extra_fields, **schema["properties"]}}
|
257
283
|
|
258
284
|
def _get_raw_json_schema(self) -> JsonSchema:
|
259
|
-
if self.
|
285
|
+
if self.use_file_transfer:
|
286
|
+
return file_transfer_schema
|
287
|
+
elif self.config.input_schema:
|
260
288
|
return self.config.get_input_schema() # type: ignore
|
261
289
|
elif self.config.schemaless:
|
262
290
|
return schemaless_schema
|
@@ -313,11 +341,6 @@ class DefaultFileBasedStream(AbstractFileBasedStream, IncrementalMixin):
|
|
313
341
|
self.config.globs or [], self.config.legacy_prefix, self.logger
|
314
342
|
)
|
315
343
|
|
316
|
-
def as_airbyte_stream(self) -> AirbyteStream:
|
317
|
-
file_stream = super().as_airbyte_stream()
|
318
|
-
file_stream.is_file_based = self.use_file_transfer
|
319
|
-
return file_stream
|
320
|
-
|
321
344
|
def infer_schema(self, files: List[RemoteFile]) -> Mapping[str, Any]:
|
322
345
|
loop = asyncio.get_event_loop()
|
323
346
|
schema = loop.run_until_complete(self._infer_schema(files))
|
@@ -61,7 +61,9 @@ class PermissionsFileBasedStream(DefaultFileBasedStream):
|
|
61
61
|
permissions_record = self.transform_record(
|
62
62
|
permissions_record, file, file_datetime_string
|
63
63
|
)
|
64
|
-
yield stream_data_to_airbyte_message(
|
64
|
+
yield stream_data_to_airbyte_message(
|
65
|
+
self.name, permissions_record, is_file_transfer_message=False
|
66
|
+
)
|
65
67
|
except Exception as e:
|
66
68
|
self.logger.error(f"Failed to retrieve permissions for file {file.uri}: {str(e)}")
|
67
69
|
yield AirbyteMessage(
|
@@ -29,7 +29,6 @@ class DefaultStream(AbstractStream):
|
|
29
29
|
logger: Logger,
|
30
30
|
cursor: Cursor,
|
31
31
|
namespace: Optional[str] = None,
|
32
|
-
supports_file_transfer: bool = False,
|
33
32
|
) -> None:
|
34
33
|
self._stream_partition_generator = partition_generator
|
35
34
|
self._name = name
|
@@ -40,7 +39,6 @@ class DefaultStream(AbstractStream):
|
|
40
39
|
self._logger = logger
|
41
40
|
self._cursor = cursor
|
42
41
|
self._namespace = namespace
|
43
|
-
self._supports_file_transfer = supports_file_transfer
|
44
42
|
|
45
43
|
def generate_partitions(self) -> Iterable[Partition]:
|
46
44
|
yield from self._stream_partition_generator.generate()
|
@@ -70,7 +68,6 @@ class DefaultStream(AbstractStream):
|
|
70
68
|
json_schema=dict(self._json_schema),
|
71
69
|
supported_sync_modes=[SyncMode.full_refresh],
|
72
70
|
is_resumable=False,
|
73
|
-
is_file_based=self._supports_file_transfer,
|
74
71
|
)
|
75
72
|
|
76
73
|
if self._namespace:
|
airbyte_cdk/sources/types.py
CHANGED
@@ -6,7 +6,6 @@ from __future__ import annotations
|
|
6
6
|
|
7
7
|
from typing import Any, ItemsView, Iterator, KeysView, List, Mapping, Optional, ValuesView
|
8
8
|
|
9
|
-
from airbyte_cdk.models import AirbyteRecordMessageFileReference
|
10
9
|
from airbyte_cdk.utils.slice_hasher import SliceHasher
|
11
10
|
|
12
11
|
# A FieldPointer designates a path to a field inside a mapping. For example, retrieving ["k1", "k1.2"] in the object {"k1" :{"k1.2":
|
@@ -24,12 +23,12 @@ class Record(Mapping[str, Any]):
|
|
24
23
|
data: Mapping[str, Any],
|
25
24
|
stream_name: str,
|
26
25
|
associated_slice: Optional[StreamSlice] = None,
|
27
|
-
|
26
|
+
is_file_transfer_message: bool = False,
|
28
27
|
):
|
29
28
|
self._data = data
|
30
29
|
self._associated_slice = associated_slice
|
31
30
|
self.stream_name = stream_name
|
32
|
-
self.
|
31
|
+
self.is_file_transfer_message = is_file_transfer_message
|
33
32
|
|
34
33
|
@property
|
35
34
|
def data(self) -> Mapping[str, Any]:
|
@@ -39,14 +38,6 @@ class Record(Mapping[str, Any]):
|
|
39
38
|
def associated_slice(self) -> Optional[StreamSlice]:
|
40
39
|
return self._associated_slice
|
41
40
|
|
42
|
-
@property
|
43
|
-
def file_reference(self) -> AirbyteRecordMessageFileReference:
|
44
|
-
return self._file_reference
|
45
|
-
|
46
|
-
@file_reference.setter
|
47
|
-
def file_reference(self, value: AirbyteRecordMessageFileReference) -> None:
|
48
|
-
self._file_reference = value
|
49
|
-
|
50
41
|
def __repr__(self) -> str:
|
51
42
|
return repr(self._data)
|
52
43
|
|
@@ -9,10 +9,10 @@ from airbyte_cdk.models import (
|
|
9
9
|
AirbyteLogMessage,
|
10
10
|
AirbyteMessage,
|
11
11
|
AirbyteRecordMessage,
|
12
|
-
AirbyteRecordMessageFileReference,
|
13
12
|
AirbyteTraceMessage,
|
14
13
|
)
|
15
14
|
from airbyte_cdk.models import Type as MessageType
|
15
|
+
from airbyte_cdk.models.file_transfer_record_message import AirbyteFileTransferRecordMessage
|
16
16
|
from airbyte_cdk.sources.streams.core import StreamData
|
17
17
|
from airbyte_cdk.sources.utils.transform import TransformConfig, TypeTransformer
|
18
18
|
|
@@ -22,7 +22,7 @@ def stream_data_to_airbyte_message(
|
|
22
22
|
data_or_message: StreamData,
|
23
23
|
transformer: TypeTransformer = TypeTransformer(TransformConfig.NoTransform),
|
24
24
|
schema: Optional[Mapping[str, Any]] = None,
|
25
|
-
|
25
|
+
is_file_transfer_message: bool = False,
|
26
26
|
) -> AirbyteMessage:
|
27
27
|
if schema is None:
|
28
28
|
schema = {}
|
@@ -36,12 +36,12 @@ def stream_data_to_airbyte_message(
|
|
36
36
|
# taken unless configured. See
|
37
37
|
# docs/connector-development/cdk-python/schemas.md for details.
|
38
38
|
transformer.transform(data, schema)
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
39
|
+
if is_file_transfer_message:
|
40
|
+
message = AirbyteFileTransferRecordMessage(
|
41
|
+
stream=stream_name, file=data, emitted_at=now_millis, data={}
|
42
|
+
)
|
43
|
+
else:
|
44
|
+
message = AirbyteRecordMessage(stream=stream_name, data=data, emitted_at=now_millis)
|
45
45
|
return AirbyteMessage(type=MessageType.RECORD, record=message)
|
46
46
|
case AirbyteTraceMessage():
|
47
47
|
return AirbyteMessage(type=MessageType.TRACE, trace=data_or_message)
|
@@ -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"
|
File without changes
|
@@ -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.declarative.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
|
@@ -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])
|
@@ -198,14 +198,6 @@ def find_template(resource: str, execution_folder: str) -> Dict[str, Any]:
|
|
198
198
|
return json.load(template_file) # type: ignore # we assume the dev correctly set up the resource file
|
199
199
|
|
200
200
|
|
201
|
-
def find_binary_response(resource: str, execution_folder: str) -> bytes:
|
202
|
-
response_filepath = str(
|
203
|
-
get_unit_test_folder(execution_folder) / "resource" / "http" / "response" / f"{resource}"
|
204
|
-
)
|
205
|
-
with open(response_filepath, "rb") as response_file:
|
206
|
-
return response_file.read() # type: ignore # we assume the dev correctly set up the resource file
|
207
|
-
|
208
|
-
|
209
201
|
def create_record_builder(
|
210
202
|
response_template: Dict[str, Any],
|
211
203
|
records_path: Union[FieldPath, NestedPath],
|
@@ -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
|
+
]
|