Skip to content

Commit 9bfdd91

Browse files
📝 Add docstrings to aj/feat/accept-components-text-input
Docstrings generation was requested by @aaronsteers. * #174 (comment) The following files were modified: * `airbyte_cdk/cli/source_declarative_manifest/_run.py` * `airbyte_cdk/sources/declarative/parsers/model_to_component_factory.py` * `airbyte_cdk/test/utils/manifest_only_fixtures.py` * `unit_tests/source_declarative_manifest/conftest.py` * `unit_tests/source_declarative_manifest/resources/source_the_guardian_api/components.py` * `unit_tests/source_declarative_manifest/test_source_declarative_w_custom_components.py`
1 parent c837745 commit 9bfdd91

File tree

6 files changed

+281
-30
lines changed

6 files changed

+281
-30
lines changed

airbyte_cdk/cli/source_declarative_manifest/_run.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -155,11 +155,25 @@ def handle_remote_manifest_command(args: list[str]) -> None:
155155
def create_declarative_source(
156156
args: list[str],
157157
) -> ConcurrentDeclarativeSource: # type: ignore [type-arg]
158-
"""Creates the source with the injected config.
159-
160-
This essentially does what other low-code sources do at build time, but at runtime,
161-
with a user-provided manifest in the config. This better reflects what happens in the
162-
connector builder.
158+
"""
159+
Create a declarative source with an injected manifest configuration.
160+
161+
This function dynamically creates a ConcurrentDeclarativeSource at runtime using a user-provided manifest, similar to how low-code sources are built. It validates the configuration and prepares the source for execution.
162+
163+
Parameters:
164+
args (list[str]): Command-line arguments containing configuration, catalog, and state information.
165+
166+
Returns:
167+
ConcurrentDeclarativeSource: A configured declarative source ready for sync operations.
168+
169+
Raises:
170+
ValueError: If the configuration is invalid or missing required manifest information.
171+
Exception: For any unexpected errors during source creation, with detailed error tracing.
172+
173+
Notes:
174+
- Requires a configuration with an '__injected_declarative_manifest' key
175+
- The manifest must be a dictionary
176+
- Provides structured error reporting for configuration issues
163177
"""
164178
try:
165179
config: Mapping[str, Any] | None

airbyte_cdk/sources/declarative/parsers/model_to_component_factory.py

Lines changed: 73 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -981,11 +981,27 @@ def create_cursor_pagination(
981981

982982
def create_custom_component(self, model: Any, config: Config, **kwargs: Any) -> Any:
983983
"""
984-
Generically creates a custom component based on the model type and a class_name reference to the custom Python class being
985-
instantiated. Only the model's additional properties that match the custom class definition are passed to the constructor
986-
:param model: The Pydantic model of the custom component being created
987-
:param config: The custom defined connector config
988-
:return: The declarative component built from the Pydantic model to be used at runtime
984+
Create a custom component from a Pydantic model with dynamic class instantiation.
985+
986+
This method dynamically creates a custom component by loading a class from a specified module and instantiating it with appropriate arguments. It handles complex scenarios such as nested components, type inference, and argument passing.
987+
988+
Parameters:
989+
model (Any): A Pydantic model representing the custom component configuration.
990+
config (Config): The connector configuration used for module and component resolution.
991+
**kwargs (Any): Additional keyword arguments to override or supplement model arguments.
992+
993+
Returns:
994+
Any: An instantiated custom component with resolved nested components and configurations.
995+
996+
Raises:
997+
ValueError: If the component class cannot be loaded or instantiated.
998+
TypeError: If arguments do not match the component's constructor signature.
999+
1000+
Notes:
1001+
- Supports nested component creation
1002+
- Performs type inference for component fields
1003+
- Handles both dictionary and list-based component configurations
1004+
- Prioritizes kwargs over model arguments in case of field collisions
9891005
"""
9901006
custom_component_class = self._get_class_from_fully_qualified_class_name(
9911007
full_qualified_class_name=model.class_name,
@@ -1046,10 +1062,25 @@ def create_custom_component(self, model: Any, config: Config, **kwargs: Any) ->
10461062
def _get_components_module_object(
10471063
config: Config,
10481064
) -> types.ModuleType:
1049-
"""Get a components module object based on the provided config.
1050-
1051-
If custom python components is provided, this will be loaded. Otherwise, we will
1052-
attempt to load from the `components` module already imported.
1065+
"""
1066+
Get a components module object based on the provided configuration.
1067+
1068+
This method dynamically creates a module for custom Python components defined in the configuration. It ensures that custom components are defined in a module named 'components' and allows runtime module creation and execution.
1069+
1070+
Parameters:
1071+
config (Config): A configuration object containing the custom components definition.
1072+
1073+
Returns:
1074+
types.ModuleType: A dynamically created module containing the custom components.
1075+
1076+
Raises:
1077+
ValueError: If no custom components are provided or if the components are not defined in a module named 'components'.
1078+
1079+
Notes:
1080+
- Uses the special key '__injected_components_py' to retrieve custom component code
1081+
- Creates a new module dynamically using types.ModuleType
1082+
- Executes the provided Python code within the new module's namespace
1083+
- Registers the module in sys.modules for future imports
10531084
"""
10541085
INJECTED_COMPONENTS_PY = "__injected_components_py"
10551086
COMPONENTS_MODULE_NAME = "components"
@@ -1073,17 +1104,24 @@ def _get_class_from_fully_qualified_class_name(
10731104
components_module: types.ModuleType,
10741105
) -> Any:
10751106
"""
1076-
Get a class from its fully qualified name, optionally using a pre-parsed module.
1077-
1078-
Args:
1079-
full_qualified_class_name (str): The fully qualified name of the class (e.g., "module.ClassName").
1080-
components_module (Optional[ModuleType]): An optional pre-parsed module.
1081-
1107+
Retrieve a class from its fully qualified name within a predefined components module.
1108+
1109+
Parameters:
1110+
full_qualified_class_name (str): The complete dot-separated path to the class (e.g., "source_declarative_manifest.components.ClassName").
1111+
components_module (types.ModuleType): The pre-parsed module containing custom components.
1112+
10821113
Returns:
1083-
Any: The class object.
1084-
1114+
Any: The requested class object.
1115+
10851116
Raises:
1086-
ValueError: If the class cannot be loaded.
1117+
ValueError: If the class cannot be loaded or does not meet module naming conventions.
1118+
- Raised when the module is not named "components"
1119+
- Raised when the full module path is not "source_declarative_manifest.components"
1120+
- Raised when the specific class cannot be found in the module
1121+
1122+
Notes:
1123+
- Enforces strict naming conventions for custom component modules
1124+
- Provides detailed error messages for debugging component loading issues
10871125
"""
10881126
split = full_qualified_class_name.split(".")
10891127
module_name_full = ".".join(split[:-1])
@@ -1108,6 +1146,23 @@ def _get_class_from_fully_qualified_class_name(
11081146

11091147
@staticmethod
11101148
def _derive_component_type_from_type_hints(field_type: Any) -> Optional[str]:
1149+
"""
1150+
Derive the component type name from type hints by unwrapping nested generic types.
1151+
1152+
This method extracts the underlying type from potentially nested generic type hints,
1153+
such as List[T], Optional[List[T]], etc., and returns the type name if it's a non-builtin type.
1154+
1155+
Parameters:
1156+
field_type (Any): The type hint to analyze for component type extraction.
1157+
1158+
Returns:
1159+
Optional[str]: The name of the underlying type if it's a non-builtin type, otherwise None.
1160+
1161+
Examples:
1162+
- List[str] returns None
1163+
- List[CustomType] returns "CustomType"
1164+
- Optional[List[CustomType]] returns "CustomType"
1165+
"""
11111166
interface = field_type
11121167
while True:
11131168
origin = get_origin(interface)

airbyte_cdk/test/utils/manifest_only_fixtures.py

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,28 @@ def connector_dir(request: pytest.FixtureRequest) -> Path:
3131

3232
@pytest.fixture(scope="session")
3333
def components_module(connector_dir: Path) -> ModuleType | None:
34-
"""Load and return the components module from the connector directory.
35-
36-
This assumes the components module is located at <connector_dir>/components.py.
34+
"""
35+
Load and return the components module from the connector directory.
36+
37+
This function attempts to load the 'components.py' module from the specified connector directory. It handles various potential failure scenarios during module loading.
38+
39+
Parameters:
40+
connector_dir (Path): The root directory of the connector containing the components module.
41+
42+
Returns:
43+
ModuleType | None: The loaded components module if successful, or None if:
44+
- The components.py file does not exist
45+
- The module specification cannot be created
46+
- The module loader is unavailable
47+
48+
Raises:
49+
No explicit exceptions are raised; returns None on failure.
50+
51+
Example:
52+
components = components_module(Path('/path/to/connector'))
53+
if components:
54+
# Use the loaded module
55+
some_component = components.SomeComponent()
3756
"""
3857
components_path = connector_dir / "components.py"
3958
if not components_path.exists():
@@ -52,9 +71,25 @@ def components_module(connector_dir: Path) -> ModuleType | None:
5271

5372

5473
def components_module_from_string(components_py_text: str) -> ModuleType | None:
55-
"""Load and return the components module from a provided string containing the python code.
56-
57-
This assumes the components module is located at <connector_dir>/components.py.
74+
"""
75+
Load a Python module from a string containing module code.
76+
77+
Parameters:
78+
components_py_text (str): A string containing valid Python code representing a module.
79+
80+
Returns:
81+
ModuleType | None: A dynamically created module object containing the executed code, or None if execution fails.
82+
83+
Raises:
84+
Exception: Potential runtime errors during code execution.
85+
86+
Example:
87+
components_code = '''
88+
def sample_component():
89+
return "Hello, World!"
90+
'''
91+
module = components_module_from_string(components_code)
92+
result = module.sample_component() # Returns "Hello, World!"
5893
"""
5994
module_name = "components"
6095

@@ -70,7 +105,22 @@ def components_module_from_string(components_py_text: str) -> ModuleType | None:
70105

71106
@pytest.fixture(scope="session")
72107
def manifest_path(connector_dir: Path) -> Path:
73-
"""Return the path to the connector's manifest file."""
108+
"""
109+
Return the path to the connector's manifest file.
110+
111+
Parameters:
112+
connector_dir (Path): The root directory of the connector.
113+
114+
Returns:
115+
Path: The absolute path to the manifest.yaml file.
116+
117+
Raises:
118+
FileNotFoundError: If the manifest.yaml file does not exist in the specified connector directory.
119+
120+
Example:
121+
manifest_file = manifest_path(Path('/path/to/connector'))
122+
# Returns Path('/path/to/connector/manifest.yaml')
123+
"""
74124
path = connector_dir / "manifest.yaml"
75125
if not path.exists():
76126
raise FileNotFoundError(f"Manifest file not found at {path}")

unit_tests/source_declarative_manifest/conftest.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,23 @@
1111

1212

1313
def hash_text(input_text: str, hash_type: Literal["md5", "sha256"] = "md5") -> str:
14+
"""
15+
Compute the hash of the input text using the specified hashing algorithm.
16+
17+
Parameters:
18+
input_text (str): The text to be hashed.
19+
hash_type (Literal["md5", "sha256"], optional): The hashing algorithm to use.
20+
Defaults to "md5". Supports "md5" and "sha256" algorithms.
21+
22+
Returns:
23+
str: The hexadecimal digest of the hashed input text.
24+
25+
Examples:
26+
>>> hash_text("hello world")
27+
'5eb63bbbe01eeed093cb22bb8f5acdc3'
28+
>>> hash_text("hello world", hash_type="sha256")
29+
'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9'
30+
"""
1431
hashers = {
1532
"md5": hashlib.md5,
1633
"sha256": hashlib.sha256,
@@ -21,6 +38,19 @@ def hash_text(input_text: str, hash_type: Literal["md5", "sha256"] = "md5") -> s
2138

2239

2340
def get_fixture_path(file_name) -> str:
41+
"""
42+
Construct the full path to a fixture file relative to the current script's directory.
43+
44+
Parameters:
45+
file_name (str): The name of the fixture file to locate.
46+
47+
Returns:
48+
str: The absolute path to the specified fixture file.
49+
50+
Example:
51+
>>> get_fixture_path('config.json')
52+
'/path/to/current/directory/config.json'
53+
"""
2454
return os.path.join(os.path.dirname(__file__), file_name)
2555

2656

unit_tests/source_declarative_manifest/resources/source_the_guardian_api/components.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,23 @@ class CustomPageIncrement(PageIncrement):
1919
"""
2020

2121
def next_page_token(self, response: requests.Response, *args) -> Optional[Any]:
22+
"""
23+
Retrieve the next page token for pagination based on the current page and total pages.
24+
25+
Extracts the current page and total pages from the API response. If more pages are available,
26+
increments the page counter and returns the next page number. Otherwise, returns None to
27+
indicate the end of pagination.
28+
29+
Parameters:
30+
response (requests.Response): The HTTP response from the API containing pagination details.
31+
*args: Variable length argument list (unused in this implementation).
32+
33+
Returns:
34+
Optional[Any]: The next page number if more pages are available, or None if pagination is complete.
35+
36+
Raises:
37+
KeyError: If the expected keys are missing in the response JSON.
38+
"""
2239
res = response.json().get("response")
2340
currPage = res.get("currentPage")
2441
totalPages = res.get("pages")
@@ -29,8 +46,23 @@ def next_page_token(self, response: requests.Response, *args) -> Optional[Any]:
2946
return None
3047

3148
def __post_init__(self, parameters: Mapping[str, Any]):
49+
"""
50+
Initialize the page increment with a starting page number of 1.
51+
52+
This method is called after the class initialization and sets the initial page
53+
to 1 by invoking the parent class's __post_init__ method and then explicitly
54+
setting the _page attribute.
55+
56+
Parameters:
57+
parameters (Mapping[str, Any]): Configuration parameters passed during initialization.
58+
"""
3259
super().__post_init__(parameters)
3360
self._page = 1
3461

3562
def reset(self):
63+
"""
64+
Reset the page counter to the initial state.
65+
66+
This method resets the internal page counter to 1, allowing pagination to start over from the beginning. It is useful when you want to restart the pagination process for a new request or after completing a previous pagination cycle.
67+
"""
3668
self._page = 1

0 commit comments

Comments
 (0)