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.
Files changed (128) hide show
  1. {etlplus-0.3.17 → etlplus-0.4.0}/Makefile +1 -1
  2. {etlplus-0.3.17/etlplus.egg-info → etlplus-0.4.0}/PKG-INFO +2 -1
  3. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/cli.py +332 -14
  4. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/transform.py +12 -0
  5. {etlplus-0.3.17 → etlplus-0.4.0/etlplus.egg-info}/PKG-INFO +2 -1
  6. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus.egg-info/SOURCES.txt +5 -0
  7. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus.egg-info/requires.txt +1 -0
  8. {etlplus-0.3.17 → etlplus-0.4.0}/pyproject.toml +1 -0
  9. {etlplus-0.3.17 → etlplus-0.4.0}/setup.py +2 -1
  10. {etlplus-0.3.17 → etlplus-0.4.0}/tests/integration/test_i_cli.py +2 -2
  11. {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/api/test_u_auth.py +1 -1
  12. {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/api/test_u_endpoint_client.py +22 -21
  13. {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/api/test_u_rate_limiter.py +1 -1
  14. etlplus-0.4.0/tests/unit/api/test_u_request_manager.py +349 -0
  15. {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/api/test_u_transport.py +48 -1
  16. etlplus-0.4.0/tests/unit/api/test_u_types.py +135 -0
  17. etlplus-0.4.0/tests/unit/config/test_u_config_utils.py +129 -0
  18. {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/config/test_u_connector.py +1 -1
  19. etlplus-0.4.0/tests/unit/config/test_u_jobs.py +131 -0
  20. {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/config/test_u_pipeline.py +1 -1
  21. etlplus-0.4.0/tests/unit/test_u_cli.py +545 -0
  22. {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/test_u_enums.py +1 -1
  23. {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/test_u_extract.py +1 -1
  24. {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/test_u_file.py +5 -6
  25. {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/test_u_load.py +4 -4
  26. etlplus-0.4.0/tests/unit/test_u_main.py +58 -0
  27. {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/test_u_mixins.py +1 -1
  28. etlplus-0.4.0/tests/unit/test_u_run.py +602 -0
  29. {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/test_u_run_helpers.py +1 -1
  30. etlplus-0.4.0/tests/unit/test_u_transform.py +860 -0
  31. {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/test_u_utils.py +3 -3
  32. {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/test_u_validate.py +4 -3
  33. etlplus-0.4.0/tests/unit/test_u_version.py +53 -0
  34. etlplus-0.3.17/tests/unit/api/test_u_request_manager.py +0 -135
  35. etlplus-0.3.17/tests/unit/test_u_cli.py +0 -185
  36. etlplus-0.3.17/tests/unit/test_u_run.py +0 -288
  37. etlplus-0.3.17/tests/unit/test_u_transform.py +0 -563
  38. {etlplus-0.3.17 → etlplus-0.4.0}/.coveragerc +0 -0
  39. {etlplus-0.3.17 → etlplus-0.4.0}/.editorconfig +0 -0
  40. {etlplus-0.3.17 → etlplus-0.4.0}/.gitattributes +0 -0
  41. {etlplus-0.3.17 → etlplus-0.4.0}/.github/actions/python-bootstrap/action.yml +0 -0
  42. {etlplus-0.3.17 → etlplus-0.4.0}/.github/workflows/ci.yml +0 -0
  43. {etlplus-0.3.17 → etlplus-0.4.0}/.gitignore +0 -0
  44. {etlplus-0.3.17 → etlplus-0.4.0}/.pre-commit-config.yaml +0 -0
  45. {etlplus-0.3.17 → etlplus-0.4.0}/.ruff.toml +0 -0
  46. {etlplus-0.3.17 → etlplus-0.4.0}/CODE_OF_CONDUCT.md +0 -0
  47. {etlplus-0.3.17 → etlplus-0.4.0}/CONTRIBUTING.md +0 -0
  48. {etlplus-0.3.17 → etlplus-0.4.0}/DEMO.md +0 -0
  49. {etlplus-0.3.17 → etlplus-0.4.0}/LICENSE +0 -0
  50. {etlplus-0.3.17 → etlplus-0.4.0}/README.md +0 -0
  51. {etlplus-0.3.17 → etlplus-0.4.0}/REFERENCES.md +0 -0
  52. {etlplus-0.3.17 → etlplus-0.4.0}/docs/pipeline-guide.md +0 -0
  53. {etlplus-0.3.17 → etlplus-0.4.0}/docs/snippets/installation_version.md +0 -0
  54. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/__init__.py +0 -0
  55. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/__main__.py +0 -0
  56. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/__version__.py +0 -0
  57. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/api/README.md +0 -0
  58. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/api/__init__.py +0 -0
  59. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/api/auth.py +0 -0
  60. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/api/config.py +0 -0
  61. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/api/endpoint_client.py +0 -0
  62. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/api/errors.py +0 -0
  63. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/api/pagination/__init__.py +0 -0
  64. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/api/pagination/client.py +0 -0
  65. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/api/pagination/config.py +0 -0
  66. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/api/pagination/paginator.py +0 -0
  67. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/api/rate_limiting/__init__.py +0 -0
  68. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/api/rate_limiting/config.py +0 -0
  69. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/api/rate_limiting/rate_limiter.py +0 -0
  70. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/api/request_manager.py +0 -0
  71. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/api/retry_manager.py +0 -0
  72. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/api/transport.py +0 -0
  73. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/api/types.py +0 -0
  74. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/config/__init__.py +0 -0
  75. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/config/connector.py +0 -0
  76. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/config/jobs.py +0 -0
  77. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/config/pipeline.py +0 -0
  78. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/config/profile.py +0 -0
  79. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/config/types.py +0 -0
  80. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/config/utils.py +0 -0
  81. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/enums.py +0 -0
  82. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/extract.py +0 -0
  83. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/file.py +0 -0
  84. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/load.py +0 -0
  85. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/mixins.py +0 -0
  86. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/py.typed +0 -0
  87. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/run.py +0 -0
  88. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/run_helpers.py +0 -0
  89. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/types.py +0 -0
  90. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/utils.py +0 -0
  91. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/validate.py +0 -0
  92. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/validation/__init__.py +0 -0
  93. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus/validation/utils.py +0 -0
  94. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus.egg-info/dependency_links.txt +0 -0
  95. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus.egg-info/entry_points.txt +0 -0
  96. {etlplus-0.3.17 → etlplus-0.4.0}/etlplus.egg-info/top_level.txt +0 -0
  97. {etlplus-0.3.17 → etlplus-0.4.0}/examples/README.md +0 -0
  98. {etlplus-0.3.17 → etlplus-0.4.0}/examples/configs/pipeline.yml +0 -0
  99. {etlplus-0.3.17 → etlplus-0.4.0}/examples/data/sample.csv +0 -0
  100. {etlplus-0.3.17 → etlplus-0.4.0}/examples/data/sample.json +0 -0
  101. {etlplus-0.3.17 → etlplus-0.4.0}/examples/data/sample.xml +0 -0
  102. {etlplus-0.3.17 → etlplus-0.4.0}/examples/data/sample.xsd +0 -0
  103. {etlplus-0.3.17 → etlplus-0.4.0}/examples/data/sample.yaml +0 -0
  104. {etlplus-0.3.17 → etlplus-0.4.0}/examples/quickstart_python.py +0 -0
  105. {etlplus-0.3.17 → etlplus-0.4.0}/pytest.ini +0 -0
  106. {etlplus-0.3.17 → etlplus-0.4.0}/setup.cfg +0 -0
  107. {etlplus-0.3.17 → etlplus-0.4.0}/tests/__init__.py +0 -0
  108. {etlplus-0.3.17 → etlplus-0.4.0}/tests/conftest.py +0 -0
  109. {etlplus-0.3.17 → etlplus-0.4.0}/tests/integration/conftest.py +0 -0
  110. {etlplus-0.3.17 → etlplus-0.4.0}/tests/integration/test_i_examples_data_parity.py +0 -0
  111. {etlplus-0.3.17 → etlplus-0.4.0}/tests/integration/test_i_pagination_strategy.py +0 -0
  112. {etlplus-0.3.17 → etlplus-0.4.0}/tests/integration/test_i_pipeline_smoke.py +0 -0
  113. {etlplus-0.3.17 → etlplus-0.4.0}/tests/integration/test_i_pipeline_yaml_load.py +0 -0
  114. {etlplus-0.3.17 → etlplus-0.4.0}/tests/integration/test_i_run.py +0 -0
  115. {etlplus-0.3.17 → etlplus-0.4.0}/tests/integration/test_i_run_profile_pagination_defaults.py +0 -0
  116. {etlplus-0.3.17 → etlplus-0.4.0}/tests/integration/test_i_run_profile_rate_limit_defaults.py +0 -0
  117. {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/api/conftest.py +0 -0
  118. {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/api/test_u_config.py +0 -0
  119. {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/api/test_u_mocks.py +0 -0
  120. {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/api/test_u_pagination_client.py +0 -0
  121. {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/api/test_u_pagination_config.py +0 -0
  122. {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/api/test_u_paginator.py +0 -0
  123. {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/api/test_u_rate_limit_config.py +0 -0
  124. {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/api/test_u_retry_manager.py +0 -0
  125. {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/conftest.py +0 -0
  126. {etlplus-0.3.17 → etlplus-0.4.0}/tests/unit/validation/test_u_validation_utils.py +0 -0
  127. {etlplus-0.3.17 → etlplus-0.4.0}/tools/run_pipeline.py +0 -0
  128. {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 setuptool wheel >/dev/null
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.17
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 prints results to stdout and errors to stderr.
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
- parser = create_parser()
846
- args = parser.parse_args(argv)
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
- # Prefer argparse's dispatch to avoid duplicating logic.
854
- func = getattr(args, 'func', None)
855
- if callable(func):
856
- return int(func(args))
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
- # Fallback: no subcommand function bound.
859
- parser.print_help()
860
- return 0
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.17
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
@@ -3,6 +3,7 @@ pyodbc>=5.3.0
3
3
  python-dotenv>=1.2.1
4
4
  pandas>=2.3.3
5
5
  requests>=2.32.5
6
+ typer>=0.21.0
6
7
 
7
8
  [dev]
8
9
  black>=25.9.0
@@ -28,6 +28,7 @@ dependencies = [
28
28
  "python-dotenv>=1.2.1",
29
29
  "pandas>=2.3.3",
30
30
  "requests>=2.32.5",
31
+ "typer>=0.21.0",
31
32
  ]
32
33
 
33
34
  [project.optional-dependencies]
@@ -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._typing import CliInvoke
25
- from tests._typing import JsonFactory
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_auth` module.
3
3
 
4
- Unit tests for ``etlplus.api.auth``.
4
+ Unit tests for :mod:`etlplus.api.auth`.
5
5
 
6
6
  Notes
7
7
  -----
@@ -1,7 +1,7 @@
1
1
  """
2
2
  :mod:`tests.unit.api.test_u_endpoint_client` module.
3
3
 
4
- Unit tests for ``etlplus.api.endpoint_client``.
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: Callable[..., CursorPaginationConfigMap],
386
+ cursor_cfg: CursorConfigFactory,
379
387
  client_factory: Callable[..., EndpointClient],
380
- stub_request_manager: Callable[
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 : Callable[..., CursorPaginationConfigMap]
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 : Callable[[Sequence[dict[str, Any]]],
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: Callable[..., CursorPaginationConfigMap],
520
+ cursor_cfg: CursorConfigFactory,
516
521
  client_factory: Callable[..., EndpointClient],
517
- stub_request_manager: Callable[
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 : Callable[..., CursorPaginationConfigMap]
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 : Callable[[Sequence[dict[str, Any]]],
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: Callable[..., CursorPaginationConfigMap],
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 : Callable[..., CursorPaginationConfigMap]
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: Callable[..., CursorPaginationConfigMap],
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 : Callable[..., CursorPaginationConfigMap]
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