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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any