etlplus 0.3.23__py3-none-any.whl → 0.4.0__py3-none-any.whl

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/cli.py CHANGED
@@ -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))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: etlplus
3
- Version: 0.3.23
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"
@@ -1,7 +1,7 @@
1
1
  etlplus/__init__.py,sha256=M2gScnyir6WOMAh_EuoQIiAzdcTls0_5hbd_Q6of8I0,1021
2
2
  etlplus/__main__.py,sha256=lSbVOF5Mnd_ljmCqK7nTuF_MRDYTkL73eZEFeUQ_vnI,510
3
3
  etlplus/__version__.py,sha256=1E0GMK_yUWCMQFKxXjTvyMwofi0qT2k4CDNiHWiymWE,327
4
- etlplus/cli.py,sha256=dmMW5dLbFiDRGne97qDsqr3YuU30g_Ekl_vo7bgDLig,21752
4
+ etlplus/cli.py,sha256=4Iiuh9YmfrG585GwSD2kgjLKhXfi4RsA1Lvcg5jFnu4,29757
5
5
  etlplus/enums.py,sha256=V_j18Ud2BCXpFsBk2pZGrvCVrvAMJ7uja1z9fppFGso,10175
6
6
  etlplus/extract.py,sha256=f44JdHhNTACxgn44USx05paKTwq7LQY-V4wANCW9hVM,6173
7
7
  etlplus/file.py,sha256=RxIAsGDN4f_vNA2B5-ct88JNd_ISAyYbooIRE5DstS8,17972
@@ -40,9 +40,9 @@ etlplus/config/types.py,sha256=a0epJ3z16HQ5bY3Ctf8s_cQPa3f0HHcwdOcjCP2xoG4,4954
40
40
  etlplus/config/utils.py,sha256=4SUHMkt5bKBhMhiJm-DrnmE2Q4TfOgdNCKz8PJDS27o,3443
41
41
  etlplus/validation/__init__.py,sha256=Pe5Xg1_EA4uiNZGYu5WTF3j7odjmyxnAJ8rcioaplSQ,1254
42
42
  etlplus/validation/utils.py,sha256=Mtqg449VIke0ziy_wd2r6yrwJzQkA1iulZC87FzXMjo,10201
43
- etlplus-0.3.23.dist-info/licenses/LICENSE,sha256=MuNO63i6kWmgnV2pbP2SLqP54mk1BGmu7CmbtxMmT-U,1069
44
- etlplus-0.3.23.dist-info/METADATA,sha256=MtgwbMsm2n3gypehX_zpP1Hb8VaKxHxwXUIwKXCkLw4,16730
45
- etlplus-0.3.23.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
46
- etlplus-0.3.23.dist-info/entry_points.txt,sha256=6w-2-jzuPa55spzK34h-UKh2JTEShh38adFRONNP9QE,45
47
- etlplus-0.3.23.dist-info/top_level.txt,sha256=aWWF-udn_sLGuHTM6W6MLh99ArS9ROkUWO8Mi8y1_2U,8
48
- etlplus-0.3.23.dist-info/RECORD,,
43
+ etlplus-0.4.0.dist-info/licenses/LICENSE,sha256=MuNO63i6kWmgnV2pbP2SLqP54mk1BGmu7CmbtxMmT-U,1069
44
+ etlplus-0.4.0.dist-info/METADATA,sha256=A3KilfgxjvCPCktoZ0TMh8ygvF_UmKWXlZJzd_ItDqs,16758
45
+ etlplus-0.4.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
46
+ etlplus-0.4.0.dist-info/entry_points.txt,sha256=6w-2-jzuPa55spzK34h-UKh2JTEShh38adFRONNP9QE,45
47
+ etlplus-0.4.0.dist-info/top_level.txt,sha256=aWWF-udn_sLGuHTM6W6MLh99ArS9ROkUWO8Mi8y1_2U,8
48
+ etlplus-0.4.0.dist-info/RECORD,,