etlplus 0.5.5__tar.gz → 0.6.1__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 (138) hide show
  1. {etlplus-0.5.5/etlplus.egg-info → etlplus-0.6.1}/PKG-INFO +1 -1
  2. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/cli/handlers.py +2 -2
  3. etlplus-0.6.1/etlplus/database/__init__.py +23 -0
  4. {etlplus-0.5.5/etlplus → etlplus-0.6.1/etlplus/database}/ddl.py +160 -46
  5. {etlplus-0.5.5 → etlplus-0.6.1/etlplus.egg-info}/PKG-INFO +1 -1
  6. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus.egg-info/SOURCES.txt +3 -1
  7. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/cli/test_u_cli_app.py +1 -1
  8. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/cli/test_u_cli_handlers.py +1 -1
  9. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/cli/test_u_cli_main.py +1 -1
  10. etlplus-0.6.1/tests/unit/database/test_u_database_ddl.py +265 -0
  11. {etlplus-0.5.5 → etlplus-0.6.1}/.coveragerc +0 -0
  12. {etlplus-0.5.5 → etlplus-0.6.1}/.editorconfig +0 -0
  13. {etlplus-0.5.5 → etlplus-0.6.1}/.gitattributes +0 -0
  14. {etlplus-0.5.5 → etlplus-0.6.1}/.github/actions/python-bootstrap/action.yml +0 -0
  15. {etlplus-0.5.5 → etlplus-0.6.1}/.github/workflows/ci.yml +0 -0
  16. {etlplus-0.5.5 → etlplus-0.6.1}/.gitignore +0 -0
  17. {etlplus-0.5.5 → etlplus-0.6.1}/.pre-commit-config.yaml +0 -0
  18. {etlplus-0.5.5 → etlplus-0.6.1}/.ruff.toml +0 -0
  19. {etlplus-0.5.5 → etlplus-0.6.1}/CODE_OF_CONDUCT.md +0 -0
  20. {etlplus-0.5.5 → etlplus-0.6.1}/CONTRIBUTING.md +0 -0
  21. {etlplus-0.5.5 → etlplus-0.6.1}/DEMO.md +0 -0
  22. {etlplus-0.5.5 → etlplus-0.6.1}/LICENSE +0 -0
  23. {etlplus-0.5.5 → etlplus-0.6.1}/MANIFEST.in +0 -0
  24. {etlplus-0.5.5 → etlplus-0.6.1}/Makefile +0 -0
  25. {etlplus-0.5.5 → etlplus-0.6.1}/README.md +0 -0
  26. {etlplus-0.5.5 → etlplus-0.6.1}/REFERENCES.md +0 -0
  27. {etlplus-0.5.5 → etlplus-0.6.1}/docs/pipeline-guide.md +0 -0
  28. {etlplus-0.5.5 → etlplus-0.6.1}/docs/snippets/installation_version.md +0 -0
  29. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/__init__.py +0 -0
  30. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/__main__.py +0 -0
  31. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/__version__.py +0 -0
  32. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/api/README.md +0 -0
  33. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/api/__init__.py +0 -0
  34. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/api/auth.py +0 -0
  35. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/api/config.py +0 -0
  36. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/api/endpoint_client.py +0 -0
  37. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/api/errors.py +0 -0
  38. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/api/pagination/__init__.py +0 -0
  39. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/api/pagination/client.py +0 -0
  40. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/api/pagination/config.py +0 -0
  41. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/api/pagination/paginator.py +0 -0
  42. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/api/rate_limiting/__init__.py +0 -0
  43. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/api/rate_limiting/config.py +0 -0
  44. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/api/rate_limiting/rate_limiter.py +0 -0
  45. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/api/request_manager.py +0 -0
  46. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/api/retry_manager.py +0 -0
  47. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/api/transport.py +0 -0
  48. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/api/types.py +0 -0
  49. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/cli/__init__.py +0 -0
  50. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/cli/app.py +0 -0
  51. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/cli/main.py +0 -0
  52. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/config/__init__.py +0 -0
  53. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/config/connector.py +0 -0
  54. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/config/jobs.py +0 -0
  55. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/config/pipeline.py +0 -0
  56. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/config/profile.py +0 -0
  57. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/config/types.py +0 -0
  58. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/config/utils.py +0 -0
  59. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/enums.py +0 -0
  60. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/extract.py +0 -0
  61. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/file.py +0 -0
  62. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/load.py +0 -0
  63. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/mixins.py +0 -0
  64. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/py.typed +0 -0
  65. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/run.py +0 -0
  66. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/run_helpers.py +0 -0
  67. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/templates/__init__.py +0 -0
  68. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/templates/ddl.sql.j2 +0 -0
  69. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/templates/view.sql.j2 +0 -0
  70. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/transform.py +0 -0
  71. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/types.py +0 -0
  72. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/utils.py +0 -0
  73. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/validate.py +0 -0
  74. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/validation/__init__.py +0 -0
  75. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/validation/utils.py +0 -0
  76. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus.egg-info/dependency_links.txt +0 -0
  77. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus.egg-info/entry_points.txt +0 -0
  78. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus.egg-info/requires.txt +0 -0
  79. {etlplus-0.5.5 → etlplus-0.6.1}/etlplus.egg-info/top_level.txt +0 -0
  80. {etlplus-0.5.5 → etlplus-0.6.1}/examples/README.md +0 -0
  81. {etlplus-0.5.5 → etlplus-0.6.1}/examples/configs/ddl_spec.yml +0 -0
  82. {etlplus-0.5.5 → etlplus-0.6.1}/examples/configs/pipeline.yml +0 -0
  83. {etlplus-0.5.5 → etlplus-0.6.1}/examples/data/sample.csv +0 -0
  84. {etlplus-0.5.5 → etlplus-0.6.1}/examples/data/sample.json +0 -0
  85. {etlplus-0.5.5 → etlplus-0.6.1}/examples/data/sample.xml +0 -0
  86. {etlplus-0.5.5 → etlplus-0.6.1}/examples/data/sample.xsd +0 -0
  87. {etlplus-0.5.5 → etlplus-0.6.1}/examples/data/sample.yaml +0 -0
  88. {etlplus-0.5.5 → etlplus-0.6.1}/examples/quickstart_python.py +0 -0
  89. {etlplus-0.5.5 → etlplus-0.6.1}/pyproject.toml +0 -0
  90. {etlplus-0.5.5 → etlplus-0.6.1}/pytest.ini +0 -0
  91. {etlplus-0.5.5 → etlplus-0.6.1}/setup.cfg +0 -0
  92. {etlplus-0.5.5 → etlplus-0.6.1}/setup.py +0 -0
  93. {etlplus-0.5.5 → etlplus-0.6.1}/tests/__init__.py +0 -0
  94. {etlplus-0.5.5 → etlplus-0.6.1}/tests/conftest.py +0 -0
  95. {etlplus-0.5.5 → etlplus-0.6.1}/tests/integration/conftest.py +0 -0
  96. {etlplus-0.5.5 → etlplus-0.6.1}/tests/integration/test_i_cli.py +0 -0
  97. {etlplus-0.5.5 → etlplus-0.6.1}/tests/integration/test_i_examples_data_parity.py +0 -0
  98. {etlplus-0.5.5 → etlplus-0.6.1}/tests/integration/test_i_pagination_strategy.py +0 -0
  99. {etlplus-0.5.5 → etlplus-0.6.1}/tests/integration/test_i_pipeline_smoke.py +0 -0
  100. {etlplus-0.5.5 → etlplus-0.6.1}/tests/integration/test_i_pipeline_yaml_load.py +0 -0
  101. {etlplus-0.5.5 → etlplus-0.6.1}/tests/integration/test_i_run.py +0 -0
  102. {etlplus-0.5.5 → etlplus-0.6.1}/tests/integration/test_i_run_profile_pagination_defaults.py +0 -0
  103. {etlplus-0.5.5 → etlplus-0.6.1}/tests/integration/test_i_run_profile_rate_limit_defaults.py +0 -0
  104. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/api/conftest.py +0 -0
  105. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/api/test_u_auth.py +0 -0
  106. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/api/test_u_config.py +0 -0
  107. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/api/test_u_endpoint_client.py +0 -0
  108. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/api/test_u_mocks.py +0 -0
  109. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/api/test_u_pagination_client.py +0 -0
  110. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/api/test_u_pagination_config.py +0 -0
  111. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/api/test_u_paginator.py +0 -0
  112. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/api/test_u_rate_limit_config.py +0 -0
  113. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/api/test_u_rate_limiter.py +0 -0
  114. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/api/test_u_request_manager.py +0 -0
  115. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/api/test_u_retry_manager.py +0 -0
  116. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/api/test_u_transport.py +0 -0
  117. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/api/test_u_types.py +0 -0
  118. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/cli/conftest.py +0 -0
  119. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/config/test_u_config_utils.py +0 -0
  120. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/config/test_u_connector.py +0 -0
  121. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/config/test_u_jobs.py +0 -0
  122. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/config/test_u_pipeline.py +0 -0
  123. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/conftest.py +0 -0
  124. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/test_u_enums.py +0 -0
  125. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/test_u_extract.py +0 -0
  126. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/test_u_file.py +0 -0
  127. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/test_u_load.py +0 -0
  128. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/test_u_main.py +0 -0
  129. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/test_u_mixins.py +0 -0
  130. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/test_u_run.py +0 -0
  131. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/test_u_run_helpers.py +0 -0
  132. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/test_u_transform.py +0 -0
  133. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/test_u_utils.py +0 -0
  134. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/test_u_validate.py +0 -0
  135. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/test_u_version.py +0 -0
  136. {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/validation/test_u_validation_utils.py +0 -0
  137. {etlplus-0.5.5 → etlplus-0.6.1}/tools/run_pipeline.py +0 -0
  138. {etlplus-0.5.5 → etlplus-0.6.1}/tools/update_demo_snippets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: etlplus
3
- Version: 0.5.5
3
+ Version: 0.6.1
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
@@ -18,8 +18,8 @@ from typing import cast
18
18
 
19
19
  from ..config import PipelineConfig
20
20
  from ..config import load_pipeline_config
21
- from ..ddl import load_table_spec
22
- from ..ddl import render_tables
21
+ from ..database import load_table_spec
22
+ from ..database import render_tables
23
23
  from ..enums import FileFormat
24
24
  from ..extract import extract
25
25
  from ..file import File
@@ -0,0 +1,23 @@
1
+ """
2
+ :mod:`etlplus.database` package.
3
+
4
+ This package defines database-related utilities for ETLPlus, including:
5
+ - DDL rendering and schema management.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .ddl import load_table_spec
11
+ from .ddl import render_table_sql
12
+ from .ddl import render_tables
13
+ from .ddl import render_tables_to_string
14
+
15
+ # SECTION: EXPORTS ========================================================== #
16
+
17
+
18
+ __all__ = [
19
+ 'load_table_spec',
20
+ 'render_table_sql',
21
+ 'render_tables',
22
+ 'render_tables_to_string',
23
+ ]
@@ -11,18 +11,20 @@ can emit DDLs without shelling out to that script.
11
11
  from __future__ import annotations
12
12
 
13
13
  import importlib.resources
14
- import json
15
14
  import os
16
15
  from collections.abc import Iterable
17
16
  from collections.abc import Mapping
18
17
  from pathlib import Path
19
18
  from typing import Any
19
+ from typing import Final
20
20
 
21
21
  from jinja2 import DictLoader
22
22
  from jinja2 import Environment
23
23
  from jinja2 import FileSystemLoader
24
24
  from jinja2 import StrictUndefined
25
25
 
26
+ from ..file import File
27
+
26
28
  # SECTION: EXPORTS ========================================================== #
27
29
 
28
30
 
@@ -31,13 +33,26 @@ __all__ = [
31
33
  'load_table_spec',
32
34
  'render_table_sql',
33
35
  'render_tables',
36
+ 'render_tables_to_string',
34
37
  ]
35
38
 
36
39
 
40
+ # SECTION: INTERNAL CONSTANTS =============================================== #
41
+
42
+
43
+ _SUPPORTED_SPEC_SUFFIXES: Final[frozenset[str]] = frozenset(
44
+ {
45
+ '.json',
46
+ '.yml',
47
+ '.yaml',
48
+ },
49
+ )
50
+
51
+
37
52
  # SECTION: CONSTANTS ======================================================== #
38
53
 
39
54
 
40
- TEMPLATES = {
55
+ TEMPLATES: Final[dict[str, str]] = {
41
56
  'ddl': 'ddl.sql.j2',
42
57
  'view': 'view.sql.j2',
43
58
  }
@@ -46,12 +61,68 @@ TEMPLATES = {
46
61
  # SECTION: INTERNAL FUNCTIONS =============================================== #
47
62
 
48
63
 
49
- def _build_env(
64
+ def _load_template_text(
65
+ filename: str,
66
+ ) -> str:
67
+ """Return the bundled template text.
68
+
69
+ Parameters
70
+ ----------
71
+ filename : str
72
+ Template filename located inside the package data folder.
73
+
74
+ Returns
75
+ -------
76
+ str
77
+ Raw template contents.
78
+
79
+ Raises
80
+ ------
81
+ FileNotFoundError
82
+ If the template file cannot be located in package data.
83
+ """
84
+
85
+ try:
86
+ return (
87
+ importlib.resources.files(
88
+ 'etlplus.templates',
89
+ )
90
+ .joinpath(filename)
91
+ .read_text(encoding='utf-8')
92
+ )
93
+ except FileNotFoundError as exc: # pragma: no cover - deployment guard
94
+ raise FileNotFoundError(
95
+ f'Could not load template {filename} '
96
+ f'from etlplus.templates package data.',
97
+ ) from exc
98
+
99
+
100
+ def _resolve_template(
50
101
  *,
51
102
  template_key: str | None,
52
103
  template_path: str | None,
53
- ) -> Environment:
54
- """Return a Jinja2 environment using a built-in or file template."""
104
+ ) -> tuple[Environment, str]:
105
+ """Return environment and template name for rendering.
106
+
107
+ Parameters
108
+ ----------
109
+ template_key : str | None
110
+ Named template key bundled with the package.
111
+ template_path : str | None
112
+ Explicit template file override.
113
+
114
+ Returns
115
+ -------
116
+ tuple[Environment, str]
117
+ Pair of configured Jinja environment and the template identifier.
118
+
119
+ Raises
120
+ ------
121
+ FileNotFoundError
122
+ If the provided template path does not exist.
123
+ ValueError
124
+ If the template key is unknown.
125
+ """
55
126
  file_override = template_path or os.environ.get('TEMPLATE_NAME')
56
127
  if file_override:
57
128
  path = Path(file_override)
@@ -64,8 +135,7 @@ def _build_env(
64
135
  trim_blocks=True,
65
136
  lstrip_blocks=True,
66
137
  )
67
- env.globals['TEMPLATE_NAME'] = path.name
68
- return env
138
+ return env, path.name
69
139
 
70
140
  key = (template_key or 'ddl').strip()
71
141
  if key not in TEMPLATES:
@@ -84,51 +154,59 @@ def _build_env(
84
154
  trim_blocks=True,
85
155
  lstrip_blocks=True,
86
156
  )
87
- env.globals['TEMPLATE_NAME'] = key
88
- return env
157
+ return env, key
89
158
 
90
159
 
91
- def _load_template_text(filename: str) -> str:
92
- """Return the raw template text bundled with the package."""
160
+ # SECTION: FUNCTIONS ======================================================== #
93
161
 
94
- try:
95
- return (
96
- importlib.resources.files(
97
- 'etlplus.templates',
98
- )
99
- .joinpath(filename)
100
- .read_text(encoding='utf-8')
101
- )
102
- except FileNotFoundError as exc: # pragma: no cover - deployment guard
103
- raise FileNotFoundError(
104
- f'Could not load template {filename} '
105
- f'from etlplus.templates package data.',
106
- ) from exc
107
162
 
163
+ def load_table_spec(
164
+ path: Path | str,
165
+ ) -> dict[str, Any]:
166
+ """
167
+ Load a table specification from disk.
108
168
 
109
- # SECTION: FUNCTIONS ======================================================== #
169
+ Parameters
170
+ ----------
171
+ path : Path | str
172
+ Path to the JSON or YAML specification file.
110
173
 
174
+ Returns
175
+ -------
176
+ dict[str, Any]
177
+ Parsed table specification mapping.
111
178
 
112
- def load_table_spec(path: Path | str) -> dict[str, Any]:
113
- """Load a table spec from JSON or YAML."""
179
+ Raises
180
+ ------
181
+ ImportError
182
+ If the file cannot be read due to missing dependencies.
183
+ RuntimeError
184
+ If the YAML dependency is missing for YAML specs.
185
+ TypeError
186
+ If the loaded spec is not a mapping.
187
+ ValueError
188
+ If the file suffix is not supported.
189
+ """
114
190
 
115
191
  spec_path = Path(path)
116
- text = spec_path.read_text(encoding='utf-8')
117
192
  suffix = spec_path.suffix.lower()
118
193
 
119
- if suffix == '.json':
120
- return json.loads(text)
194
+ if suffix not in _SUPPORTED_SPEC_SUFFIXES:
195
+ raise ValueError('Spec must be .json, .yml, or .yaml')
121
196
 
122
- if suffix in {'.yml', '.yaml'}:
123
- try:
124
- import yaml # type: ignore
125
- except Exception as exc: # pragma: no cover
197
+ try:
198
+ spec = File.read_file(spec_path)
199
+ except ImportError as e:
200
+ if suffix in {'.yml', '.yaml'}:
126
201
  raise RuntimeError(
127
202
  'Missing dependency: pyyaml is required for YAML specs.',
128
- ) from exc
129
- return yaml.safe_load(text)
203
+ ) from e
204
+ raise
130
205
 
131
- raise ValueError('Spec must be .json, .yml, or .yaml')
206
+ if not isinstance(spec, Mapping):
207
+ raise TypeError('Table spec must be a mapping')
208
+
209
+ return dict(spec)
132
210
 
133
211
 
134
212
  def render_table_sql(
@@ -153,16 +231,11 @@ def render_table_sql(
153
231
  -------
154
232
  str
155
233
  Rendered SQL string.
156
-
157
- Raises
158
- ------
159
- TypeError
160
- If the loaded template name is not a string.
161
234
  """
162
- env = _build_env(template_key=template, template_path=template_path)
163
- template_name = env.globals.get('TEMPLATE_NAME')
164
- if not isinstance(template_name, str):
165
- raise TypeError('TEMPLATE_NAME must be a string.')
235
+ env, template_name = _resolve_template(
236
+ template_key=template,
237
+ template_path=template_path,
238
+ )
166
239
  tmpl = env.get_template(template_name)
167
240
  return tmpl.render(spec=spec).rstrip() + '\n'
168
241
 
@@ -195,3 +268,44 @@ def render_tables(
195
268
  render_table_sql(spec, template=template, template_path=template_path)
196
269
  for spec in specs
197
270
  ]
271
+
272
+
273
+ def render_tables_to_string(
274
+ spec_paths: Iterable[Path | str],
275
+ *,
276
+ template: str | None = 'ddl',
277
+ template_path: Path | str | None = None,
278
+ ) -> str:
279
+ """
280
+ Render one or more specs and concatenate the SQL payloads.
281
+
282
+ Parameters
283
+ ----------
284
+ spec_paths : Iterable[Path | str]
285
+ Paths to table specification files.
286
+ template : str | None, optional
287
+ Template key bundled with ETLPlus. Defaults to ``'ddl'``.
288
+ template_path : Path | str | None, optional
289
+ Custom Jinja template to override the bundled templates.
290
+
291
+ Returns
292
+ -------
293
+ str
294
+ Concatenated SQL payload suitable for writing to disk or stdout.
295
+ """
296
+
297
+ resolved_template_path = (
298
+ str(template_path) if template_path is not None else None
299
+ )
300
+ rendered_sql: list[str] = []
301
+ for spec_path in spec_paths:
302
+ spec = load_table_spec(spec_path)
303
+ rendered_sql.append(
304
+ render_table_sql(
305
+ spec,
306
+ template=template,
307
+ template_path=resolved_template_path,
308
+ ),
309
+ )
310
+
311
+ return ''.join(rendered_sql)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: etlplus
3
- Version: 0.5.5
3
+ Version: 0.6.1
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,7 +22,6 @@ docs/snippets/installation_version.md
22
22
  etlplus/__init__.py
23
23
  etlplus/__main__.py
24
24
  etlplus/__version__.py
25
- etlplus/ddl.py
26
25
  etlplus/enums.py
27
26
  etlplus/extract.py
28
27
  etlplus/file.py
@@ -69,6 +68,8 @@ etlplus/config/pipeline.py
69
68
  etlplus/config/profile.py
70
69
  etlplus/config/types.py
71
70
  etlplus/config/utils.py
71
+ etlplus/database/__init__.py
72
+ etlplus/database/ddl.py
72
73
  etlplus/templates/__init__.py
73
74
  etlplus/templates/ddl.sql.j2
74
75
  etlplus/templates/view.sql.j2
@@ -129,6 +130,7 @@ tests/unit/config/test_u_config_utils.py
129
130
  tests/unit/config/test_u_connector.py
130
131
  tests/unit/config/test_u_jobs.py
131
132
  tests/unit/config/test_u_pipeline.py
133
+ tests/unit/database/test_u_database_ddl.py
132
134
  tests/unit/validation/test_u_validation_utils.py
133
135
  tools/run_pipeline.py
134
136
  tools/update_demo_snippets.py
@@ -1,5 +1,5 @@
1
1
  """
2
- :mod:`tests.unit.test_u_cli_app` module.
2
+ :mod:`tests.unit.cli.test_u_cli_app` module.
3
3
 
4
4
  Unit tests for :mod:`etlplus.cli.app`.
5
5
  """
@@ -1,5 +1,5 @@
1
1
  """
2
- :mod:`tests.unit.test_u_cli_handlers` module.
2
+ :mod:`tests.unit.cli.test_u_cli_handlers` module.
3
3
 
4
4
  Unit tests for :mod:`etlplus.cli.handlers`.
5
5
  """
@@ -1,5 +1,5 @@
1
1
  """
2
- :mod:`tests.unit.test_u_cli_main` module.
2
+ :mod:`tests.unit.cli.test_u_cli_main` module.
3
3
 
4
4
  Unit tests for :mod:`etlplus.cli.main`.
5
5
  """
@@ -0,0 +1,265 @@
1
+ """
2
+ :mod:`tests.unit.database.test_u_database_ddl` module.
3
+
4
+ Unit tests for :mod:`etlplus.database.ddl`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from copy import deepcopy
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+
15
+ from etlplus.database import ddl
16
+
17
+ # SECTION: HELPERS ========================================================== #
18
+
19
+
20
+ pytestmark = pytest.mark.unit
21
+
22
+
23
+ # SECTION: FIXTURES ========================================================= #
24
+
25
+
26
+ @pytest.fixture(name='sample_spec')
27
+ def fixture_sample_spec() -> dict[str, object]:
28
+ """Sample table specification for testing."""
29
+ return {
30
+ 'schema': 'dbo',
31
+ 'table': 'widgets',
32
+ 'create_schema': False,
33
+ 'columns': [
34
+ {
35
+ 'name': 'id',
36
+ 'type': 'INT',
37
+ 'nullable': False,
38
+ 'identity': {'seed': 1, 'increment': 1},
39
+ },
40
+ {
41
+ 'name': 'name',
42
+ 'type': 'NVARCHAR(255)',
43
+ 'nullable': True,
44
+ },
45
+ ],
46
+ 'primary_key': {
47
+ 'columns': ['id'],
48
+ },
49
+ 'indexes': [
50
+ {
51
+ 'name': 'IX_widgets_name',
52
+ 'columns': ['name'],
53
+ 'unique': True,
54
+ },
55
+ ],
56
+ 'foreign_keys': [],
57
+ }
58
+
59
+
60
+ # SECTION: TESTS ============================================================ #
61
+
62
+
63
+ class TestLoadTableSpec:
64
+ """Unit test suite for :func:`load_table_spec`."""
65
+
66
+ def test_missing_yaml_dependency(
67
+ self,
68
+ tmp_path: Path,
69
+ sample_spec: dict[str, object],
70
+ monkeypatch: pytest.MonkeyPatch,
71
+ ) -> None:
72
+ """Test that loading a YAML spec without PyYAML raises RuntimeError."""
73
+ yaml = pytest.importorskip('yaml')
74
+ spec_path = tmp_path / 'spec.yaml'
75
+ spec_path.write_text(
76
+ yaml.safe_dump(sample_spec, sort_keys=False),
77
+ encoding='utf-8',
78
+ )
79
+
80
+ import etlplus.file as file_mod
81
+
82
+ file_mod._YAML_CACHE.clear()
83
+
84
+ def _raise_import_error() -> None:
85
+ raise ImportError('forced failure for test')
86
+
87
+ monkeypatch.setattr(file_mod, '_get_yaml', _raise_import_error)
88
+
89
+ with pytest.raises(RuntimeError):
90
+ ddl.load_table_spec(spec_path)
91
+
92
+ def test_requires_mapping(self, tmp_path: Path) -> None:
93
+ """Test that loading a spec requires a mapping at the top level."""
94
+ spec_path = tmp_path / 'array.json'
95
+ spec_path.write_text(
96
+ json.dumps([{'not': 'mapping'}]),
97
+ encoding='utf-8',
98
+ )
99
+
100
+ with pytest.raises(TypeError):
101
+ ddl.load_table_spec(spec_path)
102
+
103
+ @pytest.mark.parametrize(
104
+ 'extension',
105
+ ['json', 'yaml'],
106
+ ids=['json', 'yaml'],
107
+ )
108
+ def test_roundtrip(
109
+ self,
110
+ tmp_path: Path,
111
+ extension: str,
112
+ sample_spec: dict[str, object],
113
+ ) -> None:
114
+ """Test loading a table spec from JSON and YAML formats."""
115
+ spec_path = tmp_path / f'spec.{extension}'
116
+ materialized = deepcopy(sample_spec)
117
+ if extension == 'json':
118
+ spec_path.write_text(json.dumps(materialized), encoding='utf-8')
119
+ else:
120
+ yaml = pytest.importorskip('yaml')
121
+ spec_path.write_text(
122
+ yaml.safe_dump(materialized, sort_keys=False),
123
+ encoding='utf-8',
124
+ )
125
+
126
+ loaded = ddl.load_table_spec(spec_path)
127
+ assert loaded == materialized
128
+
129
+ def test_unsupported_suffix(self, tmp_path: Path) -> None:
130
+ """
131
+ Test that loading a spec with an unsupported suffix raises ValueError.
132
+ """
133
+ spec_path = tmp_path / 'spec.txt'
134
+ spec_path.write_text('{}', encoding='utf-8')
135
+
136
+ with pytest.raises(ValueError):
137
+ ddl.load_table_spec(spec_path)
138
+
139
+
140
+ class TestRenderTableSql:
141
+ """Unit test suite for :func:`render_table_sql`."""
142
+
143
+ def test_custom_template_path(
144
+ self,
145
+ tmp_path: Path,
146
+ sample_spec: dict[str, object],
147
+ ) -> None:
148
+ """Test rendering SQL with a custom template path."""
149
+ template_path = tmp_path / 'custom.sql.j2'
150
+ template_path.write_text('{{ spec.table }}', encoding='utf-8')
151
+
152
+ sql = ddl.render_table_sql(
153
+ sample_spec,
154
+ template_path=str(template_path),
155
+ )
156
+
157
+ assert sql == f'{sample_spec["table"]}\n'
158
+
159
+ def test_default_template(
160
+ self,
161
+ sample_spec: dict[str, object],
162
+ ) -> None:
163
+ """Test rendering SQL with the default template."""
164
+ sql = ddl.render_table_sql(sample_spec)
165
+ assert f'CREATE TABLE [dbo].[{sample_spec["table"]}' in sql
166
+ assert '[id] INT' in sql
167
+
168
+ def test_env_override(
169
+ self,
170
+ tmp_path: Path,
171
+ sample_spec: dict[str, object],
172
+ monkeypatch: pytest.MonkeyPatch,
173
+ ) -> None:
174
+ """
175
+ Test rendering SQL with an environment variable override for the
176
+ template path.
177
+ """
178
+ template_path = tmp_path / 'env_template.sql.j2'
179
+ template_path.write_text(
180
+ '{{ spec.schema }}.{{ spec.table }}',
181
+ encoding='utf-8',
182
+ )
183
+ monkeypatch.setenv('TEMPLATE_NAME', str(template_path))
184
+
185
+ sql = ddl.render_table_sql(sample_spec, template=None)
186
+
187
+ assert sql == 'dbo.widgets\n'
188
+
189
+ def test_missing_template_path(
190
+ self,
191
+ sample_spec: dict[str, object],
192
+ ) -> None:
193
+ """Test that a missing template path raises FileNotFoundError."""
194
+ missing = Path('/nonexistent/template.sql.j2')
195
+ with pytest.raises(FileNotFoundError):
196
+ ddl.render_table_sql(sample_spec, template_path=str(missing))
197
+
198
+ def test_unknown_template_key(
199
+ self,
200
+ sample_spec: dict[str, object],
201
+ ) -> None:
202
+ """Test that an unknown template key raises ValueError."""
203
+ with pytest.raises(ValueError):
204
+ ddl.render_table_sql(sample_spec, template='does_not_exist')
205
+
206
+
207
+ class TestRenderTablesToString:
208
+ """
209
+ Unit test suite for :func:`render_tables_to_string`.
210
+ """
211
+
212
+ def test_custom_template(
213
+ self,
214
+ tmp_path: Path,
215
+ sample_spec: dict[str, object],
216
+ ) -> None:
217
+ """
218
+ Test rendering multiple table specs to a string with a custom template.
219
+ """
220
+ template_path = tmp_path / 'concat_template.sql.j2'
221
+ template_path.write_text('{{ spec.table }}', encoding='utf-8')
222
+
223
+ path = tmp_path / 'spec.json'
224
+ path.write_text(json.dumps(sample_spec), encoding='utf-8')
225
+
226
+ sql = ddl.render_tables_to_string(
227
+ [path],
228
+ template_path=template_path,
229
+ )
230
+
231
+ assert sql == f'{sample_spec["table"]}\n'
232
+
233
+ def test_from_paths(
234
+ self,
235
+ tmp_path: Path,
236
+ sample_spec: dict[str, object],
237
+ ) -> None:
238
+ """
239
+ Test rendering multiple table specs from file paths into a single SQL
240
+ string.
241
+ """
242
+ spec_paths: list[Path] = []
243
+ for idx, table_name in enumerate(('widgets', 'widgets_history')):
244
+ materialized = deepcopy(sample_spec)
245
+ materialized['table'] = table_name
246
+ path = tmp_path / f'spec_{idx}.json'
247
+ path.write_text(json.dumps(materialized), encoding='utf-8')
248
+ spec_paths.append(path)
249
+
250
+ sql = ddl.render_tables_to_string(spec_paths)
251
+
252
+ assert 'widgets' in sql
253
+ assert 'widgets_history' in sql
254
+
255
+ def test_templates_constant_exposes_builtin_keys(self) -> None:
256
+ """Test that TEMPLATES constant includes expected built-in keys."""
257
+ assert {'ddl', 'view'}.issubset(set(ddl.TEMPLATES))
258
+
259
+
260
+ class TestTemplate:
261
+ """Unit test suite for ``TEMPLATE``."""
262
+
263
+ def test_builtin_keys_exposure(self) -> None:
264
+ """Test that TEMPLATES constant includes expected built-in keys."""
265
+ assert {'ddl', 'view'}.issubset(set(ddl.TEMPLATES))
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes