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.
- exdrf_pd-0.1.17/PKG-INFO +60 -0
- exdrf_pd-0.1.17/README.md +30 -0
- exdrf_pd-0.1.17/exdrf_pd/__init__.py +18 -0
- exdrf_pd-0.1.17/exdrf_pd/__version__.py +24 -0
- exdrf_pd-0.1.17/exdrf_pd/base.py +58 -0
- exdrf_pd-0.1.17/exdrf_pd/filter_item.py +41 -0
- exdrf_pd-0.1.17/exdrf_pd/loader.py +426 -0
- exdrf_pd-0.1.17/exdrf_pd/model_import.py +50 -0
- exdrf_pd-0.1.17/exdrf_pd/paged.py +73 -0
- exdrf_pd-0.1.17/exdrf_pd/py.typed +0 -0
- exdrf_pd-0.1.17/exdrf_pd/schema_extra.py +22 -0
- exdrf_pd-0.1.17/exdrf_pd/sort_item.py +27 -0
- exdrf_pd-0.1.17/exdrf_pd/visitor.py +55 -0
- exdrf_pd-0.1.17/exdrf_pd.egg-info/PKG-INFO +60 -0
- exdrf_pd-0.1.17/exdrf_pd.egg-info/SOURCES.txt +24 -0
- exdrf_pd-0.1.17/exdrf_pd.egg-info/dependency_links.txt +1 -0
- exdrf_pd-0.1.17/exdrf_pd.egg-info/requires.txt +18 -0
- exdrf_pd-0.1.17/exdrf_pd.egg-info/top_level.txt +4 -0
- exdrf_pd-0.1.17/exdrf_pd_tests/__init__.py +0 -0
- exdrf_pd-0.1.17/pyproject.toml +64 -0
- exdrf_pd-0.1.17/setup.cfg +4 -0
- exdrf_pd-0.1.17/setup.py +6 -0
- exdrf_pd-0.1.17/tests/loader_optional_field_test.py +44 -0
- exdrf_pd-0.1.17/tests/model_import_test.py +77 -0
- exdrf_pd-0.1.17/tests/paged_schema_extra_test.py +86 -0
- exdrf_pd-0.1.17/tests/sort_item_test.py +49 -0
exdrf_pd-0.1.17/PKG-INFO
ADDED
|
@@ -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 @@
|
|
|
1
|
+
|
|
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"]
|
exdrf_pd-0.1.17/setup.py
ADDED
|
@@ -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"
|