Skip to content

Commit a8666b2

Browse files
feat(cli,mcp): add namespace support to destination smoke tests (#999)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 00ab7ca commit a8666b2

4 files changed

Lines changed: 150 additions & 6 deletions

File tree

airbyte/_util/destination_smoke_tests.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from __future__ import annotations
1414

1515
import time
16+
from datetime import datetime, timezone
1617
from pathlib import Path
1718
from typing import TYPE_CHECKING, Any
1819

@@ -23,11 +24,41 @@
2324
from airbyte.exceptions import PyAirbyteInputError
2425

2526

27+
NAMESPACE_PREFIX = "zz_deleteme"
28+
"""Prefix for auto-generated smoke test namespaces.
29+
30+
The ``zz_`` prefix sorts last alphabetically; ``deleteme`` signals the
31+
namespace is safe for automated cleanup.
32+
"""
33+
34+
DEFAULT_NAMESPACE_SUFFIX = "smoke_test"
35+
"""Default suffix appended when no explicit suffix is provided."""
36+
37+
2638
if TYPE_CHECKING:
2739
from airbyte.destinations.base import Destination
2840
from airbyte.sources.base import Source
2941

3042

43+
def generate_namespace(
44+
*,
45+
namespace_suffix: str | None = None,
46+
) -> str:
47+
"""Generate a smoke-test namespace.
48+
49+
Format: ``zz_deleteme_yyyymmdd_hhmm_<suffix>``.
50+
The ``zz_`` prefix sorts last alphabetically and the ``deleteme``
51+
token acts as a guard for automated cleanup scripts.
52+
53+
If *namespace_suffix* is not provided, ``smoke_test`` is used as the
54+
default suffix.
55+
"""
56+
suffix = namespace_suffix or DEFAULT_NAMESPACE_SUFFIX
57+
now = datetime.now(tz=timezone.utc)
58+
ts = now.strftime("%Y%m%d_%H%M")
59+
return f"{NAMESPACE_PREFIX}_{ts}_{suffix}"
60+
61+
3162
class DestinationSmokeTestResult(BaseModel):
3263
"""Result of a destination smoke test run."""
3364

@@ -37,6 +68,9 @@ class DestinationSmokeTestResult(BaseModel):
3768
destination: str
3869
"""The destination connector name."""
3970

71+
namespace: str
72+
"""The namespace used for this smoke test run."""
73+
4074
records_delivered: int
4175
"""Total number of records delivered to the destination."""
4276

@@ -53,6 +87,7 @@ class DestinationSmokeTestResult(BaseModel):
5387
def get_smoke_test_source(
5488
*,
5589
scenarios: str | list[str] = "fast",
90+
namespace: str | None = None,
5691
custom_scenarios: list[dict[str, Any]] | None = None,
5792
custom_scenarios_file: str | None = None,
5893
) -> Source:
@@ -70,6 +105,9 @@ def get_smoke_test_source(
70105
71106
`custom_scenarios` is an optional list of scenario dicts to inject directly.
72107
108+
`namespace` is an optional namespace to set on all streams. When provided,
109+
the destination will write data into this namespace (schema, database, etc.).
110+
73111
`custom_scenarios_file` is an optional path to a JSON or YAML file containing
74112
additional scenario definitions. Each scenario should have `name`, `json_schema`,
75113
and optionally `records` and `primary_key`.
@@ -134,6 +172,9 @@ def get_smoke_test_source(
134172
existing = source_config.get("custom_scenarios", [])
135173
source_config["custom_scenarios"] = existing + file_scenarios
136174

175+
if namespace:
176+
source_config["namespace"] = namespace
177+
137178
return get_source(
138179
name="source-smoke-test",
139180
config=source_config,
@@ -157,6 +198,8 @@ def run_destination_smoke_test(
157198
*,
158199
destination: Destination,
159200
scenarios: str | list[str] = "fast",
201+
namespace_suffix: str | None = None,
202+
reuse_namespace: str | None = None,
160203
custom_scenarios: list[dict[str, Any]] | None = None,
161204
custom_scenarios_file: str | None = None,
162205
) -> DestinationSmokeTestResult:
@@ -177,13 +220,26 @@ def run_destination_smoke_test(
177220
- `'all'`: runs every scenario including `large_batch_stream`.
178221
- A comma-separated string or list of specific scenario names.
179222
223+
`namespace_suffix` is an optional suffix appended to the auto-generated
224+
namespace. Defaults to ``smoke_test`` when not provided
225+
(e.g. ``zz_deleteme_20260318_2256_smoke_test``).
226+
227+
`reuse_namespace` is an exact namespace string to reuse from a previous
228+
run. When set, no new namespace is generated.
229+
180230
`custom_scenarios` is an optional list of scenario dicts to inject.
181231
182232
`custom_scenarios_file` is an optional path to a JSON/YAML file with
183233
additional scenario definitions.
184234
"""
235+
# Determine namespace
236+
namespace = reuse_namespace or generate_namespace(
237+
namespace_suffix=namespace_suffix,
238+
)
239+
185240
source_obj = get_smoke_test_source(
186241
scenarios=scenarios,
242+
namespace=namespace,
187243
custom_scenarios=custom_scenarios,
188244
custom_scenarios_file=custom_scenarios_file,
189245
)
@@ -214,6 +270,7 @@ def run_destination_smoke_test(
214270
return DestinationSmokeTestResult(
215271
success=success,
216272
destination=destination.name,
273+
namespace=namespace,
217274
records_delivered=records_delivered,
218275
scenarios_requested=scenarios_str,
219276
elapsed_seconds=round(elapsed, 2),

airbyte/cli/pyab.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,26 @@ def sync(
690690
"and 'primary_key'. These are unioned with the predefined scenarios."
691691
),
692692
)
693+
@click.option(
694+
"--namespace-suffix",
695+
type=str,
696+
default=None,
697+
help=(
698+
"Optional suffix appended to the auto-generated namespace. "
699+
"Defaults to 'smoke_test' (format: 'zz_deleteme_yyyymmdd_hhmm_{suffix}'). "
700+
"Use this to distinguish concurrent runs."
701+
),
702+
)
703+
@click.option(
704+
"--reuse-namespace",
705+
type=str,
706+
default=None,
707+
help=(
708+
"Exact namespace to reuse from a previous run. "
709+
"When set, no new namespace is generated. "
710+
"Useful for running a second test against an already-populated namespace."
711+
),
712+
)
693713
def destination_smoke_test(
694714
*,
695715
destination: str,
@@ -698,6 +718,8 @@ def destination_smoke_test(
698718
use_python: str | None = None,
699719
scenarios: str = "fast",
700720
custom_scenarios: str | None = None,
721+
namespace_suffix: str | None = None,
722+
reuse_namespace: str | None = None,
701723
) -> None:
702724
"""Run smoke tests against a destination connector.
703725
@@ -723,6 +745,13 @@ def destination_smoke_test(
723745
724746
`pyab destination-smoke-test --destination=destination-snowflake
725747
--config=./secrets/snowflake.json --scenarios=all`
748+
749+
`pyab destination-smoke-test --destination=destination-snowflake
750+
--config=./secrets/snowflake.json --namespace-suffix=run2`
751+
752+
`pyab destination-smoke-test --destination=destination-snowflake
753+
--config=./secrets/snowflake.json
754+
--reuse-namespace=zz_deleteme_20260318_2256`
726755
"""
727756
click.echo("Resolving destination...", file=sys.stderr)
728757
destination_obj = _resolve_destination_job(
@@ -736,6 +765,8 @@ def destination_smoke_test(
736765
result = run_destination_smoke_test(
737766
destination=destination_obj,
738767
scenarios=scenarios,
768+
namespace_suffix=namespace_suffix,
769+
reuse_namespace=reuse_namespace,
739770
custom_scenarios_file=custom_scenarios,
740771
)
741772

airbyte/cli/smoke_test_source/source.py

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,13 @@
5454

5555
def _build_streams_from_scenarios(
5656
scenarios: list[dict[str, Any]],
57+
namespace: str | None = None,
5758
) -> list[AirbyteStream]:
5859
"""Build AirbyteStream objects from scenario definitions."""
5960
return [
6061
AirbyteStream(
6162
name=scenario["name"],
63+
namespace=namespace,
6264
json_schema=scenario["json_schema"],
6365
supported_sync_modes=[SyncMode.full_refresh],
6466
source_defined_cursor=False,
@@ -174,6 +176,16 @@ def spec(
174176
"items": {"type": "string"},
175177
"default": [],
176178
},
179+
"namespace": {
180+
"type": ["string", "null"],
181+
"title": "Namespace",
182+
"description": (
183+
"Namespace (schema/database) to set on all "
184+
"streams. When provided, the destination will "
185+
"write data into this namespace."
186+
),
187+
"default": None,
188+
},
177189
},
178190
},
179191
)
@@ -320,14 +332,16 @@ def discover(
320332
) -> AirbyteCatalog:
321333
"""Return the catalog with all test scenario streams."""
322334
scenarios = self._get_all_scenarios(config)
323-
streams = _build_streams_from_scenarios(scenarios)
335+
namespace = config.get("namespace")
336+
streams = _build_streams_from_scenarios(scenarios, namespace=namespace)
324337
logger.info(f"Discovered {len(streams)} smoke test streams.")
325338
return AirbyteCatalog(streams=streams)
326339

327340
def _stream_status_message(
328341
self,
329342
stream_name: str,
330343
status: AirbyteStreamStatus,
344+
namespace: str | None = None,
331345
) -> AirbyteMessage:
332346
"""Build an AirbyteMessage containing a stream status trace."""
333347
return AirbyteMessage(
@@ -336,7 +350,10 @@ def _stream_status_message(
336350
type=TraceType.STREAM_STATUS,
337351
emitted_at=time.time() * 1000,
338352
stream_status=AirbyteStreamStatusTraceMessage(
339-
stream_descriptor=StreamDescriptor(name=stream_name),
353+
stream_descriptor=StreamDescriptor(
354+
name=stream_name,
355+
namespace=namespace,
356+
),
340357
status=status,
341358
),
342359
),
@@ -355,15 +372,25 @@ def read(
355372
scenario_map = {s["name"]: s for s in scenarios}
356373
now_ms = int(time.time() * 1000)
357374

375+
namespace = config.get("namespace")
376+
358377
for stream_name in selected_streams:
359378
scenario = scenario_map.get(stream_name)
360379
if not scenario:
361380
logger.warning(f"Stream '{stream_name}' not found in scenarios, skipping.")
362381
continue
363382

364383
# Emit STARTED status
365-
yield self._stream_status_message(stream_name, AirbyteStreamStatus.STARTED)
366-
yield self._stream_status_message(stream_name, AirbyteStreamStatus.RUNNING)
384+
yield self._stream_status_message(
385+
stream_name,
386+
AirbyteStreamStatus.STARTED,
387+
namespace=namespace,
388+
)
389+
yield self._stream_status_message(
390+
stream_name,
391+
AirbyteStreamStatus.RUNNING,
392+
namespace=namespace,
393+
)
367394

368395
records = get_scenario_records(scenario)
369396
logger.info(f"Emitting {len(records)} records for stream '{stream_name}'.")
@@ -373,10 +400,15 @@ def read(
373400
type=Type.RECORD,
374401
record=AirbyteRecordMessage(
375402
stream=stream_name,
403+
namespace=namespace,
376404
data=record,
377405
emitted_at=now_ms,
378406
),
379407
)
380408

381409
# Emit COMPLETE status
382-
yield self._stream_status_message(stream_name, AirbyteStreamStatus.COMPLETE)
410+
yield self._stream_status_message(
411+
stream_name,
412+
AirbyteStreamStatus.COMPLETE,
413+
namespace=namespace,
414+
)

airbyte/mcp/local.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -812,7 +812,7 @@ def run_sql_query(
812812
@mcp_tool(
813813
destructive=True,
814814
)
815-
def destination_smoke_test(
815+
def destination_smoke_test( # noqa: PLR0913, PLR0917
816816
destination_connector_name: Annotated[
817817
str,
818818
Field(
@@ -879,6 +879,28 @@ def destination_smoke_test(
879879
default=None,
880880
),
881881
],
882+
namespace_suffix: Annotated[
883+
str | None,
884+
Field(
885+
description=(
886+
"Optional suffix appended to the auto-generated namespace. "
887+
"Defaults to 'smoke_test' (format: 'zz_deleteme_yyyymmdd_hhmm_{suffix}'). "
888+
"Use this to distinguish concurrent runs."
889+
),
890+
default=None,
891+
),
892+
],
893+
reuse_namespace: Annotated[
894+
str | None,
895+
Field(
896+
description=(
897+
"Exact namespace to reuse from a previous run. "
898+
"When set, no new namespace is generated. "
899+
"Useful for running a second test against an already-populated namespace."
900+
),
901+
default=None,
902+
),
903+
],
882904
) -> DestinationSmokeTestResult:
883905
"""Run smoke tests against a destination connector.
884906
@@ -920,6 +942,8 @@ def destination_smoke_test(
920942
return run_destination_smoke_test(
921943
destination=destination_obj,
922944
scenarios=resolved_scenarios,
945+
namespace_suffix=namespace_suffix,
946+
reuse_namespace=reuse_namespace,
923947
custom_scenarios=custom_scenarios,
924948
)
925949

0 commit comments

Comments
 (0)