etlplus 0.5.5__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. {etlplus-0.5.5/etlplus.egg-info → etlplus-0.7.0}/PKG-INFO +4 -1
  2. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/cli/handlers.py +2 -2
  3. etlplus-0.7.0/etlplus/database/__init__.py +42 -0
  4. {etlplus-0.5.5/etlplus → etlplus-0.7.0/etlplus/database}/ddl.py +161 -47
  5. etlplus-0.7.0/etlplus/database/engine.py +146 -0
  6. etlplus-0.7.0/etlplus/database/orm.py +347 -0
  7. etlplus-0.7.0/etlplus/database/schema.py +273 -0
  8. {etlplus-0.5.5 → etlplus-0.7.0/etlplus.egg-info}/PKG-INFO +4 -1
  9. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus.egg-info/SOURCES.txt +9 -1
  10. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus.egg-info/requires.txt +3 -0
  11. {etlplus-0.5.5 → etlplus-0.7.0}/pyproject.toml +3 -0
  12. {etlplus-0.5.5 → etlplus-0.7.0}/setup.py +3 -0
  13. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/cli/test_u_cli_app.py +1 -1
  14. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/cli/test_u_cli_handlers.py +1 -1
  15. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/cli/test_u_cli_main.py +1 -1
  16. etlplus-0.7.0/tests/unit/database/test_u_database_ddl.py +265 -0
  17. etlplus-0.7.0/tests/unit/database/test_u_database_engine.py +147 -0
  18. etlplus-0.7.0/tests/unit/database/test_u_database_orm.py +308 -0
  19. etlplus-0.7.0/tests/unit/database/test_u_database_schema.py +213 -0
  20. {etlplus-0.5.5 → etlplus-0.7.0}/.coveragerc +0 -0
  21. {etlplus-0.5.5 → etlplus-0.7.0}/.editorconfig +0 -0
  22. {etlplus-0.5.5 → etlplus-0.7.0}/.gitattributes +0 -0
  23. {etlplus-0.5.5 → etlplus-0.7.0}/.github/actions/python-bootstrap/action.yml +0 -0
  24. {etlplus-0.5.5 → etlplus-0.7.0}/.github/workflows/ci.yml +0 -0
  25. {etlplus-0.5.5 → etlplus-0.7.0}/.gitignore +0 -0
  26. {etlplus-0.5.5 → etlplus-0.7.0}/.pre-commit-config.yaml +0 -0
  27. {etlplus-0.5.5 → etlplus-0.7.0}/.ruff.toml +0 -0
  28. {etlplus-0.5.5 → etlplus-0.7.0}/CODE_OF_CONDUCT.md +0 -0
  29. {etlplus-0.5.5 → etlplus-0.7.0}/CONTRIBUTING.md +0 -0
  30. {etlplus-0.5.5 → etlplus-0.7.0}/DEMO.md +0 -0
  31. {etlplus-0.5.5 → etlplus-0.7.0}/LICENSE +0 -0
  32. {etlplus-0.5.5 → etlplus-0.7.0}/MANIFEST.in +0 -0
  33. {etlplus-0.5.5 → etlplus-0.7.0}/Makefile +0 -0
  34. {etlplus-0.5.5 → etlplus-0.7.0}/README.md +0 -0
  35. {etlplus-0.5.5 → etlplus-0.7.0}/REFERENCES.md +0 -0
  36. {etlplus-0.5.5 → etlplus-0.7.0}/docs/pipeline-guide.md +0 -0
  37. {etlplus-0.5.5 → etlplus-0.7.0}/docs/snippets/installation_version.md +0 -0
  38. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/__init__.py +0 -0
  39. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/__main__.py +0 -0
  40. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/__version__.py +0 -0
  41. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/api/README.md +0 -0
  42. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/api/__init__.py +0 -0
  43. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/api/auth.py +0 -0
  44. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/api/config.py +0 -0
  45. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/api/endpoint_client.py +0 -0
  46. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/api/errors.py +0 -0
  47. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/api/pagination/__init__.py +0 -0
  48. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/api/pagination/client.py +0 -0
  49. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/api/pagination/config.py +0 -0
  50. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/api/pagination/paginator.py +0 -0
  51. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/api/rate_limiting/__init__.py +0 -0
  52. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/api/rate_limiting/config.py +0 -0
  53. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/api/rate_limiting/rate_limiter.py +0 -0
  54. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/api/request_manager.py +0 -0
  55. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/api/retry_manager.py +0 -0
  56. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/api/transport.py +0 -0
  57. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/api/types.py +0 -0
  58. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/cli/__init__.py +0 -0
  59. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/cli/app.py +0 -0
  60. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/cli/main.py +0 -0
  61. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/config/__init__.py +0 -0
  62. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/config/connector.py +0 -0
  63. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/config/jobs.py +0 -0
  64. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/config/pipeline.py +0 -0
  65. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/config/profile.py +0 -0
  66. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/config/types.py +0 -0
  67. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/config/utils.py +0 -0
  68. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/enums.py +0 -0
  69. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/extract.py +0 -0
  70. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/file.py +0 -0
  71. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/load.py +0 -0
  72. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/mixins.py +0 -0
  73. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/py.typed +0 -0
  74. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/run.py +0 -0
  75. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/run_helpers.py +0 -0
  76. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/templates/__init__.py +0 -0
  77. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/templates/ddl.sql.j2 +0 -0
  78. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/templates/view.sql.j2 +0 -0
  79. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/transform.py +0 -0
  80. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/types.py +0 -0
  81. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/utils.py +0 -0
  82. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/validate.py +0 -0
  83. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/validation/__init__.py +0 -0
  84. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus/validation/utils.py +0 -0
  85. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus.egg-info/dependency_links.txt +0 -0
  86. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus.egg-info/entry_points.txt +0 -0
  87. {etlplus-0.5.5 → etlplus-0.7.0}/etlplus.egg-info/top_level.txt +0 -0
  88. {etlplus-0.5.5 → etlplus-0.7.0}/examples/README.md +0 -0
  89. {etlplus-0.5.5 → etlplus-0.7.0}/examples/configs/ddl_spec.yml +0 -0
  90. {etlplus-0.5.5 → etlplus-0.7.0}/examples/configs/pipeline.yml +0 -0
  91. {etlplus-0.5.5 → etlplus-0.7.0}/examples/data/sample.csv +0 -0
  92. {etlplus-0.5.5 → etlplus-0.7.0}/examples/data/sample.json +0 -0
  93. {etlplus-0.5.5 → etlplus-0.7.0}/examples/data/sample.xml +0 -0
  94. {etlplus-0.5.5 → etlplus-0.7.0}/examples/data/sample.xsd +0 -0
  95. {etlplus-0.5.5 → etlplus-0.7.0}/examples/data/sample.yaml +0 -0
  96. {etlplus-0.5.5 → etlplus-0.7.0}/examples/quickstart_python.py +0 -0
  97. {etlplus-0.5.5 → etlplus-0.7.0}/pytest.ini +0 -0
  98. {etlplus-0.5.5 → etlplus-0.7.0}/setup.cfg +0 -0
  99. {etlplus-0.5.5 → etlplus-0.7.0}/tests/__init__.py +0 -0
  100. {etlplus-0.5.5 → etlplus-0.7.0}/tests/conftest.py +0 -0
  101. {etlplus-0.5.5 → etlplus-0.7.0}/tests/integration/conftest.py +0 -0
  102. {etlplus-0.5.5 → etlplus-0.7.0}/tests/integration/test_i_cli.py +0 -0
  103. {etlplus-0.5.5 → etlplus-0.7.0}/tests/integration/test_i_examples_data_parity.py +0 -0
  104. {etlplus-0.5.5 → etlplus-0.7.0}/tests/integration/test_i_pagination_strategy.py +0 -0
  105. {etlplus-0.5.5 → etlplus-0.7.0}/tests/integration/test_i_pipeline_smoke.py +0 -0
  106. {etlplus-0.5.5 → etlplus-0.7.0}/tests/integration/test_i_pipeline_yaml_load.py +0 -0
  107. {etlplus-0.5.5 → etlplus-0.7.0}/tests/integration/test_i_run.py +0 -0
  108. {etlplus-0.5.5 → etlplus-0.7.0}/tests/integration/test_i_run_profile_pagination_defaults.py +0 -0
  109. {etlplus-0.5.5 → etlplus-0.7.0}/tests/integration/test_i_run_profile_rate_limit_defaults.py +0 -0
  110. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/api/conftest.py +0 -0
  111. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/api/test_u_auth.py +0 -0
  112. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/api/test_u_config.py +0 -0
  113. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/api/test_u_endpoint_client.py +0 -0
  114. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/api/test_u_mocks.py +0 -0
  115. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/api/test_u_pagination_client.py +0 -0
  116. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/api/test_u_pagination_config.py +0 -0
  117. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/api/test_u_paginator.py +0 -0
  118. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/api/test_u_rate_limit_config.py +0 -0
  119. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/api/test_u_rate_limiter.py +0 -0
  120. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/api/test_u_request_manager.py +0 -0
  121. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/api/test_u_retry_manager.py +0 -0
  122. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/api/test_u_transport.py +0 -0
  123. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/api/test_u_types.py +0 -0
  124. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/cli/conftest.py +0 -0
  125. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/config/test_u_config_utils.py +0 -0
  126. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/config/test_u_connector.py +0 -0
  127. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/config/test_u_jobs.py +0 -0
  128. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/config/test_u_pipeline.py +0 -0
  129. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/conftest.py +0 -0
  130. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/test_u_enums.py +0 -0
  131. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/test_u_extract.py +0 -0
  132. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/test_u_file.py +0 -0
  133. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/test_u_load.py +0 -0
  134. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/test_u_main.py +0 -0
  135. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/test_u_mixins.py +0 -0
  136. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/test_u_run.py +0 -0
  137. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/test_u_run_helpers.py +0 -0
  138. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/test_u_transform.py +0 -0
  139. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/test_u_utils.py +0 -0
  140. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/test_u_validate.py +0 -0
  141. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/test_u_version.py +0 -0
  142. {etlplus-0.5.5 → etlplus-0.7.0}/tests/unit/validation/test_u_validation_utils.py +0 -0
  143. {etlplus-0.5.5 → etlplus-0.7.0}/tools/run_pipeline.py +0 -0
  144. {etlplus-0.5.5 → etlplus-0.7.0}/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.7.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
