etlplus 0.3.23__tar.gz → 0.3.25__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 (125) hide show
  1. {etlplus-0.3.23/etlplus.egg-info → etlplus-0.3.25}/PKG-INFO +1 -1
  2. {etlplus-0.3.23 → etlplus-0.3.25/etlplus.egg-info}/PKG-INFO +1 -1
  3. etlplus-0.3.25/tests/unit/test_u_run.py +602 -0
  4. etlplus-0.3.23/tests/unit/test_u_run.py +0 -303
  5. {etlplus-0.3.23 → etlplus-0.3.25}/.coveragerc +0 -0
  6. {etlplus-0.3.23 → etlplus-0.3.25}/.editorconfig +0 -0
  7. {etlplus-0.3.23 → etlplus-0.3.25}/.gitattributes +0 -0
  8. {etlplus-0.3.23 → etlplus-0.3.25}/.github/actions/python-bootstrap/action.yml +0 -0
  9. {etlplus-0.3.23 → etlplus-0.3.25}/.github/workflows/ci.yml +0 -0
  10. {etlplus-0.3.23 → etlplus-0.3.25}/.gitignore +0 -0
  11. {etlplus-0.3.23 → etlplus-0.3.25}/.pre-commit-config.yaml +0 -0
  12. {etlplus-0.3.23 → etlplus-0.3.25}/.ruff.toml +0 -0
  13. {etlplus-0.3.23 → etlplus-0.3.25}/CODE_OF_CONDUCT.md +0 -0
  14. {etlplus-0.3.23 → etlplus-0.3.25}/CONTRIBUTING.md +0 -0
  15. {etlplus-0.3.23 → etlplus-0.3.25}/DEMO.md +0 -0
  16. {etlplus-0.3.23 → etlplus-0.3.25}/LICENSE +0 -0
  17. {etlplus-0.3.23 → etlplus-0.3.25}/Makefile +0 -0
  18. {etlplus-0.3.23 → etlplus-0.3.25}/README.md +0 -0
  19. {etlplus-0.3.23 → etlplus-0.3.25}/REFERENCES.md +0 -0
  20. {etlplus-0.3.23 → etlplus-0.3.25}/docs/pipeline-guide.md +0 -0
  21. {etlplus-0.3.23 → etlplus-0.3.25}/docs/snippets/installation_version.md +0 -0
  22. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/__init__.py +0 -0
  23. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/__main__.py +0 -0
  24. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/__version__.py +0 -0
  25. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/api/README.md +0 -0
  26. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/api/__init__.py +0 -0
  27. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/api/auth.py +0 -0
  28. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/api/config.py +0 -0
  29. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/api/endpoint_client.py +0 -0
  30. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/api/errors.py +0 -0
  31. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/api/pagination/__init__.py +0 -0
  32. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/api/pagination/client.py +0 -0
  33. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/api/pagination/config.py +0 -0
  34. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/api/pagination/paginator.py +0 -0
  35. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/api/rate_limiting/__init__.py +0 -0
  36. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/api/rate_limiting/config.py +0 -0
  37. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/api/rate_limiting/rate_limiter.py +0 -0
  38. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/api/request_manager.py +0 -0
  39. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/api/retry_manager.py +0 -0
  40. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/api/transport.py +0 -0
  41. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/api/types.py +0 -0
  42. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/cli.py +0 -0
  43. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/config/__init__.py +0 -0
  44. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/config/connector.py +0 -0
  45. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/config/jobs.py +0 -0
  46. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/config/pipeline.py +0 -0
  47. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/config/profile.py +0 -0
  48. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/config/types.py +0 -0
  49. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/config/utils.py +0 -0
  50. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/enums.py +0 -0
  51. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/extract.py +0 -0
  52. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/file.py +0 -0
  53. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/load.py +0 -0
  54. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/mixins.py +0 -0
  55. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/py.typed +0 -0
  56. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/run.py +0 -0
  57. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/run_helpers.py +0 -0
  58. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/transform.py +0 -0
  59. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/types.py +0 -0
  60. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/utils.py +0 -0
  61. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/validate.py +0 -0
  62. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/validation/__init__.py +0 -0
  63. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus/validation/utils.py +0 -0
  64. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus.egg-info/SOURCES.txt +0 -0
  65. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus.egg-info/dependency_links.txt +0 -0
  66. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus.egg-info/entry_points.txt +0 -0
  67. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus.egg-info/requires.txt +0 -0
  68. {etlplus-0.3.23 → etlplus-0.3.25}/etlplus.egg-info/top_level.txt +0 -0
  69. {etlplus-0.3.23 → etlplus-0.3.25}/examples/README.md +0 -0
  70. {etlplus-0.3.23 → etlplus-0.3.25}/examples/configs/pipeline.yml +0 -0
  71. {etlplus-0.3.23 → etlplus-0.3.25}/examples/data/sample.csv +0 -0
  72. {etlplus-0.3.23 → etlplus-0.3.25}/examples/data/sample.json +0 -0
  73. {etlplus-0.3.23 → etlplus-0.3.25}/examples/data/sample.xml +0 -0
  74. {etlplus-0.3.23 → etlplus-0.3.25}/examples/data/sample.xsd +0 -0
  75. {etlplus-0.3.23 → etlplus-0.3.25}/examples/data/sample.yaml +0 -0
  76. {etlplus-0.3.23 → etlplus-0.3.25}/examples/quickstart_python.py +0 -0
  77. {etlplus-0.3.23 → etlplus-0.3.25}/pyproject.toml +0 -0
  78. {etlplus-0.3.23 → etlplus-0.3.25}/pytest.ini +0 -0
  79. {etlplus-0.3.23 → etlplus-0.3.25}/setup.cfg +0 -0
  80. {etlplus-0.3.23 → etlplus-0.3.25}/setup.py +0 -0
  81. {etlplus-0.3.23 → etlplus-0.3.25}/tests/__init__.py +0 -0
  82. {etlplus-0.3.23 → etlplus-0.3.25}/tests/conftest.py +0 -0
  83. {etlplus-0.3.23 → etlplus-0.3.25}/tests/integration/conftest.py +0 -0
  84. {etlplus-0.3.23 → etlplus-0.3.25}/tests/integration/test_i_cli.py +0 -0
  85. {etlplus-0.3.23 → etlplus-0.3.25}/tests/integration/test_i_examples_data_parity.py +0 -0
  86. {etlplus-0.3.23 → etlplus-0.3.25}/tests/integration/test_i_pagination_strategy.py +0 -0
  87. {etlplus-0.3.23 → etlplus-0.3.25}/tests/integration/test_i_pipeline_smoke.py +0 -0
  88. {etlplus-0.3.23 → etlplus-0.3.25}/tests/integration/test_i_pipeline_yaml_load.py +0 -0
  89. {etlplus-0.3.23 → etlplus-0.3.25}/tests/integration/test_i_run.py +0 -0
  90. {etlplus-0.3.23 → etlplus-0.3.25}/tests/integration/test_i_run_profile_pagination_defaults.py +0 -0
  91. {etlplus-0.3.23 → etlplus-0.3.25}/tests/integration/test_i_run_profile_rate_limit_defaults.py +0 -0
  92. {etlplus-0.3.23 → etlplus-0.3.25}/tests/unit/api/conftest.py +0 -0
  93. {etlplus-0.3.23 → etlplus-0.3.25}/tests/unit/api/test_u_auth.py +0 -0
  94. {etlplus-0.3.23 → etlplus-0.3.25}/tests/unit/api/test_u_config.py +0 -0
  95. {etlplus-0.3.23 → etlplus-0.3.25}/tests/unit/api/test_u_endpoint_client.py +0 -0
  96. {etlplus-0.3.23 → etlplus-0.3.25}/tests/unit/api/test_u_mocks.py +0 -0
  97. {etlplus-0.3.23 → etlplus-0.3.25}/tests/unit/api/test_u_pagination_client.py +0 -0
  98. {etlplus-0.3.23 → etlplus-0.3.25}/tests/unit/api/test_u_pagination_config.py +0 -0
  99. {etlplus-0.3.23 → etlplus-0.3.25}/tests/unit/api/test_u_paginator.py +0 -0
  100. {etlplus-0.3.23 → etlplus-0.3.25}/tests/unit/api/test_u_rate_limit_config.py +0 -0
  101. {etlplus-0.3.23 → etlplus-0.3.25}/tests/unit/api/test_u_rate_limiter.py +0 -0
  102. {etlplus-0.3.23 → etlplus-0.3.25}/tests/unit/api/test_u_request_manager.py +0 -0
  103. {etlplus-0.3.23 → etlplus-0.3.25}/tests/unit/api/test_u_retry_manager.py +0 -0
  104. {etlplus-0.3.23 → etlplus-0.3.25}/tests/unit/api/test_u_transport.py +0 -0
  105. {etlplus-0.3.23 → etlplus-0.3.25}/tests/unit/api/test_u_types.py +0 -0
  106. {etlplus-0.3.23 → etlplus-0.3.25}/tests/unit/config/test_u_config_utils.py +0 -0
  107. {etlplus-0.3.23 → etlplus-0.3.25}/tests/unit/config/test_u_connector.py +0 -0
  108. {etlplus-0.3.23 → etlplus-0.3.25}/tests/unit/config/test_u_jobs.py +0 -0
  109. {etlplus-0.3.23 → etlplus-0.3.25}/tests/unit/config/test_u_pipeline.py +0 -0
  110. {etlplus-0.3.23 → etlplus-0.3.25}/tests/unit/conftest.py +0 -0
  111. {etlplus-0.3.23 → etlplus-0.3.25}/tests/unit/test_u_cli.py +0 -0
  112. {etlplus-0.3.23 → etlplus-0.3.25}/tests/unit/test_u_enums.py +0 -0
  113. {etlplus-0.3.23 → etlplus-0.3.25}/tests/unit/test_u_extract.py +0 -0
  114. {etlplus-0.3.23 → etlplus-0.3.25}/tests/unit/test_u_file.py +0 -0
  115. {etlplus-0.3.23 → etlplus-0.3.25}/tests/unit/test_u_load.py +0 -0
  116. {etlplus-0.3.23 → etlplus-0.3.25}/tests/unit/test_u_main.py +0 -0
  117. {etlplus-0.3.23 → etlplus-0.3.25}/tests/unit/test_u_mixins.py +0 -0
  118. {etlplus-0.3.23 → etlplus-0.3.25}/tests/unit/test_u_run_helpers.py +0 -0
  119. {etlplus-0.3.23 → etlplus-0.3.25}/tests/unit/test_u_transform.py +0 -0
  120. {etlplus-0.3.23 → etlplus-0.3.25}/tests/unit/test_u_utils.py +0 -0
  121. {etlplus-0.3.23 → etlplus-0.3.25}/tests/unit/test_u_validate.py +0 -0
  122. {etlplus-0.3.23 → etlplus-0.3.25}/tests/unit/test_u_version.py +0 -0
  123. {etlplus-0.3.23 → etlplus-0.3.25}/tests/unit/validation/test_u_validation_utils.py +0 -0
  124. {etlplus-0.3.23 → etlplus-0.3.25}/tools/run_pipeline.py +0 -0
  125. {etlplus-0.3.23 → etlplus-0.3.25}/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.23
