Skip to content

Commit d458e8f

Browse files
ChristoGrabCopilot
andauthored
feat(sdm-cli): add new args --manifest-path and --components-path to SDM CLI (#556)
Co-authored-by: Copilot <[email protected]>
1 parent 0d05b2e commit d458e8f

File tree

7 files changed

+250
-24
lines changed

7 files changed

+250
-24
lines changed

airbyte_cdk/cli/source_declarative_manifest/_run.py

Lines changed: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,17 @@
1616

1717
from __future__ import annotations
1818

19+
import argparse
1920
import json
2021
import pkgutil
2122
import sys
2223
import traceback
23-
from collections.abc import Mapping
24+
from collections.abc import MutableMapping
2425
from pathlib import Path
2526
from typing import Any, cast
2627

2728
import orjson
29+
import yaml
2830

2931
from airbyte_cdk.entrypoint import AirbyteEntrypoint, launch
3032
from airbyte_cdk.models import (
@@ -54,7 +56,7 @@ class SourceLocalYaml(YamlDeclarativeSource):
5456
def __init__(
5557
self,
5658
catalog: ConfiguredAirbyteCatalog | None,
57-
config: Mapping[str, Any] | None,
59+
config: MutableMapping[str, Any] | None,
5860
state: TState,
5961
**kwargs: Any,
6062
) -> None:
@@ -91,7 +93,8 @@ def handle_command(args: list[str]) -> None:
9193

9294
def _get_local_yaml_source(args: list[str]) -> SourceLocalYaml:
9395
try:
94-
config, catalog, state = _parse_inputs_into_config_catalog_state(args)
96+
parsed_args = AirbyteEntrypoint.parse_args(args)
97+
config, catalog, state = _parse_inputs_into_config_catalog_state(parsed_args)
9598
return SourceLocalYaml(config=config, catalog=catalog, state=state)
9699
except Exception as error:
97100
print(
@@ -162,21 +165,40 @@ def create_declarative_source(
162165
connector builder.
163166
"""
164167
try:
165-
config: Mapping[str, Any] | None
168+
config: MutableMapping[str, Any] | None
166169
catalog: ConfiguredAirbyteCatalog | None
167170
state: list[AirbyteStateMessage]
168-
config, catalog, state = _parse_inputs_into_config_catalog_state(args)
169-
if config is None or "__injected_declarative_manifest" not in config:
171+
172+
parsed_args = AirbyteEntrypoint.parse_args(args)
173+
config, catalog, state = _parse_inputs_into_config_catalog_state(parsed_args)
174+
175+
if config is None:
176+
raise ValueError(
177+
"Invalid config: `__injected_declarative_manifest` should be provided at the root "
178+
"of the config or using the --manifest-path argument."
179+
)
180+
181+
# If a manifest_path is provided in the args, inject it into the config
182+
if hasattr(parsed_args, "manifest_path") and parsed_args.manifest_path:
183+
injected_manifest = _parse_manifest_from_file(parsed_args.manifest_path)
184+
if injected_manifest:
185+
config["__injected_declarative_manifest"] = injected_manifest
186+
187+
if "__injected_declarative_manifest" not in config:
170188
raise ValueError(
171189
"Invalid config: `__injected_declarative_manifest` should be provided at the root "
172-
f"of the config but config only has keys: {list(config.keys() if config else [])}"
190+
"of the config or using the --manifest-path argument. "
191+
f"Config only has keys: {list(config.keys() if config else [])}"
173192
)
174193
if not isinstance(config["__injected_declarative_manifest"], dict):
175194
raise ValueError(
176195
"Invalid config: `__injected_declarative_manifest` should be a dictionary, "
177196
f"but got type: {type(config['__injected_declarative_manifest'])}"
178197
)
179198

199+
if hasattr(parsed_args, "components_path") and parsed_args.components_path:
200+
_register_components_from_file(parsed_args.components_path)
201+
180202
return ConcurrentDeclarativeSource(
181203
config=config,
182204
catalog=catalog,
@@ -205,13 +227,12 @@ def create_declarative_source(
205227

206228

207229
def _parse_inputs_into_config_catalog_state(
208-
args: list[str],
230+
parsed_args: argparse.Namespace,
209231
) -> tuple[
210-
Mapping[str, Any] | None,
232+
MutableMapping[str, Any] | None,
211233
ConfiguredAirbyteCatalog | None,
212234
list[AirbyteStateMessage],
213235
]:
214-
parsed_args = AirbyteEntrypoint.parse_args(args)
215236
config = (
216237
ConcurrentDeclarativeSource.read_config(parsed_args.config)
217238
if hasattr(parsed_args, "config")
@@ -231,6 +252,44 @@ def _parse_inputs_into_config_catalog_state(
231252
return config, catalog, state
232253

233254

255+
def _parse_manifest_from_file(filepath: str) -> dict[str, Any] | None:
256+
"""Extract and parse a manifest file specified in the args."""
257+
try:
258+
with open(filepath, "r", encoding="utf-8") as manifest_file:
259+
manifest_content = yaml.safe_load(manifest_file)
260+
if manifest_content is None:
261+
raise ValueError(f"Manifest file at {filepath} is empty")
262+
if not isinstance(manifest_content, dict):
263+
raise ValueError(f"Manifest must be a dictionary, got {type(manifest_content)}")
264+
return manifest_content
265+
except Exception as error:
266+
raise ValueError(f"Failed to load manifest file from {filepath}: {error}")
267+
268+
269+
def _register_components_from_file(filepath: str) -> None:
270+
"""Load and register components from a Python file specified in the args."""
271+
import importlib.util
272+
import sys
273+
274+
components_path = Path(filepath)
275+
276+
module_name = "components"
277+
sdm_module_name = "source_declarative_manifest.components"
278+
279+
# Create module spec
280+
spec = importlib.util.spec_from_file_location(module_name, components_path)
281+
if spec is None or spec.loader is None:
282+
raise ImportError(f"Could not load module from {components_path}")
283+
284+
# Create module and execute code, registering the module before executing its code
285+
# To avoid issues with dataclasses that look up the module
286+
module = importlib.util.module_from_spec(spec)
287+
sys.modules[module_name] = module
288+
sys.modules[sdm_module_name] = module
289+
290+
spec.loader.exec_module(module)
291+
292+
234293
def run() -> None:
235294
args: list[str] = sys.argv[1:]
236295
handle_command(args)

airbyte_cdk/connector.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import os
99
import pkgutil
1010
from abc import ABC, abstractmethod
11-
from typing import Any, Generic, Mapping, Optional, Protocol, TypeVar
11+
from typing import Any, Generic, Mapping, MutableMapping, Optional, Protocol, TypeVar
1212

1313
import yaml
1414

@@ -41,9 +41,9 @@ def configure(self, config: Mapping[str, Any], temp_dir: str) -> TConfig:
4141
"""
4242

4343
@staticmethod
44-
def read_config(config_path: str) -> Mapping[str, Any]:
44+
def read_config(config_path: str) -> MutableMapping[str, Any]:
4545
config = BaseConnector._read_json_file(config_path)
46-
if isinstance(config, Mapping):
46+
if isinstance(config, MutableMapping):
4747
return config
4848
else:
4949
raise ValueError(

airbyte_cdk/entrypoint.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,18 @@ def parse_args(args: List[str]) -> argparse.Namespace:
8484
required_check_parser.add_argument(
8585
"--config", type=str, required=True, help="path to the json configuration file"
8686
)
87+
check_parser.add_argument(
88+
"--manifest-path",
89+
type=str,
90+
required=False,
91+
help="path to the YAML manifest file to inject into the config",
92+
)
93+
check_parser.add_argument(
94+
"--components-path",
95+
type=str,
96+
required=False,
97+
help="path to the custom components file, if it exists",
98+
)
8799

88100
# discover
89101
discover_parser = subparsers.add_parser(
@@ -95,6 +107,18 @@ def parse_args(args: List[str]) -> argparse.Namespace:
95107
required_discover_parser.add_argument(
96108
"--config", type=str, required=True, help="path to the json configuration file"
97109
)
110+
discover_parser.add_argument(
111+
"--manifest-path",
112+
type=str,
113+
required=False,
114+
help="path to the YAML manifest file to inject into the config",
115+
)
116+
discover_parser.add_argument(
117+
"--components-path",
118+
type=str,
119+
required=False,
120+
help="path to the custom components file, if it exists",
121+
)
98122

99123
# read
100124
read_parser = subparsers.add_parser(
@@ -114,6 +138,18 @@ def parse_args(args: List[str]) -> argparse.Namespace:
114138
required=True,
115139
help="path to the catalog used to determine which data to read",
116140
)
141+
read_parser.add_argument(
142+
"--manifest-path",
143+
type=str,
144+
required=False,
145+
help="path to the YAML manifest file to inject into the config",
146+
)
147+
read_parser.add_argument(
148+
"--components-path",
149+
type=str,
150+
required=False,
151+
help="path to the custom components file, if it exists",
152+
)
117153

118154
return main_parser.parse_args(args)
119155

unit_tests/source_declarative_manifest/conftest.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
# Copyright (c) 2024 Airbyte, Inc., all rights reserved.
33
#
44

5+
import sys
6+
import tempfile
57
from pathlib import Path
8+
from typing import Generator
69

710
import pytest
811
import yaml
@@ -52,3 +55,49 @@ def valid_local_config_file():
5255
@pytest.fixture
5356
def invalid_local_config_file():
5457
return get_resource_path("invalid_local_pokeapi_config.json")
58+
59+
60+
# Sample component code for testing
61+
SAMPLE_COMPONENTS_PY_TEXT = """
62+
def sample_function() -> str:
63+
return "Hello, World!"
64+
65+
class SimpleClass:
66+
def sample_method(self) -> str:
67+
return sample_function()
68+
"""
69+
70+
71+
def verify_components_loaded() -> None:
72+
"""Verify that components were properly loaded."""
73+
import components
74+
75+
assert hasattr(components, "sample_function")
76+
assert components.sample_function() == "Hello, World!"
77+
78+
# Verify the components module is registered in sys.modules
79+
assert "components" in sys.modules
80+
assert "source_declarative_manifest.components" in sys.modules
81+
82+
# Verify they are the same module
83+
assert sys.modules["components"] is sys.modules["source_declarative_manifest.components"]
84+
85+
86+
@pytest.fixture
87+
def components_file() -> Generator[str, None, None]:
88+
"""Create a temporary file with sample components code and clean up modules afterwards."""
89+
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as temp_file:
90+
temp_file.write(SAMPLE_COMPONENTS_PY_TEXT)
91+
temp_file.flush()
92+
file_path = temp_file.name
93+
94+
try:
95+
yield file_path
96+
finally:
97+
# Clean up the modules
98+
if "components" in sys.modules:
99+
del sys.modules["components"]
100+
if "source_declarative_manifest.components" in sys.modules:
101+
del sys.modules["source_declarative_manifest.components"]
102+
# Clean up the temporary file
103+
Path(file_path).unlink(missing_ok=True)

unit_tests/source_declarative_manifest/test_source_declarative_remote_manifest.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@
22
# Copyright (c) 2024 Airbyte, Inc., all rights reserved.
33
#
44

5+
from pathlib import Path
6+
from unittest.mock import mock_open, patch
7+
58
import pytest
69

710
from airbyte_cdk.cli.source_declarative_manifest._run import (
11+
_parse_manifest_from_file,
812
create_declarative_source,
913
handle_command,
1014
)
@@ -27,3 +31,11 @@ def test_given_no_injected_declarative_manifest_then_raise_value_error(invalid_r
2731
def test_given_injected_declarative_manifest_then_return_declarative_manifest(valid_remote_config):
2832
source = create_declarative_source(["check", "--config", str(valid_remote_config)])
2933
assert isinstance(source, ManifestDeclarativeSource)
34+
35+
36+
def test_parse_manifest_from_file(valid_remote_config: Path) -> None:
37+
mock_manifest_content = '{"test_manifest": "fancy_declarative_components"}'
38+
with patch("builtins.open", mock_open(read_data=mock_manifest_content)):
39+
# Test with manifest path
40+
result = _parse_manifest_from_file("manifest.yaml")
41+
assert result == {"test_manifest": "fancy_declarative_components"}

unit_tests/source_declarative_manifest/test_source_declarative_w_custom_components.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from airbyte_protocol_dataclasses.models.airbyte_protocol import AirbyteCatalog
1818

1919
from airbyte_cdk.cli.source_declarative_manifest._run import (
20+
_register_components_from_file,
2021
create_declarative_source,
2122
)
2223
from airbyte_cdk.models import ConfiguredAirbyteCatalog, ConfiguredAirbyteStream
@@ -33,15 +34,10 @@
3334
register_components_module_from_string,
3435
)
3536
from airbyte_cdk.utils.connector_paths import MANIFEST_YAML
36-
37-
SAMPLE_COMPONENTS_PY_TEXT = """
38-
def sample_function() -> str:
39-
return "Hello, World!"
40-
41-
class SimpleClass:
42-
def sample_method(self) -> str:
43-
return sample_function()
44-
"""
37+
from unit_tests.source_declarative_manifest.conftest import (
38+
SAMPLE_COMPONENTS_PY_TEXT,
39+
verify_components_loaded,
40+
)
4541

4642

4743
def get_resource_path(file_name) -> str:
@@ -288,3 +284,12 @@ def _read_fn(*args, **kwargs):
288284
_read_fn()
289285
else:
290286
_read_fn()
287+
288+
289+
def test_register_components_from_file(components_file: str) -> None:
290+
"""Test that components can be properly loaded from a file."""
291+
# Register the components
292+
_register_components_from_file(components_file)
293+
294+
# Verify the components were loaded correctly
295+
verify_components_loaded()

0 commit comments

Comments
 (0)