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.
- {etlplus-0.3.21/etlplus.egg-info → etlplus-0.3.23}/PKG-INFO +1 -1
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/transform.py +12 -0
- {etlplus-0.3.21 → etlplus-0.3.23/etlplus.egg-info}/PKG-INFO +1 -1
- etlplus-0.3.23/tests/unit/test_u_cli.py +598 -0
- etlplus-0.3.23/tests/unit/test_u_transform.py +860 -0
- etlplus-0.3.21/tests/unit/test_u_cli.py +0 -299
- etlplus-0.3.21/tests/unit/test_u_transform.py +0 -690
- {etlplus-0.3.21 → etlplus-0.3.23}/.coveragerc +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/.editorconfig +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/.gitattributes +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/.github/actions/python-bootstrap/action.yml +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/.github/workflows/ci.yml +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/.gitignore +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/.pre-commit-config.yaml +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/.ruff.toml +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/CODE_OF_CONDUCT.md +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/CONTRIBUTING.md +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/DEMO.md +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/LICENSE +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/Makefile +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/README.md +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/REFERENCES.md +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/docs/pipeline-guide.md +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/docs/snippets/installation_version.md +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/__init__.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/__main__.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/__version__.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/api/README.md +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/api/__init__.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/api/auth.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/api/config.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/api/endpoint_client.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/api/errors.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/api/pagination/__init__.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/api/pagination/client.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/api/pagination/config.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/api/pagination/paginator.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/api/rate_limiting/__init__.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/api/rate_limiting/config.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/api/rate_limiting/rate_limiter.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/api/request_manager.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/api/retry_manager.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/api/transport.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/api/types.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/cli.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/config/__init__.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/config/connector.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/config/jobs.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/config/pipeline.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/config/profile.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/config/types.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/config/utils.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/enums.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/extract.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/file.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/load.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/mixins.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/py.typed +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/run.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/run_helpers.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/types.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/utils.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/validate.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/validation/__init__.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus/validation/utils.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus.egg-info/SOURCES.txt +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus.egg-info/dependency_links.txt +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus.egg-info/entry_points.txt +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus.egg-info/requires.txt +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/etlplus.egg-info/top_level.txt +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/examples/README.md +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/examples/configs/pipeline.yml +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/examples/data/sample.csv +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/examples/data/sample.json +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/examples/data/sample.xml +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/examples/data/sample.xsd +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/examples/data/sample.yaml +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/examples/quickstart_python.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/pyproject.toml +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/pytest.ini +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/setup.cfg +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/setup.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/__init__.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/conftest.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/integration/conftest.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/integration/test_i_cli.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/integration/test_i_examples_data_parity.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/integration/test_i_pagination_strategy.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/integration/test_i_pipeline_smoke.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/integration/test_i_pipeline_yaml_load.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/integration/test_i_run.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/integration/test_i_run_profile_pagination_defaults.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/integration/test_i_run_profile_rate_limit_defaults.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/api/conftest.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/api/test_u_auth.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/api/test_u_config.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/api/test_u_endpoint_client.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/api/test_u_mocks.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/api/test_u_pagination_client.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/api/test_u_pagination_config.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/api/test_u_paginator.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/api/test_u_rate_limit_config.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/api/test_u_rate_limiter.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/api/test_u_request_manager.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/api/test_u_retry_manager.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/api/test_u_transport.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/api/test_u_types.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/config/test_u_config_utils.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/config/test_u_connector.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/config/test_u_jobs.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/config/test_u_pipeline.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/conftest.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/test_u_enums.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/test_u_extract.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/test_u_file.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/test_u_load.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/test_u_main.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/test_u_mixins.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/test_u_run.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/test_u_run_helpers.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/test_u_utils.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/test_u_validate.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/test_u_version.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tests/unit/validation/test_u_validation_utils.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tools/run_pipeline.py +0 -0
- {etlplus-0.3.21 → etlplus-0.3.23}/tools/update_demo_snippets.py +0 -0
|
@@ -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
|
|
|
@@ -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
|