3
+ Version: 0.3.25
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.23
3
+ Version: 0.3.25
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
@@ -0,0 +1,602 @@
1
+ """
2
+ :mod:`tests.unit.test_u_run` module.
3
+
4
+ Unit tests for :mod:`etlplus.run`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import importlib
10
+ from pathlib import Path
11
+ from types import SimpleNamespace
12
+ from typing import Any
13
+ from typing import ClassVar
14
+ from typing import Self
15
+
16
+ import pytest
17
+
18
+ run_mod = importlib.import_module('etlplus.run')
19
+
20
+ # SECTION: HELPERS ========================================================== #
21
+
22
+
23
+ pytestmark = pytest.mark.unit
24
+
25
+
26
+ def _make_job(
27
+ *,
28
+ name: str,
29
+ source: str,
30
+ target: str,
31
+ options: dict[str, Any] | None = None,
32
+ ) -> SimpleNamespace:
33
+ return SimpleNamespace(
34
+ name=name,
35
+ extract=SimpleNamespace(source=source, options=options or {}),
36
+ transform=SimpleNamespace(pipeline='noop'),
37
+ load=SimpleNamespace(target=target, overrides=None),
38
+ validate=None,
39
+ )
40
+
41
+
42
+ def _base_config(
43
+ job: SimpleNamespace,
44
+ source: SimpleNamespace,
45
+ target: SimpleNamespace,
46
+ ) -> SimpleNamespace:
47
+ return SimpleNamespace(
48
+ jobs=[job],
49
+ sources=[source],
50
+ targets=[target],
51
+ transforms={'noop': {}},
52
+ validations={},
53
+ )
54
+
55
+
56
+ # SECTION: TESTS ============================================================ #
57
+
58
+
59
+ class TestRun:
60
+ """Unit test suite for :func:`etlplus.run.run`."""
61
+
62
+ def test_api_source_and_target_pipeline(
63
+ self,
64
+ base_url: str,
65
+ monkeypatch: pytest.MonkeyPatch,
66
+ ) -> None:
67
+ """Test an API-to-API ETL pipeline execution."""
68
+ job = _make_job(name='api_job', source='api_src', target='api_tgt')
69
+ cfg = _base_config(
70
+ job,
71
+ SimpleNamespace(name='api_src', type='api'),
72
+ SimpleNamespace(name='api_tgt', type='api'),
73
+ )
74
+
75
+ monkeypatch.setattr(
76
+ run_mod,
77
+ 'load_pipeline_config',
78
+ lambda path, substitute=True: cfg,
79
+ )
80
+
81
+ req_env = {
82
+ 'use_endpoints': True,
83
+ 'base_url': base_url,
84
+ 'base_path': '/v1',
85
+ 'endpoints_map': {'users': '/users'},
86
+ 'endpoint_key': 'users',
87
+ 'params': {'limit': 5},
88
+ 'headers': {'Accept': 'json'},
89
+ 'timeout': 5,
90
+ 'pagination': {'type': 'page'},
91
+ 'sleep_seconds': 0.2,
92
+ 'retry': {'max_attempts': 2},
93
+ 'retry_network_errors': True,
94
+ 'session': 'session-token',
95
+ }
96
+ monkeypatch.setattr(
97
+ run_mod,
98
+ 'compose_api_request_env',
99
+ lambda cfg_obj, source_obj, opts: req_env,
100
+ )
101
+
102
+ class DummyClient:
103
+ """Dummy EndpointClient for testing purposes."""
104
+
105
+ instances: ClassVar[list[Self]] = []
106
+
107
+ def __init__(self, **kwargs: Any) -> None:
108
+ self.kwargs = kwargs
109
+ DummyClient.instances.append(self)
110
+
111
+ monkeypatch.setattr(run_mod, 'EndpointClient', DummyClient)
112
+
113
+ paginate_calls: list[dict[str, Any]] = []
114
+
115
+ def _capture_paginate(
116
+ client: Any,
117
+ endpoint_key: str,
118
+ params: Any,
119
+ headers: Any,
120
+ timeout: Any,
121
+ pagination: Any,
122
+ sleep_seconds: Any,
123
+ ) -> list[dict[str, int]]:
124
+ paginate_calls.append(
125
+ {
126
+ 'client': client,
127
+ 'endpoint_key': endpoint_key,
128
+ 'params': params,
129
+ 'headers': headers,
130
+ 'timeout': timeout,
131
+ 'pagination': pagination,
132
+ 'sleep_seconds': sleep_seconds,
133
+ },
134
+ )
135
+ return [{'id': 1}]
136
+
137
+ monkeypatch.setattr(run_mod, 'paginate_with_client', _capture_paginate)
138
+
139
+ monkeypatch.setattr(
140
+ run_mod,
141
+ 'maybe_validate',
142
+ lambda data, stage, **kwargs: data,
143
+ )
144
+
145
+ monkeypatch.setattr(run_mod, 'transform', lambda data, ops: data)
146
+
147
+ target_env = {
148
+ 'url': 'https://sink.example.com',
149
+ 'method': 'put',
150
+ 'headers': {'Auth': 'token'},
151
+ 'timeout': 7,
152
+ 'session': 'target-session',
153
+ }
154
+ monkeypatch.setattr(
155
+ run_mod,
156
+ 'compose_api_target_env',
157
+ lambda cfg_obj, target_obj, overrides: target_env,
158
+ )
159
+
160
+ load_calls: list[tuple] = []
161
+
162
+ def _capture_load(
163
+ data: Any,
164
+ connector: str,
165
+ url: str,
166
+ **kwargs: Any,
167
+ ) -> dict[str, bool]:
168
+ load_calls.append((data, connector, url, kwargs))
169
+ return {'ok': True}
170
+
171
+ monkeypatch.setattr(run_mod, 'load', _capture_load)
172
+
173
+ result = run_mod.run('api_job')
174
+
175
+ assert DummyClient.instances
176
+ assert paginate_calls[0]['endpoint_key'] == 'users'
177
+ assert paginate_calls[0]['params'] == {'limit': 5}
178
+ assert load_calls[0][1] == 'api'
179
+ assert load_calls[0][2] == 'https://sink.example.com'
180
+ assert load_calls[0][3]['method'] == 'put'
181
+ assert result == {'ok': True}
182
+
183
+ def test_file_source_missing_path_raises(
184
+ self,
185
+ monkeypatch: pytest.MonkeyPatch,
186
+ ) -> None:
187
+ """Test that file source missing path raises :class:`ValueError`."""
188
+ job = _make_job(name='job', source='src', target='tgt')
189
+ src = SimpleNamespace(name='src', type='file', format='json')
190
+ tgt = SimpleNamespace(
191
+ name='tgt',
192
+ type='file',
193
+ path='/tmp/out.json',
194
+ format='json',
195
+ )
196
+ cfg = _base_config(job, src, tgt)
197
+ monkeypatch.setattr(
198
+ run_mod,
199
+ 'load_pipeline_config',
200
+ lambda path, substitute=True: cfg,
201
+ )
202
+ with pytest.raises(ValueError, match='File source missing "path"'):
203
+ run_mod.run('job')
204
+
205
+ def test_file_target_missing_path_raises(
206
+ self,
207
+ tmp_path: Path,
208
+ monkeypatch: pytest.MonkeyPatch,
209
+ ) -> None:
210
+ """Test that file target missing path raises :class:`ValueError`."""
211
+ job = _make_job(name='job', source='src', target='tgt')
212
+ src_path = tmp_path / 'in.json'
213
+ src_path.write_text('[]', encoding='utf-8')
214
+ src = SimpleNamespace(
215
+ name='src',
216
+ type='file',
217
+ path=str(src_path),
218
+ format='json',
219
+ )
220
+ tgt = SimpleNamespace(
221
+ name='tgt',
222
+ type='file',
223
+ format='json',
224
+ )
225
+ cfg = _base_config(job, src, tgt)
226
+ monkeypatch.setattr(
227
+ run_mod,
228
+ 'load_pipeline_config',
229
+ lambda path, substitute=True: cfg,
230
+ )
231
+ with pytest.raises(
232
+ ValueError,
233
+ match=r'(?i)(file target).*path|missing\s+"?path"?',
234
+ ):
235
+ run_mod.run('job')
236
+
237
+ def test_file_to_file_pipeline(
238
+ self,
239
+ monkeypatch: pytest.MonkeyPatch,
240
+ ) -> None:
241
+ """Test a file-to-file ETL pipeline execution."""
242
+ # pylint: disable=unused-argument
243
+ job = _make_job(name='file_job', source='file_src', target='file_tgt')
244
+ cfg = _base_config(
245
+ job,
246
+ SimpleNamespace(
247
+ name='file_src',
248
+ type='file',
249
+ path='/tmp/input.json',
250
+ format='json',
251
+ ),
252
+ SimpleNamespace(
253
+ name='file_tgt',
254
+ type='file',
255
+ path='/tmp/output.json',
256
+ format='json',
257
+ ),
258
+ )
259
+
260
+ monkeypatch.setattr(
261
+ run_mod,
262
+ 'load_pipeline_config',
263
+ lambda path, substitute=True: cfg,
264
+ )
265
+
266
+ extract_calls: list[tuple] = []
267
+
268
+ def _capture_extract(
269
+ stype: str,
270
+ source: str,
271
+ **kwargs: Any,
272
+ ) -> list[dict[str, int]]:
273
+ extract_calls.append((stype, source, kwargs))
274
+ return [{'id': 1}]
275
+
276
+ monkeypatch.setattr(run_mod, 'extract', _capture_extract)
277
+
278
+ transform_calls: list[Any] = []
279
+
280
+ def _capture_transform(data: Any, ops: Any) -> dict[str, Any]:
281
+ transform_calls.append((data, ops))
282
+ return {'payload': data}
283
+
284
+ monkeypatch.setattr(run_mod, 'transform', _capture_transform)
285
+
286
+ stages: list[str] = []
287
+
288
+ def _capture_validate(data: Any, stage: str, **kwargs: Any) -> Any:
289
+ stages.append(stage)
290
+ return data
291
+
292
+ monkeypatch.setattr(run_mod, 'maybe_validate', _capture_validate)
293
+
294
+ load_calls: list[tuple] = []
295
+
296
+ def _capture_load_file(
297
+ data: Any,
298
+ connector: str,
299
+ target: str,
300
+ **kwargs: Any,
301
+ ) -> dict[str, str]:
302
+ load_calls.append((data, connector, target, kwargs))
303
+ return {'status': 'ok'}
304
+
305
+ monkeypatch.setattr(run_mod, 'load', _capture_load_file)
306
+
307
+ result = run_mod.run('file_job')
308
+
309
+ assert extract_calls[0][0] == 'file'
310
+ assert extract_calls[0][1] == '/tmp/input.json'
311
+ assert transform_calls
312
+ assert stages == ['before_transform', 'after_transform']
313
+ assert load_calls[0][1] == 'file'
314
+ assert load_calls[0][2] == '/tmp/output.json'
315
+ assert result == {'status': 'ok'}
316
+
317
+ @pytest.mark.parametrize(
318
+ 'cfg',
319
+ [
320
+ SimpleNamespace(
321
+ jobs=[],
322
+ sources=[],
323
+ targets=[],
324
+ transforms={},
325
+ validations={},
326
+ ),
327
+ _base_config(
328
+ _make_job(name='other', source='src', target='tgt'),
329
+ SimpleNamespace(
330
+ name='src',
331
+ type='file',
332
+ path='/tmp/in.json',
333
+ format='json',
334
+ ),
335
+ SimpleNamespace(
336
+ name='tgt',
337
+ type='file',
338
+ path='/tmp/out.json',
339
+ format='json',
340
+ ),
341
+ ),
342
+ ],
343
+ ids=['no-jobs', 'different-job'],
344
+ )
345
+ def test_job_not_found_raises(
346
+ self,
347
+ monkeypatch: pytest.MonkeyPatch,
348
+ cfg: Any,
349
+ ) -> None:
350
+ """Test that requesting a missing job raises :class:`ValueError`."""
351
+ monkeypatch.setattr(
352
+ run_mod,
353
+ 'load_pipeline_config',
354
+ lambda path, substitute=True: cfg,
355
+ )
356
+
357
+ with pytest.raises(ValueError, match='Job not found'):
358
+ run_mod.run('missing')
359
+
360
+ def test_load_missing_section_raises(
361
+ self,
362
+ tmp_path: Path,
363
+ monkeypatch: pytest.MonkeyPatch,
364
+ ) -> None:
365
+ """Test that missing load section raises :class:`ValueError`."""
366
+ job = _make_job(name='job', source='src', target='tgt')
367
+ job.load = None
368
+ src_path = tmp_path / 'in.json'
369
+ src_path.write_text('[]', encoding='utf-8')
370
+ tgt_path = tmp_path / 'out.json'
371
+ tgt_path.write_text('[]', encoding='utf-8')
372
+
373
+ src = SimpleNamespace(
374
+ name='src',
375
+ type='file',
376
+ path=str(src_path),
377
+ format='json',
378
+ )
379
+ tgt = SimpleNamespace(
380
+ name='tgt',
381
+ type='file',
382
+ path=str(tgt_path),
383
+ format='json',
384
+ )
385
+ cfg = _base_config(job, src, tgt)
386
+ monkeypatch.setattr(
387
+ run_mod,
388
+ 'load_pipeline_config',
389
+ lambda path, substitute=True: cfg,
390
+ )
391
+ with pytest.raises(ValueError, match=r'(?i)load'):
392
+ run_mod.run('job')
393
+
394
+ def test_missing_extract_section_raises(
395
+ self,
396
+ monkeypatch: pytest.MonkeyPatch,
397
+ ) -> None:
398
+ """Test that missing extract section raises :class:`ValueError`."""
399
+ job = SimpleNamespace(
400
+ name='job',
401
+ extract=None,
402
+ transform=None,
403
+ load=None,
404
+ validate=None,
405
+ )
406
+ cfg = SimpleNamespace(
407
+ jobs=[job],
408
+ sources=[],
409
+ targets=[],
410
+ transforms={},
411
+ validations={},
412
+ )
413
+ monkeypatch.setattr(
414
+ run_mod,
415
+ 'load_pipeline_config',
416
+ lambda path, substitute=True: cfg,
417
+ )
418
+ with pytest.raises(ValueError, match='extract'):
419
+ run_mod.run('job')
420
+
421
+ def test_transform_and_validation_branches(
422
+ self,
423
+ tmp_path: Path,
424
+ monkeypatch: pytest.MonkeyPatch,
425
+ ) -> None:
426
+ """Test transform and validation branches are called."""
427
+ # pylint: disable=unused-argument
428
+
429
+ job = _make_job(name='job', source='src', target='tgt')
430
+ job.transform = SimpleNamespace(pipeline='noop')
431
+ job.validate = SimpleNamespace(
432
+ ruleset='rules',
433
+ phase='phase',
434
+ severity='severity',
435
+ )
436
+ src_path = tmp_path / 'in.json'
437
+ src_path.write_text('[]', encoding='utf-8')
438
+ tgt_path = tmp_path / 'out.json'
439
+ tgt_path.write_text('[]', encoding='utf-8')
440
+
441
+ src = SimpleNamespace(
442
+ name='src',
443
+ type='file',
444
+ path=str(src_path),
445
+ format='json',
446
+ )
447
+ tgt = SimpleNamespace(
448
+ name='tgt',
449
+ type='file',
450
+ path=str(tgt_path),
451
+ format='json',
452
+ )
453
+ cfg = _base_config(job, src, tgt)
454
+ cfg.validations = {'rules': {}}
455
+
456
+ monkeypatch.setattr(
457
+ run_mod,
458
+ 'load_pipeline_config',
459
+ lambda path, substitute=True: cfg,
460
+ )
461
+
462
+ monkeypatch.setattr(run_mod, 'extract', lambda *a, **k: [{'id': 1}])
463
+
464
+ validate_stages: list[str] = []
465
+
466
+ def _capture_validate(data: Any, stage: str, **kwargs: Any) -> Any:
467
+ validate_stages.append(stage)
468
+ return data
469
+
470
+ monkeypatch.setattr(run_mod, 'maybe_validate', _capture_validate)
471
+
472
+ transform_calls: list[tuple[Any, Any]] = []
473
+
474
+ def _capture_transform(data, ops):
475
+ transform_calls.append((data, ops))
476
+ return data
477
+
478
+ monkeypatch.setattr(run_mod, 'transform', _capture_transform)
479
+
480
+ load_calls: list[tuple[Any, str, str]] = []
481
+
482
+ def _capture_load(data, connector, path, **kwargs):
483
+ load_calls.append((data, connector, path))
484
+ return {'status': 'ok'}
485
+
486
+ monkeypatch.setattr(run_mod, 'load', _capture_load)
487
+
488
+ result = run_mod.run('job')
489
+
490
+ assert validate_stages[:1] == ['before_transform']
491
+ assert validate_stages[-1:] == ['after_transform']
492
+ assert transform_calls
493
+ assert load_calls == [([{'id': 1}], 'file', str(tgt_path))]
494
+ assert result == {'status': 'ok'}
495
+
496
+ def test_unknown_source_raises(
497
+ self,
498
+ monkeypatch: pytest.MonkeyPatch,
499
+ ) -> None:
500
+ """Test that unknown source raises :class:`ValueError`."""
501
+ job = _make_job(name='job', source='src', target='tgt')
502
+ cfg = SimpleNamespace(
503
+ jobs=[job],
504
+ sources=[],
505
+ targets=[],
506
+ transforms={},
507
+ validations={},
508
+ )
509
+ monkeypatch.setattr(
510
+ run_mod,
511
+ 'load_pipeline_config',
512
+ lambda path, substitute=True: cfg,
513
+ )
514
+ with pytest.raises(ValueError, match='Unknown source'):
515
+ run_mod.run('job')
516
+
517
+ def test_unknown_target_raises(
518
+ self,
519
+ tmp_path: Path,
520
+ monkeypatch: pytest.MonkeyPatch,
521
+ ) -> None:
522
+ """Test that unknown target raises :class:`ValueError`."""
523
+ job = _make_job(name='job', source='src', target='tgt')
524
+ src_path = tmp_path / 'in.json'
525
+ src_path.write_text('[]', encoding='utf-8')
526
+ src = SimpleNamespace(
527
+ name='src',
528
+ type='file',
529
+ path=str(src_path),
530
+ format='json',
531
+ )
532
+ cfg = SimpleNamespace(
533
+ jobs=[job],
534
+ sources=[src],
535
+ targets=[],
536
+ transforms={},
537
+ validations={},
538
+ )
539
+ monkeypatch.setattr(
540
+ run_mod,
541
+ 'load_pipeline_config',
542
+ lambda path, substitute=True: cfg,
543
+ )
544
+ with pytest.raises(ValueError, match=r'(?i)target'):
545
+ run_mod.run('job')
546
+
547
+ def test_unsupported_source_type_raises(
548
+ self,
549
+ monkeypatch: pytest.MonkeyPatch,
550
+ ) -> None:
551
+ """Test that unsupported source type raises :class:`ValueError`."""
552
+ job = _make_job(name='job', source='src', target='tgt')
553
+ src = SimpleNamespace(
554
+ name='src',
555
+ type='unsupported',
556
+ )
557
+ tgt = SimpleNamespace(
558
+ name='tgt',
559
+ type='file',
560
+ path='/tmp/out.json',
561
+ format='json',
562
+ )
563
+ cfg = _base_config(job, src, tgt)
564
+ monkeypatch.setattr(
565
+ run_mod,
566
+ 'load_pipeline_config',
567
+ lambda path, substitute=True: cfg,
568
+ )
569
+ with pytest.raises(ValueError, match=r'(?i)unsupported'):
570
+ run_mod.run('job')
571
+
572
+ def test_unsupported_target_type_raises(
573
+ self,
574
+ tmp_path: Path,
575
+ monkeypatch: pytest.MonkeyPatch,
576
+ ) -> None:
577
+ """Test that unsupported target type raises :class:`ValueError`."""
578
+ job = _make_job(name='job', source='src', target='tgt')
579
+ src_path = tmp_path / 'in.json'
580
+ src_path.write_text('[]', encoding='utf-8')
581
+ src = SimpleNamespace(
582
+ name='src',
583
+ type='file',
584
+ path=str(src_path),
585
+ format='json',
586
+ )
587
+ tgt_path = tmp_path / 'out.json'
588
+ tgt_path.write_text('[]', encoding='utf-8')
589
+ tgt = SimpleNamespace(
590
+ name='tgt',
591
+ type='unsupported',
592
+ path=str(tgt_path),
593
+ format='json',
594
+ )
595
+ cfg = _base_config(job, src, tgt)
596
+ monkeypatch.setattr(
597
+ run_mod,
598
+ 'load_pipeline_config',
599
+ lambda path, substitute=True: cfg,
600
+ )
601
+ with pytest.raises(ValueError, match=r'(?i)unsupported'):
602
+ run_mod.run('job')