exdrf-pd 0.1.17__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.
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: exdrf-pd
3
+ Version: 0.1.17
4
+ Summary: Use Pydantic with Ex-DRF.
5
+ Author-email: Nicu Tofan <nicu.tofan@gmail.com>
6
+ License-Expression: MIT
7
+ Classifier: Operating System :: OS Independent
8
+ Classifier: Programming Language :: Python :: 3 :: Only
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Typing :: Typed
11
+ Requires-Python: >=3.12.2
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: exdrf>=0.1.17
14
+ Requires-Dist: pydantic>=2.10.6
15
+ Provides-Extra: dev
16
+ Requires-Dist: autoflake; extra == "dev"
17
+ Requires-Dist: black; extra == "dev"
18
+ Requires-Dist: build; extra == "dev"
19
+ Requires-Dist: flake8; extra == "dev"
20
+ Requires-Dist: isort; extra == "dev"
21
+ Requires-Dist: mypy; extra == "dev"
22
+ Requires-Dist: pre-commit; extra == "dev"
23
+ Requires-Dist: pyproject-flake8; extra == "dev"
24
+ Requires-Dist: pytest-cov; extra == "dev"
25
+ Requires-Dist: pytest-mock; extra == "dev"
26
+ Requires-Dist: pytest; extra == "dev"
27
+ Requires-Dist: twine; extra == "dev"
28
+ Requires-Dist: wheel; extra == "dev"
29
+ Requires-Dist: click<9,>=8.2.1; extra == "dev"
30
+
31
+ # Pydantic support for Ex-DRF
32
+
33
+ **exdrf-pd** connects **Pydantic v2** models to the **exdrf** dataset tree. It
34
+ is the usual path for HTTP APIs and validators that prefer **`BaseModel`** over
35
+ SQLAlchemy declarative classes.
36
+
37
+ ## What it provides
38
+
39
+ - **`ExModel`** subclasses and visitors that expose resources and fields in the
40
+ same shapes **exdrf** uses elsewhere, so codegen and tooling can treat ORM and
41
+ Pydantic sources uniformly where supported.
42
+ - **`dataset_from_pydantic`** (and related imports under **`exdrf_pd`**) for
43
+ building an **`ExDataset`** from your model modules—used by plugins such as
44
+ **exdrf-gen-pd2dare**.
45
+
46
+ ## Dependencies
47
+
48
+ **exdrf** and **pydantic** (see `pyproject.toml`). Python **3.12.2+** is
49
+ required.
50
+
51
+ ## Related packages
52
+
53
+ - **exdrf-gen-al2pd** — emits Pydantic `Xxx` / `XxxEx` / `XxxCreate` / `XxxEdit`
54
+ from SQLAlchemy metadata (often paired with **exdrf-gen-al2r**).
55
+ - **exdrf-ts** — maps field types and Python annotations to TypeScript for DARE
56
+ and other TS emitters; depends on **exdrf-pd**.
57
+ - **exdrf-gen-pd2dare** — generates DARE TypeScript from **`ExModel`** classes.
58
+
59
+ Install **exdrf-gen** and the plugin you need; see **`exdrf-gen`** README for
60
+ the plugin entry-point pattern.
@@ -0,0 +1,30 @@
1
+ # Pydantic support for Ex-DRF
2
+
3
+ **exdrf-pd** connects **Pydantic v2** models to the **exdrf** dataset tree. It
4
+ is the usual path for HTTP APIs and validators that prefer **`BaseModel`** over
5
+ SQLAlchemy declarative classes.
6
+
7
+ ## What it provides
8
+
9
+ - **`ExModel`** subclasses and visitors that expose resources and fields in the
10
+ same shapes **exdrf** uses elsewhere, so codegen and tooling can treat ORM and
11
+ Pydantic sources uniformly where supported.
12
+ - **`dataset_from_pydantic`** (and related imports under **`exdrf_pd`**) for
13
+ building an **`ExDataset`** from your model modules—used by plugins such as
14
+ **exdrf-gen-pd2dare**.
15
+
16
+ ## Dependencies
17
+
18
+ **exdrf** and **pydantic** (see `pyproject.toml`). Python **3.12.2+** is
19
+ required.
20
+
21
+ ## Related packages
22
+
23
+ - **exdrf-gen-al2pd** — emits Pydantic `Xxx` / `XxxEx` / `XxxCreate` / `XxxEdit`
24
+ from SQLAlchemy metadata (often paired with **exdrf-gen-al2r**).
25
+ - **exdrf-ts** — maps field types and Python annotations to TypeScript for DARE
26
+ and other TS emitters; depends on **exdrf-pd**.
27
+ - **exdrf-gen-pd2dare** — generates DARE TypeScript from **`ExModel`** classes.
28
+
29
+ Install **exdrf-gen** and the plugin you need; see **`exdrf-gen`** README for
30
+ the plugin entry-point pattern.
@@ -0,0 +1,18 @@
1
+ """Pydantic helpers used alongside exdrf-generated APIs."""
2
+
3
+ from exdrf_pd.base import ExModel
4
+ from exdrf_pd.paged import PagedList, paged_list_empty_factory
5
+ from exdrf_pd.schema_extra import (
6
+ EXDRF_JSON_SCHEMA_EXTRA_KEY,
7
+ wrap_exdrf_props,
8
+ )
9
+ from exdrf_pd.sort_item import SortItem
10
+
11
+ __all__ = [
12
+ "EXDRF_JSON_SCHEMA_EXTRA_KEY",
13
+ "ExModel",
14
+ "PagedList",
15
+ "SortItem",
16
+ "paged_list_empty_factory",
17
+ "wrap_exdrf_props",
18
+ ]
@@ -0,0 +1,24 @@
1
+ """Package version from PEP 621 or installed metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from exdrf.pep621_version import distribution_version, version_tuple_from_string
8
+
9
+ _PYPROJECT = Path(__file__).resolve().parents[1] / "pyproject.toml"
10
+ _DIST_NAME = "exdrf-pd"
11
+
12
+ __version__ = version = distribution_version(_DIST_NAME, _PYPROJECT)
13
+ __version_tuple__ = version_tuple = version_tuple_from_string(__version__)
14
+
15
+ __commit_id__ = commit_id = None
16
+
17
+ __all__ = [
18
+ "__version__",
19
+ "__version_tuple__",
20
+ "version",
21
+ "version_tuple",
22
+ "__commit_id__",
23
+ "commit_id",
24
+ ]
@@ -0,0 +1,58 @@
1
+ from typing import TYPE_CHECKING, ClassVar, List, Type
2
+
3
+ from pydantic import BaseModel
4
+
5
+ if TYPE_CHECKING:
6
+ from exdrf_pd.visitor import ExModelVisitor
7
+
8
+
9
+ class ExModel(BaseModel):
10
+ """A Pydantic BaseModel that automatically registers its subclasses."""
11
+
12
+ _registry: ClassVar[List[Type["ExModel"]]] = []
13
+
14
+ def __init_subclass__(cls, **kwargs):
15
+ super().__init_subclass__(**kwargs)
16
+ ExModel._registry.append(cls)
17
+
18
+ @classmethod
19
+ def get_subclasses(cls):
20
+ """Return all registered subclasses."""
21
+ return cls._registry
22
+
23
+ @classmethod
24
+ def visit(cls, visitor: "ExModelVisitor"):
25
+ """Visit all the models derived from this class."""
26
+ for model in cls._registry:
27
+ # Compute the categories (list of modules) of the model.
28
+ categories = visitor.category(model)
29
+
30
+ # Create the tree of categories.
31
+ m_map = visitor.categ_map
32
+ for c in categories:
33
+ # Get the category at this level.
34
+ c_map = m_map.get(c, None)
35
+ if c_map is None:
36
+ # It does not exist, so create it.
37
+ c_map = m_map[c] = {}
38
+
39
+ # Move to the next level.
40
+ m_map = c_map
41
+
42
+ # The leaf receives the model.
43
+ m_map[model.__name__] = model
44
+
45
+ # Visit the model itself.
46
+ visitor.visit_model(
47
+ model,
48
+ model.__name__,
49
+ categories, # type: ignore
50
+ )
51
+
52
+ # Then visit all fields of the model.
53
+ for field_name, field_info in model.model_fields.items():
54
+ visitor.visit_field(
55
+ model,
56
+ field_name,
57
+ field_info, # type: ignore
58
+ )
@@ -0,0 +1,41 @@
1
+ from typing import Any, Optional
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from exdrf.filter import FieldFilter
6
+
7
+
8
+ class FilterItem(BaseModel):
9
+ """
10
+ An item in a filter list.
11
+
12
+ Valid examples:
13
+
14
+ - { "fld": "id", "op": "==", "vl": 1 }
15
+ - { "fld": "deleted", "op": true }
16
+ - { "fld": "age", "op": ">=", "vl": 18 }
17
+
18
+ Attributes:
19
+ fld: the column or other identifier that indicates
20
+ the subject of the filter;
21
+ op: the operation that is used to filter
22
+ (something like ``==``, ``!=``);
23
+ vl: the value to compare the field against;
24
+ for some operations this might be required,
25
+ for others may not be used at all;
26
+ """
27
+
28
+ fld: str
29
+ op: str
30
+ vl: Optional[Any] = None
31
+
32
+ class Config:
33
+ from_attributes = True
34
+ populate_by_name = True
35
+
36
+ @property
37
+ def as_op(self):
38
+ """
39
+ Create the filter operation from parsed object.
40
+ """
41
+ return FieldFilter(fld=self.fld, op=self.op, vl=self.vl)
@@ -0,0 +1,426 @@
1
+ import re
2
+ from datetime import date, datetime
3
+ from typing import (
4
+ TYPE_CHECKING,
5
+ Any,
6
+ ForwardRef,
7
+ Optional,
8
+ cast,
9
+ get_args,
10
+ get_origin,
11
+ )
12
+
13
+ from annotated_types import Ge, Gt, Le, Lt, MaxLen, MinLen
14
+ from attrs import define
15
+
16
+ try:
17
+ from pydantic.functional_validators import (
18
+ AfterValidator,
19
+ BeforeValidator,
20
+ PlainValidator,
21
+ WrapValidator,
22
+ )
23
+
24
+ _PYDANTIC_METADATA_SKIP_TYPES: tuple[type[Any], ...] = (
25
+ BeforeValidator,
26
+ AfterValidator,
27
+ PlainValidator,
28
+ WrapValidator,
29
+ )
30
+ except ImportError: # pragma: no cover - older pydantic
31
+ _PYDANTIC_METADATA_SKIP_TYPES = ()
32
+ from exdrf.api import (
33
+ BoolField,
34
+ DateField,
35
+ DateTimeField,
36
+ EnumField,
37
+ FloatField,
38
+ FloatListField,
39
+ IntField,
40
+ IntListField,
41
+ RefOneToManyField,
42
+ RefOneToOneField,
43
+ StrField,
44
+ StrListField,
45
+ )
46
+ from exdrf.field import ExField
47
+ from exdrf_pd.visitor import ExModelVisitor
48
+
49
+ if TYPE_CHECKING:
50
+ from pydantic.fields import FieldInfo as PdFieldInfo # noqa: F401
51
+
52
+ from exdrf.dataset import ExDataset
53
+ from exdrf.resource import ExResource
54
+
55
+
56
+ def _optional_inner_type(annotation: Any) -> Any | None:
57
+ """Return ``T`` when ``annotation`` is ``Optional[T]`` or ``T | None``.
58
+
59
+ Args:
60
+ annotation: A typing annotation (e.g. from ``FieldInfo.annotation``).
61
+
62
+ Returns:
63
+ The non-``None`` member for a plain optional union; ``None`` if the
64
+ annotation is not exactly one non-``None`` type plus ``None``.
65
+ """
66
+
67
+ origin = get_origin(annotation)
68
+ args = get_args(annotation)
69
+ if origin is None or not args:
70
+ return None
71
+ if type(None) not in args:
72
+ return None
73
+ non_none = [a for a in args if a is not type(None)]
74
+ if len(non_none) != 1:
75
+ return None
76
+ return non_none[0]
77
+
78
+
79
+ def _exmodel_by_name() -> dict[str, type]:
80
+ """Map ``ExModel`` subclass simple names to their class objects.
81
+
82
+ Returns:
83
+ A dictionary keyed by :attr:`type.__name__` for every registered
84
+ :class:`exdrf_pd.base.ExModel` subclass.
85
+ """
86
+
87
+ from exdrf_pd.base import ExModel
88
+
89
+ return {cls.__name__: cls for cls in ExModel.get_subclasses()}
90
+
91
+
92
+ def _is_exmodel_type(tp: Any) -> bool:
93
+ """Return whether ``tp`` is a concrete :class:`exdrf_pd.base.ExModel` type.
94
+
95
+ Args:
96
+ tp: Candidate type object.
97
+
98
+ Returns:
99
+ ``True`` when ``tp`` is a class derived from :class:`exdrf_pd.base.ExModel`.
100
+ """
101
+
102
+ from exdrf_pd.base import ExModel
103
+
104
+ try:
105
+ return isinstance(tp, type) and issubclass(tp, ExModel)
106
+ except TypeError:
107
+ return False
108
+
109
+
110
+ def _paged_list_item_type(annotation: Any) -> Any | None:
111
+ """Return the item type for a concrete ``PagedList[T]`` annotation.
112
+
113
+ Args:
114
+ annotation: A specialized :class:`exdrf_pd.paged.PagedList` model class.
115
+
116
+ Returns:
117
+ ``T`` when ``annotation`` is ``PagedList[T]``; ``None`` otherwise.
118
+ """
119
+
120
+ meta = getattr(annotation, "__pydantic_generic_metadata__", None)
121
+ if not isinstance(meta, dict):
122
+ return None
123
+ from exdrf_pd.paged import PagedList
124
+
125
+ if meta.get("origin") is not PagedList:
126
+ return None
127
+ args_raw = meta.get("args") or ()
128
+ args_tuple = tuple(args_raw)
129
+ if len(args_tuple) != 1:
130
+ return None
131
+ return args_tuple[0]
132
+
133
+
134
+ def _parse_forward_ref_arg(
135
+ expr: str,
136
+ models_by_name: dict[str, type],
137
+ ) -> Any | None:
138
+ """Turn a :class:`typing.ForwardRef` argument string into a live annotation.
139
+
140
+ Args:
141
+ expr: The forward reference's ``__forward_arg__`` text.
142
+ models_by_name: Map of :class:`exdrf_pd.base.ExModel` names to classes.
143
+
144
+ Returns:
145
+ A concrete annotation suitable for :func:`field_from_pydantic`, or
146
+ ``None`` when the expression is not supported.
147
+ """
148
+
149
+ from exdrf_pd.paged import PagedList
150
+
151
+ expr = expr.strip()
152
+ if expr.endswith(" | None"):
153
+ inner_expr = expr[: -len(" | None")].strip()
154
+ inner = _parse_forward_ref_arg(inner_expr, models_by_name)
155
+ if inner is None:
156
+ return None
157
+ return inner | type(None)
158
+
159
+ m = re.fullmatch(r"PagedList\[(\w+)\]", expr)
160
+ if m:
161
+ inner_cls = models_by_name.get(m.group(1))
162
+ if inner_cls is None:
163
+ return None
164
+ return cast(Any, PagedList)[inner_cls]
165
+
166
+ return models_by_name.get(expr)
167
+
168
+
169
+ def _resolve_forward_ref_annotation(annotation: ForwardRef) -> Any | None:
170
+ """Resolve a :class:`typing.ForwardRef` using registered ``ExModel`` names.
171
+
172
+ Args:
173
+ annotation: Pydantic field annotation that is still a forward ref.
174
+
175
+ Returns:
176
+ Evaluated annotation, or ``None`` if it cannot be resolved here.
177
+ """
178
+
179
+ raw = getattr(annotation, "__forward_arg__", None)
180
+ if raw is None:
181
+ return None
182
+ return _parse_forward_ref_arg(str(raw), _exmodel_by_name())
183
+
184
+
185
+ def field_from_pydantic(
186
+ resource: "ExResource",
187
+ field_name,
188
+ src_field: "PdFieldInfo",
189
+ annotation: Optional[type] = None,
190
+ **kwargs: Any,
191
+ ) -> "ExField":
192
+ """Create a Field object from a Pydantic field.
193
+
194
+ Args:
195
+ field_name: The name of the field.
196
+ src_field: The source field.
197
+ annotation: The type of the field; this is used when called
198
+ recursively to create a field from a list of fields. If
199
+ not provided the annotation of the source field is used.
200
+ **kwargs: Additional arguments to pass to the Field constructor.
201
+
202
+ Returns:
203
+ The internal representation of the field.
204
+ """
205
+ # ds = resource.dataset
206
+ extra = {
207
+ "resource": resource,
208
+ "src": src_field,
209
+ "name": field_name,
210
+ "title": src_field.title or field_name.replace("_", " ").title(),
211
+ "description": src_field.description,
212
+ **kwargs,
213
+ }
214
+ if annotation is None:
215
+ annotation = src_field.annotation
216
+
217
+ # Iterate field metadata keys and extract the information that we
218
+ # understand.
219
+ for md in src_field.metadata:
220
+ if isinstance(md, MinLen):
221
+ extra["min_length"] = md.min_length
222
+ elif isinstance(md, MaxLen):
223
+ extra["max_length"] = md.max_length
224
+ elif isinstance(md, Gt):
225
+ extra["min"] = md.gt
226
+ elif isinstance(md, Ge):
227
+ extra["min"] = md.ge
228
+ elif isinstance(md, Lt):
229
+ extra["max"] = md.lt
230
+ elif isinstance(md, Le):
231
+ extra["max"] = md.le
232
+ elif _PYDANTIC_METADATA_SKIP_TYPES and isinstance(
233
+ md,
234
+ _PYDANTIC_METADATA_SKIP_TYPES,
235
+ ):
236
+ continue
237
+ else:
238
+ raise ValueError(f"Unknown metadata type: {md}")
239
+
240
+ inner_opt = _optional_inner_type(annotation)
241
+ if inner_opt is not None:
242
+ return field_from_pydantic(
243
+ resource,
244
+ field_name,
245
+ src_field,
246
+ annotation=inner_opt,
247
+ nullable=True,
248
+ **kwargs,
249
+ )
250
+
251
+ result: ExField
252
+
253
+ paged_item = _paged_list_item_type(annotation)
254
+ if paged_item is not None and _is_exmodel_type(paged_item):
255
+ ds = resource.dataset
256
+ paged_ref = RefOneToManyField(
257
+ ref=ds[paged_item.__name__],
258
+ expect_lots=True,
259
+ **extra,
260
+ )
261
+ resource.add_field(paged_ref)
262
+ return paged_ref
263
+
264
+ if _is_exmodel_type(annotation):
265
+ ds = resource.dataset
266
+ model_cls = cast(type, annotation)
267
+ result = RefOneToOneField(
268
+ ref=ds[model_cls.__name__],
269
+ **extra,
270
+ )
271
+ resource.add_field(result)
272
+ return result
273
+
274
+ if annotation is bool:
275
+ result = BoolField(
276
+ **extra,
277
+ )
278
+ elif annotation is str:
279
+ result = StrField(
280
+ **extra,
281
+ )
282
+ elif annotation is int:
283
+ result = IntField(
284
+ **extra,
285
+ )
286
+ elif annotation is float:
287
+ result = FloatField(
288
+ **extra,
289
+ )
290
+ elif annotation is datetime:
291
+ result = DateTimeField(
292
+ **extra,
293
+ )
294
+ elif annotation is date:
295
+ result = DateField(
296
+ **extra,
297
+ )
298
+ elif annotation is Any:
299
+ result = StrField(
300
+ **extra,
301
+ )
302
+ elif str(annotation).startswith("typing.Literal["):
303
+ values = annotation.__args__ # type: ignore
304
+ result = EnumField(
305
+ enum_values=[(a, a.title()) for a in values],
306
+ **extra,
307
+ )
308
+ elif get_origin(annotation) is list:
309
+ # Homogeneous ``list[T]`` / ``List[T]`` (Pydantic v2 uses ``list[int]``).
310
+ args = get_args(annotation)
311
+ list_field: Optional[ExField] = None
312
+ if len(args) == 1:
313
+ referenced = args[0]
314
+ if isinstance(referenced, ForwardRef):
315
+ pass
316
+ elif referenced is str:
317
+ list_field = StrListField(
318
+ **extra,
319
+ )
320
+ elif referenced is int:
321
+ list_field = IntListField(
322
+ **extra,
323
+ )
324
+ elif referenced is float:
325
+ list_field = FloatListField(
326
+ **extra,
327
+ )
328
+
329
+ if list_field is None:
330
+ # Composite lists (for example ``list[dict[str, Any]]`` relation key
331
+ # payloads): use a string scalar exdrf field for metadata, but keep
332
+ # the original pydantic ``FieldInfo`` as ``src`` so ``m2ts`` can read
333
+ # ``field.src.annotation`` when emitting TypeScript.
334
+ list_field = StrField(
335
+ **extra,
336
+ )
337
+ result = list_field
338
+ elif isinstance(annotation, ForwardRef):
339
+ resolved = _resolve_forward_ref_annotation(annotation)
340
+ if resolved is not None:
341
+ return field_from_pydantic(
342
+ resource,
343
+ field_name,
344
+ src_field,
345
+ annotation=resolved,
346
+ **kwargs,
347
+ )
348
+ raise NotImplementedError(
349
+ "Unsupported forward reference: %r" % (annotation.__forward_arg__,)
350
+ )
351
+ elif str(annotation).startswith("<class 'exdrf_models."):
352
+ c_path = str(annotation)[8:-2]
353
+ _, cls_name = c_path.rsplit(".", 1)
354
+ raise NotImplementedError
355
+ # result = RefManyField(
356
+ # ref=ds[cls_name],
357
+ # **extra,
358
+ # )
359
+ # elif field_name == "filter":
360
+ # result = FilterField(
361
+ # **extra,
362
+ # )
363
+ else:
364
+ assert False, f"Unknown field type: {annotation}"
365
+
366
+ resource.add_field(result)
367
+ return result
368
+
369
+
370
+ def dataset_from_pydantic(d_set: "ExDataset") -> "ExDataset":
371
+ """Create a dataset from a SQLAlchemy database.
372
+
373
+ Args:
374
+ d_set: The dataset to populate.
375
+
376
+ Returns:
377
+ The populated dataset.
378
+ """
379
+ ResClass = d_set.res_class
380
+
381
+ @define
382
+ class Visitor(ExModelVisitor):
383
+ """A visitor that simply creates one resource for each model."""
384
+
385
+ def visit_model(self, model, name, categories):
386
+ # Get the docstring and format it.
387
+ _, doc_lines = self.get_docs(model)
388
+
389
+ # Create a Resource object for the model.
390
+ rs = ResClass(
391
+ src=model,
392
+ dataset=d_set,
393
+ name=name,
394
+ categories=categories,
395
+ description="\n".join(doc_lines),
396
+ )
397
+
398
+ # Add the resource to the dataset.
399
+ d_set.resources.append(rs)
400
+
401
+ # Iterate all modes that inherit from ExModel and create a resource
402
+ # for each of them.
403
+ v = Visitor.run()
404
+
405
+ # The visitor also creates a map of categories to models.
406
+ d_set.category_map = v.categ_map
407
+
408
+ # Retrieve fields from the models and create a Field object for each
409
+ # field in the resource.
410
+ for resource in d_set.resources:
411
+ # TODO:
412
+ # if resource.name in ("ListResponse", "ListRequest"):
413
+ # continue
414
+
415
+ # Get a list of fields sorted by name, but with the id
416
+ # field first.
417
+ fields = sorted(
418
+ ((a, b) for a, b in resource.src.model_fields.items()),
419
+ key=lambda x: x[0] if x[0] != "id" else "",
420
+ )
421
+
422
+ # Create a Field object for each field in the resource.
423
+ for field_name, src_field in fields:
424
+ field_from_pydantic(resource, field_name, src_field)
425
+
426
+ return d_set
@@ -0,0 +1,50 @@
1
+ """Import Pydantic model modules so ``dataset_from_pydantic`` can see subclasses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import os
7
+ from typing import Iterable, List
8
+
9
+ _ENV_PRIMARY = "EXDRF_PYDANTIC_MODELS_MODULES"
10
+ _ENV_LEGACY = "RESI_PYDANTIC_MODELS_MODULES"
11
+
12
+
13
+ def load_pydantic_modules(modules: Iterable[str]) -> List[str]:
14
+ """Import each module name so ExModel subclasses register before dataset build.
15
+
16
+ Args:
17
+ modules: Iterable of dotted Python module names.
18
+
19
+ Returns:
20
+ Module names that were imported successfully, in order.
21
+ """
22
+
23
+ imported: List[str] = []
24
+
25
+ # Import each module in order to register its Pydantic subclasses.
26
+ for module_name in modules:
27
+ clean_name = module_name.strip()
28
+ if not clean_name:
29
+ continue
30
+ importlib.import_module(clean_name)
31
+ imported.append(clean_name)
32
+
33
+ return imported
34
+
35
+
36
+ def load_pydantic_modules_from_env() -> List[str]:
37
+ """Import modules listed in ``EXDRF_PYDANTIC_MODELS_MODULES``.
38
+
39
+ If ``EXDRF_PYDANTIC_MODELS_MODULES`` is unset or empty, falls back to
40
+ ``RESI_PYDANTIC_MODELS_MODULES`` for backward compatibility (deprecated).
41
+
42
+ Returns:
43
+ The list of imported module names.
44
+ """
45
+
46
+ raw_value = os.getenv(_ENV_PRIMARY, "")
47
+ if not raw_value.strip():
48
+ raw_value = os.getenv(_ENV_LEGACY, "")
49
+ modules = raw_value.split(",") if raw_value else []
50
+ return load_pydantic_modules(modules)
@@ -0,0 +1,73 @@
1
+ """Paged collection shapes for API / Pydantic models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Generic, TypeVar
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+ T = TypeVar("T")
10
+
11
+
12
+ class PagedList(BaseModel, Generic[T]):
13
+ """A slice of a larger collection with stable paging fields.
14
+
15
+ Use this instead of a bare ``list[T]`` when the backing relation can be
16
+ large and clients should load data in pages (``offset`` / ``page_size`` /
17
+ ``total``).
18
+
19
+ Attributes:
20
+ total: Number of items available in the full collection.
21
+ offset: Zero-based index of the first entry in ``items`` within the
22
+ full collection.
23
+ page_size: Requested page capacity; ``len(items)`` may be smaller
24
+ (for example on the last page).
25
+ items: Entries returned for this page.
26
+ """
27
+
28
+ model_config = ConfigDict(extra="forbid")
29
+
30
+ total: int = Field(default=0, ge=0, description="Total items available")
31
+ offset: int = Field(default=0, ge=0, description="Start index of this page")
32
+ page_size: int = Field(
33
+ default=0,
34
+ ge=0,
35
+ description="Requested maximum items per page",
36
+ )
37
+ items: list[T] = Field(
38
+ default_factory=list,
39
+ description="Items loaded for this page",
40
+ )
41
+
42
+ @classmethod
43
+ def empty(cls) -> PagedList[T]:
44
+ """Return an empty page (all counters zero, no items).
45
+
46
+ Returns:
47
+ A validated empty :class:`PagedList` suitable for
48
+ ``Field(default_factory=...)``.
49
+ """
50
+ return cls.model_construct(
51
+ total=0,
52
+ offset=0,
53
+ page_size=0,
54
+ items=[],
55
+ )
56
+
57
+
58
+ def paged_list_empty_factory() -> PagedList[Any]:
59
+ """Build an empty page without naming the item type (forward-ref safe).
60
+
61
+ Use as ``Field(default_factory=paged_list_empty_factory)`` on
62
+ ``PagedList[SomeModel]`` fields when ``SomeModel`` is only imported under
63
+ ``TYPE_CHECKING``.
64
+
65
+ Returns:
66
+ An empty :class:`PagedList` instance.
67
+ """
68
+ return PagedList.model_construct(
69
+ total=0,
70
+ offset=0,
71
+ page_size=0,
72
+ items=[],
73
+ )
File without changes
@@ -0,0 +1,22 @@
1
+ """Constants for embedding exdrf metadata in Pydantic JSON schema extras."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Mapping
6
+
7
+ # Key used under ``Field(json_schema_extra=...)`` / ``model_config`` so
8
+ # OpenAPI and clients can find exdrf field/resource metadata without
9
+ # colliding with other extensions.
10
+ EXDRF_JSON_SCHEMA_EXTRA_KEY = "exdrf"
11
+
12
+
13
+ def wrap_exdrf_props(props: Mapping[str, Any]) -> dict[str, Any]:
14
+ """Nest exdrf properties under :data:`EXDRF_JSON_SCHEMA_EXTRA_KEY`.
15
+
16
+ Args:
17
+ props: Field or resource properties (JSON-serializable values).
18
+
19
+ Returns:
20
+ A single-key dict suitable for ``json_schema_extra``.
21
+ """
22
+ return {EXDRF_JSON_SCHEMA_EXTRA_KEY: dict(props)}
@@ -0,0 +1,27 @@
1
+ """Sort keys for list queries (JSON alongside :class:`~exdrf_pd.filter_item.FilterItem`)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+
10
+ class SortItem(BaseModel):
11
+ """One attribute and direction in an ordered ``ORDER BY`` specification.
12
+
13
+ Sort keys are applied in list order (first key is primary, later keys
14
+ break ties). Serialize as JSON objects in a list, for example
15
+ ``[{"attr": "name", "order": "asc"}, {"attr": "id", "order": "desc"}]``.
16
+
17
+ Attributes:
18
+ attr: Column or ORM attribute name to sort by.
19
+ order: ``asc`` or ``desc``.
20
+ """
21
+
22
+ model_config = ConfigDict(extra="forbid")
23
+
24
+ attr: str = Field(description="Attribute or column name to sort by.")
25
+ order: Literal["asc", "desc"] = Field(
26
+ description="Sort direction for this key.",
27
+ )
@@ -0,0 +1,55 @@
1
+ import textwrap
2
+ from typing import List, Type
3
+
4
+ from attrs import define, field
5
+ from pydantic.fields import FieldInfo
6
+
7
+ from exdrf_pd.base import ExModel
8
+
9
+
10
+ @define
11
+ class ExModelVisitor:
12
+ """A visitor that can be used to traverse a hierarchy of ExModels.
13
+
14
+ Attributes:
15
+ categ_map: Tree of categories, where each key is a category and the
16
+ value is a dictionary of subcategories or models. The tree is built
17
+ by the `visit` method, so it is not complete until that method
18
+ has been called.
19
+ """
20
+
21
+ categ_map: dict = field(factory=dict)
22
+
23
+ def visit_model(self, model: Type["ExModel"], name: str, categories: List[str]):
24
+ """Visit a model."""
25
+
26
+ def visit_field(self, model: Type["ExModel"], name: str, field: "FieldInfo"):
27
+ """Visit a field."""
28
+
29
+ @staticmethod
30
+ def category(model) -> List[str]:
31
+ """Get the category of a module.
32
+
33
+ This is the list of nested python modules in which the pydantic model
34
+ is defined, except for the first (package name) and the last (the file
35
+ name, which is usually the same as the resource name).
36
+ """
37
+ return model.__module__.split(".")[1:-1]
38
+
39
+ @staticmethod
40
+ def get_docs(thing):
41
+ """Retrieve the documentation of a pydantic model or field."""
42
+ doc = textwrap.dedent(thing.__doc__).strip() if thing.__doc__ else ""
43
+
44
+ doc_lines = doc.split("\n")
45
+ for i in range(1, len(doc_lines)):
46
+ if doc_lines[i].startswith(" "):
47
+ doc_lines[i] = doc_lines[i][4:]
48
+ return doc, doc_lines
49
+
50
+ @classmethod
51
+ def run(cls, *args, **kwargs):
52
+ """Run the visitor."""
53
+ v = cls(*args, **kwargs)
54
+ ExModel.visit(v) # type: ignore[call-arg]
55
+ return v
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: exdrf-pd
3
+ Version: 0.1.17
4
+ Summary: Use Pydantic with Ex-DRF.
5
+ Author-email: Nicu Tofan <nicu.tofan@gmail.com>
6
+ License-Expression: MIT
7
+ Classifier: Operating System :: OS Independent
8
+ Classifier: Programming Language :: Python :: 3 :: Only
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Typing :: Typed
11
+ Requires-Python: >=3.12.2
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: exdrf>=0.1.17
14
+ Requires-Dist: pydantic>=2.10.6
15
+ Provides-Extra: dev
16
+ Requires-Dist: autoflake; extra == "dev"
17
+ Requires-Dist: black; extra == "dev"
18
+ Requires-Dist: build; extra == "dev"
19
+ Requires-Dist: flake8; extra == "dev"
20
+ Requires-Dist: isort; extra == "dev"
21
+ Requires-Dist: mypy; extra == "dev"
22
+ Requires-Dist: pre-commit; extra == "dev"
23
+ Requires-Dist: pyproject-flake8; extra == "dev"
24
+ Requires-Dist: pytest-cov; extra == "dev"
25
+ Requires-Dist: pytest-mock; extra == "dev"
26
+ Requires-Dist: pytest; extra == "dev"
27
+ Requires-Dist: twine; extra == "dev"
28
+ Requires-Dist: wheel; extra == "dev"
29
+ Requires-Dist: click<9,>=8.2.1; extra == "dev"
30
+
31
+ # Pydantic support for Ex-DRF
32
+
33
+ **exdrf-pd** connects **Pydantic v2** models to the **exdrf** dataset tree. It
34
+ is the usual path for HTTP APIs and validators that prefer **`BaseModel`** over
35
+ SQLAlchemy declarative classes.
36
+
37
+ ## What it provides
38
+
39
+ - **`ExModel`** subclasses and visitors that expose resources and fields in the
40
+ same shapes **exdrf** uses elsewhere, so codegen and tooling can treat ORM and
41
+ Pydantic sources uniformly where supported.
42
+ - **`dataset_from_pydantic`** (and related imports under **`exdrf_pd`**) for
43
+ building an **`ExDataset`** from your model modules—used by plugins such as
44
+ **exdrf-gen-pd2dare**.
45
+
46
+ ## Dependencies
47
+
48
+ **exdrf** and **pydantic** (see `pyproject.toml`). Python **3.12.2+** is
49
+ required.
50
+
51
+ ## Related packages
52
+
53
+ - **exdrf-gen-al2pd** — emits Pydantic `Xxx` / `XxxEx` / `XxxCreate` / `XxxEdit`
54
+ from SQLAlchemy metadata (often paired with **exdrf-gen-al2r**).
55
+ - **exdrf-ts** — maps field types and Python annotations to TypeScript for DARE
56
+ and other TS emitters; depends on **exdrf-pd**.
57
+ - **exdrf-gen-pd2dare** — generates DARE TypeScript from **`ExModel`** classes.
58
+
59
+ Install **exdrf-gen** and the plugin you need; see **`exdrf-gen`** README for
60
+ the plugin entry-point pattern.
@@ -0,0 +1,24 @@
1
+ README.md
2
+ pyproject.toml
3
+ setup.py
4
+ exdrf_pd/__init__.py
5
+ exdrf_pd/__version__.py
6
+ exdrf_pd/base.py
7
+ exdrf_pd/filter_item.py
8
+ exdrf_pd/loader.py
9
+ exdrf_pd/model_import.py
10
+ exdrf_pd/paged.py
11
+ exdrf_pd/py.typed
12
+ exdrf_pd/schema_extra.py
13
+ exdrf_pd/sort_item.py
14
+ exdrf_pd/visitor.py
15
+ exdrf_pd.egg-info/PKG-INFO
16
+ exdrf_pd.egg-info/SOURCES.txt
17
+ exdrf_pd.egg-info/dependency_links.txt
18
+ exdrf_pd.egg-info/requires.txt
19
+ exdrf_pd.egg-info/top_level.txt
20
+ exdrf_pd_tests/__init__.py
21
+ tests/loader_optional_field_test.py
22
+ tests/model_import_test.py
23
+ tests/paged_schema_extra_test.py
24
+ tests/sort_item_test.py
@@ -0,0 +1,18 @@
1
+ exdrf>=0.1.17
2
+ pydantic>=2.10.6
3
+
4
+ [dev]
5
+ autoflake
6
+ black
7
+ build
8
+ flake8
9
+ isort
10
+ mypy
11
+ pre-commit
12
+ pyproject-flake8
13
+ pytest-cov
14
+ pytest-mock
15
+ pytest
16
+ twine
17
+ wheel
18
+ click<9,>=8.2.1
@@ -0,0 +1,4 @@
1
+ dist
2
+ exdrf_pd
3
+ exdrf_pd_tests
4
+ tests
File without changes
@@ -0,0 +1,64 @@
1
+ [project]
2
+ authors = [
3
+ { name = "Nicu Tofan", email = "nicu.tofan@gmail.com" },
4
+ ]
5
+ license = "MIT"
6
+ classifiers = [
7
+ "Operating System :: OS Independent",
8
+ "Programming Language :: Python :: 3 :: Only",
9
+ "Development Status :: 3 - Alpha",
10
+ "Typing :: Typed",
11
+ ]
12
+ description = "Use Pydantic with Ex-DRF."
13
+ version = "0.1.17"
14
+ name = "exdrf-pd"
15
+ readme = "README.md"
16
+ requires-python = ">=3.12.2"
17
+ dependencies = [
18
+ "exdrf>=0.1.17",
19
+ "pydantic>=2.10.6",
20
+ ]
21
+
22
+ [project.optional-dependencies]
23
+ dev = [
24
+ "autoflake",
25
+ "black",
26
+ "build",
27
+ "flake8",
28
+ "isort",
29
+ "mypy",
30
+ "pre-commit",
31
+ "pyproject-flake8",
32
+ "pytest-cov",
33
+ "pytest-mock",
34
+ "pytest",
35
+ "twine",
36
+ "wheel",
37
+ "click>=8.2.1,<9",
38
+ ]
39
+
40
+ [build-system]
41
+ build-backend = "setuptools.build_meta"
42
+ requires = ["setuptools>=67.0"]
43
+
44
+ [tool.setuptools]
45
+ include-package-data = true
46
+
47
+ [tool.setuptools.package-data]
48
+ exdrf_pd = ["py.typed"]
49
+
50
+ [tool.setuptools.packages.find]
51
+ exclude = ["venv*", "playground*"]
52
+
53
+
54
+ [tool.isort]
55
+ profile = "black"
56
+
57
+ [tool.black]
58
+ line-length = 80
59
+ target-version = ['py312']
60
+
61
+ [tool.flake8]
62
+ docstring-convention = "google"
63
+ max-line-length = 80
64
+ extend-ignore = ["E203", "E501", "W503"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env python
2
+
3
+ import setuptools
4
+
5
+ if __name__ == "__main__":
6
+ setuptools.setup(use_scm_version=True)
@@ -0,0 +1,44 @@
1
+ """Tests for :mod:`exdrf_pd.loader` optional / PEP 604 union annotations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from pydantic import BaseModel
8
+
9
+ from exdrf.field_types.str_field import StrField
10
+ from exdrf.resource import ExResource
11
+ from exdrf_pd.loader import field_from_pydantic
12
+
13
+
14
+ class TestFieldFromPydanticOptional:
15
+ """Covers ``str | None`` and ``Optional[str]`` field annotations."""
16
+
17
+ def test_pep604_str_or_none_produces_str_field(self) -> None:
18
+ """``str | None`` unwraps to :class:`StrField` instead of failing."""
19
+
20
+ class Sample(BaseModel):
21
+ """Minimal model with a PEP 604 optional string."""
22
+
23
+ label: str | None = None
24
+
25
+ resource = ExResource(name="Sample", src=Sample)
26
+ info = Sample.model_fields["label"]
27
+ field_from_pydantic(resource, "label", info)
28
+ assert len(resource.fields) == 1
29
+ assert isinstance(resource.fields[0], StrField)
30
+ assert resource.fields[0].name == "label"
31
+
32
+ def test_optional_str_produces_str_field(self) -> None:
33
+ """``Optional[str]`` is handled like ``str | None``."""
34
+
35
+ class Sample(BaseModel):
36
+ """Minimal model with ``typing.Optional``."""
37
+
38
+ label: Optional[str] = None
39
+
40
+ resource = ExResource(name="Sample", src=Sample)
41
+ info = Sample.model_fields["label"]
42
+ field_from_pydantic(resource, "label", info)
43
+ assert len(resource.fields) == 1
44
+ assert isinstance(resource.fields[0], StrField)
@@ -0,0 +1,77 @@
1
+ """Tests for ``exdrf_pd.model_import``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import types
7
+
8
+ import pytest
9
+
10
+ from exdrf_pd import model_import
11
+
12
+
13
+ def test_load_pydantic_modules_skips_empty_and_imports(
14
+ monkeypatch: pytest.MonkeyPatch,
15
+ ) -> None:
16
+ """Whitespace-only entries are skipped; valid modules are imported."""
17
+
18
+ created: list[str] = []
19
+
20
+ def fake_import_module(name: str) -> types.ModuleType:
21
+ created.append(name)
22
+ mod = types.ModuleType(name)
23
+ sys.modules[name] = mod
24
+ return mod
25
+
26
+ monkeypatch.setattr(model_import.importlib, "import_module", fake_import_module)
27
+ result = model_import.load_pydantic_modules([" ", "a.b", " c.d ", ""])
28
+ assert result == ["a.b", "c.d"]
29
+ assert created == ["a.b", "c.d"]
30
+ for name in created:
31
+ sys.modules.pop(name, None)
32
+
33
+
34
+ def test_load_pydantic_modules_from_env_prefers_exdrf_var(
35
+ monkeypatch: pytest.MonkeyPatch,
36
+ ) -> None:
37
+ """``EXDRF_PYDANTIC_MODELS_MODULES`` wins when set."""
38
+
39
+ monkeypatch.delenv("EXDRF_PYDANTIC_MODELS_MODULES", raising=False)
40
+ monkeypatch.delenv("RESI_PYDANTIC_MODELS_MODULES", raising=False)
41
+ monkeypatch.setenv("EXDRF_PYDANTIC_MODELS_MODULES", "mod_a")
42
+ monkeypatch.setenv("RESI_PYDANTIC_MODELS_MODULES", "mod_b")
43
+
44
+ imported: list[str] = []
45
+
46
+ def fake_import(name: str) -> types.ModuleType:
47
+ imported.append(name)
48
+ m = types.ModuleType(name)
49
+ sys.modules[name] = m
50
+ return m
51
+
52
+ monkeypatch.setattr(model_import.importlib, "import_module", fake_import)
53
+ model_import.load_pydantic_modules_from_env()
54
+ assert imported == ["mod_a"]
55
+ sys.modules.pop("mod_a", None)
56
+
57
+
58
+ def test_load_pydantic_modules_from_env_resi_fallback(
59
+ monkeypatch: pytest.MonkeyPatch,
60
+ ) -> None:
61
+ """When ``EXDRF_*`` is empty, ``RESI_*`` is used."""
62
+
63
+ monkeypatch.delenv("EXDRF_PYDANTIC_MODELS_MODULES", raising=False)
64
+ monkeypatch.setenv("RESI_PYDANTIC_MODELS_MODULES", "legacy_mod")
65
+
66
+ imported: list[str] = []
67
+
68
+ def fake_import(name: str) -> types.ModuleType:
69
+ imported.append(name)
70
+ m = types.ModuleType(name)
71
+ sys.modules[name] = m
72
+ return m
73
+
74
+ monkeypatch.setattr(model_import.importlib, "import_module", fake_import)
75
+ model_import.load_pydantic_modules_from_env()
76
+ assert imported == ["legacy_mod"]
77
+ sys.modules.pop("legacy_mod", None)
@@ -0,0 +1,86 @@
1
+ """Tests for ``exdrf_pd.paged`` and ``exdrf_pd.schema_extra`` primitives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+ from pydantic import BaseModel, ValidationError
7
+
8
+ from exdrf_pd.paged import PagedList, paged_list_empty_factory
9
+ from exdrf_pd.schema_extra import (
10
+ EXDRF_JSON_SCHEMA_EXTRA_KEY,
11
+ wrap_exdrf_props,
12
+ )
13
+
14
+
15
+ class TestPagedList:
16
+ """Covers validation, defaults, and ``extra="forbid"`` for paging models."""
17
+
18
+ def test_round_trip_simple_items(self) -> None:
19
+ """A populated page validates and preserves counters."""
20
+
21
+ page = PagedList[int](
22
+ total=100,
23
+ offset=10,
24
+ page_size=20,
25
+ items=[1, 2],
26
+ )
27
+ assert page.total == 100
28
+ assert page.offset == 10
29
+ assert page.page_size == 20
30
+ assert page.items == [1, 2]
31
+
32
+ def test_empty_classmethod(self) -> None:
33
+ """``empty()`` yields a zeroed envelope with no items."""
34
+
35
+ page = PagedList[int].empty()
36
+ assert page.total == 0
37
+ assert page.offset == 0
38
+ assert page.page_size == 0
39
+ assert page.items == []
40
+
41
+ def test_paged_list_empty_factory_matches_empty(self) -> None:
42
+ """The forward-ref-safe factory matches ``PagedList[Any].empty()``."""
43
+
44
+ from_any = paged_list_empty_factory()
45
+ typed_empty = PagedList[int].empty()
46
+ assert from_any.model_dump() == typed_empty.model_dump()
47
+
48
+ def test_rejects_extra_keys(self) -> None:
49
+ """Unknown fields are rejected."""
50
+
51
+ with pytest.raises(ValidationError):
52
+ PagedList[int].model_validate(
53
+ {
54
+ "total": 0,
55
+ "offset": 0,
56
+ "page_size": 0,
57
+ "items": [],
58
+ "unexpected": True,
59
+ }
60
+ )
61
+
62
+ def test_json_schema_includes_core_properties(self) -> None:
63
+ """OpenAPI-oriented schema lists the stable paging field names."""
64
+
65
+ class Item(BaseModel):
66
+ """Minimal nested item."""
67
+
68
+ id: int
69
+
70
+ schema = PagedList[Item].model_json_schema()
71
+ props = set(schema.get("properties", {}))
72
+ assert props == {"total", "offset", "page_size", "items"}
73
+
74
+
75
+ class TestWrapExdrfProps:
76
+ """Tests for :func:`wrap_exdrf_props`."""
77
+
78
+ def test_nests_under_exdrf_key(self) -> None:
79
+ """Props are wrapped under :data:`EXDRF_JSON_SCHEMA_EXTRA_KEY`."""
80
+
81
+ wrapped = wrap_exdrf_props({"kind": "list", "resource": "towns"})
82
+ assert set(wrapped.keys()) == {EXDRF_JSON_SCHEMA_EXTRA_KEY}
83
+ assert wrapped[EXDRF_JSON_SCHEMA_EXTRA_KEY] == {
84
+ "kind": "list",
85
+ "resource": "towns",
86
+ }
@@ -0,0 +1,49 @@
1
+ """Tests for :class:`~exdrf_pd.sort_item.SortItem`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import pytest
8
+ from pydantic import TypeAdapter, ValidationError
9
+
10
+ from exdrf_pd.sort_item import SortItem
11
+
12
+
13
+ def test_package_exports_sort_item() -> None:
14
+ """``from exdrf_pd import SortItem`` re-exports the model."""
15
+
16
+ from exdrf_pd import SortItem as SortItemFromRoot
17
+
18
+ assert SortItemFromRoot is SortItem
19
+
20
+
21
+ class TestSortItem:
22
+ """Validation and JSON round-trip for :class:`SortItem`."""
23
+
24
+ def test_accepts_asc_desc(self) -> None:
25
+ """Both literal orders validate."""
26
+
27
+ assert SortItem(attr="id", order="asc").order == "asc"
28
+ assert SortItem(attr="id", order="desc").order == "desc"
29
+
30
+ def test_rejects_invalid_order(self) -> None:
31
+ """Unknown direction raises validation error."""
32
+
33
+ with pytest.raises(ValidationError):
34
+ SortItem(attr="x", order="up") # type: ignore[arg-type]
35
+
36
+ def test_json_roundtrip_list(self) -> None:
37
+ """``TypeAdapter`` parses the JSON list shape used in query strings."""
38
+
39
+ raw = json.dumps(
40
+ [
41
+ {"attr": "name", "order": "asc"},
42
+ {"attr": "id", "order": "desc"},
43
+ ],
44
+ )
45
+ adapter = TypeAdapter(list[SortItem])
46
+ items = adapter.validate_json(raw.encode("utf-8"))
47
+ assert len(items) == 2
48
+ assert items[0].attr == "name"
49
+ assert items[1].order == "desc"