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.
Files changed (128) hide show
  1. {etlplus-0.3.16/etlplus.egg-info → etlplus-0.3.19}/PKG-INFO +1 -1
  2. {etlplus-0.3.16 → etlplus-0.3.19/etlplus.egg-info}/PKG-INFO +1 -1
  3. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus.egg-info/SOURCES.txt +5 -0
  4. etlplus-0.3.19/tests/conftest.py +210 -0
  5. {etlplus-0.3.16 → etlplus-0.3.19}/tests/integration/conftest.py +105 -16
  6. etlplus-0.3.19/tests/integration/test_i_cli.py +244 -0
  7. {etlplus-0.3.16 → etlplus-0.3.19}/tests/integration/test_i_examples_data_parity.py +5 -0
  8. etlplus-0.3.19/tests/integration/test_i_pagination_strategy.py +551 -0
  9. {etlplus-0.3.16 → etlplus-0.3.19}/tests/integration/test_i_pipeline_smoke.py +41 -36
  10. {etlplus-0.3.16 → etlplus-0.3.19}/tests/integration/test_i_pipeline_yaml_load.py +6 -0
  11. etlplus-0.3.19/tests/integration/test_i_run.py +69 -0
  12. {etlplus-0.3.16 → etlplus-0.3.19}/tests/integration/test_i_run_profile_pagination_defaults.py +11 -7
  13. {etlplus-0.3.16 → etlplus-0.3.19}/tests/integration/test_i_run_profile_rate_limit_defaults.py +6 -0
  14. {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/api/conftest.py +42 -15
  15. {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/api/test_u_auth.py +113 -123
  16. {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/api/test_u_config.py +60 -16
  17. {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/api/test_u_endpoint_client.py +455 -275
  18. {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/api/test_u_pagination_client.py +5 -0
  19. {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/api/test_u_pagination_config.py +5 -0
  20. {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/api/test_u_paginator.py +5 -0
  21. {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/api/test_u_rate_limit_config.py +5 -0
  22. {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/api/test_u_rate_limiter.py +5 -0
  23. {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/api/test_u_request_manager.py +71 -4
  24. {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/api/test_u_retry_manager.py +6 -0
  25. {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/api/test_u_transport.py +51 -0
  26. etlplus-0.3.19/tests/unit/api/test_u_types.py +136 -0
  27. etlplus-0.3.19/tests/unit/config/test_u_connector.py +119 -0
  28. etlplus-0.3.19/tests/unit/config/test_u_pipeline.py +285 -0
  29. {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/conftest.py +30 -30
  30. etlplus-0.3.19/tests/unit/test_u_cli.py +185 -0
  31. etlplus-0.3.19/tests/unit/test_u_enums.py +135 -0
  32. {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/test_u_extract.py +212 -0
  33. etlplus-0.3.19/tests/unit/test_u_file.py +296 -0
  34. {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/test_u_load.py +201 -0
  35. etlplus-0.3.19/tests/unit/test_u_mixins.py +47 -0
  36. etlplus-0.3.19/tests/unit/test_u_run.py +289 -0
  37. etlplus-0.3.19/tests/unit/test_u_run_helpers.py +385 -0
  38. {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/test_u_transform.py +80 -0
  39. {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/test_u_utils.py +92 -3
  40. {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/test_u_validate.py +40 -0
  41. etlplus-0.3.16/tests/conftest.py +0 -11
  42. etlplus-0.3.16/tests/integration/test_i_cli.py +0 -348
  43. etlplus-0.3.16/tests/integration/test_i_pagination_strategy.py +0 -452
  44. etlplus-0.3.16/tests/integration/test_i_run.py +0 -133
  45. etlplus-0.3.16/tests/unit/config/test_u_connector.py +0 -54
  46. etlplus-0.3.16/tests/unit/config/test_u_pipeline.py +0 -194
  47. etlplus-0.3.16/tests/unit/test_u_cli.py +0 -124
  48. etlplus-0.3.16/tests/unit/test_u_file.py +0 -100
  49. {etlplus-0.3.16 → etlplus-0.3.19}/.coveragerc +0 -0
  50. {etlplus-0.3.16 → etlplus-0.3.19}/.editorconfig +0 -0
  51. {etlplus-0.3.16 → etlplus-0.3.19}/.gitattributes +0 -0
  52. {etlplus-0.3.16 → etlplus-0.3.19}/.github/actions/python-bootstrap/action.yml +0 -0
  53. {etlplus-0.3.16 → etlplus-0.3.19}/.github/workflows/ci.yml +0 -0
  54. {etlplus-0.3.16 → etlplus-0.3.19}/.gitignore +0 -0
  55. {etlplus-0.3.16 → etlplus-0.3.19}/.pre-commit-config.yaml +0 -0
  56. {etlplus-0.3.16 → etlplus-0.3.19}/.ruff.toml +0 -0
  57. {etlplus-0.3.16 → etlplus-0.3.19}/CODE_OF_CONDUCT.md +0 -0
  58. {etlplus-0.3.16 → etlplus-0.3.19}/CONTRIBUTING.md +0 -0
  59. {etlplus-0.3.16 → etlplus-0.3.19}/DEMO.md +0 -0
  60. {etlplus-0.3.16 → etlplus-0.3.19}/LICENSE +0 -0
  61. {etlplus-0.3.16 → etlplus-0.3.19}/Makefile +0 -0
  62. {etlplus-0.3.16 → etlplus-0.3.19}/README.md +0 -0
  63. {etlplus-0.3.16 → etlplus-0.3.19}/REFERENCES.md +0 -0
  64. {etlplus-0.3.16 → etlplus-0.3.19}/docs/pipeline-guide.md +0 -0
  65. {etlplus-0.3.16 → etlplus-0.3.19}/docs/snippets/installation_version.md +0 -0
  66. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/__init__.py +0 -0
  67. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/__main__.py +0 -0
  68. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/__version__.py +0 -0
  69. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/api/README.md +0 -0
  70. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/api/__init__.py +0 -0
  71. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/api/auth.py +0 -0
  72. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/api/config.py +0 -0
  73. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/api/endpoint_client.py +0 -0
  74. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/api/errors.py +0 -0
  75. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/api/pagination/__init__.py +0 -0
  76. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/api/pagination/client.py +0 -0
  77. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/api/pagination/config.py +0 -0
  78. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/api/pagination/paginator.py +0 -0
  79. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/api/rate_limiting/__init__.py +0 -0
  80. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/api/rate_limiting/config.py +0 -0
  81. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/api/rate_limiting/rate_limiter.py +0 -0
  82. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/api/request_manager.py +0 -0
  83. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/api/retry_manager.py +0 -0
  84. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/api/transport.py +0 -0
  85. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/api/types.py +0 -0
  86. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/cli.py +0 -0
  87. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/config/__init__.py +0 -0
  88. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/config/connector.py +0 -0
  89. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/config/jobs.py +0 -0
  90. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/config/pipeline.py +0 -0
  91. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/config/profile.py +0 -0
  92. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/config/types.py +0 -0
  93. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/config/utils.py +0 -0
  94. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/enums.py +0 -0
  95. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/extract.py +0 -0
  96. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/file.py +0 -0
  97. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/load.py +0 -0
  98. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/mixins.py +0 -0
  99. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/py.typed +0 -0
  100. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/run.py +0 -0
  101. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/run_helpers.py +0 -0
  102. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/transform.py +0 -0
  103. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/types.py +0 -0
  104. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/utils.py +0 -0
  105. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/validate.py +0 -0
  106. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/validation/__init__.py +0 -0
  107. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus/validation/utils.py +0 -0
  108. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus.egg-info/dependency_links.txt +0 -0
  109. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus.egg-info/entry_points.txt +0 -0
  110. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus.egg-info/requires.txt +0 -0
  111. {etlplus-0.3.16 → etlplus-0.3.19}/etlplus.egg-info/top_level.txt +0 -0
  112. {etlplus-0.3.16 → etlplus-0.3.19}/examples/README.md +0 -0
  113. {etlplus-0.3.16 → etlplus-0.3.19}/examples/configs/pipeline.yml +0 -0
  114. {etlplus-0.3.16 → etlplus-0.3.19}/examples/data/sample.csv +0 -0
  115. {etlplus-0.3.16 → etlplus-0.3.19}/examples/data/sample.json +0 -0
  116. {etlplus-0.3.16 → etlplus-0.3.19}/examples/data/sample.xml +0 -0
  117. {etlplus-0.3.16 → etlplus-0.3.19}/examples/data/sample.xsd +0 -0
  118. {etlplus-0.3.16 → etlplus-0.3.19}/examples/data/sample.yaml +0 -0
  119. {etlplus-0.3.16 → etlplus-0.3.19}/examples/quickstart_python.py +0 -0
  120. {etlplus-0.3.16 → etlplus-0.3.19}/pyproject.toml +0 -0
  121. {etlplus-0.3.16 → etlplus-0.3.19}/pytest.ini +0 -0
  122. {etlplus-0.3.16 → etlplus-0.3.19}/setup.cfg +0 -0
  123. {etlplus-0.3.16 → etlplus-0.3.19}/setup.py +0 -0
  124. {etlplus-0.3.16 → etlplus-0.3.19}/tests/__init__.py +0 -0
  125. {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/api/test_u_mocks.py +0 -0
  126. {etlplus-0.3.16 → etlplus-0.3.19}/tests/unit/validation/test_u_validation_utils.py +0 -0
  127. {etlplus-0.3.16 → etlplus-0.3.19}/tools/run_pipeline.py +0 -0
  128. {etlplus-0.3.16 → etlplus-0.3.19}/tools/update_demo_snippets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: etlplus
3
- Version: 0.3.16
3
+ Version: 0.3.19
4
4
  Summary: A Swiss Army knife for simple ETL operations
5
5
  Home-page: https://github.com/Dagitali/ETLPlus
6
6
  Author: ETLPlus Team
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: etlplus
3
- Version: 0.3.16
3
+ Version: 0.3.19
4
4
  Summary: A Swiss Army knife for simple ETL operations
5
5
  Home-page: https://github.com/Dagitali/ETLPlus
6
6
  Author: ETLPlus Team
@@ -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 capture_load_to_api(
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 fake_endpoint_client() -> tuple[
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 pipeline_cfg_factory(
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='https://api.example.com',
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 run_patched(
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]: