etlantic-sqlmodel 0.14.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.
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""etlantic-sqlmodel — ContractModel ↔ SQLModel bridge (no sessions)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date, datetime
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from typing import Any, get_args, get_origin
|
|
8
|
+
|
|
9
|
+
from sqlmodel.main import SQLModelMetaclass
|
|
10
|
+
|
|
11
|
+
from etlantic.contracts import Data, is_data_contract_type
|
|
12
|
+
from etlantic.dataframe.helpers import logical_type_from_annotation
|
|
13
|
+
from etlantic.diagnostics import Diagnostic, Severity, ValidationReport
|
|
14
|
+
from etlantic.schema_drift import (
|
|
15
|
+
NormalizedField,
|
|
16
|
+
NormalizedSchema,
|
|
17
|
+
diff_normalized_schemas,
|
|
18
|
+
normalize_schema_from_fields,
|
|
19
|
+
normalize_schema_from_model,
|
|
20
|
+
)
|
|
21
|
+
from sqlmodel import Field, SQLModel
|
|
22
|
+
|
|
23
|
+
__version__ = "0.14.0"
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"SqlModelIntegrationPlugin",
|
|
27
|
+
"__version__",
|
|
28
|
+
"compare_metadata",
|
|
29
|
+
"contract_to_sqlmodel",
|
|
30
|
+
"create_plugin",
|
|
31
|
+
"run_conformance_checks",
|
|
32
|
+
"sqlmodel_to_contract",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
_PYTHON_FROM_LOGICAL: dict[str, type[Any]] = {
|
|
36
|
+
"string": str,
|
|
37
|
+
"str": str,
|
|
38
|
+
"integer": int,
|
|
39
|
+
"int": int,
|
|
40
|
+
"number": float,
|
|
41
|
+
"float": float,
|
|
42
|
+
"boolean": bool,
|
|
43
|
+
"bool": bool,
|
|
44
|
+
"timestamp": datetime,
|
|
45
|
+
"datetime": datetime,
|
|
46
|
+
"date": date,
|
|
47
|
+
"decimal": Decimal,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def contract_to_sqlmodel(
|
|
52
|
+
contract_cls: type[Data],
|
|
53
|
+
*,
|
|
54
|
+
table_name: str | None = None,
|
|
55
|
+
primary_key: tuple[str, ...] | None = None,
|
|
56
|
+
) -> type[SQLModel]:
|
|
57
|
+
"""Generate a SQLModel table class from a ``Data`` / ContractModel class."""
|
|
58
|
+
if not is_data_contract_type(contract_cls):
|
|
59
|
+
raise TypeError("Expected a Data / ContractModel subclass")
|
|
60
|
+
|
|
61
|
+
resolved_table = table_name or _default_table_name(contract_cls)
|
|
62
|
+
pk_fields = set(primary_key or ())
|
|
63
|
+
annotations: dict[str, Any] = {}
|
|
64
|
+
attrs: dict[str, Any] = {
|
|
65
|
+
"__tablename__": resolved_table,
|
|
66
|
+
"__table_args__": {"extend_existing": True},
|
|
67
|
+
"__annotations__": annotations,
|
|
68
|
+
}
|
|
69
|
+
for name, field_info in contract_cls.model_fields.items():
|
|
70
|
+
python_type = _python_type_from_annotation(field_info.annotation)
|
|
71
|
+
if not field_info.is_required():
|
|
72
|
+
python_type = python_type | None
|
|
73
|
+
annotations[name] = python_type
|
|
74
|
+
if name in pk_fields:
|
|
75
|
+
attrs[name] = Field(primary_key=True)
|
|
76
|
+
elif field_info.default is not ...:
|
|
77
|
+
attrs[name] = Field(default=field_info.default)
|
|
78
|
+
elif not field_info.is_required():
|
|
79
|
+
attrs[name] = Field(default=None)
|
|
80
|
+
|
|
81
|
+
model_name = f"{contract_cls.__name__}Table"
|
|
82
|
+
return SQLModelMetaclass(model_name, (SQLModel,), attrs, table=True)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def sqlmodel_to_contract(model_cls: type[Any]) -> dict[str, Any]:
|
|
86
|
+
"""Extract draft contract metadata from a SQLModel table class."""
|
|
87
|
+
if not _is_sqlmodel_table(model_cls):
|
|
88
|
+
raise TypeError("Expected a SQLModel table class")
|
|
89
|
+
|
|
90
|
+
fields: list[dict[str, Any]] = []
|
|
91
|
+
table = getattr(model_cls, "__table__", None)
|
|
92
|
+
columns = {str(col.name): col for col in table.columns} if table is not None else {}
|
|
93
|
+
|
|
94
|
+
for name, field_info in getattr(model_cls, "model_fields", {}).items():
|
|
95
|
+
column = columns.get(name)
|
|
96
|
+
logical = logical_type_from_annotation(field_info.annotation)
|
|
97
|
+
fields.append(
|
|
98
|
+
{
|
|
99
|
+
"name": name,
|
|
100
|
+
"logical_type": logical,
|
|
101
|
+
"required": field_info.is_required(),
|
|
102
|
+
"nullable": _is_nullable(field_info.annotation)
|
|
103
|
+
or (bool(column.nullable) if column is not None else False),
|
|
104
|
+
"primary_key": bool(getattr(column, "primary_key", False)),
|
|
105
|
+
"sql_type": str(column.type) if column is not None else None,
|
|
106
|
+
}
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
"class_name": model_cls.__name__,
|
|
111
|
+
"table_name": getattr(model_cls, "__tablename__", None),
|
|
112
|
+
"schema": getattr(table, "schema", None) if table is not None else None,
|
|
113
|
+
"fields": fields,
|
|
114
|
+
"source": "sqlmodel",
|
|
115
|
+
"review_required": _review_flags(fields),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def compare_metadata(
|
|
120
|
+
left: Any,
|
|
121
|
+
right: Any,
|
|
122
|
+
) -> ValidationReport:
|
|
123
|
+
"""Compare contract and SQLModel metadata; return a ValidationReport."""
|
|
124
|
+
left_schema = _canonicalize_schema(
|
|
125
|
+
_coerce_normalized_schema(left, label="left"),
|
|
126
|
+
)
|
|
127
|
+
right_schema = _canonicalize_schema(
|
|
128
|
+
_coerce_normalized_schema(right, label="right"),
|
|
129
|
+
)
|
|
130
|
+
change_set = diff_normalized_schemas(left_schema, right_schema)
|
|
131
|
+
|
|
132
|
+
diagnostics: list[Diagnostic] = []
|
|
133
|
+
for change in change_set.changes:
|
|
134
|
+
severity = (
|
|
135
|
+
Severity.ERROR
|
|
136
|
+
if change.impact.value in {"breaking", "unknown"}
|
|
137
|
+
else Severity.WARNING
|
|
138
|
+
)
|
|
139
|
+
diagnostics.append(
|
|
140
|
+
Diagnostic(
|
|
141
|
+
code="PSQL410",
|
|
142
|
+
severity=severity,
|
|
143
|
+
message=(
|
|
144
|
+
f"{change.kind} at {change.path}: "
|
|
145
|
+
f"{change.previous!r} -> {change.current!r}"
|
|
146
|
+
),
|
|
147
|
+
path=(change.path,),
|
|
148
|
+
metadata={
|
|
149
|
+
"impact": change.impact.value,
|
|
150
|
+
"previous": change.previous,
|
|
151
|
+
"current": change.current,
|
|
152
|
+
},
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if not diagnostics and left_schema.fingerprint() == right_schema.fingerprint():
|
|
157
|
+
diagnostics.append(
|
|
158
|
+
Diagnostic(
|
|
159
|
+
code="PSQL411",
|
|
160
|
+
severity=Severity.INFO,
|
|
161
|
+
message="Metadata fingerprints match.",
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
return ValidationReport.from_diagnostics(diagnostics, phases=("sqlmodel",))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def create_plugin() -> SqlModelIntegrationPlugin:
|
|
169
|
+
"""Entry-point factory for tooling and conformance helpers."""
|
|
170
|
+
return SqlModelIntegrationPlugin()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class SqlModelIntegrationPlugin:
|
|
174
|
+
"""Schema bridge helpers without ORM sessions or migrations."""
|
|
175
|
+
|
|
176
|
+
name = "etlantic-sqlmodel"
|
|
177
|
+
version = __version__
|
|
178
|
+
|
|
179
|
+
def contract_to_table(
|
|
180
|
+
self,
|
|
181
|
+
contract_cls: type[Data],
|
|
182
|
+
*,
|
|
183
|
+
table_name: str | None = None,
|
|
184
|
+
primary_key: tuple[str, ...] | None = None,
|
|
185
|
+
) -> type[SQLModel]:
|
|
186
|
+
return contract_to_sqlmodel(
|
|
187
|
+
contract_cls,
|
|
188
|
+
table_name=table_name,
|
|
189
|
+
primary_key=primary_key,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def table_metadata(self, model_cls: type[Any]) -> dict[str, Any]:
|
|
193
|
+
return sqlmodel_to_contract(model_cls)
|
|
194
|
+
|
|
195
|
+
def compare(
|
|
196
|
+
self,
|
|
197
|
+
left: Any,
|
|
198
|
+
right: Any,
|
|
199
|
+
) -> ValidationReport:
|
|
200
|
+
return compare_metadata(left, right)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def run_conformance_checks(
|
|
204
|
+
contract_cls: type[Data],
|
|
205
|
+
*,
|
|
206
|
+
table_name: str | None = None,
|
|
207
|
+
primary_key: tuple[str, ...] | None = None,
|
|
208
|
+
) -> ValidationReport:
|
|
209
|
+
"""Round-trip contract → SQLModel → metadata and compare schemas."""
|
|
210
|
+
model = contract_to_sqlmodel(
|
|
211
|
+
contract_cls,
|
|
212
|
+
table_name=table_name,
|
|
213
|
+
primary_key=primary_key,
|
|
214
|
+
)
|
|
215
|
+
metadata = sqlmodel_to_contract(model)
|
|
216
|
+
report = compare_metadata(contract_cls, model)
|
|
217
|
+
if metadata.get("review_required"):
|
|
218
|
+
extra = ValidationReport.from_diagnostics(
|
|
219
|
+
[
|
|
220
|
+
Diagnostic(
|
|
221
|
+
code="PSQL412",
|
|
222
|
+
severity=Severity.WARNING,
|
|
223
|
+
message=(
|
|
224
|
+
"SQLModel metadata includes fields requiring human review."
|
|
225
|
+
),
|
|
226
|
+
metadata={"review_required": metadata["review_required"]},
|
|
227
|
+
)
|
|
228
|
+
],
|
|
229
|
+
phases=("sqlmodel",),
|
|
230
|
+
)
|
|
231
|
+
return report.merge(extra)
|
|
232
|
+
return report
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
_CANONICAL_LOGICAL: dict[str, str] = {
|
|
236
|
+
"int": "integer",
|
|
237
|
+
"integer": "integer",
|
|
238
|
+
"str": "string",
|
|
239
|
+
"string": "string",
|
|
240
|
+
"float": "number",
|
|
241
|
+
"number": "number",
|
|
242
|
+
"bool": "boolean",
|
|
243
|
+
"boolean": "boolean",
|
|
244
|
+
"datetime": "timestamp",
|
|
245
|
+
"timestamp": "timestamp",
|
|
246
|
+
"date": "date",
|
|
247
|
+
"decimal": "decimal",
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _canonical_logical(logical: str) -> str:
|
|
252
|
+
return _CANONICAL_LOGICAL.get(logical, logical)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _canonicalize_schema(schema: NormalizedSchema) -> NormalizedSchema:
|
|
256
|
+
fields = tuple(
|
|
257
|
+
NormalizedField(
|
|
258
|
+
name=field.name,
|
|
259
|
+
logical_type=_canonical_logical(field.logical_type),
|
|
260
|
+
required=field.required,
|
|
261
|
+
nullable=field.nullable,
|
|
262
|
+
metadata=dict(field.metadata),
|
|
263
|
+
)
|
|
264
|
+
for field in schema.fields
|
|
265
|
+
)
|
|
266
|
+
return NormalizedSchema(
|
|
267
|
+
identity=schema.identity,
|
|
268
|
+
fields=fields,
|
|
269
|
+
metadata=dict(schema.metadata),
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _coerce_normalized_schema(value: Any, *, label: str) -> NormalizedSchema:
|
|
274
|
+
if isinstance(value, NormalizedSchema):
|
|
275
|
+
return value
|
|
276
|
+
if is_data_contract_type(value):
|
|
277
|
+
return normalize_schema_from_model(value, identity=f"{label}:contract")
|
|
278
|
+
if _is_sqlmodel_table(value):
|
|
279
|
+
metadata = sqlmodel_to_contract(value)
|
|
280
|
+
identity = (
|
|
281
|
+
f"{label}:sqlmodel:"
|
|
282
|
+
f"{metadata.get('table_name') or getattr(value, '__name__', 'table')}"
|
|
283
|
+
)
|
|
284
|
+
return normalize_schema_from_fields(metadata["fields"], identity=identity)
|
|
285
|
+
if isinstance(value, dict) and "fields" in value:
|
|
286
|
+
identity = str(value.get("identity") or f"{label}:metadata")
|
|
287
|
+
return normalize_schema_from_fields(list(value["fields"]), identity=identity)
|
|
288
|
+
raise TypeError(
|
|
289
|
+
f"Unsupported {label} metadata type {type(value)!r}; "
|
|
290
|
+
"expected Data contract, SQLModel table, NormalizedSchema, or field dict."
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _is_sqlmodel_table(model_cls: Any) -> bool:
|
|
295
|
+
return (
|
|
296
|
+
isinstance(model_cls, type)
|
|
297
|
+
and issubclass(model_cls, SQLModel)
|
|
298
|
+
and getattr(model_cls, "__tablename__", None) is not None
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _default_table_name(contract_cls: type[Data]) -> str:
|
|
303
|
+
name = contract_cls.__name__
|
|
304
|
+
if name.endswith("Model"):
|
|
305
|
+
name = name[: -len("Model")]
|
|
306
|
+
return _snake_case(name)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _snake_case(name: str) -> str:
|
|
310
|
+
chars: list[str] = []
|
|
311
|
+
for index, char in enumerate(name):
|
|
312
|
+
if char.isupper() and index > 0:
|
|
313
|
+
chars.append("_")
|
|
314
|
+
chars.append(char.lower())
|
|
315
|
+
return "".join(chars)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _python_type_from_annotation(annotation: Any) -> type[Any]:
|
|
319
|
+
origin = get_origin(annotation)
|
|
320
|
+
if origin is not None:
|
|
321
|
+
args = get_args(annotation)
|
|
322
|
+
non_none = [arg for arg in args if arg is not type(None)]
|
|
323
|
+
if non_none:
|
|
324
|
+
return _python_type_from_annotation(non_none[0])
|
|
325
|
+
if isinstance(annotation, type):
|
|
326
|
+
return annotation
|
|
327
|
+
logical = logical_type_from_annotation(annotation)
|
|
328
|
+
return _PYTHON_FROM_LOGICAL.get(logical, str)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _is_nullable(annotation: Any) -> bool:
|
|
332
|
+
origin = get_origin(annotation)
|
|
333
|
+
if origin is None:
|
|
334
|
+
return False
|
|
335
|
+
return any(arg is type(None) for arg in get_args(annotation))
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _review_flags(fields: list[dict[str, Any]]) -> list[str]:
|
|
339
|
+
flags: list[str] = []
|
|
340
|
+
for field in fields:
|
|
341
|
+
logical = str(field.get("logical_type") or "")
|
|
342
|
+
if logical in {"object", "array", "unknown"}:
|
|
343
|
+
flags.append(f"{field['name']}:unsupported_logical_type")
|
|
344
|
+
if field.get("primary_key") and logical not in {
|
|
345
|
+
"integer",
|
|
346
|
+
"int",
|
|
347
|
+
"string",
|
|
348
|
+
"str",
|
|
349
|
+
}:
|
|
350
|
+
flags.append(f"{field['name']}:nonstandard_primary_key")
|
|
351
|
+
return flags
|
|
File without changes
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: etlantic-sqlmodel
|
|
3
|
+
Version: 0.14.0
|
|
4
|
+
Summary: ContractModel ↔ SQLModel bridge for ETLantic.
|
|
5
|
+
Project-URL: Homepage, https://github.com/eddiethedean/etlantic
|
|
6
|
+
Project-URL: Documentation, https://github.com/eddiethedean/etlantic/tree/main/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/eddiethedean/etlantic
|
|
8
|
+
Project-URL: Issues, https://github.com/eddiethedean/etlantic/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/eddiethedean/etlantic/blob/main/CHANGELOG.md
|
|
10
|
+
Author-email: Odo Matthews <odosmatthews@gmail.com>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: etlantic<0.15,>=0.14.0
|
|
22
|
+
Requires-Dist: sqlmodel<1,>=0.0.22
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# etlantic-sqlmodel
|
|
26
|
+
|
|
27
|
+
Optional bridge between ETLantic `Data` contracts and
|
|
28
|
+
[SQLModel](https://sqlmodel.tiangolo.com/) table models for ETLantic 0.14.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install 'etlantic==0.14.0' 'etlantic-sqlmodel==0.14.0'
|
|
32
|
+
# or: pip install 'etlantic[sqlmodel]'
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Sessions, Alembic migrations, and repository helpers are deferred to 1.1+.
|
|
36
|
+
This package focuses on schema mapping and metadata comparison only.
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from etlantic import Data
|
|
42
|
+
from etlantic_sqlmodel import (
|
|
43
|
+
compare_metadata,
|
|
44
|
+
contract_to_sqlmodel,
|
|
45
|
+
create_plugin,
|
|
46
|
+
sqlmodel_to_contract,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Customer(Data):
|
|
51
|
+
customer_id: int
|
|
52
|
+
name: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
CustomerTable = contract_to_sqlmodel(
|
|
56
|
+
Customer,
|
|
57
|
+
table_name="customer",
|
|
58
|
+
primary_key=("customer_id",),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
metadata = sqlmodel_to_contract(CustomerTable)
|
|
62
|
+
report = compare_metadata(Customer, CustomerTable)
|
|
63
|
+
assert report.valid
|
|
64
|
+
|
|
65
|
+
plugin = create_plugin()
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
This bridge is used explicitly through its public conversion helpers; it does
|
|
69
|
+
not require a profile engine setting. Register `create_plugin()` only with
|
|
70
|
+
tooling that consumes the schema-mapping plugin object.
|
|
71
|
+
|
|
72
|
+
Generated SQLModel classes are reviewable starting points — relational choices
|
|
73
|
+
such as primary keys and table names must be supplied explicitly.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
etlantic_sqlmodel/__init__.py,sha256=-3n38bl8vQOyaQQYIxDcmPK5v0ec7C6zQU1_0rSQQiE,11212
|
|
2
|
+
etlantic_sqlmodel/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
etlantic_sqlmodel-0.14.0.dist-info/METADATA,sha256=ndtlPTazWPpLHi7_b1AeOPVAGbywrqRxv_Ci0Znsgac,2335
|
|
4
|
+
etlantic_sqlmodel-0.14.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
5
|
+
etlantic_sqlmodel-0.14.0.dist-info/RECORD,,
|