etlplus 0.3.21__tar.gz → 0.3.23__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 (126) hide show
  1. {etlplus-0.3.21/etlplus.egg-info → etlplus-0.3.23}/PKG-INFO +1 -1
  2. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/transform.py +12 -0
  3. {etlplus-0.3.21 → etlplus-0.3.23/etlplus.egg-info}/PKG-INFO +1 -1
  4. etlplus-0.3.23/tests/unit/test_u_cli.py +598 -0
  5. etlplus-0.3.23/tests/unit/test_u_transform.py +860 -0
  6. etlplus-0.3.21/tests/unit/test_u_cli.py +0 -299
  7. etlplus-0.3.21/tests/unit/test_u_transform.py +0 -690
  8. {etlplus-0.3.21 → etlplus-0.3.23}/.coveragerc +0 -0
  9. {etlplus-0.3.21 → etlplus-0.3.23}/.editorconfig +0 -0
  10. {etlplus-0.3.21 → etlplus-0.3.23}/.gitattributes +0 -0
  11. {etlplus-0.3.21 → etlplus-0.3.23}/.github/actions/python-bootstrap/action.yml +0 -0
  12. {etlplus-0.3.21 → etlplus-0.3.23}/.github/workflows/ci.yml +0 -0
  13. {etlplus-0.3.21 → etlplus-0.3.23}/.gitignore +0 -0
  14. {etlplus-0.3.21 → etlplus-0.3.23}/.pre-commit-config.yaml +0 -0
  15. {etlplus-0.3.21 → etlplus-0.3.23}/.ruff.toml +0 -0
  16. {etlplus-0.3.21 → etlplus-0.3.23}/CODE_OF_CONDUCT.md +0 -0
  17. {etlplus-0.3.21 → etlplus-0.3.23}/CONTRIBUTING.md +0 -0
  18. {etlplus-0.3.21 → etlplus-0.3.23}/DEMO.md +0 -0
  19. {etlplus-0.3.21 → etlplus-0.3.23}/LICENSE +0 -0
  20. {etlplus-0.3.21 → etlplus-0.3.23}/Makefile +0 -0
  21. {etlplus-0.3.21 → etlplus-0.3.23}/README.md +0 -0
  22. {etlplus-0.3.21 → etlplus-0.3.23}/REFERENCES.md +0 -0
  23. {etlplus-0.3.21 → etlplus-0.3.23}/docs/pipeline-guide.md +0 -0
  24. {etlplus-0.3.21 → etlplus-0.3.23}/docs/snippets/installation_version.md +0 -0
  25. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/__init__.py +0 -0
  26. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/__main__.py +0 -0
  27. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/__version__.py +0 -0
  28. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/api/README.md +0 -0
  29. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/api/__init__.py +0 -0
  30. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/api/auth.py +0 -0
  31. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/api/config.py +0 -0
  32. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/api/endpoint_client.py +0 -0
  33. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/api/errors.py +0 -0
  34. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/api/pagination/__init__.py +0 -0
  35. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/api/pagination/client.py +0 -0
  36. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/api/pagination/config.py +0 -0
  37. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/api/pagination/paginator.py +0 -0
  38. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/api/rate_limiting/__init__.py +0 -0
  39. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/api/rate_limiting/config.py +0 -0
  40. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/api/rate_limiting/rate_limiter.py +0 -0
  41. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/api/request_manager.py +0 -0
  42. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/api/retry_manager.py +0 -0
  43. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/api/transport.py +0 -0
  44. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/api/types.py +0 -0
  45. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/cli.py +0 -0
  46. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/config/__init__.py +0 -0
  47. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/config/connector.py +0 -0
  48. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/config/jobs.py +0 -0
  49. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/config/pipeline.py +0 -0
  50. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/config/profile.py +0 -0
  51. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/config/types.py +0 -0
  52. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/config/utils.py +0 -0
  53. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/enums.py +0 -0
  54. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/extract.py +0 -0
  55. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/file.py +0 -0
  56. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/load.py +0 -0
  57. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/mixins.py +0 -0
  58. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/py.typed +0 -0
  59. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/run.py +0 -0
  60. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/run_helpers.py +0 -0
  61. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/types.py +0 -0
  62. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/utils.py +0 -0
  63. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/validate.py +0 -0
  64. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/validation/__init__.py +0 -0
  65. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/validation/utils.py +0 -0
  66. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus.egg-info/SOURCES.txt +0 -0
  67. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus.egg-info/dependency_links.txt +0 -0
  68. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus.egg-info/entry_points.txt +0 -0
  69. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus.egg-info/requires.txt +0 -0
  70. {etlplus-0.3.21 → etlplus-0.3.23}/etlplus.egg-info/top_level.txt +0 -0
  71. {etlplus-0.3.21 → etlplus-0.3.23}/examples/README.md +0 -0
  72. {etlplus-0.3.21 → etlplus-0.3.23}/examples/configs/pipeline.yml +0 -0
  73. {etlplus-0.3.21 → etlplus-0.3.23}/examples/data/sample.csv +0 -0
  74. {etlplus-0.3.21 → etlplus-0.3.23}/examples/data/sample.json +0 -0
  75. {etlplus-0.3.21 → etlplus-0.3.23}/examples/data/sample.xml +0 -0
  76. {etlplus-0.3.21 → etlplus-0.3.23}/examples/data/sample.xsd +0 -0
  77. {etlplus-0.3.21 → etlplus-0.3.23}/examples/data/sample.yaml +0 -0
  78. {etlplus-0.3.21 → etlplus-0.3.23}/examples/quickstart_python.py +0 -0
  79. {etlplus-0.3.21 → etlplus-0.3.23}/pyproject.toml +0 -0
  80. {etlplus-0.3.21 → etlplus-0.3.23}/pytest.ini +0 -0
  81. {etlplus-0.3.21 → etlplus-0.3.23}/setup.cfg +0 -0
  82. {etlplus-0.3.21 → etlplus-0.3.23}/setup.py +0 -0
  83. {etlplus-0.3.21 → etlplus-0.3.23}/tests/__init__.py +0 -0
  84. {etlplus-0.3.21 → etlplus-0.3.23}/tests/conftest.py +0 -0
  85. {etlplus-0.3.21 → etlplus-0.3.23}/tests/integration/conftest.py +0 -0
  86. {etlplus-0.3.21 → etlplus-0.3.23}/tests/integration/test_i_cli.py +0 -0
  87. {etlplus-0.3.21 → etlplus-0.3.23}/tests/integration/test_i_examples_data_parity.py +0 -0
  88. {etlplus-0.3.21 → etlplus-0.3.23}/tests/integration/test_i_pagination_strategy.py +0 -0
  89. {etlplus-0.3.21 → etlplus-0.3.23}/tests/integration/test_i_pipeline_smoke.py +0 -0
  90. {etlplus-0.3.21 → etlplus-0.3.23}/tests/integration/test_i_pipeline_yaml_load.py +0 -0
  91. {etlplus-0.3.21 → etlplus-0.3.23}/tests/integration/test_i_run.py +0 -0
  92. {etlplus-0.3.21 → etlplus-0.3.23}/tests/integration/test_i_run_profile_pagination_defaults.py +0 -0
  93. {etlplus-0.3.21 → etlplus-0.3.23}/tests/integration/test_i_run_profile_rate_limit_defaults.py +0 -0
  94. {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/api/conftest.py +0 -0
  95. {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/api/test_u_auth.py +0 -0
  96. {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/api/test_u_config.py +0 -0
  97. {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/api/test_u_endpoint_client.py +0 -0
  98. {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/api/test_u_mocks.py +0 -0
  99. {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/api/test_u_pagination_client.py +0 -0
  100. {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/api/test_u_pagination_config.py +0 -0
  101. {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/api/test_u_paginator.py +0 -0
  102. {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/api/test_u_rate_limit_config.py +0 -0
  103. {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/api/test_u_rate_limiter.py +0 -0
  104. {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/api/test_u_request_manager.py +0 -0
  105. {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/api/test_u_retry_manager.py +0 -0
  106. {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/api/test_u_transport.py +0 -0
  107. {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/api/test_u_types.py +0 -0
  108. {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/config/test_u_config_utils.py +0 -0
  109. {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/config/test_u_connector.py +0 -0
  110. {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/config/test_u_jobs.py +0 -0
  111. {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/config/test_u_pipeline.py +0 -0
  112. {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/conftest.py +0 -0
  113. {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/test_u_enums.py +0 -0
  114. {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/test_u_extract.py +0 -0
  115. {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/test_u_file.py +0 -0
  116. {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/test_u_load.py +0 -0
  117. {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/test_u_main.py +0 -0
  118. {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/test_u_mixins.py +0 -0
  119. {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/test_u_run.py +0 -0
  120. {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/test_u_run_helpers.py +0 -0
  121. {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/test_u_utils.py +0 -0
  122. {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/test_u_validate.py +0 -0
  123. {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/test_u_version.py +0 -0
  124. {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/validation/test_u_validation_utils.py +0 -0
  125. {etlplus-0.3.21 → etlplus-0.3.23}/tools/run_pipeline.py +0 -0
  126. {etlplus-0.3.21 → etlplus-0.3.23}/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.21
3
+ Version: 0.3.23
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
@@ -67,6 +67,18 @@ from .types import StepSpec
67
67
  from .types import StrPath
68
68
  from .utils import to_number
69
69
 
70
+ # SECTION: EXPORTS ========================================================== #
71
+
72
+
73
+ __all__ = [
74
+ 'apply_aggregate',
75
+ 'apply_filter',
76
+ 'apply_map',
77
+ 'apply_select',
78
+ 'apply_sort',
79
+ 'transform',
80
+ ]
81
+
70
82
  # SECTION: INTERNAL FUNCTIONS ============================================== #
71
83
 
72
84
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: etlplus
3
- Version: 0.3.21
3
+ Version: 0.3.23
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,598 @@
1
+ """
2
+ :mod:`tests.unit.test_u_cli` module.
3
+
4
+ Unit tests for :mod:`etlplus.cli`.
5
+
6
+ Notes
7
+ -----
8
+ These tests are intended to be hermetic. They avoid real network I/O and keep
9
+ file I/O limited to pytest-managed temporary directories.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import types
16
+ from collections.abc import Callable
17
+ from collections.abc import Mapping
18
+ from collections.abc import Sequence
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import Final
22
+ from unittest.mock import Mock
23
+
24
+ import pytest
25
+
26
+ import etlplus.cli as cli
27
+
28
+ # SECTION: HELPERS ========================================================== #
29
+
30
+ pytestmark = pytest.mark.unit
31
+
32
+ type ParseCli = Callable[[Sequence[str]], argparse.Namespace]
33
+
34
+ CSV_TEXT: Final[str] = 'a,b\n1,2\n3,4\n'
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class ParserCase:
39
+ """
40
+ Declarative CLI parser test case.
41
+
42
+ Attributes
43
+ ----------
44
+ identifier : str
45
+ Stable ID for pytest parametrization.
46
+ args : tuple[str, ...]
47
+ Argument vector passed to :meth:`argparse.ArgumentParser.parse_args`.
48
+ expected : Mapping[str, object]
49
+ Mapping of expected attribute values on the returned namespace.
50
+ """
51
+
52
+ identifier: str
53
+ args: tuple[str, ...]
54
+ expected: Mapping[str, object]
55
+
56
+
57
+ # Shared parser cases to keep parametrization DRY and self-documenting.
58
+ PARSER_CASES: Final[tuple[ParserCase, ...]] = (
59
+ ParserCase(
60
+ identifier='extract-default-format',
61
+ args=('extract', 'file', '/path/to/file.json'),
62
+ expected={
63
+ 'command': 'extract',
64
+ 'source_type': 'file',
65
+ 'source': '/path/to/file.json',
66
+ 'format': 'json',
67
+ },
68
+ ),
69
+ ParserCase(
70
+ identifier='extract-explicit-format',
71
+ args=('extract', 'file', '/path/to/file.csv', '--format', 'csv'),
72
+ expected={
73
+ 'command': 'extract',
74
+ 'source_type': 'file',
75
+ 'source': '/path/to/file.csv',
76
+ 'format': 'csv',
77
+ '_format_explicit': True,
78
+ },
79
+ ),
80
+ ParserCase(
81
+ identifier='load-default-format',
82
+ args=('load', '/path/to/file.json', 'file', '/path/to/output.json'),
83
+ expected={
84
+ 'command': 'load',
85
+ 'source': '/path/to/file.json',
86
+ 'target_type': 'file',
87
+ 'target': '/path/to/output.json',
88
+ },
89
+ ),
90
+ ParserCase(
91
+ identifier='load-explicit-format',
92
+ args=(
93
+ 'load',
94
+ '/path/to/file.json',
95
+ 'file',
96
+ '/path/to/output.csv',
97
+ '--format',
98
+ 'csv',
99
+ ),
100
+ expected={
101
+ 'command': 'load',
102
+ 'source': '/path/to/file.json',
103
+ 'target_type': 'file',
104
+ 'target': '/path/to/output.csv',
105
+ 'format': 'csv',
106
+ '_format_explicit': True,
107
+ },
108
+ ),
109
+ # ParserCase(
110
+ # identifier='no-subcommand',
111
+ # args=(),
112
+ # expected={'command': None},
113
+ # ),
114
+ ParserCase(
115
+ identifier='transform',
116
+ args=('transform', '/path/to/file.json'),
117
+ expected={'command': 'transform', 'source': '/path/to/file.json'},
118
+ ),
119
+ ParserCase(
120
+ identifier='validate',
121
+ args=('validate', '/path/to/file.json'),
122
+ expected={'command': 'validate', 'source': '/path/to/file.json'},
123
+ ),
124
+ )
125
+
126
+
127
+ def _subcommand_dests(parser: argparse.ArgumentParser) -> set[str]:
128
+ """Extract registered subcommand dests from an argparse parser.
129
+
130
+ Notes
131
+ -----
132
+ This inspects argparse internals to keep the test small and explicit.
133
+
134
+ Parameters
135
+ ----------
136
+ parser : argparse.ArgumentParser
137
+ Parser to introspect.
138
+
139
+ Returns
140
+ -------
141
+ set[str]
142
+ Set of subcommand dest names.
143
+ """
144
+ # pylint: disable=protected-access
145
+
146
+ subparsers = getattr(parser, '_subparsers', None)
147
+ if subparsers is None:
148
+ return set()
149
+
150
+ group_actions = getattr(subparsers, '_group_actions', [])
151
+ if not group_actions:
152
+ return set()
153
+
154
+ action = group_actions[0]
155
+ choices = getattr(action, '_choices_actions', [])
156
+ return {a.dest for a in choices}
157
+
158
+
159
+ @dataclass(slots=True)
160
+ class ParserStub:
161
+ """
162
+ Minimal stand-in for :class:`argparse.ArgumentParser`.
163
+
164
+ Notes
165
+ -----
166
+ The production :func:`etlplus.cli.main` only needs a ``parse_args`` method
167
+ returning a namespace.
168
+
169
+ Attributes
170
+ ----------
171
+ namespace : argparse.Namespace
172
+ Namespace returned by :meth:`parse_args`.
173
+ """
174
+
175
+ namespace: argparse.Namespace
176
+
177
+ def parse_args(
178
+ self,
179
+ _args: Sequence[str] | None = None,
180
+ ) -> argparse.Namespace:
181
+ """Return the pre-configured namespace."""
182
+ return self.namespace
183
+
184
+
185
+ class DummyCfg:
186
+ """Minimal stand-in pipeline config for CLI helper tests."""
187
+
188
+ name = 'p1'
189
+ version = 'v1'
190
+ sources = [types.SimpleNamespace(name='s1')]
191
+ targets = [types.SimpleNamespace(name='t1')]
192
+ transforms = [types.SimpleNamespace(name='tr1')]
193
+ jobs = [types.SimpleNamespace(name='j1')]
194
+
195
+
196
+ # SECTION: FIXTURES ========================================================= #
197
+
198
+
199
+ @pytest.fixture(name='cli_parser')
200
+ def cli_parser_fixture() -> argparse.ArgumentParser:
201
+ """
202
+ Provide a fresh CLI parser per test.
203
+
204
+ Returns
205
+ -------
206
+ argparse.ArgumentParser
207
+ Newly constructed parser instance.
208
+ """
209
+ return cli.create_parser()
210
+
211
+
212
+ @pytest.fixture(name='parse_cli')
213
+ def parse_cli_fixture(
214
+ cli_parser: argparse.ArgumentParser,
215
+ ) -> ParseCli:
216
+ """
217
+ Provide a callable that parses argv into a namespace.
218
+
219
+ Parameters
220
+ ----------
221
+ cli_parser : argparse.ArgumentParser
222
+ Parser instance created per test.
223
+
224
+ Returns
225
+ -------
226
+ ParseCli
227
+ Callable that parses CLI args into an :class:`argparse.Namespace`.
228
+ """
229
+
230
+ def _parse(args: Sequence[str]) -> argparse.Namespace:
231
+ return cli_parser.parse_args(list(args))
232
+
233
+ return _parse
234
+
235
+
236
+ # SECTION: TESTS ============================================================ #
237
+
238
+
239
+ class TestCreateParser:
240
+ """Tests for :func:`etlplus.cli.create_parser`."""
241
+
242
+ def test_create_parser_smoke(
243
+ self,
244
+ cli_parser: argparse.ArgumentParser,
245
+ ) -> None:
246
+ """Parser is constructed and uses the expected program name."""
247
+ assert isinstance(cli_parser, argparse.ArgumentParser)
248
+ assert cli_parser.prog == 'etlplus'
249
+
250
+ @pytest.mark.parametrize('case', PARSER_CASES, ids=lambda c: c.identifier)
251
+ def test_parser_commands(
252
+ self,
253
+ parse_cli: ParseCli,
254
+ case: ParserCase,
255
+ ) -> None:
256
+ """Known argv patterns map to the expected argparse namespace."""
257
+ ns = parse_cli(case.args)
258
+ for key, expected in case.expected.items():
259
+ assert getattr(ns, key, None) == expected
260
+
261
+ def test_parser_version_flag_exits_zero(
262
+ self,
263
+ cli_parser: argparse.ArgumentParser,
264
+ ) -> None:
265
+ """``--version`` exits successfully."""
266
+ with pytest.raises(SystemExit) as exc_info:
267
+ cli_parser.parse_args(['--version'])
268
+ assert exc_info.value.code == 0
269
+
270
+ def test_parser_includes_expected_subcommands(
271
+ self,
272
+ cli_parser: argparse.ArgumentParser,
273
+ ) -> None:
274
+ """Expected subcommands are registered on the parser."""
275
+ dests = _subcommand_dests(cli_parser)
276
+ for cmd in (
277
+ 'extract',
278
+ 'validate',
279
+ 'transform',
280
+ 'load',
281
+ 'pipeline',
282
+ 'list',
283
+ 'run',
284
+ ):
285
+ assert cmd in dests
286
+
287
+
288
+ class TestCliInternalHelpers:
289
+ """Unit tests for internal CLI helpers in :mod:`etlplus.cli`."""
290
+
291
+ def test_format_action_sets_flag(self) -> None:
292
+ """``_FormatAction`` sets ``_format_explicit`` when used."""
293
+ # pylint: disable=protected-access
294
+
295
+ parser = argparse.ArgumentParser()
296
+ parser.add_argument('--format', action=cli._FormatAction)
297
+ ns = parser.parse_args(['--format', 'json'])
298
+ assert ns.format == 'json'
299
+ assert ns._format_explicit is True
300
+
301
+ def test_add_format_options_sets_defaults(self) -> None:
302
+ """``_add_format_options`` establishes default values."""
303
+ # pylint: disable=protected-access
304
+
305
+ parser = argparse.ArgumentParser()
306
+ cli._add_format_options(parser, context='source')
307
+
308
+ ns = parser.parse_args([])
309
+ assert ns._format_explicit is False
310
+
311
+ ns_strict = parser.parse_args(['--strict-format'])
312
+ assert ns_strict.strict_format is True
313
+
314
+ ns_format = parser.parse_args(['--format', 'json'])
315
+ assert ns_format.format == 'json'
316
+ assert ns_format._format_explicit is True
317
+
318
+ @pytest.mark.parametrize(
319
+ ('behavior', 'expected_err', 'should_raise'),
320
+ [
321
+ pytest.param('ignore', '', False, id='ignore'),
322
+ pytest.param('silent', '', False, id='silent'),
323
+ pytest.param('warn', 'Warning:', False, id='warn'),
324
+ pytest.param('error', '', True, id='error'),
325
+ ],
326
+ )
327
+ def test_emit_behavioral_notice(
328
+ self,
329
+ behavior: str,
330
+ expected_err: str,
331
+ should_raise: bool,
332
+ capsys: pytest.CaptureFixture[str],
333
+ ) -> None:
334
+ """
335
+ Test that :func:`_emit_behavioral_notice` raises or emits stderr per
336
+ behavior.
337
+ """
338
+ # pylint: disable=protected-access
339
+
340
+ if should_raise:
341
+ with pytest.raises(ValueError):
342
+ cli._emit_behavioral_notice('msg', behavior)
343
+ return
344
+
345
+ cli._emit_behavioral_notice('msg', behavior)
346
+ captured = capsys.readouterr()
347
+ assert expected_err in captured.err
348
+
349
+ def test_format_behavior_strict(self) -> None:
350
+ """Strict mode maps to error behavior."""
351
+ # pylint: disable=protected-access
352
+
353
+ assert cli._format_behavior(True) == 'error'
354
+
355
+ def test_format_behavior_env(
356
+ self,
357
+ monkeypatch: pytest.MonkeyPatch,
358
+ ) -> None:
359
+ """Test that environment overrides behavior when not strict."""
360
+ # pylint: disable=protected-access
361
+
362
+ monkeypatch.setenv(cli.FORMAT_ENV_KEY, 'fail')
363
+ assert cli._format_behavior(False) == 'fail'
364
+
365
+ monkeypatch.delenv(cli.FORMAT_ENV_KEY, raising=False)
366
+ assert cli._format_behavior(False) == 'warn'
367
+
368
+ @pytest.mark.parametrize(
369
+ ('resource_type', 'format_explicit', 'should_raise'),
370
+ [
371
+ pytest.param('file', True, True, id='file-explicit'),
372
+ pytest.param('file', False, False, id='file-implicit'),
373
+ pytest.param('database', True, False, id='nonfile-explicit'),
374
+ ],
375
+ )
376
+ def test_handle_format_guard(
377
+ self,
378
+ monkeypatch: pytest.MonkeyPatch,
379
+ resource_type: str,
380
+ format_explicit: bool,
381
+ should_raise: bool,
382
+ ) -> None:
383
+ """
384
+ Test that guard raises only for explicit formats on file resources.
385
+ """
386
+ # pylint: disable=protected-access
387
+
388
+ monkeypatch.setattr(cli, '_format_behavior', lambda _strict: 'error')
389
+
390
+ def call():
391
+ return cli._handle_format_guard(
392
+ io_context='source',
393
+ resource_type=resource_type,
394
+ format_explicit=format_explicit,
395
+ strict=False,
396
+ )
397
+
398
+ if should_raise:
399
+ with pytest.raises(ValueError):
400
+ call()
401
+ else:
402
+ call()
403
+
404
+ def test_list_sections_all(self) -> None:
405
+ """Test that :func:`_list_sections` includes all requested sections."""
406
+ # pylint: disable=protected-access
407
+
408
+ args = argparse.Namespace(
409
+ pipelines=True,
410
+ sources=True,
411
+ targets=True,
412
+ transforms=True,
413
+ )
414
+ result = cli._list_sections(DummyCfg(), args) # type: ignore[arg-type]
415
+ assert set(result) >= {'pipelines', 'sources', 'targets', 'transforms'}
416
+
417
+ def test_list_sections_default(self) -> None:
418
+ """
419
+ Test that :func:`_list_sections` defaults to jobs when no flags are
420
+ set.
421
+ """
422
+ # pylint: disable=protected-access
423
+
424
+ args = argparse.Namespace(
425
+ pipelines=False,
426
+ sources=False,
427
+ targets=False,
428
+ transforms=False,
429
+ )
430
+ result = cli._list_sections(DummyCfg(), args) # type: ignore[arg-type]
431
+ assert 'jobs' in result
432
+
433
+ def test_materialize_csv_payload_non_str(self) -> None:
434
+ """Test that non-string payloads return unchanged."""
435
+ # pylint: disable=protected-access
436
+
437
+ payload: object = {'foo': 1}
438
+ assert cli._materialize_csv_payload(payload) is payload
439
+
440
+ def test_materialize_csv_payload_non_csv(self, tmp_path: Path) -> None:
441
+ """Non-CSV file paths are returned unchanged."""
442
+ # pylint: disable=protected-access
443
+
444
+ f = tmp_path / 'file.txt'
445
+ f.write_text('abc')
446
+ assert cli._materialize_csv_payload(str(f)) == str(f)
447
+
448
+ def test_materialize_csv_payload_csv(self, tmp_path: Path) -> None:
449
+ """CSV file paths are loaded into row dictionaries."""
450
+ # pylint: disable=protected-access
451
+
452
+ f = tmp_path / 'file.csv'
453
+ f.write_text(CSV_TEXT)
454
+ rows = cli._materialize_csv_payload(str(f))
455
+
456
+ assert isinstance(rows, list)
457
+ assert rows[0] == {'a': '1', 'b': '2'}
458
+
459
+ def test_pipeline_summary(self) -> None:
460
+ """``_pipeline_summary`` returns a mapping for a pipeline config."""
461
+ # pylint: disable=protected-access
462
+
463
+ result = cli._pipeline_summary(DummyCfg()) # type: ignore[arg-type]
464
+ assert result['name'] == 'p1'
465
+ assert result['version'] == 'v1'
466
+ assert set(result) >= {'sources', 'targets', 'jobs'}
467
+
468
+ def test_read_csv_rows(self, tmp_path: Path) -> None:
469
+ """
470
+ Test that :func:`_read_csv_rows` reads a CSV into a list of row
471
+ dictionaries.
472
+ """
473
+ # pylint: disable=protected-access
474
+
475
+ f = tmp_path / 'data.csv'
476
+ f.write_text(CSV_TEXT)
477
+ assert cli._read_csv_rows(f) == [
478
+ {'a': '1', 'b': '2'},
479
+ {'a': '3', 'b': '4'},
480
+ ]
481
+
482
+ def test_write_json_output_stdout_is_quiet(
483
+ self,
484
+ capsys: pytest.CaptureFixture[str],
485
+ ) -> None:
486
+ """
487
+ Test that, when writing to stdout, the helper does not print JSON to
488
+ stdout.
489
+ """
490
+ # pylint: disable=protected-access
491
+
492
+ data = {'x': 1}
493
+ assert (
494
+ cli._write_json_output(data, None, success_message='msg') is False
495
+ )
496
+ assert capsys.readouterr().out == ''
497
+
498
+ def test_write_json_output_file(
499
+ self,
500
+ monkeypatch: pytest.MonkeyPatch,
501
+ ) -> None:
502
+ """
503
+ Test that, when a file path is provided, the helper writes JSON via
504
+ :class:`File`.
505
+ """
506
+ # pylint: disable=protected-access
507
+
508
+ data = {'x': 1}
509
+
510
+ dummy_file = Mock()
511
+ monkeypatch.setattr(cli, 'File', lambda _p, _f: dummy_file)
512
+
513
+ cli._write_json_output(data, 'out.json', success_message='msg')
514
+ dummy_file.write_json.assert_called_once_with(data)
515
+
516
+
517
+ class TestMain:
518
+ """Unit test suite for :func:`etlplus.cli.main`."""
519
+
520
+ @pytest.mark.parametrize(
521
+ 'argv',
522
+ [
523
+ pytest.param(['extract', 'file', 'foo'], id='extract'),
524
+ pytest.param(['validate', 'foo'], id='validate'),
525
+ pytest.param(['transform', 'foo'], id='transform'),
526
+ pytest.param(['load', 'foo', 'file', 'bar'], id='load'),
527
+ pytest.param(['pipeline', '--config', 'foo.yml'], id='pipeline'),
528
+ pytest.param(['list', '--config', 'foo.yml'], id='list'),
529
+ pytest.param(['run', '--config', 'foo.yml'], id='run'),
530
+ ],
531
+ )
532
+ def test_dispatches_all_subcommands(
533
+ self,
534
+ monkeypatch: pytest.MonkeyPatch,
535
+ argv: list[str],
536
+ ) -> None:
537
+ """
538
+ Test that :func:`main` dispatches all subcommands to ``args.func``.
539
+ """
540
+ parser = cli.create_parser()
541
+ args = parser.parse_args(argv)
542
+
543
+ args.func = Mock(return_value=0)
544
+
545
+ monkeypatch.setattr(cli, 'create_parser', lambda: parser)
546
+ monkeypatch.setattr(parser, 'parse_args', lambda _argv: args)
547
+
548
+ assert cli.main(argv) == 0
549
+ args.func.assert_called_once_with(args)
550
+
551
+ def test_no_command_is_usage_error(self) -> None:
552
+ """Test that no subcommand is a usage error (argparse exit code 2)."""
553
+ try:
554
+ result = cli.main([])
555
+ except SystemExit as exc:
556
+ assert exc.code == 2
557
+ else:
558
+ assert result == 0
559
+
560
+ def test_handles_keyboard_interrupt(
561
+ self,
562
+ monkeypatch: pytest.MonkeyPatch,
563
+ ) -> None:
564
+ """KeyboardInterrupt maps to the conventional exit code 130."""
565
+ cmd = Mock(side_effect=KeyboardInterrupt)
566
+ ns = argparse.Namespace(command='dummy', func=cmd)
567
+ monkeypatch.setattr(cli, 'create_parser', lambda: ParserStub(ns))
568
+
569
+ assert cli.main([]) == 130
570
+
571
+ def test_handles_system_exit_from_command(
572
+ self,
573
+ monkeypatch: pytest.MonkeyPatch,
574
+ ) -> None:
575
+ """
576
+ Test that :func:`main` does not swallow :class:`SystemExit` from the
577
+ dispatched command.
578
+ """
579
+ cmd = Mock(side_effect=SystemExit(5))
580
+ ns = argparse.Namespace(command='dummy', func=cmd)
581
+ monkeypatch.setattr(cli, 'create_parser', lambda: ParserStub(ns))
582
+
583
+ with pytest.raises(SystemExit) as exc_info:
584
+ cli.main([])
585
+ assert exc_info.value.code == 5
586
+
587
+ def test_value_error_returns_exit_code_1(
588
+ self,
589
+ monkeypatch: pytest.MonkeyPatch,
590
+ capsys: pytest.CaptureFixture[str],
591
+ ) -> None:
592
+ """Test that :class:`ValueError` from a command maps to exit code 1."""
593
+ cmd = Mock(side_effect=ValueError('fail'))
594
+ ns = argparse.Namespace(command='dummy', func=cmd)
595
+ monkeypatch.setattr(cli, 'create_parser', lambda: ParserStub(ns))
596
+
597
+ assert cli.main([]) == 1
598
+ assert 'Error:' in capsys.readouterr().err