Skip to content

Commit f4c0779

Browse files
feat(check-stream): support configurable stream names (#1030)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 57110be commit f4c0779

2 files changed

Lines changed: 164 additions & 1 deletion

File tree

airbyte_cdk/sources/declarative/checks/check_stream.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
from airbyte_cdk.sources.streams.core import Stream
1414
from airbyte_cdk.sources.streams.http.availability_strategy import HttpAvailabilityStrategy
1515

16+
CHECK_STREAM_NAMES_CONFIG_KEY = "__airbyte_check_stream_names"
17+
1618

1719
def evaluate_availability(
1820
stream: Union[Stream, AbstractStream], logger: logging.Logger
@@ -70,6 +72,7 @@ def check_connection(
7072
config: Mapping[str, Any],
7173
) -> Tuple[bool, Any]:
7274
"""Checks the connection to the source and its streams."""
75+
stream_names = self._get_stream_names(config)
7376
try:
7477
streams: List[Union[Stream, AbstractStream]] = source.streams(config=config) # type: ignore # this is a migration step and we expect the declarative CDK to migrate off of ConnectionChecker
7578
if not streams:
@@ -78,7 +81,7 @@ def check_connection(
7881
return self._log_error(logger, "discovering streams", error)
7982

8083
stream_name_to_stream = {s.name: s for s in streams}
81-
for stream_name in self.stream_names:
84+
for stream_name in stream_names:
8285
if stream_name not in stream_name_to_stream:
8386
raise ValueError(
8487
f"{stream_name} is not part of the catalog. Expected one of {list(stream_name_to_stream.keys())}."
@@ -101,6 +104,19 @@ def check_connection(
101104

102105
return True, None
103106

107+
def _get_stream_names(self, config: Mapping[str, Any]) -> List[str]:
108+
if (
109+
CHECK_STREAM_NAMES_CONFIG_KEY not in config
110+
or config[CHECK_STREAM_NAMES_CONFIG_KEY] == []
111+
):
112+
return self.stream_names
113+
configured_stream_names = config[CHECK_STREAM_NAMES_CONFIG_KEY]
114+
if not isinstance(configured_stream_names, list) or not all(
115+
isinstance(stream_name, str) for stream_name in configured_stream_names
116+
):
117+
raise ValueError(f"{CHECK_STREAM_NAMES_CONFIG_KEY} must be a list of strings.")
118+
return configured_stream_names
119+
104120
def _check_stream_availability(
105121
self,
106122
stream_name_to_stream: Dict[str, Union[Stream, AbstractStream]],

unit_tests/sources/declarative/checks/test_check_stream.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,102 @@ def mock_read_records(responses, default_response=None, **kwargs):
7878
)
7979

8080

81+
def test_check_stream_names_can_be_overridden_from_config():
82+
static_stream = MagicMock(spec=Stream)
83+
static_stream.name = "static_stream"
84+
static_stream.availability_strategy = None
85+
selected_stream = MagicMock(spec=Stream)
86+
selected_stream.name = "selected_stream"
87+
selected_stream.availability_strategy = None
88+
selected_stream.read_records.return_value = iter([record])
89+
selected_stream.stream_slices.return_value = iter([{}])
90+
source = MagicMock()
91+
source.streams.return_value = [static_stream, selected_stream]
92+
93+
check_stream = CheckStream(["static_stream"], parameters={})
94+
95+
assert check_stream.check_connection(
96+
source, logger, {"__airbyte_check_stream_names": ["selected_stream"]}
97+
) == (True, None)
98+
static_stream.stream_slices.assert_not_called()
99+
100+
101+
def test_check_stream_names_override_empty_list_falls_back_to_manifest_streams():
102+
stream = MagicMock(spec=Stream)
103+
stream.name = "static_stream"
104+
stream.availability_strategy = None
105+
stream.read_records.return_value = iter([record])
106+
stream.stream_slices.return_value = iter([{}])
107+
source = MagicMock()
108+
source.streams.return_value = [stream]
109+
110+
check_stream = CheckStream(["static_stream"], parameters={})
111+
112+
assert check_stream.check_connection(source, logger, {"__airbyte_check_stream_names": []}) == (
113+
True,
114+
None,
115+
)
116+
stream.stream_slices.assert_called_once()
117+
118+
119+
@pytest.mark.parametrize("override", ["selected_stream", [1], ["selected_stream", 1], None])
120+
def test_check_stream_names_override_requires_list_of_strings(override):
121+
stream = MagicMock(spec=Stream)
122+
stream.name = "selected_stream"
123+
stream.availability_strategy = None
124+
source = MagicMock()
125+
source.streams.return_value = [stream]
126+
127+
check_stream = CheckStream(["selected_stream"], parameters={})
128+
129+
with pytest.raises(ValueError, match="__airbyte_check_stream_names must be a list of strings."):
130+
check_stream.check_connection(source, logger, {"__airbyte_check_stream_names": override})
131+
132+
133+
def test_check_stream_names_override_rejects_unknown_stream():
134+
stream = MagicMock(spec=Stream)
135+
stream.name = "selected_stream"
136+
stream.availability_strategy = None
137+
source = MagicMock()
138+
source.streams.return_value = [stream]
139+
140+
check_stream = CheckStream(["selected_stream"], parameters={})
141+
142+
with pytest.raises(ValueError, match="unknown_stream is not part of the catalog."):
143+
check_stream.check_connection(
144+
source, logger, {"__airbyte_check_stream_names": ["unknown_stream"]}
145+
)
146+
147+
148+
def test_check_stream_names_override_returns_unavailable_stream_message():
149+
stream = MagicMock(spec=Stream)
150+
stream.name = "selected_stream"
151+
stream.availability_strategy = None
152+
stream.stream_slices.return_value = iter([])
153+
source = MagicMock()
154+
source.streams.return_value = [stream]
155+
156+
check_stream = CheckStream(["other_stream"], parameters={})
157+
158+
stream_is_available, reason = check_stream.check_connection(
159+
source, logger, {"__airbyte_check_stream_names": ["selected_stream"]}
160+
)
161+
assert not stream_is_available
162+
assert "no stream slices were found, likely because the parent stream is empty" in reason
163+
164+
165+
def test_check_stream_names_override_validates_before_stream_discovery():
166+
source = MagicMock()
167+
check_stream = CheckStream(["selected_stream"], parameters={})
168+
169+
with pytest.raises(ValueError, match="__airbyte_check_stream_names must be a list of strings."):
170+
check_stream.check_connection(
171+
source, logger, {"__airbyte_check_stream_names": "selected_stream"}
172+
)
173+
174+
source.streams.assert_not_called()
175+
176+
81177
def test_check_empty_stream():
82178
stream = MagicMock(spec=Stream)
83179
stream.name = "s1"
@@ -700,6 +796,57 @@ def test_check_stream1(
700796
assert connection_status.status == expected_result
701797

702798

799+
def test_check_empty_static_stream_override_falls_back_to_manifest_streams_and_checks_dynamic_streams():
800+
manifest = {
801+
**deepcopy(_MANIFEST_WITHOUT_CHECK_COMPONENT),
802+
**{
803+
"check": {
804+
"type": "CheckStream",
805+
"stream_names": ["static_stream"],
806+
"dynamic_streams_check_configs": [
807+
{
808+
"type": "DynamicStreamCheckConfig",
809+
"dynamic_stream_name": "http_dynamic_stream",
810+
},
811+
],
812+
}
813+
},
814+
}
815+
check_config = {**_CONFIG, "__airbyte_check_stream_names": []}
816+
817+
with HttpMocker() as http_mocker:
818+
static_stream_request = HttpRequest(url="https://api.test.com/static")
819+
static_stream_response = HttpResponse(body=json.dumps([]), status_code=500)
820+
http_mocker.get(static_stream_request, static_stream_response)
821+
822+
items_request = HttpRequest(url="https://api.test.com/items")
823+
items_response = HttpResponse(
824+
body=json.dumps([{"id": 1, "name": "item_1"}, {"id": 2, "name": "item_2"}])
825+
)
826+
http_mocker.get(items_request, items_response)
827+
828+
item_request_1 = HttpRequest(url="https://api.test.com/items/1")
829+
item_response = HttpResponse(body=json.dumps([]), status_code=200)
830+
http_mocker.get(item_request_1, item_response)
831+
832+
item_request_2 = HttpRequest(url="https://api.test.com/items/2")
833+
item_response = HttpResponse(body=json.dumps([]), status_code=200)
834+
http_mocker.get(item_request_2, item_response)
835+
836+
source = ConcurrentDeclarativeSource(
837+
source_config=manifest,
838+
config=check_config,
839+
catalog=None,
840+
state=None,
841+
)
842+
843+
connection_status = source.check(logger, check_config)
844+
845+
http_mocker.assert_number_of_calls(static_stream_request, 6)
846+
http_mocker.assert_number_of_calls(item_request_2, 0)
847+
assert connection_status.status == Status.FAILED
848+
849+
703850
def test_check_stream_missing_fields():
704851
"""Test if ValueError is raised when dynamic_streams_check_configs is missing required fields."""
705852
manifest = {

0 commit comments

Comments
 (0)