etlplus 0.3.17__tar.gz → 0.4.0__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.17 → etlplus-0.4.0}/Makefile +1 -1
- {etlplus-0.3.17/etlplus.egg-info → etlplus-0.4.0}/PKG-INFO +2 -1
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/cli.py +332 -14
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/transform.py +12 -0
- {etlplus-0.3.17 → etlplus-0.4.0/etlplus.egg-info}/PKG-INFO +2 -1
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus.egg-info/SOURCES.txt +5 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus.egg-info/requires.txt +1 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/pyproject.toml +1 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/setup.py +2 -1
- {etlplus-0.3.17 → etlplus-0.4.0}/tests/integration/test_i_cli.py +2 -2
- {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/api/test_u_auth.py +1 -1
- {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/api/test_u_endpoint_client.py +22 -21
- {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/api/test_u_rate_limiter.py +1 -1
- etlplus-0.4.0/tests/unit/api/test_u_request_manager.py +349 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/api/test_u_transport.py +48 -1
- etlplus-0.4.0/tests/unit/api/test_u_types.py +135 -0
- etlplus-0.4.0/tests/unit/config/test_u_config_utils.py +129 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/config/test_u_connector.py +1 -1
- etlplus-0.4.0/tests/unit/config/test_u_jobs.py +131 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/config/test_u_pipeline.py +1 -1
- etlplus-0.4.0/tests/unit/test_u_cli.py +545 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/test_u_enums.py +1 -1
- {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/test_u_extract.py +1 -1
- {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/test_u_file.py +5 -6
- {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/test_u_load.py +4 -4
- etlplus-0.4.0/tests/unit/test_u_main.py +58 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/test_u_mixins.py +1 -1
- etlplus-0.4.0/tests/unit/test_u_run.py +602 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/test_u_run_helpers.py +1 -1
- etlplus-0.4.0/tests/unit/test_u_transform.py +860 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/test_u_utils.py +3 -3
- {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/test_u_validate.py +4 -3
- etlplus-0.4.0/tests/unit/test_u_version.py +53 -0
- etlplus-0.3.17/tests/unit/api/test_u_request_manager.py +0 -135
- etlplus-0.3.17/tests/unit/test_u_cli.py +0 -185
- etlplus-0.3.17/tests/unit/test_u_run.py +0 -288
- etlplus-0.3.17/tests/unit/test_u_transform.py +0 -563
- {etlplus-0.3.17 → etlplus-0.4.0}/.coveragerc +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/.editorconfig +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/.gitattributes +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/.github/actions/python-bootstrap/action.yml +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/.github/workflows/ci.yml +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/.gitignore +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/.pre-commit-config.yaml +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/.ruff.toml +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/CODE_OF_CONDUCT.md +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/CONTRIBUTING.md +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/DEMO.md +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/LICENSE +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/README.md +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/REFERENCES.md +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/docs/pipeline-guide.md +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/docs/snippets/installation_version.md +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/__init__.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/__main__.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/__version__.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/api/README.md +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/api/__init__.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/api/auth.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/api/config.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/api/endpoint_client.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/api/errors.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/api/pagination/__init__.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/api/pagination/client.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/api/pagination/config.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/api/pagination/paginator.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/api/rate_limiting/__init__.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/api/rate_limiting/config.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/api/rate_limiting/rate_limiter.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/api/request_manager.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/api/retry_manager.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/api/transport.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/api/types.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/config/__init__.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/config/connector.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/config/jobs.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/config/pipeline.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/config/profile.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/config/types.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/config/utils.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/enums.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/extract.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/file.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/load.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/mixins.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/py.typed +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/run.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/run_helpers.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/types.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/utils.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/validate.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/validation/__init__.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/validation/utils.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus.egg-info/dependency_links.txt +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus.egg-info/entry_points.txt +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/etlplus.egg-info/top_level.txt +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/examples/README.md +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/examples/configs/pipeline.yml +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/examples/data/sample.csv +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/examples/data/sample.json +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/examples/data/sample.xml +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/examples/data/sample.xsd +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/examples/data/sample.yaml +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/examples/quickstart_python.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/pytest.ini +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/setup.cfg +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/tests/__init__.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/tests/conftest.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/tests/integration/conftest.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/tests/integration/test_i_examples_data_parity.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/tests/integration/test_i_pagination_strategy.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/tests/integration/test_i_pipeline_smoke.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/tests/integration/test_i_pipeline_yaml_load.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/tests/integration/test_i_run.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/tests/integration/test_i_run_profile_pagination_defaults.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/tests/integration/test_i_run_profile_rate_limit_defaults.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/api/conftest.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/api/test_u_config.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/api/test_u_mocks.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/api/test_u_pagination_client.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/api/test_u_pagination_config.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/api/test_u_paginator.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/api/test_u_rate_limit_config.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/api/test_u_retry_manager.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/conftest.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/validation/test_u_validation_utils.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/tools/run_pipeline.py +0 -0
- {etlplus-0.3.17 → etlplus-0.4.0}/tools/update_demo_snippets.py +0 -0
|
@@ -253,7 +253,7 @@ venv: ## Create the virtual environment (at $(VENV_DIR))
|
|
|
253
253
|
else \
|
|
254
254
|
$(call ECHO_INFO, "Using existing venv: $(VENV_DIR)"); \
|
|
255
255
|
fi
|
|
256
|
-
@$(PYTHON) -m pip install --upgrade pip
|
|
256
|
+
@$(PYTHON) -m pip install --upgrade pip setuptools wheel >/dev/null
|
|
257
257
|
@$(call ECHO_OK,"venv ready")
|
|
258
258
|
|
|
259
259
|
##@ CI
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: etlplus
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
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
|
|
@@ -22,6 +22,7 @@ Requires-Dist: pyodbc>=5.3.0
|
|
|
22
22
|
Requires-Dist: python-dotenv>=1.2.1
|
|
23
23
|
Requires-Dist: pandas>=2.3.3
|
|
24
24
|
Requires-Dist: requests>=2.32.5
|
|
25
|
+
Requires-Dist: typer>=0.21.0
|
|
25
26
|
Provides-Extra: dev
|
|
26
27
|
Requires-Dist: black>=25.9.0; extra == "dev"
|
|
27
28
|
Requires-Dist: build>=1.2.2; extra == "dev"
|
|
@@ -26,6 +26,8 @@ from typing import Any
|
|
|
26
26
|
from typing import Literal
|
|
27
27
|
from typing import cast
|
|
28
28
|
|
|
29
|
+
import typer
|
|
30
|
+
|
|
29
31
|
from . import __version__
|
|
30
32
|
from .config import PipelineConfig
|
|
31
33
|
from .config import load_pipeline_config
|
|
@@ -90,6 +92,9 @@ PROJECT_URL = 'https://github.com/Dagitali/ETLPlus'
|
|
|
90
92
|
_FORMAT_ERROR_STATES = {'error', 'fail', 'strict'}
|
|
91
93
|
_FORMAT_SILENT_STATES = {'ignore', 'silent'}
|
|
92
94
|
|
|
95
|
+
_SOURCE_CHOICES = set(DataConnectorType.choices())
|
|
96
|
+
_FORMAT_CHOICES = set(FileFormat.choices())
|
|
97
|
+
|
|
93
98
|
|
|
94
99
|
# SECTION: TYPE ALIASES ===================================================== #
|
|
95
100
|
|
|
@@ -292,6 +297,12 @@ def _materialize_csv_payload(
|
|
|
292
297
|
return _read_csv_rows(path)
|
|
293
298
|
|
|
294
299
|
|
|
300
|
+
def _ns(**kwargs: object) -> argparse.Namespace:
|
|
301
|
+
"""Create an :class:`argparse.Namespace` for legacy command handlers."""
|
|
302
|
+
|
|
303
|
+
return argparse.Namespace(**kwargs)
|
|
304
|
+
|
|
305
|
+
|
|
295
306
|
def _pipeline_summary(
|
|
296
307
|
cfg: PipelineConfig,
|
|
297
308
|
) -> dict[str, Any]:
|
|
@@ -341,6 +352,18 @@ def _read_csv_rows(
|
|
|
341
352
|
return [dict(row) for row in reader]
|
|
342
353
|
|
|
343
354
|
|
|
355
|
+
def _validate_choice(value: str, choices: set[str], *, label: str) -> str:
|
|
356
|
+
"""Validate a string against allowed choices for nice CLI errors."""
|
|
357
|
+
|
|
358
|
+
v = (value or '').strip()
|
|
359
|
+
if v in choices:
|
|
360
|
+
return v
|
|
361
|
+
allowed = ', '.join(sorted(choices))
|
|
362
|
+
raise typer.BadParameter(
|
|
363
|
+
f"Invalid {label} '{value}'. Choose from: {allowed}",
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
|
|
344
367
|
def _write_json_output(
|
|
345
368
|
data: Any,
|
|
346
369
|
output_path: str | None,
|
|
@@ -838,31 +861,326 @@ def main(
|
|
|
838
861
|
int
|
|
839
862
|
Zero on success, non-zero on error.
|
|
840
863
|
|
|
864
|
+
Raises
|
|
865
|
+
------
|
|
866
|
+
SystemExit
|
|
867
|
+
Re-raises SystemExit exceptions to preserve exit codes.
|
|
868
|
+
|
|
841
869
|
Notes
|
|
842
870
|
-----
|
|
843
|
-
This function
|
|
871
|
+
This function uses Typer (Click) for parsing/dispatch, but preserves the
|
|
872
|
+
existing `cmd_*` handlers by adapting parsed arguments into an
|
|
873
|
+
:class:`argparse.Namespace`.
|
|
844
874
|
"""
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
if not args.command:
|
|
849
|
-
parser.print_help()
|
|
850
|
-
return 0
|
|
875
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
876
|
+
command = typer.main.get_command(app)
|
|
851
877
|
|
|
852
878
|
try:
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
879
|
+
result = command.main(
|
|
880
|
+
args=list(argv),
|
|
881
|
+
prog_name='etlplus',
|
|
882
|
+
standalone_mode=False,
|
|
883
|
+
)
|
|
884
|
+
return int(result or 0)
|
|
857
885
|
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
886
|
+
except typer.Exit as exc:
|
|
887
|
+
return int(exc.exit_code)
|
|
888
|
+
|
|
889
|
+
except typer.Abort:
|
|
890
|
+
return 1
|
|
861
891
|
|
|
862
892
|
except KeyboardInterrupt:
|
|
863
893
|
# Conventional exit code for SIGINT
|
|
864
894
|
return 130
|
|
865
895
|
|
|
896
|
+
except SystemExit as e:
|
|
897
|
+
print(f'Error: {e}', file=sys.stderr)
|
|
898
|
+
raise e
|
|
899
|
+
|
|
866
900
|
except (OSError, TypeError, ValueError) as e:
|
|
867
901
|
print(f'Error: {e}', file=sys.stderr)
|
|
868
902
|
return 1
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
# SECTION: TYPER APP ======================================================== #
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
app = typer.Typer(
|
|
909
|
+
name='etlplus',
|
|
910
|
+
help='ETLPlus - A Swiss Army knife for simple ETL operations.',
|
|
911
|
+
add_completion=True,
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
@app.callback(invoke_without_command=True)
|
|
916
|
+
def _root(
|
|
917
|
+
ctx: typer.Context,
|
|
918
|
+
version: bool = typer.Option(
|
|
919
|
+
False,
|
|
920
|
+
'-V',
|
|
921
|
+
'--version',
|
|
922
|
+
is_eager=True,
|
|
923
|
+
help='Show the version and exit.',
|
|
924
|
+
),
|
|
925
|
+
) -> None:
|
|
926
|
+
"""Root command callback to show help or version."""
|
|
927
|
+
|
|
928
|
+
if version:
|
|
929
|
+
typer.echo(f'etlplus {__version__}')
|
|
930
|
+
raise typer.Exit(0)
|
|
931
|
+
|
|
932
|
+
if ctx.invoked_subcommand is None:
|
|
933
|
+
typer.echo(ctx.get_help())
|
|
934
|
+
raise typer.Exit(0)
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
@app.command('extract')
|
|
938
|
+
def extract_cmd(
|
|
939
|
+
source_type: str = typer.Argument(
|
|
940
|
+
...,
|
|
941
|
+
help='Type of source to extract from',
|
|
942
|
+
),
|
|
943
|
+
source: str = typer.Argument(
|
|
944
|
+
...,
|
|
945
|
+
help=(
|
|
946
|
+
'Source location '
|
|
947
|
+
'(file path, database connection string, or API URL)'
|
|
948
|
+
),
|
|
949
|
+
),
|
|
950
|
+
output: str | None = typer.Option(
|
|
951
|
+
None,
|
|
952
|
+
'-o',
|
|
953
|
+
'--output',
|
|
954
|
+
help='Output file to save extracted data (JSON format)',
|
|
955
|
+
),
|
|
956
|
+
strict_format: bool = typer.Option(
|
|
957
|
+
False,
|
|
958
|
+
'--strict-format',
|
|
959
|
+
help=(
|
|
960
|
+
'Treat providing --format for file sources as an error '
|
|
961
|
+
'(overrides environment behavior)'
|
|
962
|
+
),
|
|
963
|
+
),
|
|
964
|
+
source_format: str | None = typer.Option(
|
|
965
|
+
None,
|
|
966
|
+
'--format',
|
|
967
|
+
help=(
|
|
968
|
+
'Format of the source when not a file. For file sources this '
|
|
969
|
+
'option is ignored and the format is inferred from the filename '
|
|
970
|
+
'extension.'
|
|
971
|
+
),
|
|
972
|
+
),
|
|
973
|
+
) -> int:
|
|
974
|
+
"""Typer front-end for :func:`cmd_extract`."""
|
|
975
|
+
|
|
976
|
+
source_type = _validate_choice(
|
|
977
|
+
source_type,
|
|
978
|
+
_SOURCE_CHOICES,
|
|
979
|
+
label='source_type',
|
|
980
|
+
)
|
|
981
|
+
if source_format is not None:
|
|
982
|
+
source_format = _validate_choice(
|
|
983
|
+
source_format,
|
|
984
|
+
_FORMAT_CHOICES,
|
|
985
|
+
label='format',
|
|
986
|
+
)
|
|
987
|
+
|
|
988
|
+
ns = _ns(
|
|
989
|
+
command='extract',
|
|
990
|
+
source_type=source_type,
|
|
991
|
+
source=source,
|
|
992
|
+
output=output,
|
|
993
|
+
strict_format=strict_format,
|
|
994
|
+
format=(source_format or 'json'),
|
|
995
|
+
_format_explicit=(source_format is not None),
|
|
996
|
+
)
|
|
997
|
+
return int(cmd_extract(ns))
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
@app.command('validate')
|
|
1001
|
+
def validate_cmd(
|
|
1002
|
+
source: str = typer.Argument(
|
|
1003
|
+
...,
|
|
1004
|
+
help='Data source to validate (file path or JSON string)',
|
|
1005
|
+
),
|
|
1006
|
+
rules: str = typer.Option(
|
|
1007
|
+
'{}',
|
|
1008
|
+
'--rules',
|
|
1009
|
+
help='Validation rules as JSON string',
|
|
1010
|
+
),
|
|
1011
|
+
) -> int:
|
|
1012
|
+
"""Typer front-end for :func:`cmd_validate`."""
|
|
1013
|
+
|
|
1014
|
+
ns = _ns(command='validate', source=source, rules=json_type(rules))
|
|
1015
|
+
return int(cmd_validate(ns))
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
@app.command('transform')
|
|
1019
|
+
def transform_cmd(
|
|
1020
|
+
source: str = typer.Argument(
|
|
1021
|
+
...,
|
|
1022
|
+
help='Data source to transform (file path or JSON string)',
|
|
1023
|
+
),
|
|
1024
|
+
operations: str = typer.Option(
|
|
1025
|
+
'{}',
|
|
1026
|
+
'--operations',
|
|
1027
|
+
help='Transformation operations as JSON string',
|
|
1028
|
+
),
|
|
1029
|
+
output: str | None = typer.Option(
|
|
1030
|
+
None,
|
|
1031
|
+
'-o',
|
|
1032
|
+
'--output',
|
|
1033
|
+
help='Output file to save transformed data',
|
|
1034
|
+
),
|
|
1035
|
+
) -> int:
|
|
1036
|
+
"""Typer front-end for :func:`cmd_transform`."""
|
|
1037
|
+
|
|
1038
|
+
ns = _ns(
|
|
1039
|
+
command='transform',
|
|
1040
|
+
source=source,
|
|
1041
|
+
operations=json_type(operations),
|
|
1042
|
+
output=output,
|
|
1043
|
+
)
|
|
1044
|
+
return int(cmd_transform(ns))
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
@app.command('load')
|
|
1048
|
+
def load_cmd(
|
|
1049
|
+
source: str = typer.Argument(
|
|
1050
|
+
...,
|
|
1051
|
+
help='Data source to load (file path or JSON string)',
|
|
1052
|
+
),
|
|
1053
|
+
target_type: str = typer.Argument(..., help='Type of target to load to'),
|
|
1054
|
+
target: str = typer.Argument(
|
|
1055
|
+
...,
|
|
1056
|
+
help=(
|
|
1057
|
+
'Target location '
|
|
1058
|
+
'(file path, database connection string, or API URL)'
|
|
1059
|
+
),
|
|
1060
|
+
),
|
|
1061
|
+
strict_format: bool = typer.Option(
|
|
1062
|
+
False,
|
|
1063
|
+
'--strict-format',
|
|
1064
|
+
help=(
|
|
1065
|
+
'Treat providing --format for file targets as an error '
|
|
1066
|
+
'(overrides environment behavior)'
|
|
1067
|
+
),
|
|
1068
|
+
),
|
|
1069
|
+
target_format: str | None = typer.Option(
|
|
1070
|
+
None,
|
|
1071
|
+
'--format',
|
|
1072
|
+
help=(
|
|
1073
|
+
'Format of the target when not a file. For file targets this '
|
|
1074
|
+
'option is ignored and the format is inferred from the filename '
|
|
1075
|
+
'extension.'
|
|
1076
|
+
),
|
|
1077
|
+
),
|
|
1078
|
+
) -> int:
|
|
1079
|
+
"""Typer front-end for :func:`cmd_load`."""
|
|
1080
|
+
|
|
1081
|
+
target_type = _validate_choice(
|
|
1082
|
+
target_type,
|
|
1083
|
+
_SOURCE_CHOICES,
|
|
1084
|
+
label='target_type',
|
|
1085
|
+
)
|
|
1086
|
+
if target_format is not None:
|
|
1087
|
+
target_format = _validate_choice(
|
|
1088
|
+
target_format,
|
|
1089
|
+
_FORMAT_CHOICES,
|
|
1090
|
+
label='format',
|
|
1091
|
+
)
|
|
1092
|
+
|
|
1093
|
+
ns = _ns(
|
|
1094
|
+
command='load',
|
|
1095
|
+
source=source,
|
|
1096
|
+
target_type=target_type,
|
|
1097
|
+
target=target,
|
|
1098
|
+
strict_format=strict_format,
|
|
1099
|
+
format=(target_format or 'json'),
|
|
1100
|
+
_format_explicit=(target_format is not None),
|
|
1101
|
+
)
|
|
1102
|
+
return int(cmd_load(ns))
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
@app.command('pipeline')
|
|
1106
|
+
def pipeline_cmd(
|
|
1107
|
+
config: str = typer.Option(
|
|
1108
|
+
...,
|
|
1109
|
+
'--config',
|
|
1110
|
+
help='Path to pipeline YAML configuration file',
|
|
1111
|
+
),
|
|
1112
|
+
list_: bool = typer.Option(
|
|
1113
|
+
False,
|
|
1114
|
+
'--list',
|
|
1115
|
+
help='List available job names and exit',
|
|
1116
|
+
),
|
|
1117
|
+
run_job: str | None = typer.Option(
|
|
1118
|
+
None,
|
|
1119
|
+
'--run',
|
|
1120
|
+
metavar='JOB',
|
|
1121
|
+
help='Run a specific job by name',
|
|
1122
|
+
),
|
|
1123
|
+
) -> int:
|
|
1124
|
+
"""Typer front-end for :func:`cmd_pipeline`."""
|
|
1125
|
+
|
|
1126
|
+
ns = _ns(command='pipeline', config=config, list=list_, run=run_job)
|
|
1127
|
+
return int(cmd_pipeline(ns))
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
@app.command('list')
|
|
1131
|
+
def list_cmd(
|
|
1132
|
+
config: str = typer.Option(
|
|
1133
|
+
...,
|
|
1134
|
+
'--config',
|
|
1135
|
+
help='Path to pipeline YAML configuration file',
|
|
1136
|
+
),
|
|
1137
|
+
pipelines: bool = typer.Option(
|
|
1138
|
+
False,
|
|
1139
|
+
'--pipelines',
|
|
1140
|
+
help='List ETL pipelines',
|
|
1141
|
+
),
|
|
1142
|
+
sources: bool = typer.Option(False, '--sources', help='List data sources'),
|
|
1143
|
+
targets: bool = typer.Option(False, '--targets', help='List data targets'),
|
|
1144
|
+
transforms: bool = typer.Option(
|
|
1145
|
+
False,
|
|
1146
|
+
'--transforms',
|
|
1147
|
+
help='List data transforms',
|
|
1148
|
+
),
|
|
1149
|
+
) -> int:
|
|
1150
|
+
"""Typer front-end for :func:`cmd_list`."""
|
|
1151
|
+
|
|
1152
|
+
ns = _ns(
|
|
1153
|
+
command='list',
|
|
1154
|
+
config=config,
|
|
1155
|
+
pipelines=pipelines,
|
|
1156
|
+
sources=sources,
|
|
1157
|
+
targets=targets,
|
|
1158
|
+
transforms=transforms,
|
|
1159
|
+
)
|
|
1160
|
+
return int(cmd_list(ns))
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
@app.command('run')
|
|
1164
|
+
def run_cmd(
|
|
1165
|
+
config: str = typer.Option(
|
|
1166
|
+
...,
|
|
1167
|
+
'--config',
|
|
1168
|
+
help='Path to pipeline YAML configuration file',
|
|
1169
|
+
),
|
|
1170
|
+
job: str | None = typer.Option(
|
|
1171
|
+
None,
|
|
1172
|
+
'-j',
|
|
1173
|
+
'--job',
|
|
1174
|
+
help='Name of the job to run',
|
|
1175
|
+
),
|
|
1176
|
+
pipeline: str | None = typer.Option(
|
|
1177
|
+
None,
|
|
1178
|
+
'-p',
|
|
1179
|
+
'--pipeline',
|
|
1180
|
+
help='Name of the pipeline to run',
|
|
1181
|
+
),
|
|
1182
|
+
) -> int:
|
|
1183
|
+
"""Typer front-end for :func:`cmd_run`."""
|
|
1184
|
+
|
|
1185
|
+
ns = _ns(command='run', config=config, job=job, pipeline=pipeline)
|
|
1186
|
+
return int(cmd_run(ns))
|
|
@@ -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
|
+
Version: 0.4.0
|
|
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
|
|
@@ -22,6 +22,7 @@ Requires-Dist: pyodbc>=5.3.0
|
|
|
22
22
|
Requires-Dist: python-dotenv>=1.2.1
|
|
23
23
|
Requires-Dist: pandas>=2.3.3
|
|
24
24
|
Requires-Dist: requests>=2.32.5
|
|
25
|
+
Requires-Dist: typer>=0.21.0
|
|
25
26
|
Provides-Extra: dev
|
|
26
27
|
Requires-Dist: black>=25.9.0; extra == "dev"
|
|
27
28
|
Requires-Dist: build>=1.2.2; extra == "dev"
|
|
@@ -91,12 +91,14 @@ tests/unit/test_u_enums.py
|
|
|
91
91
|
tests/unit/test_u_extract.py
|
|
92
92
|
tests/unit/test_u_file.py
|
|
93
93
|
tests/unit/test_u_load.py
|
|
94
|
+
tests/unit/test_u_main.py
|
|
94
95
|
tests/unit/test_u_mixins.py
|
|
95
96
|
tests/unit/test_u_run.py
|
|
96
97
|
tests/unit/test_u_run_helpers.py
|
|
97
98
|
tests/unit/test_u_transform.py
|
|
98
99
|
tests/unit/test_u_utils.py
|
|
99
100
|
tests/unit/test_u_validate.py
|
|
101
|
+
tests/unit/test_u_version.py
|
|
100
102
|
tests/unit/api/conftest.py
|
|
101
103
|
tests/unit/api/test_u_auth.py
|
|
102
104
|
tests/unit/api/test_u_config.py
|
|
@@ -110,7 +112,10 @@ tests/unit/api/test_u_rate_limiter.py
|
|
|
110
112
|
tests/unit/api/test_u_request_manager.py
|
|
111
113
|
tests/unit/api/test_u_retry_manager.py
|
|
112
114
|
tests/unit/api/test_u_transport.py
|
|
115
|
+
tests/unit/api/test_u_types.py
|
|
116
|
+
tests/unit/config/test_u_config_utils.py
|
|
113
117
|
tests/unit/config/test_u_connector.py
|
|
118
|
+
tests/unit/config/test_u_jobs.py
|
|
114
119
|
tests/unit/config/test_u_pipeline.py
|
|
115
120
|
tests/unit/validation/test_u_validation_utils.py
|
|
116
121
|
tools/run_pipeline.py
|
|
@@ -43,10 +43,11 @@ setup(
|
|
|
43
43
|
python_requires='>=3.13,<3.15',
|
|
44
44
|
install_requires=[
|
|
45
45
|
'jinja2>=3.1.6',
|
|
46
|
+
'pandas>=2.3.3',
|
|
46
47
|
'pyodbc>=5.3.0',
|
|
47
48
|
'python-dotenv>=1.2.1',
|
|
48
|
-
'pandas>=2.3.3',
|
|
49
49
|
'requests>=2.32.5',
|
|
50
|
+
'typer>=0.21.0',
|
|
50
51
|
],
|
|
51
52
|
extras_require={
|
|
52
53
|
'dev': [
|
|
@@ -21,8 +21,8 @@ from typing import TYPE_CHECKING
|
|
|
21
21
|
import pytest
|
|
22
22
|
|
|
23
23
|
if TYPE_CHECKING: # pragma: no cover - typing helpers only
|
|
24
|
-
from tests.
|
|
25
|
-
from tests.
|
|
24
|
+
from tests.conftest import CliInvoke
|
|
25
|
+
from tests.conftest import JsonFactory
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
# SECTION: HELPERS ========================================================== #
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
2
|
:mod:`tests.unit.api.test_u_endpoint_client` module.
|
|
3
3
|
|
|
4
|
-
Unit tests for
|
|
4
|
+
Unit tests for :mod:`etlplus.api.endpoint_client`.
|
|
5
5
|
|
|
6
6
|
Notes
|
|
7
7
|
-----
|
|
@@ -41,6 +41,12 @@ pytestmark = pytest.mark.unit
|
|
|
41
41
|
|
|
42
42
|
EXAMPLE_BASE_URL = 'https://example.test'
|
|
43
43
|
|
|
44
|
+
type CursorConfigFactory = Callable[..., CursorPaginationConfigMap]
|
|
45
|
+
type StubRequestManager = Callable[
|
|
46
|
+
[Sequence[dict[str, Any]]],
|
|
47
|
+
list[dict[str, Any]],
|
|
48
|
+
]
|
|
49
|
+
|
|
44
50
|
# Optional Hypothesis import with safe stubs when missing.
|
|
45
51
|
try: # pragma: no try
|
|
46
52
|
from hypothesis import given # type: ignore[import-not-found]
|
|
@@ -110,6 +116,7 @@ def _page_responder(
|
|
|
110
116
|
Callable[..., list[dict[str, Any]]]
|
|
111
117
|
Handler compatible with ``patch_request_once``.
|
|
112
118
|
"""
|
|
119
|
+
# pylint: disable=unused-argument
|
|
113
120
|
|
|
114
121
|
def _handler(
|
|
115
122
|
self: EndpointClient,
|
|
@@ -155,6 +162,7 @@ def _stub_request_manager(
|
|
|
155
162
|
ValueError
|
|
156
163
|
If ``responses`` is empty.
|
|
157
164
|
"""
|
|
165
|
+
# pylint: disable=unused-argument
|
|
158
166
|
|
|
159
167
|
if not responses:
|
|
160
168
|
msg = 'responses must contain at least one payload'
|
|
@@ -375,12 +383,9 @@ class TestCursorPagination:
|
|
|
375
383
|
)
|
|
376
384
|
def test_page_size_normalizes(
|
|
377
385
|
self,
|
|
378
|
-
cursor_cfg:
|
|
386
|
+
cursor_cfg: CursorConfigFactory,
|
|
379
387
|
client_factory: Callable[..., EndpointClient],
|
|
380
|
-
stub_request_manager:
|
|
381
|
-
[Sequence[dict[str, Any]]],
|
|
382
|
-
list[dict[str, Any]],
|
|
383
|
-
],
|
|
388
|
+
stub_request_manager: StubRequestManager,
|
|
384
389
|
raw_page_size: Any,
|
|
385
390
|
expected_limit: int,
|
|
386
391
|
) -> None:
|
|
@@ -389,12 +394,11 @@ class TestCursorPagination:
|
|
|
389
394
|
|
|
390
395
|
Parameters
|
|
391
396
|
----------
|
|
392
|
-
cursor_cfg :
|
|
397
|
+
cursor_cfg : CursorConfigFactory
|
|
393
398
|
Factory for cursor pagination config.
|
|
394
399
|
client_factory : Callable[..., EndpointClient]
|
|
395
400
|
Factory fixture used to construct :class:`EndpointClient`.
|
|
396
|
-
stub_request_manager :
|
|
397
|
-
list[dict[str, Any]]]
|
|
401
|
+
stub_request_manager : StubRequestManager
|
|
398
402
|
Fixture that patches the underlying :class:`RequestManager`.
|
|
399
403
|
raw_page_size : Any
|
|
400
404
|
Raw page size input.
|
|
@@ -473,6 +477,7 @@ class TestRequestOptionIntegration:
|
|
|
473
477
|
client_factory: Callable[..., EndpointClient],
|
|
474
478
|
) -> None:
|
|
475
479
|
"""Paginated iterations override RequestOptions params per call."""
|
|
480
|
+
# pylint: disable=unused-argument
|
|
476
481
|
|
|
477
482
|
client = client_factory(base_url=EXAMPLE_BASE_URL, endpoints={})
|
|
478
483
|
observed: list[RequestOptions] = []
|
|
@@ -512,24 +517,20 @@ class TestRequestOptionIntegration:
|
|
|
512
517
|
|
|
513
518
|
def test_adds_limit_and_advances_cursor(
|
|
514
519
|
self,
|
|
515
|
-
cursor_cfg:
|
|
520
|
+
cursor_cfg: CursorConfigFactory,
|
|
516
521
|
client_factory: Callable[..., EndpointClient],
|
|
517
|
-
stub_request_manager:
|
|
518
|
-
[Sequence[dict[str, Any]]],
|
|
519
|
-
list[dict[str, Any]],
|
|
520
|
-
],
|
|
522
|
+
stub_request_manager: StubRequestManager,
|
|
521
523
|
) -> None:
|
|
522
524
|
"""
|
|
523
525
|
Test that limit is added and cursor advances correctly.
|
|
524
526
|
|
|
525
527
|
Parameters
|
|
526
528
|
----------
|
|
527
|
-
cursor_cfg :
|
|
529
|
+
cursor_cfg : CursorConfigFactory
|
|
528
530
|
Factory for cursor pagination config.
|
|
529
531
|
client_factory : Callable[..., EndpointClient]
|
|
530
532
|
Factory fixture used to construct :class:`EndpointClient`.
|
|
531
|
-
stub_request_manager :
|
|
532
|
-
list[dict[str, Any]]]
|
|
533
|
+
stub_request_manager : StubRequestManager
|
|
533
534
|
Fixture that patches :class:`RequestManager` responses.
|
|
534
535
|
"""
|
|
535
536
|
calls = stub_request_manager(
|
|
@@ -561,7 +562,7 @@ class TestRequestOptionIntegration:
|
|
|
561
562
|
self,
|
|
562
563
|
base_url: str,
|
|
563
564
|
patch_request_once: Callable[[Callable[..., Any]], Callable[..., Any]],
|
|
564
|
-
cursor_cfg:
|
|
565
|
+
cursor_cfg: CursorConfigFactory,
|
|
565
566
|
client_factory: Callable[..., EndpointClient],
|
|
566
567
|
) -> None:
|
|
567
568
|
"""
|
|
@@ -577,7 +578,7 @@ class TestRequestOptionIntegration:
|
|
|
577
578
|
Common base URL used across tests.
|
|
578
579
|
patch_request_once : Callable[[Callable[..., Any]], Callable[..., Any]]
|
|
579
580
|
Helper that patches the request helper for deterministic failures.
|
|
580
|
-
cursor_cfg :
|
|
581
|
+
cursor_cfg : CursorConfigFactory
|
|
581
582
|
Factory for cursor pagination config.
|
|
582
583
|
client_factory : Callable[..., EndpointClient]
|
|
583
584
|
Factory fixture used to construct :class:`EndpointClient`.
|
|
@@ -700,7 +701,7 @@ class TestRequestOptionIntegration:
|
|
|
700
701
|
|
|
701
702
|
def test_retry_backoff_sleeps(
|
|
702
703
|
self,
|
|
703
|
-
cursor_cfg:
|
|
704
|
+
cursor_cfg: CursorConfigFactory,
|
|
704
705
|
capture_sleeps: list[float],
|
|
705
706
|
jitter: Callable[[list[float]], list[float]],
|
|
706
707
|
patch_request_once: Callable[[Callable[..., Any]], Callable[..., Any]],
|
|
@@ -713,7 +714,7 @@ class TestRequestOptionIntegration:
|
|
|
713
714
|
|
|
714
715
|
Parameters
|
|
715
716
|
----------
|
|
716
|
-
cursor_cfg :
|
|
717
|
+
cursor_cfg : CursorConfigFactory
|
|
717
718
|
Factory for cursor pagination config.
|
|
718
719
|
capture_sleeps : list[float]
|
|
719
720
|
List to capture sleep durations.
|
|
@@ -48,7 +48,7 @@ def fixed_limiter_fixture() -> RateLimiter:
|
|
|
48
48
|
return RateLimiter.fixed(0.25)
|
|
49
49
|
|
|
50
50
|
|
|
51
|
-
# SECTION: TESTS
|
|
51
|
+
# SECTION: TESTS ============================================================ #
|
|
52
52
|
|
|
53
53
|
|
|
54
54
|
@pytest.mark.unit
|