@@ -21,7 +21,10 @@ Requires-Dist: jinja2>=3.1.6
21
21
  Requires-Dist: pyodbc>=5.3.0
22
22
  Requires-Dist: python-dotenv>=1.2.1
23
23
  Requires-Dist: pandas>=2.3.3
24
+ Requires-Dist: pydantic>=2.12.5
25
+ Requires-Dist: PyYAML>=6.0.3
24
26
  Requires-Dist: requests>=2.32.5
27
+ Requires-Dist: SQLAlchemy>=2.0.45
25
28
  Requires-Dist: typer>=0.21.0
26
29
  Provides-Extra: dev
27
30
  Requires-Dist: black>=25.9.0; extra == "dev"
@@ -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,42 @@
1
+ """
2
+ :mod:`etlplus.database` package.
3
+
4
+ Database utilities for:
5
+ - DDL rendering and schema management.
6
+ - Schema parsing from configuration files.
7
+ - Dynamic ORM generation.
8
+ - Database engine/session management.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from .ddl import load_table_spec
14
+ from .ddl import render_table_sql
15
+ from .ddl import render_tables
16
+ from .ddl import render_tables_to_string
17
+ from .engine import engine
18
+ from .engine import load_database_url_from_config
19
+ from .engine import make_engine
20
+ from .engine import session
21
+ from .orm import build_models
22
+ from .orm import load_and_build_models
23
+ from .schema import load_table_specs
24
+
25
+ # SECTION: EXPORTS ========================================================== #
26
+
27
+
28
+ __all__ = [
29
+ # Functions
30
+ 'build_models',
31
+ 'load_and_build_models',
32
+ 'load_database_url_from_config',
33
+ 'load_table_spec',
34
+ 'load_table_specs',
35
+ 'make_engine',
36
+ 'render_table_sql',
37
+ 'render_tables',
38
+ 'render_tables_to_string',
39
+ # Singletons
40
+ 'engine',
41
+ 'session',
42
+ ]
@@ -1,5 +1,5 @@
1
1
  """
