etlplus 0.11.2__tar.gz → 0.11.9__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.11.2/etlplus.egg-info → etlplus-0.11.9}/PKG-INFO +1 -1
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/cli/handlers.py +1 -1
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/database/ddl.py +1 -1
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/database/engine.py +1 -1
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/database/schema.py +1 -1
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/file/__init__.py +0 -2
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/file/core.py +50 -105
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/file/enums.py +0 -28
- {etlplus-0.11.2 → etlplus-0.11.9/etlplus.egg-info}/PKG-INFO +1 -1
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus.egg-info/SOURCES.txt +3 -1
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/integration/test_i_examples_data_parity.py +2 -2
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/cli/test_u_cli_handlers.py +4 -9
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/database/test_u_database_engine.py +5 -4
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/database/test_u_database_schema.py +12 -14
- etlplus-0.11.2/tests/unit/test_u_file.py → etlplus-0.11.9/tests/unit/file/test_u_file_core.py +6 -154
- etlplus-0.11.9/tests/unit/file/test_u_file_enums.py +90 -0
- etlplus-0.11.9/tests/unit/file/test_u_file_yaml.py +110 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/.coveragerc +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/.editorconfig +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/.gitattributes +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/.github/actions/python-bootstrap/action.yml +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/.github/workflows/ci.yml +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/.gitignore +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/.pre-commit-config.yaml +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/.ruff.toml +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/CODE_OF_CONDUCT.md +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/CONTRIBUTING.md +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/DEMO.md +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/LICENSE +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/MANIFEST.in +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/Makefile +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/README.md +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/REFERENCES.md +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/docs/README.md +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/docs/pipeline-guide.md +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/docs/snippets/installation_version.md +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/__init__.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/__main__.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/__version__.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/api/README.md +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/api/__init__.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/api/auth.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/api/config.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/api/endpoint_client.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/api/errors.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/api/pagination/__init__.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/api/pagination/client.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/api/pagination/config.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/api/pagination/paginator.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/api/rate_limiting/__init__.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/api/rate_limiting/config.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/api/rate_limiting/rate_limiter.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/api/request_manager.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/api/retry_manager.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/api/transport.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/api/types.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/cli/__init__.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/cli/commands.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/cli/constants.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/cli/io.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/cli/main.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/cli/options.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/cli/state.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/cli/types.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/config/__init__.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/config/connector.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/config/jobs.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/config/pipeline.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/config/profile.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/config/types.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/config/utils.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/database/__init__.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/database/orm.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/database/types.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/enums.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/extract.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/file/csv.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/file/json.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/file/xml.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/file/yaml.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/load.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/mixins.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/py.typed +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/run.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/run_helpers.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/templates/__init__.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/templates/ddl.sql.j2 +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/templates/view.sql.j2 +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/transform.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/types.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/utils.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/validate.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/validation/__init__.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus/validation/utils.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus.egg-info/dependency_links.txt +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus.egg-info/entry_points.txt +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus.egg-info/requires.txt +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/etlplus.egg-info/top_level.txt +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/examples/README.md +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/examples/configs/ddl_spec.yml +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/examples/configs/pipeline.yml +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/examples/data/sample.csv +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/examples/data/sample.json +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/examples/data/sample.xml +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/examples/data/sample.xsd +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/examples/data/sample.yaml +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/examples/quickstart_python.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/pyproject.toml +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/pytest.ini +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/setup.cfg +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/setup.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/__init__.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/conftest.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/integration/conftest.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/integration/test_i_cli.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/integration/test_i_pagination_strategy.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/integration/test_i_pipeline_smoke.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/integration/test_i_pipeline_yaml_load.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/integration/test_i_run.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/integration/test_i_run_profile_pagination_defaults.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/integration/test_i_run_profile_rate_limit_defaults.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/api/conftest.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/api/test_u_auth.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/api/test_u_config.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/api/test_u_endpoint_client.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/api/test_u_mocks.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/api/test_u_pagination_client.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/api/test_u_pagination_config.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/api/test_u_paginator.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/api/test_u_rate_limit_config.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/api/test_u_rate_limiter.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/api/test_u_request_manager.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/api/test_u_retry_manager.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/api/test_u_transport.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/api/test_u_types.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/cli/conftest.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/cli/test_u_cli_io.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/cli/test_u_cli_main.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/cli/test_u_cli_state.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/config/test_u_config_utils.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/config/test_u_connector.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/config/test_u_jobs.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/config/test_u_pipeline.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/conftest.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/database/test_u_database_ddl.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/database/test_u_database_orm.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/test_u_enums.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/test_u_extract.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/test_u_load.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/test_u_main.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/test_u_mixins.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/test_u_run.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/test_u_run_helpers.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/test_u_transform.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/test_u_utils.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/test_u_validate.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/test_u_version.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tests/unit/validation/test_u_validation_utils.py +0 -0
- {etlplus-0.11.2 → etlplus-0.11.9}/tools/update_demo_snippets.py +0 -0
|
@@ -570,7 +570,7 @@ def transform_handler(
|
|
|
570
570
|
data = transform(payload, cast(TransformOperations, operations_payload))
|
|
571
571
|
|
|
572
572
|
if target and target != '-':
|
|
573
|
-
File
|
|
573
|
+
File(target, file_format=target_format).write(data)
|
|
574
574
|
print(f'Data transformed and saved to {target}')
|
|
575
575
|
return 0
|
|
576
576
|
|
|
@@ -203,7 +203,7 @@ def load_table_spec(
|
|
|
203
203
|
raise ValueError('Spec must be .json, .yml, or .yaml')
|
|
204
204
|
|
|
205
205
|
try:
|
|
206
|
-
spec = File
|
|
206
|
+
spec = File(spec_path).read()
|
|
207
207
|
except ImportError as e:
|
|
208
208
|
if suffix in {'.yml', '.yaml'}:
|
|
209
209
|
raise RuntimeError(
|
|
@@ -113,7 +113,7 @@ def load_database_url_from_config(
|
|
|
113
113
|
ValueError
|
|
114
114
|
If no connection string/URL/DSN is found for the specified entry.
|
|
115
115
|
"""
|
|
116
|
-
cfg = File
|
|
116
|
+
cfg = File(Path(path)).read()
|
|
117
117
|
if not isinstance(cfg, Mapping):
|
|
118
118
|
raise TypeError('Database config must be a mapping')
|
|
119
119
|
|
|
@@ -9,7 +9,6 @@ from __future__ import annotations
|
|
|
9
9
|
from .core import File
|
|
10
10
|
from .enums import CompressionFormat
|
|
11
11
|
from .enums import FileFormat
|
|
12
|
-
from .enums import coerce_file_format
|
|
13
12
|
from .enums import infer_file_format_and_compression
|
|
14
13
|
|
|
15
14
|
# SECTION: EXPORTS ========================================================== #
|
|
@@ -22,6 +21,5 @@ __all__ = [
|
|
|
22
21
|
'CompressionFormat',
|
|
23
22
|
'FileFormat',
|
|
24
23
|
# Functions
|
|
25
|
-
'coerce_file_format',
|
|
26
24
|
'infer_file_format_and_compression',
|
|
27
25
|
]
|
|
@@ -11,7 +11,6 @@ from dataclasses import dataclass
|
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
|
|
13
13
|
from ..types import JSONData
|
|
14
|
-
from ..types import StrPath
|
|
15
14
|
from . import csv
|
|
16
15
|
from . import json
|
|
17
16
|
from . import xml
|
|
@@ -43,7 +42,15 @@ class File:
|
|
|
43
42
|
Path to the file on disk.
|
|
44
43
|
file_format : FileFormat | None, optional
|
|
45
44
|
Explicit format. If omitted, the format is inferred from the file
|
|
46
|
-
extension (``.csv``, ``.json``,
|
|
45
|
+
extension (``.csv``, ``.json``, etc.).
|
|
46
|
+
|
|
47
|
+
Parameters
|
|
48
|
+
----------
|
|
49
|
+
path : StrPath
|
|
50
|
+
Path to the file on disk.
|
|
51
|
+
file_format : FileFormat | str | None, optional
|
|
52
|
+
Explicit format. If omitted, the format is inferred from the file
|
|
53
|
+
extension (``.csv``, ``.json``, etc.).
|
|
47
54
|
"""
|
|
48
55
|
|
|
49
56
|
# -- Attributes -- #
|
|
@@ -62,16 +69,10 @@ class File:
|
|
|
62
69
|
extension is unknown, the attribute is left as ``None`` and will be
|
|
63
70
|
validated later by :meth:`_ensure_format`.
|
|
64
71
|
"""
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
self.path = Path(self.path)
|
|
68
|
-
|
|
72
|
+
self.path = Path(self.path)
|
|
73
|
+
self.file_format = self._coerce_format(self.file_format)
|
|
69
74
|
if self.file_format is None:
|
|
70
|
-
|
|
71
|
-
self.file_format = self._guess_format()
|
|
72
|
-
except ValueError:
|
|
73
|
-
# Leave as None; _ensure_format() will raise on use if needed.
|
|
74
|
-
pass
|
|
75
|
+
self.file_format = self._maybe_guess_format()
|
|
75
76
|
|
|
76
77
|
# -- Internal Instance Methods -- #
|
|
77
78
|
|
|
@@ -84,6 +85,28 @@ class File:
|
|
|
84
85
|
if not self.path.exists():
|
|
85
86
|
raise FileNotFoundError(f'File not found: {self.path}')
|
|
86
87
|
|
|
88
|
+
def _coerce_format(
|
|
89
|
+
self,
|
|
90
|
+
file_format: FileFormat | str | None,
|
|
91
|
+
) -> FileFormat | None:
|
|
92
|
+
"""
|
|
93
|
+
Normalize the file format input.
|
|
94
|
+
|
|
95
|
+
Parameters
|
|
96
|
+
----------
|
|
97
|
+
file_format : FileFormat | str | None
|
|
98
|
+
File format specifier. Strings are coerced into
|
|
99
|
+
:class:`FileFormat`.
|
|
100
|
+
|
|
101
|
+
Returns
|
|
102
|
+
-------
|
|
103
|
+
FileFormat | None
|
|
104
|
+
A normalized file format, or ``None`` when unspecified.
|
|
105
|
+
"""
|
|
106
|
+
if file_format is None or isinstance(file_format, FileFormat):
|
|
107
|
+
return file_format
|
|
108
|
+
return FileFormat.coerce(file_format)
|
|
109
|
+
|
|
87
110
|
def _ensure_format(self) -> FileFormat:
|
|
88
111
|
"""
|
|
89
112
|
Resolve the active format, guessing from extension if needed.
|
|
@@ -125,7 +148,22 @@ class File:
|
|
|
125
148
|
f'Cannot infer file format from extension {self.path.suffix!r}',
|
|
126
149
|
)
|
|
127
150
|
|
|
128
|
-
|
|
151
|
+
def _maybe_guess_format(self) -> FileFormat | None:
|
|
152
|
+
"""
|
|
153
|
+
Try to infer the format, returning ``None`` if it cannot be inferred.
|
|
154
|
+
|
|
155
|
+
Returns
|
|
156
|
+
-------
|
|
157
|
+
FileFormat | None
|
|
158
|
+
The inferred format, or ``None`` if inference fails.
|
|
159
|
+
"""
|
|
160
|
+
try:
|
|
161
|
+
return self._guess_format()
|
|
162
|
+
except ValueError:
|
|
163
|
+
# Leave as None; _ensure_format() will raise on use if needed.
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
# -- Instance Methods -- #
|
|
129
167
|
|
|
130
168
|
def read(self) -> JSONData:
|
|
131
169
|
"""
|
|
@@ -192,96 +230,3 @@ class File:
|
|
|
192
230
|
case FileFormat.YAML:
|
|
193
231
|
return yaml.write(self.path, data)
|
|
194
232
|
raise ValueError(f'Unsupported format: {fmt}')
|
|
195
|
-
|
|
196
|
-
# -- Class Methods -- #
|
|
197
|
-
|
|
198
|
-
@classmethod
|
|
199
|
-
def from_path(
|
|
200
|
-
cls,
|
|
201
|
-
path: StrPath,
|
|
202
|
-
*,
|
|
203
|
-
file_format: FileFormat | str | None = None,
|
|
204
|
-
) -> File:
|
|
205
|
-
"""
|
|
206
|
-
Create a :class:`File` from any path-like and optional format.
|
|
207
|
-
|
|
208
|
-
Parameters
|
|
209
|
-
----------
|
|
210
|
-
path : StrPath
|
|
211
|
-
Path to the file on disk.
|
|
212
|
-
file_format : FileFormat | str | None, optional
|
|
213
|
-
Explicit format. If omitted, the format is inferred from the file
|
|
214
|
-
extension (``.csv``, ``.json``, or ``.xml``).
|
|
215
|
-
|
|
216
|
-
Returns
|
|
217
|
-
-------
|
|
218
|
-
File
|
|
219
|
-
The constructed :class:`File` instance.
|
|
220
|
-
"""
|
|
221
|
-
resolved = Path(path)
|
|
222
|
-
ff: FileFormat | None
|
|
223
|
-
if isinstance(file_format, str):
|
|
224
|
-
ff = FileFormat.coerce(file_format)
|
|
225
|
-
else:
|
|
226
|
-
ff = file_format
|
|
227
|
-
|
|
228
|
-
return cls(resolved, ff)
|
|
229
|
-
|
|
230
|
-
@classmethod
|
|
231
|
-
def read_file(
|
|
232
|
-
cls,
|
|
233
|
-
path: StrPath,
|
|
234
|
-
file_format: FileFormat | str | None = None,
|
|
235
|
-
) -> JSONData:
|
|
236
|
-
"""
|
|
237
|
-
Read structured data.
|
|
238
|
-
|
|
239
|
-
Parameters
|
|
240
|
-
----------
|
|
241
|
-
path : StrPath
|
|
242
|
-
Path to the file on disk.
|
|
243
|
-
file_format : FileFormat | str | None, optional
|
|
244
|
-
Explicit format. If omitted, the format is inferred from the file
|
|
245
|
-
extension (``.csv``, ``.json``, or ``.xml``).
|
|
246
|
-
|
|
247
|
-
Returns
|
|
248
|
-
-------
|
|
249
|
-
JSONData
|
|
250
|
-
The structured data read from the file.
|
|
251
|
-
"""
|
|
252
|
-
return cls.from_path(path, file_format=file_format).read()
|
|
253
|
-
|
|
254
|
-
@classmethod
|
|
255
|
-
def write_file(
|
|
256
|
-
cls,
|
|
257
|
-
path: StrPath,
|
|
258
|
-
data: JSONData,
|
|
259
|
-
file_format: FileFormat | str | None = None,
|
|
260
|
-
*,
|
|
261
|
-
root_tag: str = xml.DEFAULT_XML_ROOT,
|
|
262
|
-
) -> int:
|
|
263
|
-
"""
|
|
264
|
-
Write structured data and count written records.
|
|
265
|
-
|
|
266
|
-
Parameters
|
|
267
|
-
----------
|
|
268
|
-
path : StrPath
|
|
269
|
-
Path to the file on disk.
|
|
270
|
-
data : JSONData
|
|
271
|
-
Data to write to the file.
|
|
272
|
-
file_format : FileFormat | str | None, optional
|
|
273
|
-
Explicit format. If omitted, the format is inferred from the file
|
|
274
|
-
extension (``.csv``, ``.json``, or ``.xml``).
|
|
275
|
-
root_tag : str, optional
|
|
276
|
-
Root tag name to use when writing XML files. Defaults to
|
|
277
|
-
``'root'``.
|
|
278
|
-
|
|
279
|
-
Returns
|
|
280
|
-
-------
|
|
281
|
-
int
|
|
282
|
-
The number of records written to the file.
|
|
283
|
-
"""
|
|
284
|
-
return cls.from_path(path, file_format=file_format).write(
|
|
285
|
-
data,
|
|
286
|
-
root_tag=root_tag,
|
|
287
|
-
)
|
|
@@ -16,8 +16,6 @@ from ..types import StrStrMap
|
|
|
16
16
|
__all__ = [
|
|
17
17
|
'CompressionFormat',
|
|
18
18
|
'FileFormat',
|
|
19
|
-
'coerce_compression_format',
|
|
20
|
-
'coerce_file_format',
|
|
21
19
|
'infer_file_format_and_compression',
|
|
22
20
|
]
|
|
23
21
|
|
|
@@ -164,32 +162,6 @@ _COMPRESSION_FILE_FORMATS: set[FileFormat] = {
|
|
|
164
162
|
# SECTION: FUNCTIONS ======================================================== #
|
|
165
163
|
|
|
166
164
|
|
|
167
|
-
# TODO: Deprecate in favor of using the enum methods directly.
|
|
168
|
-
def coerce_compression_format(
|
|
169
|
-
compression_format: CompressionFormat | str,
|
|
170
|
-
) -> CompressionFormat:
|
|
171
|
-
"""
|
|
172
|
-
Normalize textual compression format values to :class:`CompressionFormat`.
|
|
173
|
-
|
|
174
|
-
This thin wrapper is kept for backward compatibility; prefer
|
|
175
|
-
:meth:`CompressionFormat.coerce` going forward.
|
|
176
|
-
"""
|
|
177
|
-
return CompressionFormat.coerce(compression_format)
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
# TODO: Deprecate in favor of using the enum methods directly.
|
|
181
|
-
def coerce_file_format(
|
|
182
|
-
file_format: FileFormat | str,
|
|
183
|
-
) -> FileFormat:
|
|
184
|
-
"""
|
|
185
|
-
Normalize textual file format values to :class:`FileFormat`.
|
|
186
|
-
|
|
187
|
-
This thin wrapper is kept for backward compatibility; prefer
|
|
188
|
-
:meth:`FileFormat.coerce` going forward.
|
|
189
|
-
"""
|
|
190
|
-
return FileFormat.coerce(file_format)
|
|
191
|
-
|
|
192
|
-
|
|
193
165
|
# TODO: Convert to a method on FileFormat or CompressionFormat?
|
|
194
166
|
def infer_file_format_and_compression(
|
|
195
167
|
value: object,
|
|
@@ -114,7 +114,6 @@ tests/integration/test_i_run_profile_rate_limit_defaults.py
|
|
|
114
114
|
tests/unit/conftest.py
|
|
115
115
|
tests/unit/test_u_enums.py
|
|
116
116
|
tests/unit/test_u_extract.py
|
|
117
|
-
tests/unit/test_u_file.py
|
|
118
117
|
tests/unit/test_u_load.py
|
|
119
118
|
tests/unit/test_u_main.py
|
|
120
119
|
tests/unit/test_u_mixins.py
|
|
@@ -151,5 +150,8 @@ tests/unit/database/test_u_database_ddl.py
|
|
|
151
150
|
tests/unit/database/test_u_database_engine.py
|
|
152
151
|
tests/unit/database/test_u_database_orm.py
|
|
153
152
|
tests/unit/database/test_u_database_schema.py
|
|
153
|
+
tests/unit/file/test_u_file_core.py
|
|
154
|
+
tests/unit/file/test_u_file_enums.py
|
|
155
|
+
tests/unit/file/test_u_file_yaml.py
|
|
154
156
|
tests/unit/validation/test_u_validation_utils.py
|
|
155
157
|
tools/update_demo_snippets.py
|
|
@@ -48,8 +48,8 @@ def test_examples_sample_csv_json_parity_integration():
|
|
|
48
48
|
assert csv_path.exists(), f'Missing CSV fixture: {csv_path}'
|
|
49
49
|
assert json_path.exists(), f'Missing JSON fixture: {json_path}'
|
|
50
50
|
|
|
51
|
-
csv_data = File
|
|
52
|
-
json_data = File
|
|
51
|
+
csv_data = File(csv_path).read()
|
|
52
|
+
json_data = File(json_path).read()
|
|
53
53
|
|
|
54
54
|
assert isinstance(csv_data, list), 'CSV should load as a list of dicts'
|
|
55
55
|
assert isinstance(json_data, list), 'JSON should load as a list of dicts'
|
|
@@ -683,15 +683,11 @@ class TestTransformHandler:
|
|
|
683
683
|
)
|
|
684
684
|
write_calls: dict[str, object] = {}
|
|
685
685
|
|
|
686
|
-
def fake_write(
|
|
687
|
-
path
|
|
688
|
-
|
|
689
|
-
*,
|
|
690
|
-
file_format: str | None,
|
|
691
|
-
) -> None:
|
|
692
|
-
write_calls['params'] = (path, data, file_format)
|
|
686
|
+
def fake_write(self, data, **kwargs):
|
|
687
|
+
# Only capture path and data; ignore root_tag.
|
|
688
|
+
write_calls['params'] = (str(self.path), data)
|
|
693
689
|
|
|
694
|
-
monkeypatch.setattr(handlers.File, '
|
|
690
|
+
monkeypatch.setattr(handlers.File, 'write', fake_write)
|
|
695
691
|
|
|
696
692
|
assert (
|
|
697
693
|
handlers.transform_handler(
|
|
@@ -709,7 +705,6 @@ class TestTransformHandler:
|
|
|
709
705
|
'payload': {'source': 'data.json'},
|
|
710
706
|
'ops': {'select': ['id']},
|
|
711
707
|
},
|
|
712
|
-
'json',
|
|
713
708
|
)
|
|
714
709
|
assert (
|
|
715
710
|
'Data transformed and saved to out.json' in capsys.readouterr().out
|
|
@@ -41,7 +41,8 @@ class TestLoadDatabaseUrlFromConfig:
|
|
|
41
41
|
self,
|
|
42
42
|
monkeypatch: pytest.MonkeyPatch,
|
|
43
43
|
) -> Callable[[Any], None]:
|
|
44
|
-
"""
|
|
44
|
+
"""
|
|
45
|
+
Return a helper that patches :meth:`read` to return a payload.
|
|
45
46
|
|
|
46
47
|
Parameters
|
|
47
48
|
----------
|
|
@@ -51,14 +52,14 @@ class TestLoadDatabaseUrlFromConfig:
|
|
|
51
52
|
Returns
|
|
52
53
|
-------
|
|
53
54
|
Callable[[Any], None]
|
|
54
|
-
Function that patches ``File.
|
|
55
|
+
Function that patches ``File.read`` to return the payload.
|
|
55
56
|
"""
|
|
56
57
|
|
|
57
58
|
def _apply(payload: Any) -> None:
|
|
58
59
|
monkeypatch.setattr(
|
|
59
60
|
engine_mod.File,
|
|
60
|
-
'
|
|
61
|
-
|
|
61
|
+
'read',
|
|
62
|
+
lambda self: payload,
|
|
62
63
|
)
|
|
63
64
|
|
|
64
65
|
return _apply
|
|
@@ -76,7 +76,7 @@ class TestLoadTableSpecs:
|
|
|
76
76
|
|
|
77
77
|
Notes
|
|
78
78
|
-----
|
|
79
|
-
Reuses a helper fixture to patch
|
|
79
|
+
Reuses a helper fixture to patch :meth:`File.read` and avoid disk IO.
|
|
80
80
|
"""
|
|
81
81
|
|
|
82
82
|
@pytest.fixture()
|
|
@@ -84,7 +84,9 @@ class TestLoadTableSpecs:
|
|
|
84
84
|
self,
|
|
85
85
|
monkeypatch: pytest.MonkeyPatch,
|
|
86
86
|
) -> Callable[[Any], None]:
|
|
87
|
-
"""
|
|
87
|
+
"""
|
|
88
|
+
Return helper that patches the :meth:`read` instance method to return a
|
|
89
|
+
payload.
|
|
88
90
|
|
|
89
91
|
Parameters
|
|
90
92
|
----------
|
|
@@ -98,18 +100,14 @@ class TestLoadTableSpecs:
|
|
|
98
100
|
"""
|
|
99
101
|
|
|
100
102
|
def _apply(payload: Any) -> None:
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
schema_mod.File,
|
|
110
|
-
'read_file',
|
|
111
|
-
staticmethod(lambda path: payload),
|
|
112
|
-
)
|
|
103
|
+
# pylint: disable=unused-argument
|
|
104
|
+
"""Apply the patch to :meth:`File.read` to return the payload."""
|
|
105
|
+
|
|
106
|
+
def fake_read(self, *args, **kwargs):
|
|
107
|
+
"""Fake :meth:`File.read` method returning the payload."""
|
|
108
|
+
return payload(self.path) if callable(payload) else payload
|
|
109
|
+
|
|
110
|
+
monkeypatch.setattr(schema_mod.File, 'read', fake_read)
|
|
113
111
|
|
|
114
112
|
return _apply
|
|
115
113
|
|
etlplus-0.11.2/tests/unit/test_u_file.py → etlplus-0.11.9/tests/unit/file/test_u_file_core.py
RENAMED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
|
-
:mod:`tests.unit.
|
|
2
|
+
:mod:`tests.unit.test_u_file_core` module.
|
|
3
3
|
|
|
4
|
-
Unit tests for :mod:`etlplus.file`.
|
|
4
|
+
Unit tests for :mod:`etlplus.file.core`.
|
|
5
5
|
|
|
6
6
|
Notes
|
|
7
7
|
-----
|
|
@@ -11,17 +11,13 @@ Notes
|
|
|
11
11
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
|
-
from collections.abc import Generator
|
|
15
14
|
from pathlib import Path
|
|
16
15
|
from typing import cast
|
|
17
16
|
|
|
18
17
|
import pytest
|
|
19
18
|
|
|
20
|
-
import etlplus.file.yaml as yaml_module
|
|
21
|
-
from etlplus.file import CompressionFormat
|
|
22
19
|
from etlplus.file import File
|
|
23
20
|
from etlplus.file import FileFormat
|
|
24
|
-
from etlplus.file import infer_file_format_and_compression
|
|
25
21
|
from etlplus.types import JSONDict
|
|
26
22
|
|
|
27
23
|
# SECTION: HELPERS ========================================================== #
|
|
@@ -30,46 +26,6 @@ from etlplus.types import JSONDict
|
|
|
30
26
|
pytestmark = pytest.mark.unit
|
|
31
27
|
|
|
32
28
|
|
|
33
|
-
class _StubYaml:
|
|
34
|
-
"""Minimal PyYAML substitute to avoid optional dependency in tests."""
|
|
35
|
-
|
|
36
|
-
def __init__(self) -> None:
|
|
37
|
-
self.dump_calls: list[dict[str, object]] = []
|
|
38
|
-
|
|
39
|
-
def safe_load(
|
|
40
|
-
self,
|
|
41
|
-
handle: object,
|
|
42
|
-
) -> dict[str, str]:
|
|
43
|
-
"""Stub for PyYAML's ``safe_load`` function."""
|
|
44
|
-
text = ''
|
|
45
|
-
if hasattr(handle, 'read'): # type: ignore[call-arg]
|
|
46
|
-
text = handle.read()
|
|
47
|
-
return {'loaded': str(text).strip()}
|
|
48
|
-
|
|
49
|
-
def safe_dump(
|
|
50
|
-
self,
|
|
51
|
-
data: object,
|
|
52
|
-
handle: object,
|
|
53
|
-
**kwargs: object,
|
|
54
|
-
) -> None:
|
|
55
|
-
"""Stub for PyYAML's ``safe_dump`` function."""
|
|
56
|
-
self.dump_calls.append({'data': data, 'kwargs': kwargs})
|
|
57
|
-
if hasattr(handle, 'write'):
|
|
58
|
-
handle.write('yaml') # type: ignore[call-arg]
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
@pytest.fixture(name='yaml_stub')
|
|
62
|
-
def yaml_stub_fixture() -> Generator[_StubYaml]:
|
|
63
|
-
"""Install a stub PyYAML module for YAML tests."""
|
|
64
|
-
# pylint: disable=protected-access
|
|
65
|
-
|
|
66
|
-
stub = _StubYaml()
|
|
67
|
-
yaml_module._YAML_CACHE.clear()
|
|
68
|
-
yaml_module._YAML_CACHE['mod'] = stub
|
|
69
|
-
yield stub
|
|
70
|
-
yaml_module._YAML_CACHE.clear()
|
|
71
|
-
|
|
72
|
-
|
|
73
29
|
# SECTION: TESTS ============================================================ #
|
|
74
30
|
|
|
75
31
|
|
|
@@ -82,18 +38,18 @@ class TestFile:
|
|
|
82
38
|
- Exercises JSON detection and defers errors for unknown extensions.
|
|
83
39
|
"""
|
|
84
40
|
|
|
85
|
-
def
|
|
41
|
+
def test_instance_methods_round_trip(
|
|
86
42
|
self,
|
|
87
43
|
tmp_path: Path,
|
|
88
44
|
) -> None:
|
|
89
45
|
"""
|
|
90
|
-
Test
|
|
46
|
+
Test :meth:`read` and :meth:`write` round-tripping data.
|
|
91
47
|
"""
|
|
92
48
|
path = tmp_path / 'delegated.json'
|
|
93
49
|
data = {'name': 'delegated'}
|
|
94
50
|
|
|
95
|
-
File
|
|
96
|
-
result = File
|
|
51
|
+
File(path, file_format=FileFormat.JSON).write(data)
|
|
52
|
+
result = File(path, file_format=FileFormat.JSON).read()
|
|
97
53
|
|
|
98
54
|
assert isinstance(result, dict)
|
|
99
55
|
assert result['name'] == 'delegated'
|
|
@@ -303,107 +259,3 @@ class TestFile:
|
|
|
303
259
|
text = path.read_text(encoding='utf-8')
|
|
304
260
|
assert text.startswith('<?xml')
|
|
305
261
|
assert '<records>' in text
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
class TestFileFormat:
|
|
309
|
-
"""Unit test suite for :class:`etlplus.enums.FileFormat`."""
|
|
310
|
-
|
|
311
|
-
@pytest.mark.parametrize(
|
|
312
|
-
'value,expected',
|
|
313
|
-
[
|
|
314
|
-
('JSON', FileFormat.JSON),
|
|
315
|
-
('application/xml', FileFormat.XML),
|
|
316
|
-
('yml', FileFormat.YAML),
|
|
317
|
-
],
|
|
318
|
-
)
|
|
319
|
-
def test_aliases(
|
|
320
|
-
self,
|
|
321
|
-
value: str,
|
|
322
|
-
expected: FileFormat,
|
|
323
|
-
) -> None:
|
|
324
|
-
"""Test alias coercions."""
|
|
325
|
-
assert FileFormat.coerce(value) is expected
|
|
326
|
-
|
|
327
|
-
def test_coerce(self) -> None:
|
|
328
|
-
"""Test :meth:`coerce`."""
|
|
329
|
-
assert FileFormat.coerce('csv') is FileFormat.CSV
|
|
330
|
-
|
|
331
|
-
def test_invalid_value(self) -> None:
|
|
332
|
-
"""Test that invalid values raise ValueError."""
|
|
333
|
-
with pytest.raises(ValueError, match='Invalid FileFormat'):
|
|
334
|
-
FileFormat.coerce('ini')
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
class TestInferFileFormatAndCompression:
|
|
338
|
-
"""Unit test suite for :func:`infer_file_format_and_compression`."""
|
|
339
|
-
|
|
340
|
-
@pytest.mark.parametrize(
|
|
341
|
-
'value,filename,expected_format,expected_compression',
|
|
342
|
-
[
|
|
343
|
-
('data.csv.gz', None, FileFormat.CSV, CompressionFormat.GZ),
|
|
344
|
-
('data.jsonl.gz', None, FileFormat.NDJSON, CompressionFormat.GZ),
|
|
345
|
-
('data.zip', None, None, CompressionFormat.ZIP),
|
|
346
|
-
('application/json; charset=utf-8', None, FileFormat.JSON, None),
|
|
347
|
-
('application/gzip', None, None, CompressionFormat.GZ),
|
|
348
|
-
(
|
|
349
|
-
'application/octet-stream',
|
|
350
|
-
'payload.csv.gz',
|
|
351
|
-
FileFormat.CSV,
|
|
352
|
-
CompressionFormat.GZ,
|
|
353
|
-
),
|
|
354
|
-
('application/octet-stream', None, None, None),
|
|
355
|
-
(FileFormat.GZ, None, None, CompressionFormat.GZ),
|
|
356
|
-
(CompressionFormat.ZIP, None, None, CompressionFormat.ZIP),
|
|
357
|
-
],
|
|
358
|
-
)
|
|
359
|
-
def test_infers_format_and_compression(
|
|
360
|
-
self,
|
|
361
|
-
value: object,
|
|
362
|
-
filename: object | None,
|
|
363
|
-
expected_format: FileFormat | None,
|
|
364
|
-
expected_compression: CompressionFormat | None,
|
|
365
|
-
) -> None:
|
|
366
|
-
"""Test mixed inputs for format and compression inference."""
|
|
367
|
-
fmt, compression = infer_file_format_and_compression(value, filename)
|
|
368
|
-
assert fmt is expected_format
|
|
369
|
-
assert compression is expected_compression
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
@pytest.mark.unit
|
|
373
|
-
class TestYamlSupport:
|
|
374
|
-
"""Unit tests exercising YAML read/write helpers without PyYAML."""
|
|
375
|
-
|
|
376
|
-
def test_read_yaml_uses_stub(
|
|
377
|
-
self,
|
|
378
|
-
tmp_path: Path,
|
|
379
|
-
yaml_stub: _StubYaml,
|
|
380
|
-
) -> None:
|
|
381
|
-
"""
|
|
382
|
-
Test reading YAML should invoke stub ``safe_load``.
|
|
383
|
-
"""
|
|
384
|
-
# pylint: disable=protected-access
|
|
385
|
-
|
|
386
|
-
assert yaml_module._YAML_CACHE['mod'] is yaml_stub
|
|
387
|
-
path = tmp_path / 'data.yaml'
|
|
388
|
-
path.write_text('name: etl', encoding='utf-8')
|
|
389
|
-
|
|
390
|
-
result = File(path, FileFormat.YAML).read()
|
|
391
|
-
|
|
392
|
-
assert result == {'loaded': 'name: etl'}
|
|
393
|
-
|
|
394
|
-
def test_write_yaml_uses_stub(
|
|
395
|
-
self,
|
|
396
|
-
tmp_path: Path,
|
|
397
|
-
yaml_stub: _StubYaml,
|
|
398
|
-
) -> None:
|
|
399
|
-
"""
|
|
400
|
-
Test writing YAML should invoke stub ``safe_dump``.
|
|
401
|
-
"""
|
|
402
|
-
path = tmp_path / 'data.yaml'
|
|
403
|
-
payload = [{'name': 'etl'}]
|
|
404
|
-
|
|
405
|
-
written = File(path, FileFormat.YAML).write(payload)
|
|
406
|
-
|
|
407
|
-
assert written == 1
|
|
408
|
-
assert yaml_stub.dump_calls
|
|
409
|
-
assert yaml_stub.dump_calls[0]['data'] == payload
|