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 +61 -0
- alchemic/_constants.py +31 -0
- alchemic/_fields.py +325 -0
- alchemic/_mapped_schema.py +20 -0
- alchemic/_metadata.py +45 -0
- alchemic/_receivers.py +292 -0
- alchemic/_relationships.py +307 -0
- alchemic/_sqlalchemy_private.py +79 -0
- alchemic/_typing.py +440 -0
- alchemic/extensions.py +113 -0
- alchemic/model.py +723 -0
- alchemic/py.typed +0 -0
- alchemic/session.py +235 -0
- alchemic-0.1.0.dist-info/METADATA +382 -0
- alchemic-0.1.0.dist-info/RECORD +17 -0
- alchemic-0.1.0.dist-info/WHEEL +4 -0
- alchemic-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|