etlplus 0.3.25__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 +332 -14
- {etlplus-0.3.25.dist-info → etlplus-0.4.0.dist-info}/METADATA +2 -1
- {etlplus-0.3.25.dist-info → etlplus-0.4.0.dist-info}/RECORD +7 -7
- {etlplus-0.3.25.dist-info → etlplus-0.4.0.dist-info}/WHEEL +0 -0
- {etlplus-0.3.25.dist-info → etlplus-0.4.0.dist-info}/entry_points.txt +0 -0
- {etlplus-0.3.25.dist-info → etlplus-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {etlplus-0.3.25.dist-info → etlplus-0.4.0.dist-info}/top_level.txt +0 -0
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
|
|
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))
|
|
@@ -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"
|
|
@@ -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=
|
|
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.
|
|
44
|
-
etlplus-0.
|
|
45
|
-
etlplus-0.
|
|
46
|
-
etlplus-0.
|
|
47
|
-
etlplus-0.
|
|
48
|
-
etlplus-0.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|