alchemic 0.1.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.

Potentially problematic release.


This version of alchemic might be problematic. Click here for more details.

alchemic/__init__.py ADDED
@@ -0,0 +1,61 @@
1
+ """alchemic -- SQLAlchemy 2.0 models that are also Pydantic models.
2
+
3
+ One class, one set of ``Mapped[T]`` annotations, and a type checker that can tell
4
+ a column from a value.
5
+ """
6
+
7
+ from sqlalchemy import delete, insert, select, update
8
+
9
+ from alchemic._receivers import Change, Deleted, Inserted, Updated, on_commit
10
+ from alchemic._typing import (
11
+ Mapped,
12
+ Path,
13
+ Related,
14
+ RelatedAttribute,
15
+ TypedAttribute,
16
+ link,
17
+ mapped_column,
18
+ path,
19
+ prefetch,
20
+ references,
21
+ relationship,
22
+ )
23
+ from alchemic.extensions import Extension, FieldExtension, get_field_info, get_field_metadata
24
+ from alchemic.model import DeclarativeRoot, Model, ModelMeta, create_model_base
25
+ from alchemic.session import AsyncSession, Session, async_sessionmaker
26
+
27
+ __all__ = [
28
+ "AsyncSession",
29
+ "Change",
30
+ "DeclarativeRoot",
31
+ "Deleted",
32
+ "Extension",
33
+ "FieldExtension",
34
+ "Inserted",
35
+ "Mapped",
36
+ "Model",
37
+ "ModelMeta",
38
+ "Path",
39
+ "Related",
40
+ "RelatedAttribute",
41
+ "Session",
42
+ "TypedAttribute",
43
+ "Updated",
44
+ "async_sessionmaker",
45
+ "create_model_base",
46
+ "delete",
47
+ "get_field_info",
48
+ "get_field_metadata",
49
+ "insert",
50
+ "link",
51
+ "mapped_column",
52
+ "on_commit",
53
+ "path",
54
+ "prefetch",
55
+ "references",
56
+ "relationship",
57
+ "select",
58
+ "update",
59
+ ]
60
+
61
+ __version__ = "0.1.0"
alchemic/_constants.py ADDED
@@ -0,0 +1,31 @@
1
+ """Attribute names and dictionary keys shared across alchemic's modules."""
2
+
3
+ from typing import Final
4
+
5
+ ATTR_MAPPER_ARGS: Final = "__mapper_args__"
6
+ SA_VERSION_ID_COL: Final = "version_id_col"
7
+
8
+ PYDANTIC_FIELDS_SET: Final = "__pydantic_fields_set__"
9
+ PYDANTIC_EXTRA: Final = "__pydantic_extra__"
10
+ PYDANTIC_PRIVATE: Final = "__pydantic_private__"
11
+
12
+ ATTR_NON_COLUMNS: Final = "__alchemic_non_columns__"
13
+ ATTR_DECLARED_DEFAULTS: Final = "__alchemic_declared_defaults__"
14
+ ATTR_NO_INIT: Final = "__alchemic_no_init__"
15
+ ATTR_NON_COLUMN_ANNOTATIONS: Final = "__alchemic_non_column_annotations__"
16
+ ATTR_READONLY: Final = "__alchemic_readonly__"
17
+
18
+ INFO_READONLY: Final = "alchemic.readonly"
19
+ INFO_VERSION: Final = "alchemic.version"
20
+
21
+ # A subclass of a mapped class inherits these from the parent's mapper.
22
+ INHERITED_MAPPER_ARGS_EXCLUSIONS: Final[frozenset[str]] = frozenset({SA_VERSION_ID_COL})
23
+
24
+ # ``FieldInfo`` attributes Pydantic drops, with an ``UnsupportedFieldAttributeWarning``,
25
+ # from a ``Field()`` nested in ``Annotated``.
26
+ FIELD_INFO_PASSTHROUGH_ATTRS: Final[tuple[str, ...]] = (
27
+ "exclude",
28
+ "alias",
29
+ "serialization_alias",
30
+ "validation_alias",
31
+ )
alchemic/_fields.py ADDED
@@ -0,0 +1,325 @@
1
+ """Reconcile a mapped class's Pydantic fields with its SQLAlchemy columns.
2
+
3
+ Relationships and other non-column attributes are removed from the fields.
4
+ """
5
+
6
+ from collections.abc import Iterable, Iterator, Mapping
7
+ from decimal import Decimal
8
+ from enum import Enum
9
+ from types import MappingProxyType, NoneType, UnionType
10
+ from typing import Annotated, Any, NewType, TypeIs, Union, get_args, get_origin
11
+
12
+ from pydantic import BaseModel, Field
13
+ from pydantic.fields import FieldInfo as PydanticFieldInfo
14
+ from pydantic_core import PydanticUndefined
15
+ from sqlalchemy import Column, Numeric, String
16
+ from sqlalchemy import inspect as sa_inspect
17
+ from sqlalchemy.orm import InstrumentedAttribute, Mapped, MappedColumn, Mapper
18
+
19
+ from alchemic._constants import (
20
+ ATTR_DECLARED_DEFAULTS,
21
+ ATTR_NO_INIT,
22
+ ATTR_NON_COLUMN_ANNOTATIONS,
23
+ ATTR_NON_COLUMNS,
24
+ ATTR_READONLY,
25
+ FIELD_INFO_PASSTHROUGH_ATTRS,
26
+ INFO_READONLY,
27
+ INFO_VERSION,
28
+ )
29
+ from alchemic._metadata import extract_metadata_items, normalize_meta_dict
30
+ from alchemic._sqlalchemy_private import NO_ARG, attribute_options
31
+
32
+ __all__ = ["Declared", "declared_defaults", "find_column", "is_mapped_column", "repair_model_fields"]
33
+
34
+
35
+ class Declared(Enum):
36
+ """How a column's declaration makes it a constructor argument."""
37
+
38
+ REQUIRED = "required" # no ``default=``
39
+ DEFAULT = "default" # ``default=...``, ``default=None`` included
40
+ NO_INIT = "no_init" # ``init=False``: not a constructor argument
41
+
42
+
43
+ def is_mapped_column(value: object) -> TypeIs[MappedColumn[Any]]:
44
+ """Whether ``value`` is a ``mapped_column()`` not yet consumed by mapping."""
45
+ return isinstance(value, MappedColumn)
46
+
47
+
48
+ def _declared(column: MappedColumn[Any]) -> Declared:
49
+ options = attribute_options(column)
50
+ if options.dataclasses_init is False:
51
+ return Declared.NO_INIT
52
+ if options.dataclasses_default is not NO_ARG:
53
+ return Declared.DEFAULT
54
+ return Declared.REQUIRED
55
+
56
+
57
+ def _declared_in(klass: type) -> dict[str, Declared]:
58
+ """``declared_defaults`` as seen from an existing class, nearest declaration first.
59
+
60
+ A ``ModelMeta`` class carries its record; any other class is scanned for ``MappedColumn`` objects.
61
+ """
62
+ view: dict[str, Declared] = {}
63
+ for ancestor in klass.__mro__:
64
+ for name, flag in ancestor.__dict__.get(ATTR_DECLARED_DEFAULTS, {}).items():
65
+ view.setdefault(name, flag)
66
+ for name, value in ancestor.__dict__.items():
67
+ if is_mapped_column(value):
68
+ view.setdefault(name, _declared(value))
69
+ return view
70
+
71
+
72
+ def declared_defaults(bases: tuple[type, ...], namespace: Mapping[str, Any]) -> Mapping[str, Declared]:
73
+ """For each declared column, how its ``mapped_column()`` call makes it a constructor argument.
74
+
75
+ Call before the class is created, which consumes the ``MappedColumn`` objects.
76
+ """
77
+ view = {name: _declared(value) for name, value in namespace.items() if is_mapped_column(value)}
78
+ for base in bases:
79
+ for name, flag in _declared_in(base).items():
80
+ view.setdefault(name, flag)
81
+ return MappingProxyType(view)
82
+
83
+
84
+ def _default_factory_from(column: Any) -> Any:
85
+ """Wrap a SQLAlchemy callable default, with or without a context parameter, as a no-argument factory."""
86
+ fn = column.default.arg
87
+
88
+ def factory() -> Any:
89
+ try:
90
+ return fn()
91
+ except TypeError:
92
+ return fn(None)
93
+
94
+ return factory
95
+
96
+
97
+ def find_column(cls: type, name: str) -> Any | None:
98
+ """Return the ``Column`` mapped to attribute ``name``, or ``None`` if it maps none.
99
+
100
+ Safe before the mapper is configured. Under joined inheritance it is the subclass's own column.
101
+ """
102
+ mapper: Mapper[Any] | None = sa_inspect(cls, raiseerr=False)
103
+ if mapper is None or name not in mapper.columns:
104
+ return None
105
+ column = getattr(cls, name).property.columns[0]
106
+ return column if isinstance(column, Column) else None
107
+
108
+
109
+ def _unwrap_mapped_annotation(field: PydanticFieldInfo) -> bool:
110
+ """Replace ``Mapped[T]`` in ``field.annotation`` with ``T``, as a plain ``BaseModel`` field would read.
111
+
112
+ An ``Annotated`` ``T`` is unwrapped too, its extras added to ``field.metadata``.
113
+ Returns ``True`` if the annotation was rewritten.
114
+ """
115
+ if get_origin(field.annotation) is not Mapped:
116
+ return False
117
+
118
+ inner = get_args(field.annotation)
119
+ if not inner:
120
+ return False
121
+
122
+ unwrapped = inner[0]
123
+ if get_origin(unwrapped) is Annotated:
124
+ parts = get_args(unwrapped)
125
+ unwrapped = parts[0]
126
+ field.metadata = [*field.metadata, *parts[1:]]
127
+ field.annotation = unwrapped
128
+ return True
129
+
130
+
131
+ def _resolve_column_default(field: PydanticFieldInfo, column: Any, has_default: bool) -> bool:
132
+ """Rebuild the Pydantic default: required unless declared, valued from the ``Column``.
133
+
134
+ Returns ``True`` if the field changed.
135
+ """
136
+ if not has_default:
137
+ field.default = PydanticUndefined
138
+ field.default_factory = None
139
+ elif column.default is not None and column.default.is_callable:
140
+ field.default = PydanticUndefined
141
+ field.default_factory = _default_factory_from(column)
142
+ else:
143
+ field.default = None if column.default is None else column.default.arg
144
+ field.default_factory = None
145
+ return True
146
+
147
+
148
+ def _value_type(annotation: Any) -> Any:
149
+ """The type under ``T | None`` and any ``NewType``: ``str`` for ``Name | None``."""
150
+ if get_origin(annotation) in (Union, UnionType):
151
+ members = [arg for arg in get_args(annotation) if arg is not NoneType]
152
+ if len(members) != 1:
153
+ return None
154
+ annotation = members[0]
155
+ while isinstance(annotation, NewType):
156
+ annotation = annotation.__supertype__
157
+ return annotation
158
+
159
+
160
+ def _declared_limits(metadata: Iterable[Any], constraint: str) -> Iterator[int]:
161
+ """Each value of ``constraint`` in ``metadata``, including inside a nested ``Field()``."""
162
+ for item in metadata:
163
+ if isinstance(item, PydanticFieldInfo):
164
+ yield from _declared_limits(item.metadata, constraint)
165
+ elif (value := getattr(item, constraint, None)) is not None:
166
+ yield value
167
+
168
+
169
+ def _tighten(field: PydanticFieldInfo, constraint: str, limit: int, enforced: PydanticFieldInfo) -> bool:
170
+ """Make ``field`` enforce ``constraint <= limit``, unless it already enforces as much.
171
+
172
+ ``enforced`` is ``Field(constraint=limit)``, whose metadata is appended.
173
+ """
174
+ declared = list(_declared_limits(field.metadata, constraint))
175
+ if declared and min(declared) <= limit:
176
+ return False
177
+ field.metadata.extend(enforced.metadata)
178
+ return True
179
+
180
+
181
+ def _constrain_to_column_type(field: PydanticFieldInfo, column: Any) -> bool:
182
+ """Validate the limits the column type sets: a ``String``'s length, a ``Numeric``'s digits.
183
+
184
+ Only a ``str`` or ``Decimal`` value, or a ``NewType`` of one, is constrained.
185
+ Returns ``True`` if the field changed.
186
+ """
187
+ value_type = _value_type(field.annotation)
188
+ column_type = column.type
189
+ if not isinstance(value_type, type):
190
+ return False
191
+ if isinstance(column_type, String):
192
+ if column_type.length is None or not issubclass(value_type, str) or issubclass(value_type, Enum):
193
+ return False
194
+ length = column_type.length
195
+ return _tighten(field, "max_length", length, Field(max_length=length))
196
+ if isinstance(column_type, Numeric) and issubclass(value_type, Decimal):
197
+ changed = False
198
+ if (digits := column_type.precision) is not None:
199
+ changed = _tighten(field, "max_digits", digits, Field(max_digits=digits)) or changed
200
+ if (places := column_type.scale) is not None:
201
+ changed = _tighten(field, "decimal_places", places, Field(decimal_places=places)) or changed
202
+ return changed
203
+ return False
204
+
205
+
206
+ def is_readonly(cls: type, column: Any) -> bool:
207
+ """Whether ``column`` is ``readonly``: declared so, or a version SQLAlchemy generates."""
208
+ declared = column.info.get(INFO_READONLY)
209
+ if declared is not None:
210
+ return bool(declared)
211
+ if not column.info.get(INFO_VERSION):
212
+ return False
213
+ mapper: Mapper[Any] | None = sa_inspect(cls, raiseerr=False)
214
+ return mapper is None or mapper.version_id_generator is not False
215
+
216
+
217
+ def _sync_field_metadata(
218
+ field: PydanticFieldInfo, column: Any, declared_annotation: Any, readonly: bool = False
219
+ ) -> bool:
220
+ """Copy ``Annotated`` metadata into ``column.info``, and ``column.info`` into the JSON schema.
221
+
222
+ ``Field()`` attributes Pydantic drops from a nested ``Annotated`` are set on ``field``.
223
+ Returns ``True`` if the field changed.
224
+ """
225
+ changed = False
226
+
227
+ meta_items = list(field.metadata)
228
+ meta_items.extend(extract_metadata_items(declared_annotation))
229
+
230
+ for meta in meta_items:
231
+ plain = normalize_meta_dict(meta)
232
+ if plain is not None:
233
+ column.info.update(plain)
234
+ elif isinstance(meta, PydanticFieldInfo):
235
+ for attribute in FIELD_INFO_PASSTHROUGH_ATTRS:
236
+ value = getattr(meta, attribute, None)
237
+ if value is not None and getattr(field, attribute, None) is None:
238
+ setattr(field, attribute, value)
239
+ changed = True
240
+
241
+ schema_extra = {
242
+ key: value for key, value in column.info.items() if key not in (INFO_READONLY, INFO_VERSION)
243
+ }
244
+ if readonly:
245
+ schema_extra["readOnly"] = True
246
+ if schema_extra:
247
+ extra = field.json_schema_extra
248
+ if extra is None:
249
+ field.json_schema_extra = schema_extra
250
+ changed = True
251
+ elif not callable(extra):
252
+ extra.update(schema_extra)
253
+ changed = True
254
+
255
+ return changed
256
+
257
+
258
+ def _prune_non_column_fields(
259
+ cls: type, fields: dict[str, PydanticFieldInfo], non_column_names: set[str], annotations: dict[str, Any]
260
+ ) -> bool:
261
+ """Drop non-column fields from the Pydantic field set, their names and annotations recorded.
262
+
263
+ Returns ``True`` if any field was removed.
264
+ """
265
+ changed = False
266
+ for name in list(fields):
267
+ if find_column(cls, name) is None:
268
+ annotations[name] = fields.pop(name).annotation
269
+ non_column_names.add(name)
270
+ changed = True
271
+ return changed
272
+
273
+
274
+ def _collect_unannotated_descriptors(cls: type, non_column_names: set[str]) -> None:
275
+ """Record any class-level ``InstrumentedAttribute``s that are not columns."""
276
+ for base in cls.__mro__:
277
+ for attr_name, attr_val in base.__dict__.items():
278
+ if isinstance(attr_val, InstrumentedAttribute) and find_column(cls, attr_name) is None:
279
+ non_column_names.add(attr_name)
280
+
281
+
282
+ def repair_model_fields(cls: type[BaseModel], declared: Mapping[str, Declared]) -> None:
283
+ """Reconcile Pydantic fields with SQLAlchemy columns on a mapped table class.
284
+
285
+ ``declared`` is this class's ``declared_defaults``; it is recorded even on a class without a table.
286
+ """
287
+ setattr(cls, ATTR_DECLARED_DEFAULTS, declared)
288
+ setattr(
289
+ cls,
290
+ ATTR_NO_INIT,
291
+ frozenset(name for name, declared_as in declared.items() if declared_as is Declared.NO_INIT),
292
+ )
293
+ table = getattr(cls, "__table__", None)
294
+ if table is None:
295
+ return
296
+
297
+ # The mapper must not be configured here: a relationship may name a class not defined yet.
298
+ fields = cls.model_fields
299
+ non_column_names: set[str] = set()
300
+ annotations: dict[str, Any] = {}
301
+ readonly: set[str] = set()
302
+ changed = _prune_non_column_fields(cls, fields, non_column_names, annotations)
303
+
304
+ for name, field in fields.items():
305
+ column = find_column(cls, name)
306
+ field_is_readonly = column is not None and is_readonly(cls, column)
307
+ if field_is_readonly:
308
+ readonly.add(name)
309
+
310
+ declared_annotation = field.annotation
311
+
312
+ changed = _unwrap_mapped_annotation(field) or changed
313
+ has_default = declared.get(name, Declared.REQUIRED) is not Declared.REQUIRED
314
+ changed = _resolve_column_default(field, column, has_default) or changed
315
+ changed = _sync_field_metadata(field, column, declared_annotation, field_is_readonly) or changed
316
+ changed = _constrain_to_column_type(field, column) or changed
317
+
318
+ _collect_unannotated_descriptors(cls, non_column_names)
319
+
320
+ setattr(cls, ATTR_NON_COLUMNS, frozenset(non_column_names))
321
+ setattr(cls, ATTR_NON_COLUMN_ANNOTATIONS, MappingProxyType(annotations))
322
+ setattr(cls, ATTR_READONLY, frozenset(readonly))
323
+
324
+ if changed:
325
+ cls.model_rebuild(force=True)
@@ -0,0 +1,20 @@
1
+ """Teach Pydantic to validate SQLAlchemy's ``Mapped[T]`` as ``T``."""
2
+
3
+ from typing import Any, get_args
4
+
5
+ from sqlalchemy.orm import Mapped
6
+
7
+ __all__ = ["install_mapped_schema_hook"]
8
+
9
+
10
+ def _mapped_core_schema(_cls: Any, source: Any, handler: Any) -> Any:
11
+ """Validate ``Mapped[T]`` as ``T``, and a bare ``Mapped`` as ``Any``."""
12
+ args = get_args(source)
13
+ return handler.generate_schema(args[0] if args else Any)
14
+
15
+
16
+ def install_mapped_schema_hook() -> None:
17
+ """Install the hook on ``Mapped``, unless SQLAlchemy already provides one."""
18
+ if hasattr(Mapped, "__get_pydantic_core_schema__"):
19
+ return
20
+ Mapped.__get_pydantic_core_schema__ = classmethod(_mapped_core_schema) # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
alchemic/_metadata.py ADDED
@@ -0,0 +1,45 @@
1
+ """Read the ``Annotated`` metadata of a field's declared annotation."""
2
+
3
+ from collections.abc import Callable, Mapping
4
+ from typing import Annotated, Any, cast, get_args, get_origin
5
+
6
+ __all__ = ["as_dict", "extract_metadata_items", "normalize_meta_dict"]
7
+
8
+
9
+ def as_dict(value: object) -> dict[str, Any] | None:
10
+ """Return a ``dict`` copy of ``value`` if it is a ``Mapping``, else ``None``."""
11
+ return dict(cast("Mapping[str, Any]", value)) if isinstance(value, Mapping) else None
12
+
13
+
14
+ def extract_metadata_items(annotation: Any) -> list[Any]:
15
+ """Return the ``Annotated`` extras of ``annotation``, or of an ``Annotated`` one level inside it.
16
+
17
+ ``Annotated[T, x]`` and ``Mapped[Annotated[T, x]]`` both give ``[x]``.
18
+ """
19
+ if annotation is None:
20
+ return []
21
+
22
+ if get_origin(annotation) is Annotated:
23
+ return list(get_args(annotation)[1:])
24
+
25
+ if get_origin(annotation) is not None:
26
+ for arg in get_args(annotation):
27
+ if get_origin(arg) is Annotated:
28
+ return list(get_args(arg)[1:])
29
+
30
+ return []
31
+
32
+
33
+ def normalize_meta_dict(item: Any) -> dict[str, Any] | None:
34
+ """Return ``item`` as a dict if it is a ``Mapping`` or its ``as_dict()`` returns one, else ``None``."""
35
+ mapping = as_dict(item)
36
+ if mapping is not None:
37
+ return mapping
38
+ method = _get_as_dict(item)
39
+ return None if method is None else as_dict(method())
40
+
41
+
42
+ def _get_as_dict(item: Any) -> Callable[[], Any] | None:
43
+ """Return ``item.as_dict`` if present and callable, else ``None``."""
44
+ method = getattr(item, "as_dict", None)
45
+ return method if callable(method) else None