etlplus 0.3.16__tar.gz → 0.3.19__tar.gz
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.
- {etlplus-0.3.16/etlplus.egg-info → etlplus-0.3.19}/PKG-INFO +1 -1
- {etlplus-0.3.16 → etlplus-0.3.19/etlplus.egg-info}/PKG-INFO +1 -1
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus.egg-info/SOURCES.txt +5 -0
- etlplus-0.3.19/tests/conftest.py +210 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/tests/integration/conftest.py +105 -16
- etlplus-0.3.19/tests/integration/test_i_cli.py +244 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/tests/integration/test_i_examples_data_parity.py +5 -0
- etlplus-0.3.19/tests/integration/test_i_pagination_strategy.py +551 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/tests/integration/test_i_pipeline_smoke.py +41 -36
- {etlplus-0.3.16 → etlplus-0.3.19}/tests/integration/test_i_pipeline_yaml_load.py +6 -0
- etlplus-0.3.19/tests/integration/test_i_run.py +69 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/tests/integration/test_i_run_profile_pagination_defaults.py +11 -7
- {etlplus-0.3.16 → etlplus-0.3.19}/tests/integration/test_i_run_profile_rate_limit_defaults.py +6 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/api/conftest.py +42 -15
- {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/api/test_u_auth.py +113 -123
- {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/api/test_u_config.py +60 -16
- {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/api/test_u_endpoint_client.py +455 -275
- {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/api/test_u_pagination_client.py +5 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/api/test_u_pagination_config.py +5 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/api/test_u_paginator.py +5 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/api/test_u_rate_limit_config.py +5 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/api/test_u_rate_limiter.py +5 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/api/test_u_request_manager.py +71 -4
- {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/api/test_u_retry_manager.py +6 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/api/test_u_transport.py +51 -0
- etlplus-0.3.19/tests/unit/api/test_u_types.py +136 -0
- etlplus-0.3.19/tests/unit/config/test_u_connector.py +119 -0
- etlplus-0.3.19/tests/unit/config/test_u_pipeline.py +285 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/conftest.py +30 -30
- etlplus-0.3.19/tests/unit/test_u_cli.py +185 -0
- etlplus-0.3.19/tests/unit/test_u_enums.py +135 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/test_u_extract.py +212 -0
- etlplus-0.3.19/tests/unit/test_u_file.py +296 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/test_u_load.py +201 -0
- etlplus-0.3.19/tests/unit/test_u_mixins.py +47 -0
- etlplus-0.3.19/tests/unit/test_u_run.py +289 -0
- etlplus-0.3.19/tests/unit/test_u_run_helpers.py +385 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/test_u_transform.py +80 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/test_u_utils.py +92 -3
- {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/test_u_validate.py +40 -0
- etlplus-0.3.16/tests/conftest.py +0 -11
- etlplus-0.3.16/tests/integration/test_i_cli.py +0 -348
- etlplus-0.3.16/tests/integration/test_i_pagination_strategy.py +0 -452
- etlplus-0.3.16/tests/integration/test_i_run.py +0 -133
- etlplus-0.3.16/tests/unit/config/test_u_connector.py +0 -54
- etlplus-0.3.16/tests/unit/config/test_u_pipeline.py +0 -194
- etlplus-0.3.16/tests/unit/test_u_cli.py +0 -124
- etlplus-0.3.16/tests/unit/test_u_file.py +0 -100
- {etlplus-0.3.16 → etlplus-0.3.19}/.coveragerc +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/.editorconfig +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/.gitattributes +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/.github/actions/python-bootstrap/action.yml +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/.github/workflows/ci.yml +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/.gitignore +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/.pre-commit-config.yaml +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/.ruff.toml +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/CODE_OF_CONDUCT.md +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/CONTRIBUTING.md +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/DEMO.md +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/LICENSE +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/Makefile +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/README.md +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/REFERENCES.md +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/docs/pipeline-guide.md +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/docs/snippets/installation_version.md +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/__init__.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/__main__.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/__version__.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/api/README.md +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/api/__init__.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/api/auth.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/api/config.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/api/endpoint_client.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/api/errors.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/api/pagination/__init__.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/api/pagination/client.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/api/pagination/config.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/api/pagination/paginator.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/api/rate_limiting/__init__.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/api/rate_limiting/config.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/api/rate_limiting/rate_limiter.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/api/request_manager.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/api/retry_manager.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/api/transport.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/api/types.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/cli.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/config/__init__.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/config/connector.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/config/jobs.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/config/pipeline.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/config/profile.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/config/types.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/config/utils.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/enums.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/extract.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/file.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/load.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/mixins.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/py.typed +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/run.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/run_helpers.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/transform.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/types.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/utils.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/validate.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/validation/__init__.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/validation/utils.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus.egg-info/dependency_links.txt +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus.egg-info/entry_points.txt +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus.egg-info/requires.txt +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/etlplus.egg-info/top_level.txt +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/examples/README.md +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/examples/configs/pipeline.yml +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/examples/data/sample.csv +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/examples/data/sample.json +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/examples/data/sample.xml +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/examples/data/sample.xsd +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/examples/data/sample.yaml +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/examples/quickstart_python.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/pyproject.toml +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/pytest.ini +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/setup.cfg +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/setup.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/tests/__init__.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/api/test_u_mocks.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/validation/test_u_validation_utils.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/tools/run_pipeline.py +0 -0
- {etlplus-0.3.16 → etlplus-0.3.19}/tools/update_demo_snippets.py +0 -0
|
@@ -87,9 +87,13 @@ tests/integration/test_i_run_profile_pagination_defaults.py
|
|
|
87
87
|
tests/integration/test_i_run_profile_rate_limit_defaults.py
|
|
88
88
|
tests/unit/conftest.py
|
|
89
89
|
tests/unit/test_u_cli.py
|
|
90
|
+
tests/unit/test_u_enums.py
|
|
90
91
|
tests/unit/test_u_extract.py
|
|
91
92
|
tests/unit/test_u_file.py
|
|
92
93
|
tests/unit/test_u_load.py
|
|
94
|
+
tests/unit/test_u_mixins.py
|
|
95
|
+
tests/unit/test_u_run.py
|
|
96
|
+
tests/unit/test_u_run_helpers.py
|
|
93
97
|
tests/unit/test_u_transform.py
|
|
94
98
|
tests/unit/test_u_utils.py
|
|
95
99
|
tests/unit/test_u_validate.py
|
|
@@ -106,6 +110,7 @@ tests/unit/api/test_u_rate_limiter.py
|
|
|
106
110
|
tests/unit/api/test_u_request_manager.py
|
|
107
111
|
tests/unit/api/test_u_retry_manager.py
|
|
108
112
|
tests/unit/api/test_u_transport.py
|
|
113
|
+
tests/unit/api/test_u_types.py
|
|
109
114
|
tests/unit/config/test_u_connector.py
|
|
110
115
|
tests/unit/config/test_u_pipeline.py
|
|
111
116
|
tests/unit/validation/test_u_validation_utils.py
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""
|
|
2
|
+
:mod:`tests.conftest` module.
|
|
3
|
+
|
|
4
|
+
Global pytest fixtures shared across unit, integration, and end-to-end tests.
|
|
5
|
+
|
|
6
|
+
Notes
|
|
7
|
+
-----
|
|
8
|
+
- Provides CLI helpers so tests no longer need to monkeypatch ``sys.argv``
|
|
9
|
+
inline.
|
|
10
|
+
- Supplies JSON file factories that rely on ``tmp_path`` for automatic
|
|
11
|
+
cleanup.
|
|
12
|
+
- Keeps docstrings NumPy-formatted to satisfy numpydoc linting.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
from collections.abc import Sequence
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
from typing import Protocol
|
|
23
|
+
|
|
24
|
+
import pytest
|
|
25
|
+
from requests import PreparedRequest # type: ignore[import]
|
|
26
|
+
|
|
27
|
+
from etlplus.cli import main
|
|
28
|
+
|
|
29
|
+
# SECTION: HELPERS ========================================================== #
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _coerce_cli_args(
|
|
33
|
+
cli_args: tuple[str | Sequence[str], ...],
|
|
34
|
+
) -> tuple[str, ...]:
|
|
35
|
+
"""
|
|
36
|
+
Normalize CLI arguments into a ``tuple[str, ...]``.
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
cli_args : tuple[str | Sequence[str], ...]
|
|
41
|
+
Arguments provided to ``cli_runner``/``cli_invoke``.
|
|
42
|
+
|
|
43
|
+
Returns
|
|
44
|
+
-------
|
|
45
|
+
tuple[str, ...]
|
|
46
|
+
Normalized argument tuple safe to concatenate with ``sys.argv``.
|
|
47
|
+
"""
|
|
48
|
+
if (
|
|
49
|
+
len(cli_args) == 1
|
|
50
|
+
and isinstance(cli_args[0], Sequence)
|
|
51
|
+
and not isinstance(cli_args[0], (str, bytes))
|
|
52
|
+
):
|
|
53
|
+
return tuple(str(part) for part in cli_args[0])
|
|
54
|
+
return tuple(str(part) for part in cli_args)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class CliInvoke(Protocol):
|
|
58
|
+
"""Protocol describing the :func:`cli_invoke` fixture."""
|
|
59
|
+
|
|
60
|
+
def __call__(
|
|
61
|
+
self,
|
|
62
|
+
*cli_args: str | Sequence[str],
|
|
63
|
+
) -> tuple[int, str, str]: ...
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class CliRunner(Protocol):
|
|
67
|
+
"""Protocol describing the ``cli_runner`` fixture."""
|
|
68
|
+
|
|
69
|
+
def __call__(self, *cli_args: str | Sequence[str]) -> int: ...
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class JsonFactory(Protocol):
|
|
73
|
+
"""Protocol describing the :func:`json_file_factory` fixture."""
|
|
74
|
+
|
|
75
|
+
def __call__(
|
|
76
|
+
self,
|
|
77
|
+
payload: Any,
|
|
78
|
+
*,
|
|
79
|
+
filename: str | None = None,
|
|
80
|
+
ensure_ascii: bool = False,
|
|
81
|
+
) -> Path: ...
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class RequestFactory(Protocol):
|
|
85
|
+
"""Protocol describing prepared-request factories."""
|
|
86
|
+
|
|
87
|
+
def __call__(
|
|
88
|
+
self,
|
|
89
|
+
url: str | None = None,
|
|
90
|
+
) -> PreparedRequest: ...
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# SECTION: FIXTURES ========================================================= #
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@pytest.fixture(name='base_url')
|
|
97
|
+
def base_url_fixture() -> str:
|
|
98
|
+
"""Return the canonical base URL shared across tests."""
|
|
99
|
+
|
|
100
|
+
return 'https://api.example.com'
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@pytest.fixture(name='json_file_factory')
|
|
104
|
+
def json_file_factory_fixture(
|
|
105
|
+
tmp_path: Path,
|
|
106
|
+
) -> JsonFactory:
|
|
107
|
+
"""
|
|
108
|
+
Create JSON files under ``tmp_path`` and return their paths.
|
|
109
|
+
|
|
110
|
+
Parameters
|
|
111
|
+
----------
|
|
112
|
+
tmp_path : Path
|
|
113
|
+
Temporary directory managed by pytest.
|
|
114
|
+
|
|
115
|
+
Returns
|
|
116
|
+
-------
|
|
117
|
+
JsonFactory
|
|
118
|
+
Factory that persists the provided payload as JSON and returns the
|
|
119
|
+
resulting path.
|
|
120
|
+
|
|
121
|
+
Examples
|
|
122
|
+
--------
|
|
123
|
+
>>> path = json_file_factory({'name': 'Ada'})
|
|
124
|
+
>>> path.exists()
|
|
125
|
+
True
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def _create(
|
|
129
|
+
payload: Any,
|
|
130
|
+
*,
|
|
131
|
+
filename: str | None = None,
|
|
132
|
+
ensure_ascii: bool = False,
|
|
133
|
+
) -> Path:
|
|
134
|
+
target = tmp_path / (filename or 'payload.json')
|
|
135
|
+
data = (
|
|
136
|
+
payload
|
|
137
|
+
if isinstance(payload, str)
|
|
138
|
+
else json.dumps(payload, indent=2, ensure_ascii=ensure_ascii)
|
|
139
|
+
)
|
|
140
|
+
target.write_text(data)
|
|
141
|
+
return target
|
|
142
|
+
|
|
143
|
+
return _create
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@pytest.fixture(name='cli_runner')
|
|
147
|
+
def cli_runner_fixture(
|
|
148
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
149
|
+
) -> CliRunner:
|
|
150
|
+
"""
|
|
151
|
+
Invoke ``etlplus`` CLI commands with isolated ``sys.argv`` state.
|
|
152
|
+
|
|
153
|
+
Parameters
|
|
154
|
+
----------
|
|
155
|
+
monkeypatch : pytest.MonkeyPatch
|
|
156
|
+
Built-in pytest fixture used to patch ``sys.argv``.
|
|
157
|
+
|
|
158
|
+
Returns
|
|
159
|
+
-------
|
|
160
|
+
CliRunner
|
|
161
|
+
Helper that accepts CLI arguments, runs :func:`etlplus.cli.main`, and
|
|
162
|
+
returns the exit code.
|
|
163
|
+
|
|
164
|
+
Examples
|
|
165
|
+
--------
|
|
166
|
+
>>> cli_runner(('extract', 'file', 'data.json'))
|
|
167
|
+
0
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
def _run(*cli_args: str | Sequence[str]) -> int:
|
|
171
|
+
args = _coerce_cli_args(cli_args)
|
|
172
|
+
monkeypatch.setattr(sys, 'argv', ['etlplus', *args])
|
|
173
|
+
return main()
|
|
174
|
+
|
|
175
|
+
return _run
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@pytest.fixture
|
|
179
|
+
def cli_invoke(
|
|
180
|
+
cli_runner: CliRunner,
|
|
181
|
+
capsys: pytest.CaptureFixture[str],
|
|
182
|
+
) -> CliInvoke:
|
|
183
|
+
"""
|
|
184
|
+
Run CLI commands and return exit code, stdout, and stderr.
|
|
185
|
+
|
|
186
|
+
Parameters
|
|
187
|
+
----------
|
|
188
|
+
cli_runner : CliRunner
|
|
189
|
+
Helper fixture defined above.
|
|
190
|
+
capsys : pytest.CaptureFixture[str]
|
|
191
|
+
Pytest fixture for capturing stdout/stderr.
|
|
192
|
+
|
|
193
|
+
Returns
|
|
194
|
+
-------
|
|
195
|
+
CliInvoke
|
|
196
|
+
Helper that yields ``(exit_code, stdout, stderr)`` tuples.
|
|
197
|
+
|
|
198
|
+
Examples
|
|
199
|
+
--------
|
|
200
|
+
>>> code, out, err = cli_invoke(('extract', 'file', 'data.json'))
|
|
201
|
+
>>> code
|
|
202
|
+
0
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
def _invoke(*cli_args: str | Sequence[str]) -> tuple[int, str, str]:
|
|
206
|
+
exit_code = cli_runner(*cli_args)
|
|
207
|
+
captured = capsys.readouterr()
|
|
208
|
+
return exit_code, captured.out, captured.err
|
|
209
|
+
|
|
210
|
+
return _invoke
|
|
@@ -11,6 +11,7 @@ Notes
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
13
|
import importlib
|
|
14
|
+
import json
|
|
14
15
|
import pathlib
|
|
15
16
|
from collections.abc import Callable
|
|
16
17
|
from typing import Any
|
|
@@ -34,6 +35,12 @@ from etlplus.config import PipelineConfig
|
|
|
34
35
|
# SECTION: HELPERS ========================================================== #
|
|
35
36
|
|
|
36
37
|
|
|
38
|
+
pytestmark = pytest.mark.integration
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# SECTION: HELPERS ========================================================== #
|
|
42
|
+
|
|
43
|
+
|
|
37
44
|
# Directory-level marker for integration tests.
|
|
38
45
|
pytestmark = pytest.mark.integration
|
|
39
46
|
|
|
@@ -55,8 +62,8 @@ class FakeEndpointClientProtocol(Protocol):
|
|
|
55
62
|
# SECTION: FIXTURES ========================================================= #
|
|
56
63
|
|
|
57
64
|
|
|
58
|
-
@pytest.fixture
|
|
59
|
-
def
|
|
65
|
+
@pytest.fixture(name='capture_load_to_api')
|
|
66
|
+
def capture_load_to_api_fixture(
|
|
60
67
|
monkeypatch: pytest.MonkeyPatch,
|
|
61
68
|
) -> dict[str, Any]:
|
|
62
69
|
"""
|
|
@@ -112,8 +119,8 @@ def capture_load_to_api(
|
|
|
112
119
|
return seen
|
|
113
120
|
|
|
114
121
|
|
|
115
|
-
@pytest.fixture
|
|
116
|
-
def
|
|
122
|
+
@pytest.fixture(name='fake_endpoint_client')
|
|
123
|
+
def fake_endpoint_client_fixture() -> tuple[
|
|
117
124
|
type[FakeEndpointClientProtocol],
|
|
118
125
|
list[FakeEndpointClientProtocol],
|
|
119
126
|
]: # noqa: ANN201
|
|
@@ -170,9 +177,83 @@ def fake_endpoint_client() -> tuple[
|
|
|
170
177
|
return FakeClient, created
|
|
171
178
|
|
|
172
179
|
|
|
173
|
-
@pytest.fixture
|
|
174
|
-
def
|
|
180
|
+
@pytest.fixture(name='file_to_api_pipeline_factory')
|
|
181
|
+
def file_to_api_pipeline_factory_fixture(
|
|
182
|
+
tmp_path: pathlib.Path,
|
|
183
|
+
base_url: str,
|
|
184
|
+
) -> Callable[..., PipelineConfig]:
|
|
185
|
+
"""Build a pipeline wiring a JSON file source to an API target."""
|
|
186
|
+
|
|
187
|
+
def _make(
|
|
188
|
+
*,
|
|
189
|
+
payload: Any | None = None,
|
|
190
|
+
base_url: str = base_url,
|
|
191
|
+
base_path: str | None = '/v1',
|
|
192
|
+
endpoint_path: str = '/ingest',
|
|
193
|
+
endpoint_name: str = 'ingest',
|
|
194
|
+
method: str = 'post',
|
|
195
|
+
headers: dict[str, str] | None = None,
|
|
196
|
+
job_name: str = 'send',
|
|
197
|
+
target_name: str = 'ingest_out',
|
|
198
|
+
) -> PipelineConfig:
|
|
199
|
+
source_path = tmp_path / f'{job_name}_input.json'
|
|
200
|
+
effective_payload = payload if payload is not None else {'ok': True}
|
|
201
|
+
text = (
|
|
202
|
+
effective_payload
|
|
203
|
+
if isinstance(effective_payload, str)
|
|
204
|
+
else json.dumps(effective_payload)
|
|
205
|
+
)
|
|
206
|
+
source_path.write_text(text, encoding='utf-8')
|
|
207
|
+
|
|
208
|
+
profile = ApiProfileConfig(
|
|
209
|
+
base_url=base_url,
|
|
210
|
+
headers={},
|
|
211
|
+
base_path=base_path or '',
|
|
212
|
+
auth={},
|
|
213
|
+
rate_limit_defaults=None,
|
|
214
|
+
pagination_defaults=None,
|
|
215
|
+
)
|
|
216
|
+
api = ApiConfig(
|
|
217
|
+
base_url=base_url,
|
|
218
|
+
profiles={'default': profile},
|
|
219
|
+
endpoints={endpoint_name: EndpointConfig(path=endpoint_path)},
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
src = ConnectorFile(
|
|
223
|
+
name='file_src',
|
|
224
|
+
type='file',
|
|
225
|
+
format='json',
|
|
226
|
+
path=str(source_path),
|
|
227
|
+
)
|
|
228
|
+
tgt = ConnectorApi(
|
|
229
|
+
name=target_name,
|
|
230
|
+
type='api',
|
|
231
|
+
api='svc',
|
|
232
|
+
endpoint=endpoint_name,
|
|
233
|
+
method=method,
|
|
234
|
+
headers=headers or {},
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
return PipelineConfig(
|
|
238
|
+
apis={'svc': api},
|
|
239
|
+
sources=[src],
|
|
240
|
+
targets=[tgt],
|
|
241
|
+
jobs=[
|
|
242
|
+
JobConfig(
|
|
243
|
+
name=job_name,
|
|
244
|
+
extract=ExtractRef(source='file_src'),
|
|
245
|
+
load=LoadRef(target=target_name),
|
|
246
|
+
),
|
|
247
|
+
],
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
return _make
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@pytest.fixture(name='pipeline_cfg_factory')
|
|
254
|
+
def pipeline_cfg_factory_fixture(
|
|
175
255
|
tmp_path: pathlib.Path,
|
|
256
|
+
base_url: str,
|
|
176
257
|
) -> Callable[..., PipelineConfig]:
|
|
177
258
|
"""
|
|
178
259
|
Factory to build a minimal PipelineConfig for runner tests.
|
|
@@ -185,6 +266,8 @@ def pipeline_cfg_factory(
|
|
|
185
266
|
----------
|
|
186
267
|
tmp_path : pathlib.Path
|
|
187
268
|
The pytest temporary path fixture.
|
|
269
|
+
base_url : str
|
|
270
|
+
Common base URL used across tests.
|
|
188
271
|
|
|
189
272
|
Returns
|
|
190
273
|
-------
|
|
@@ -196,9 +279,10 @@ def pipeline_cfg_factory(
|
|
|
196
279
|
*,
|
|
197
280
|
pagination_defaults: PaginationConfig | None = None,
|
|
198
281
|
rate_limit_defaults: RateLimitConfig | None = None,
|
|
282
|
+
extract_options: dict[str, Any] | None = None,
|
|
199
283
|
) -> PipelineConfig:
|
|
200
284
|
prof = ApiProfileConfig(
|
|
201
|
-
base_url=
|
|
285
|
+
base_url=base_url,
|
|
202
286
|
headers={},
|
|
203
287
|
base_path='/v1',
|
|
204
288
|
auth={},
|
|
@@ -219,24 +303,29 @@ def pipeline_cfg_factory(
|
|
|
219
303
|
format='json',
|
|
220
304
|
path=str(out_path),
|
|
221
305
|
)
|
|
306
|
+
job = JobConfig(
|
|
307
|
+
name='job',
|
|
308
|
+
extract=ExtractRef(source='s'),
|
|
309
|
+
load=LoadRef(target='t'),
|
|
310
|
+
)
|
|
311
|
+
if extract_options is not None:
|
|
312
|
+
if job.extract is None:
|
|
313
|
+
msg = 'job.extract is None; cannot set options'
|
|
314
|
+
raise ValueError(msg)
|
|
315
|
+
job.extract.options = extract_options
|
|
316
|
+
|
|
222
317
|
return PipelineConfig(
|
|
223
318
|
apis={'svc': api},
|
|
224
319
|
sources=[src],
|
|
225
320
|
targets=[tgt],
|
|
226
|
-
jobs=[
|
|
227
|
-
JobConfig(
|
|
228
|
-
name='job',
|
|
229
|
-
extract=ExtractRef(source='s'),
|
|
230
|
-
load=LoadRef(target='t'),
|
|
231
|
-
),
|
|
232
|
-
],
|
|
321
|
+
jobs=[job],
|
|
233
322
|
)
|
|
234
323
|
|
|
235
324
|
return _make
|
|
236
325
|
|
|
237
326
|
|
|
238
|
-
@pytest.fixture
|
|
239
|
-
def
|
|
327
|
+
@pytest.fixture(name='run_patched')
|
|
328
|
+
def run_patched_fixture(
|
|
240
329
|
monkeypatch: pytest.MonkeyPatch,
|
|
241
330
|
) -> Callable[..., dict[str, Any]]:
|
|
242
331
|
"""
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""
|
|
2
|
+
:mod:`tests.integration.test_i_cli` module.
|
|
3
|
+
|
|
4
|
+
End-to-end CLI integration test suite that exercises the ``etlplus`` command
|
|
5
|
+
without external dependencies. Tests rely on shared fixtures for CLI
|
|
6
|
+
invocation and filesystem management to maximize reuse.
|
|
7
|
+
|
|
8
|
+
Notes
|
|
9
|
+
-----
|
|
10
|
+
- Uses ``cli_invoke``/``cli_runner`` fixtures to avoid ad-hoc monkeypatching.
|
|
11
|
+
- Creates JSON files through ``json_file_factory`` for deterministic cleanup.
|
|
12
|
+
- Keeps docstrings NumPy-compliant for automated linting.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
import pytest
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING: # pragma: no cover - typing helpers only
|
|
24
|
+
from tests.conftest import CliInvoke
|
|
25
|
+
from tests.conftest import JsonFactory
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# SECTION: HELPERS ========================================================== #
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
pytestmark = pytest.mark.integration
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# SECTION: TESTS ============================================================ #
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TestCliEndToEnd:
|
|
38
|
+
"""Integration test suite for :mod:`etlplus.cli`."""
|
|
39
|
+
|
|
40
|
+
@pytest.mark.parametrize(
|
|
41
|
+
('extra_flags', 'expected_code', 'expected_message'),
|
|
42
|
+
[
|
|
43
|
+
pytest.param(
|
|
44
|
+
['--strict-format'],
|
|
45
|
+
1,
|
|
46
|
+
'Error:',
|
|
47
|
+
id='strict-errors',
|
|
48
|
+
),
|
|
49
|
+
pytest.param(
|
|
50
|
+
[],
|
|
51
|
+
0,
|
|
52
|
+
'Warning:',
|
|
53
|
+
id='warns-default',
|
|
54
|
+
),
|
|
55
|
+
],
|
|
56
|
+
)
|
|
57
|
+
def test_extract_format_feedback(
|
|
58
|
+
self,
|
|
59
|
+
json_file_factory: JsonFactory,
|
|
60
|
+
cli_invoke: CliInvoke,
|
|
61
|
+
extra_flags: list[str],
|
|
62
|
+
expected_code: int,
|
|
63
|
+
expected_message: str,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Verify ``extract`` error/warning flow with optional strict flag."""
|
|
66
|
+
source = json_file_factory({'x': 1}, filename='payload.json')
|
|
67
|
+
args: list[str] = [
|
|
68
|
+
'extract',
|
|
69
|
+
'file',
|
|
70
|
+
str(source),
|
|
71
|
+
'--format',
|
|
72
|
+
'json',
|
|
73
|
+
*extra_flags,
|
|
74
|
+
]
|
|
75
|
+
code, _out, err = cli_invoke(args)
|
|
76
|
+
assert code == expected_code
|
|
77
|
+
assert expected_message in err
|
|
78
|
+
|
|
79
|
+
@pytest.mark.parametrize(
|
|
80
|
+
(
|
|
81
|
+
'extra_flags',
|
|
82
|
+
'expected_code',
|
|
83
|
+
'expected_message',
|
|
84
|
+
'expect_output',
|
|
85
|
+
),
|
|
86
|
+
[
|
|
87
|
+
pytest.param(
|
|
88
|
+
['--strict-format'],
|
|
89
|
+
1,
|
|
90
|
+
'Error:',
|
|
91
|
+
False,
|
|
92
|
+
id='strict-errors',
|
|
93
|
+
),
|
|
94
|
+
pytest.param(
|
|
95
|
+
[],
|
|
96
|
+
0,
|
|
97
|
+
'Warning:',
|
|
98
|
+
True,
|
|
99
|
+
id='warns-default',
|
|
100
|
+
),
|
|
101
|
+
],
|
|
102
|
+
)
|
|
103
|
+
def test_load_format_feedback(
|
|
104
|
+
self,
|
|
105
|
+
tmp_path: Path,
|
|
106
|
+
cli_invoke: CliInvoke,
|
|
107
|
+
extra_flags: list[str],
|
|
108
|
+
expected_code: int,
|
|
109
|
+
expected_message: str,
|
|
110
|
+
expect_output: bool,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""
|
|
113
|
+
Validate ``load`` warnings/errors and resulting output file state.
|
|
114
|
+
"""
|
|
115
|
+
output_path = tmp_path / 'output.csv'
|
|
116
|
+
args: list[str] = [
|
|
117
|
+
'load',
|
|
118
|
+
'{"name": "John"}',
|
|
119
|
+
'file',
|
|
120
|
+
str(output_path),
|
|
121
|
+
'--format',
|
|
122
|
+
'csv',
|
|
123
|
+
*extra_flags,
|
|
124
|
+
]
|
|
125
|
+
code, _out, err = cli_invoke(args)
|
|
126
|
+
assert code == expected_code
|
|
127
|
+
assert expected_message in err
|
|
128
|
+
assert output_path.exists() is expect_output
|
|
129
|
+
|
|
130
|
+
def test_main_no_command(self, cli_invoke: CliInvoke) -> None:
|
|
131
|
+
"""Test that running :func:`main` with no command shows usage."""
|
|
132
|
+
code, out, _err = cli_invoke()
|
|
133
|
+
assert code == 0
|
|
134
|
+
assert 'usage:' in out.lower()
|
|
135
|
+
|
|
136
|
+
def test_main_extract_file(
|
|
137
|
+
self,
|
|
138
|
+
json_file_factory: JsonFactory,
|
|
139
|
+
cli_invoke: CliInvoke,
|
|
140
|
+
) -> None:
|
|
141
|
+
"""Test that ``extract file`` prints the serialized payload."""
|
|
142
|
+
payload = {'name': 'John', 'age': 30}
|
|
143
|
+
source = json_file_factory(payload, filename='input.json')
|
|
144
|
+
code, out, _err = cli_invoke(('extract', 'file', str(source)))
|
|
145
|
+
assert code == 0
|
|
146
|
+
assert json.loads(out) == payload
|
|
147
|
+
|
|
148
|
+
def test_main_validate_data(
|
|
149
|
+
self,
|
|
150
|
+
cli_invoke: CliInvoke,
|
|
151
|
+
) -> None:
|
|
152
|
+
"""
|
|
153
|
+
Test that running :func:`main` with the ``validate`` command works.
|
|
154
|
+
"""
|
|
155
|
+
json_data = '{"name": "John", "age": 30}'
|
|
156
|
+
code, out, _err = cli_invoke(('validate', json_data))
|
|
157
|
+
assert code == 0
|
|
158
|
+
assert json.loads(out)['valid'] is True
|
|
159
|
+
|
|
160
|
+
def test_main_transform_data(
|
|
161
|
+
self,
|
|
162
|
+
cli_invoke: CliInvoke,
|
|
163
|
+
) -> None:
|
|
164
|
+
"""
|
|
165
|
+
Test that running :func:`main` with the ``transform`` command works.
|
|
166
|
+
"""
|
|
167
|
+
json_data = '[{"name": "John", "age": 30}]'
|
|
168
|
+
operations = '{"select": ["name"]}'
|
|
169
|
+
code, out, _err = cli_invoke(
|
|
170
|
+
('transform', json_data, '--operations', operations),
|
|
171
|
+
)
|
|
172
|
+
assert code == 0
|
|
173
|
+
output = json.loads(out)
|
|
174
|
+
assert len(output) == 1 and 'age' not in output[0]
|
|
175
|
+
|
|
176
|
+
def test_main_load_file(
|
|
177
|
+
self,
|
|
178
|
+
tmp_path: Path,
|
|
179
|
+
cli_invoke: CliInvoke,
|
|
180
|
+
) -> None:
|
|
181
|
+
"""
|
|
182
|
+
Test that running :func:`main` with the ``load`` file command works.
|
|
183
|
+
"""
|
|
184
|
+
output_path = tmp_path / 'output.json'
|
|
185
|
+
json_data = '{"name": "John", "age": 30}'
|
|
186
|
+
code, _out, _err = cli_invoke(
|
|
187
|
+
('load', json_data, 'file', str(output_path)),
|
|
188
|
+
)
|
|
189
|
+
assert code == 0
|
|
190
|
+
assert output_path.exists()
|
|
191
|
+
|
|
192
|
+
def test_main_extract_with_output(
|
|
193
|
+
self,
|
|
194
|
+
tmp_path: Path,
|
|
195
|
+
json_file_factory: JsonFactory,
|
|
196
|
+
cli_invoke: CliInvoke,
|
|
197
|
+
) -> None:
|
|
198
|
+
"""Test extract command with ``-o`` output persistence."""
|
|
199
|
+
test_data = {'name': 'John', 'age': 30}
|
|
200
|
+
source = json_file_factory(test_data, filename='input.json')
|
|
201
|
+
output_path = tmp_path / 'output.json'
|
|
202
|
+
code, _out, _err = cli_invoke(
|
|
203
|
+
(
|
|
204
|
+
'extract',
|
|
205
|
+
'file',
|
|
206
|
+
str(source),
|
|
207
|
+
'-o',
|
|
208
|
+
str(output_path),
|
|
209
|
+
),
|
|
210
|
+
)
|
|
211
|
+
assert code == 0
|
|
212
|
+
assert output_path.exists()
|
|
213
|
+
assert json.loads(output_path.read_text()) == test_data
|
|
214
|
+
|
|
215
|
+
def test_main_error_handling(
|
|
216
|
+
self,
|
|
217
|
+
cli_invoke: CliInvoke,
|
|
218
|
+
) -> None:
|
|
219
|
+
"""Test that running :func:`main` with an invalid command errors."""
|
|
220
|
+
code, _out, err = cli_invoke(
|
|
221
|
+
('extract', 'file', '/nonexistent/file.json'),
|
|
222
|
+
)
|
|
223
|
+
assert code == 1
|
|
224
|
+
assert 'Error:' in err
|
|
225
|
+
|
|
226
|
+
def test_main_strict_format_error(
|
|
227
|
+
self,
|
|
228
|
+
cli_invoke: CliInvoke,
|
|
229
|
+
) -> None:
|
|
230
|
+
"""
|
|
231
|
+
Test ``extract`` with ``--strict-format`` rejects mismatched args.
|
|
232
|
+
"""
|
|
233
|
+
code, _out, err = cli_invoke(
|
|
234
|
+
(
|
|
235
|
+
'extract',
|
|
236
|
+
'file',
|
|
237
|
+
'data.csv',
|
|
238
|
+
'--format',
|
|
239
|
+
'csv',
|
|
240
|
+
'--strict-format',
|
|
241
|
+
),
|
|
242
|
+
)
|
|
243
|
+
assert code == 1
|
|
244
|
+
assert 'Error:' in err
|
|
@@ -13,11 +13,16 @@ Notes
|
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
from typing import Any
|
|
15
15
|
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
16
18
|
from etlplus.file import File
|
|
17
19
|
|
|
18
20
|
# SECTION: HELPERS ========================================================== #
|
|
19
21
|
|
|
20
22
|
|
|
23
|
+
pytestmark = pytest.mark.integration
|
|
24
|
+
|
|
25
|
+
|
|
21
26
|
def _norm_record(
|
|
22
27
|
rec: dict[str, Any],
|
|
23
28
|
) -> dict[str, Any]:
|