2
- :mod:`etlplus.ddl` module.
2
+ :mod:`etlplus.database.ddl` module.
3
3
 
4
4
  DDL rendering utilities for pipeline table schemas.
5
5
 
@@ -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)
@@ -0,0 +1,146 @@
1
+ """
2
+ :mod:`etlplus.database.engine` module.
3
+
4
+ Lightweight engine/session factory with optional config-driven URL loading.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from collections.abc import Mapping
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from sqlalchemy import create_engine
15
+ from sqlalchemy.engine import Engine
16
+ from sqlalchemy.orm import sessionmaker
17
+
18
+ from ..file import File
19
+
20
+ # SECTION: EXPORTS ========================================================== #
21
+
22
+
23
+ __all__ = [
24
+ # Functions
25
+ 'load_database_url_from_config',
26
+ 'make_engine',
27
+ # Singletons
28
+ 'engine',
29
+ 'session',
30
+ ]
31
+
32
+
33
+ # SECTION: INTERNAL CONSTANTS =============================================== #
34
+
35
+
36
+ DATABASE_URL: str = (
37
+ os.getenv('DATABASE_URL')
38
+ or os.getenv('DATABASE_DSN')
39
+ or 'sqlite+pysqlite:///:memory:'
40
+ )
41
+
42
+
43
+ # SECTION: INTERNAL FUNCTIONS =============================================== #
44
+
45
+
46
+ def _resolve_url_from_mapping(cfg: Mapping[str, Any]) -> str | None:
47
+ """
48
+ Return a URL/DSN from a mapping if present.
49
+
50
+ Parameters
51
+ ----------
52
+ cfg : Mapping[str, Any]
53
+ Configuration mapping potentially containing connection fields.
54
+
55
+ Returns
56
+ -------
57
+ str | None
58
+ Resolved URL/DSN string, if present.
59
+ """
60
+ conn = cfg.get('connection_string') or cfg.get('url') or cfg.get('dsn')
61
+ if isinstance(conn, str) and conn.strip():
62
+ return conn.strip()
63
+
64
+ # Some configs nest defaults.
65
+ # E.g., databases: { mssql: { default: {...} } }
66
+ default_cfg = cfg.get('default')
67
+ if isinstance(default_cfg, Mapping):
68
+ return _resolve_url_from_mapping(default_cfg)
69
+
70
+ return None
71
+
72
+
73
+ # SECTION: FUNCTIONS ======================================================== #
74
+
75
+
76
+ def load_database_url_from_config(
77
+ path: str | Path,
78
+ *,
79
+ name: str | None = None,
80
+ ) -> str:
81
+ """
82
+ Extract a database URL/DSN from a YAML/JSON config file.
83
+
84
+ The loader is schema-tolerant: it looks for a top-level "databases" map
85
+ and then for a named entry (``name``). Each entry may contain either a
86
+ ``connection_string``/``url``/``dsn`` or a nested ``default`` block with
87
+ those fields.
88
+
89
+ Parameters
90
+ ----------
91
+ path : str | Path
92
+ Location of the configuration file.
93
+ name : str | None, optional
94
+ Named database entry under the ``databases`` map (default:
95
+ ``default``).
96
+
97
+ Returns
98
+ -------
99
+ str
100
+ Resolved database URL/DSN string.
101
+
102
+ Raises
103
+ ------
104
+ KeyError
105
+ If the specified database entry is not found.
106
+ TypeError
107
+ If the config structure is invalid.
108
+ ValueError
109
+ If no connection string/URL/DSN is found for the specified entry.
110
+ """
111
+ cfg = File.read_file(Path(path))
112
+ if not isinstance(cfg, Mapping):
113
+ raise TypeError('Database config must be a mapping')
114
+
115
+ databases = cfg.get('databases') if isinstance(cfg, Mapping) else None
116
+ if not isinstance(databases, Mapping):
117
+ raise KeyError('Config missing top-level "databases" mapping')
118
+
119
+ target = name or 'default'
120
+ entry = databases.get(target)
121
+ if entry is None:
122
+ raise KeyError(f'Database entry "{target}" not found in config')
123
+ if not isinstance(entry, Mapping):
124
+ raise TypeError(f'Database entry "{target}" must be a mapping')
125
+
126
+ url = _resolve_url_from_mapping(entry)
127
+ if not url:
128
+ raise ValueError(
129
+ f'Database entry "{target}" lacks connection_string/url/dsn',
130
+ )
131
+ return url
132
+
133
+
134
+ def make_engine(url: str | None = None, **engine_kwargs: Any) -> Engine:
135
+ """Create a SQLAlchemy Engine, defaulting to env config if no URL given."""
136
+
137
+ resolved_url = url or DATABASE_URL
138
+ return create_engine(resolved_url, pool_pre_ping=True, **engine_kwargs)
139
+
140
+
141
+ # SECTION: SINGLETONS ======================================================= #
142
+
143
+
144
+ # Default engine/session for callers that rely on module-level singletons.
145
+ engine = make_engine()
146
+ session = sessionmaker(bind=engine, autoflush=False, autocommit=False)