pydantic-views 0.1.0b0__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.
- pydantic_views-0.1.0b0/LICENSE +21 -0
- pydantic_views-0.1.0b0/PKG-INFO +18 -0
- pydantic_views-0.1.0b0/README.md +3 -0
- pydantic_views-0.1.0b0/pyproject.toml +102 -0
- pydantic_views-0.1.0b0/src/pydantic_views/__init__.py +0 -0
- pydantic_views-0.1.0b0/src/pydantic_views/annotations.py +40 -0
- pydantic_views-0.1.0b0/src/pydantic_views/builder.py +336 -0
- pydantic_views-0.1.0b0/src/pydantic_views/manager.py +36 -0
- pydantic_views-0.1.0b0/src/pydantic_views/py.typed +0 -0
- pydantic_views-0.1.0b0/src/pydantic_views/view.py +92 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Alfred Santacatalina Gea
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: pydantic-views
|
|
3
|
+
Version: 0.1.0b0
|
|
4
|
+
Summary: View for Pydantic models
|
|
5
|
+
License: LGPL-3.0-or-later
|
|
6
|
+
Author: Alfred Santacatalina
|
|
7
|
+
Author-email: alfred.santacatalinagea@telefonica.com
|
|
8
|
+
Requires-Python: >=3.13,<4.0.0
|
|
9
|
+
Classifier: License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Requires-Dist: pydantic (>=2.10.6,<3.0.0)
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# Views for pydantic models
|
|
16
|
+
|
|
17
|
+
TBD
|
|
18
|
+
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pydantic-views"
|
|
3
|
+
version = "0.1.0b0"
|
|
4
|
+
description = "View for Pydantic models"
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "Alfred Santacatalina",email = "alfred.santacatalinagea@telefonica.com"}
|
|
7
|
+
]
|
|
8
|
+
license = "LGPL-3.0-or-later"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.13,<4.0.0"
|
|
11
|
+
dependencies = ["pydantic (>=2.10.6,<3.0.0)"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
16
|
+
build-backend = "poetry.core.masonry.api"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
[tool.poetry]
|
|
20
|
+
requires-poetry = ">=2.0"
|
|
21
|
+
packages = [{ from = "src", include = "pydantic_views" }]
|
|
22
|
+
|
|
23
|
+
[tool.poetry.group.dev.dependencies]
|
|
24
|
+
flake8 = "^7.1.2"
|
|
25
|
+
pytest-cov = "^6.0.0"
|
|
26
|
+
isort = "^5.13.2"
|
|
27
|
+
absolufy-imports = "^0.3.1"
|
|
28
|
+
ruff = "^0.9.9"
|
|
29
|
+
mypy = "^1.15.0"
|
|
30
|
+
pytest = "^8.3.5"
|
|
31
|
+
flake8-pyproject = "^1.2.3"
|
|
32
|
+
autoflake = "^2.3.1"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
[tool.poetry.group.docs.dependencies]
|
|
36
|
+
sphinx = "^8.2.3"
|
|
37
|
+
autodoc-pydantic = "^2.2.0"
|
|
38
|
+
|
|
39
|
+
[tool.ruff]
|
|
40
|
+
exclude = [".venv/*"]
|
|
41
|
+
|
|
42
|
+
[tool.flake8]
|
|
43
|
+
exclude = [".venv/*"]
|
|
44
|
+
max-line-length = 120
|
|
45
|
+
extend-ignore = "E251"
|
|
46
|
+
|
|
47
|
+
[tool.isort]
|
|
48
|
+
profile = "black"
|
|
49
|
+
src_paths = ["src", "tests"]
|
|
50
|
+
skip_glob = [".venv/*"]
|
|
51
|
+
reverse_relative = true
|
|
52
|
+
split_on_trailing_comma = true
|
|
53
|
+
multi_line_output = 3
|
|
54
|
+
include_trailing_comma = true
|
|
55
|
+
force_grid_wrap = 0
|
|
56
|
+
use_parentheses = true
|
|
57
|
+
ensure_newline_before_comments = true
|
|
58
|
+
|
|
59
|
+
[tool.mypy]
|
|
60
|
+
files = ["src", "tests"]
|
|
61
|
+
exclude = [".venv/.*", "docs/source/.*"]
|
|
62
|
+
disable_error_code="valid-type,import-untyped"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
[tool.pydantic-mypy]
|
|
66
|
+
init_forbid_extra = true
|
|
67
|
+
init_typed = true
|
|
68
|
+
warn_required_dynamic_aliases = true
|
|
69
|
+
warn_untyped_fields = true
|
|
70
|
+
|
|
71
|
+
[tool.coverage.run]
|
|
72
|
+
omit = [".venv/*"]
|
|
73
|
+
branch = true
|
|
74
|
+
relative_files = false
|
|
75
|
+
|
|
76
|
+
[tool.coverage.report]
|
|
77
|
+
# Regexes for lines to exclude from consideration
|
|
78
|
+
exclude_also = [
|
|
79
|
+
# Dont complain about missing debug-only code:
|
|
80
|
+
"def __repr__",
|
|
81
|
+
"if self\\.debug",
|
|
82
|
+
|
|
83
|
+
# Don't complain if tests don't hit defensive assertion code:
|
|
84
|
+
"raise AssertionError",
|
|
85
|
+
"raise NotImplementedError",
|
|
86
|
+
|
|
87
|
+
# Don't complain if non-runnable code isn't run:
|
|
88
|
+
"if 0:",
|
|
89
|
+
"if __name__ == .__main__.:",
|
|
90
|
+
|
|
91
|
+
# Don't complain about abstract methods, they aren't run:
|
|
92
|
+
"@(abc\\.)?abstractmethod",
|
|
93
|
+
|
|
94
|
+
# Don't complain type checking imports, they aren't run:
|
|
95
|
+
"if TYPE_CHECKING",
|
|
96
|
+
|
|
97
|
+
# Don't complain overloads, they aren't run:
|
|
98
|
+
"@overload"
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
[tool.coverage.paths]
|
|
102
|
+
source = ["src/"]
|
|
File without changes
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from enum import Enum, auto
|
|
2
|
+
from typing import Annotated, TypeVar
|
|
3
|
+
|
|
4
|
+
T = TypeVar("T")
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AccessMode(Enum):
|
|
8
|
+
READ_AND_WRITE = auto()
|
|
9
|
+
READ_ONLY = auto()
|
|
10
|
+
WRITE_ONLY = auto()
|
|
11
|
+
READ_ONLY_ON_CREATION = auto()
|
|
12
|
+
WRITE_ONLY_ON_CREATION = auto()
|
|
13
|
+
HIDDEN = auto()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FieldAccess:
|
|
17
|
+
__slots__ = ("_mode",)
|
|
18
|
+
|
|
19
|
+
def __init__(self, mode: AccessMode = AccessMode.READ_AND_WRITE):
|
|
20
|
+
self._mode = mode
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def mode(self) -> AccessMode:
|
|
24
|
+
return self._mode
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
RW = FieldAccess(AccessMode.READ_AND_WRITE)
|
|
28
|
+
RO = FieldAccess(AccessMode.READ_ONLY)
|
|
29
|
+
WO = FieldAccess(AccessMode.WRITE_ONLY)
|
|
30
|
+
ROOC = FieldAccess(AccessMode.READ_ONLY_ON_CREATION)
|
|
31
|
+
WOOC = FieldAccess(AccessMode.WRITE_ONLY_ON_CREATION)
|
|
32
|
+
HIDDEN = FieldAccess(AccessMode.HIDDEN)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
ReadAndWrite = Annotated[T, RW]
|
|
36
|
+
ReadOnly = Annotated[T, RO]
|
|
37
|
+
WriteOnly = Annotated[T, WO]
|
|
38
|
+
ReadOnlyOnCreation = Annotated[T, ROOC]
|
|
39
|
+
WriteOnlyOnCreation = Annotated[T, WOOC]
|
|
40
|
+
Hidden = Annotated[T, HIDDEN]
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
from collections.abc import Iterable, Mapping
|
|
2
|
+
from copy import deepcopy
|
|
3
|
+
from types import NoneType, UnionType
|
|
4
|
+
from typing import Union # type: ignore[deprecated]
|
|
5
|
+
from typing import (
|
|
6
|
+
Any,
|
|
7
|
+
ForwardRef,
|
|
8
|
+
cast,
|
|
9
|
+
get_args,
|
|
10
|
+
get_origin,
|
|
11
|
+
)
|
|
12
|
+
from weakref import ref
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, RootModel, create_model
|
|
15
|
+
from pydantic.fields import ComputedFieldInfo, FieldInfo
|
|
16
|
+
from pydantic_core import PydanticUndefined
|
|
17
|
+
|
|
18
|
+
from .annotations import AccessMode, FieldAccess
|
|
19
|
+
from .manager import Manager
|
|
20
|
+
from .view import RootView, View
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Builder:
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
suffix: str,
|
|
27
|
+
access_modes: tuple[AccessMode, ...],
|
|
28
|
+
all_optional: bool = False,
|
|
29
|
+
all_nullable: bool = False,
|
|
30
|
+
hide_default_null: bool = False,
|
|
31
|
+
include_computed_fields: bool = False,
|
|
32
|
+
) -> None:
|
|
33
|
+
self.suffix = suffix
|
|
34
|
+
self.access_modes = access_modes
|
|
35
|
+
self.all_optional = all_optional
|
|
36
|
+
self.all_nullable = all_nullable
|
|
37
|
+
self.hide_default_null = hide_default_null
|
|
38
|
+
self.include_computed_fields = include_computed_fields
|
|
39
|
+
self._views: dict[type[BaseModel], type[View[BaseModel]] | ForwardRef] = {}
|
|
40
|
+
|
|
41
|
+
def build_view[T: BaseModel](self, model: type[T]) -> type[View[T] | T]:
|
|
42
|
+
manager = ensure_model_views(model)
|
|
43
|
+
try:
|
|
44
|
+
result: type[View[T] | T] = manager[self.suffix]
|
|
45
|
+
except (KeyError, TypeError):
|
|
46
|
+
result = manager.build_view(self)
|
|
47
|
+
|
|
48
|
+
[
|
|
49
|
+
v.model_rebuild() # type: ignore
|
|
50
|
+
for v in self._views.values()
|
|
51
|
+
if not isinstance(v, ForwardRef)
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
def get_view_ref[T: BaseModel](
|
|
57
|
+
self, model: type[T]
|
|
58
|
+
) -> type[View[T] | T] | ForwardRef:
|
|
59
|
+
try:
|
|
60
|
+
return cast(type[View[T]] | ForwardRef, self._views[model])
|
|
61
|
+
except KeyError:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
manager = ensure_model_views(model)
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
view: type[View[T] | T] = manager[self.suffix]
|
|
68
|
+
except KeyError:
|
|
69
|
+
view = manager.build_view(self)
|
|
70
|
+
|
|
71
|
+
self._views[model] = cast(type[View[BaseModel]], view)
|
|
72
|
+
|
|
73
|
+
return view
|
|
74
|
+
|
|
75
|
+
def _filter_field(self, f_info: FieldInfo):
|
|
76
|
+
am = {m.mode for m in f_info.metadata if isinstance(m, FieldAccess)}
|
|
77
|
+
return len((am & set(self.access_modes))) == 0 and len(am) > 0
|
|
78
|
+
|
|
79
|
+
def _iter_fields[T: BaseModel](self, model: type[T]):
|
|
80
|
+
for f_name, f_info in model.model_fields.items():
|
|
81
|
+
if self._filter_field(f_info):
|
|
82
|
+
continue
|
|
83
|
+
yield f_name, f_info
|
|
84
|
+
|
|
85
|
+
def _filter_computed_field(self, f_info: ComputedFieldInfo) -> bool:
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
def _iter_computed_fields[T: BaseModel](self, model: type[T]):
|
|
89
|
+
for f_name, cf_info in model.model_computed_fields.items():
|
|
90
|
+
if self._filter_computed_field(
|
|
91
|
+
cf_info
|
|
92
|
+
): # [TODO] does it have sense? # pragma: no cover
|
|
93
|
+
continue
|
|
94
|
+
yield f_name, cf_info
|
|
95
|
+
|
|
96
|
+
def build_from_model[T: BaseModel](self, model: type[T]) -> type[View[T] | T]:
|
|
97
|
+
from pydantic._internal._config import ConfigWrapper
|
|
98
|
+
|
|
99
|
+
view_name = model.__name__ + self.suffix[0].upper() + self.suffix[1:]
|
|
100
|
+
try:
|
|
101
|
+
view_cache = self._views[model]
|
|
102
|
+
if not isinstance(view_cache, ForwardRef):
|
|
103
|
+
return cast(type[View[T] | T], view_cache)
|
|
104
|
+
except KeyError:
|
|
105
|
+
self._views[model] = ForwardRef(view_name, module=model.__module__)
|
|
106
|
+
|
|
107
|
+
view: type[View[T] | T]
|
|
108
|
+
|
|
109
|
+
manager = ensure_model_views(model)
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
view = manager[self.suffix]
|
|
113
|
+
self._views[model] = cast(type[View[BaseModel]], view)
|
|
114
|
+
except KeyError:
|
|
115
|
+
model_fields: dict[str, tuple[type[Any] | None, FieldInfo]] = {}
|
|
116
|
+
for f_name, f_info in self._iter_fields(model):
|
|
117
|
+
model_fields[f_name] = self._map_field_info(
|
|
118
|
+
f_info.annotation,
|
|
119
|
+
f_info,
|
|
120
|
+
ignore_nullable=issubclass(model, RootModel),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if self.include_computed_fields:
|
|
124
|
+
for f_name, cf_info in self._iter_computed_fields(model):
|
|
125
|
+
model_fields[f_name] = self._map_computed_field_info(cf_info)
|
|
126
|
+
|
|
127
|
+
base_view: type[View[T]] | type[RootView[T]]
|
|
128
|
+
|
|
129
|
+
if issubclass(model, RootModel):
|
|
130
|
+
|
|
131
|
+
class _RootView(RootView[T]):
|
|
132
|
+
model_config = deepcopy(model.model_config)
|
|
133
|
+
|
|
134
|
+
__model_class_root__ = ref(cast(type[T], model))
|
|
135
|
+
|
|
136
|
+
base_view = _RootView
|
|
137
|
+
|
|
138
|
+
else:
|
|
139
|
+
|
|
140
|
+
class _View(View[T]):
|
|
141
|
+
model_config = deepcopy(model.model_config)
|
|
142
|
+
|
|
143
|
+
__model_class_root__ = ref(cast(type[T], model))
|
|
144
|
+
|
|
145
|
+
_View.model_config["protected_namespaces"] = tuple(
|
|
146
|
+
{
|
|
147
|
+
*ConfigWrapper(_View.model_config).protected_namespaces,
|
|
148
|
+
*ConfigWrapper(View.model_config).protected_namespaces,
|
|
149
|
+
}
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
base_view = _View
|
|
153
|
+
|
|
154
|
+
params: dict[str, Any] = {
|
|
155
|
+
"__module__": model.__module__,
|
|
156
|
+
"__base__": base_view,
|
|
157
|
+
"__doc__": (
|
|
158
|
+
f"View `{self.suffix}` "
|
|
159
|
+
f"of model :class:`~{model.__module__}.{model.__qualname__}`"
|
|
160
|
+
),
|
|
161
|
+
**model_fields,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
view = cast(
|
|
165
|
+
type[View[T]],
|
|
166
|
+
create_model(
|
|
167
|
+
view_name,
|
|
168
|
+
**params,
|
|
169
|
+
),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
self._views[model] = cast(type[View[BaseModel]], view)
|
|
173
|
+
|
|
174
|
+
return view
|
|
175
|
+
|
|
176
|
+
def _map_field_info(
|
|
177
|
+
self,
|
|
178
|
+
annotation: type[Any] | None,
|
|
179
|
+
f_info: FieldInfo,
|
|
180
|
+
*,
|
|
181
|
+
ignore_nullable: bool = False,
|
|
182
|
+
) -> tuple[type[Any] | None, FieldInfo]:
|
|
183
|
+
f_info = FieldInfo.merge_field_infos(
|
|
184
|
+
f_info,
|
|
185
|
+
annotation=self._map_annotation(
|
|
186
|
+
f_info.annotation, ignore_nullable=ignore_nullable
|
|
187
|
+
),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
if self.all_optional:
|
|
191
|
+
f_info = FieldInfo.merge_field_infos(
|
|
192
|
+
f_info,
|
|
193
|
+
default_factory=lambda: PydanticUndefined,
|
|
194
|
+
)
|
|
195
|
+
f_info.default = PydanticUndefined
|
|
196
|
+
|
|
197
|
+
if self.hide_default_null and f_info.default is None:
|
|
198
|
+
f_info = FieldInfo.merge_field_infos(
|
|
199
|
+
f_info,
|
|
200
|
+
default_factory=lambda: PydanticUndefined,
|
|
201
|
+
)
|
|
202
|
+
f_info.default = PydanticUndefined
|
|
203
|
+
|
|
204
|
+
return f_info.annotation, f_info
|
|
205
|
+
|
|
206
|
+
def _map_annotation(
|
|
207
|
+
self, annotation: type[Any] | None, *, ignore_nullable: bool = False
|
|
208
|
+
) -> type[Any] | ForwardRef | None | UnionType:
|
|
209
|
+
def finish_annotation(a: type[Any] | None) -> type[Any] | None | UnionType:
|
|
210
|
+
if not ignore_nullable and self.all_nullable and a is not Ellipsis: # type: ignore
|
|
211
|
+
return Union[a, None] # type: ignore
|
|
212
|
+
|
|
213
|
+
return a
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
if annotation and issubclass(annotation, BaseModel):
|
|
217
|
+
return finish_annotation(self.get_view_ref(annotation)) # type: ignore
|
|
218
|
+
except TypeError:
|
|
219
|
+
pass
|
|
220
|
+
|
|
221
|
+
origin = get_origin(annotation)
|
|
222
|
+
type_args = get_args(annotation)
|
|
223
|
+
|
|
224
|
+
if origin is None:
|
|
225
|
+
return finish_annotation(annotation)
|
|
226
|
+
|
|
227
|
+
if origin is not Union and not issubclass(origin, UnionType): # type: ignore
|
|
228
|
+
if issubclass(origin, Mapping):
|
|
229
|
+
return finish_annotation(
|
|
230
|
+
origin[ # type: ignore
|
|
231
|
+
self._map_annotation(type_args[0], ignore_nullable=True),
|
|
232
|
+
self._map_annotation(type_args[1]),
|
|
233
|
+
]
|
|
234
|
+
)
|
|
235
|
+
elif issubclass(origin, Iterable):
|
|
236
|
+
return finish_annotation(
|
|
237
|
+
origin[ # type: ignore
|
|
238
|
+
*(
|
|
239
|
+
self._map_annotation(
|
|
240
|
+
t, ignore_nullable=issubclass(origin, (list, set))
|
|
241
|
+
)
|
|
242
|
+
for t in type_args
|
|
243
|
+
)
|
|
244
|
+
]
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
return Union[ # type: ignore
|
|
248
|
+
*(
|
|
249
|
+
self._map_annotation(a)
|
|
250
|
+
for a in type_args
|
|
251
|
+
if a is not NoneType or not self.hide_default_null
|
|
252
|
+
)
|
|
253
|
+
]
|
|
254
|
+
|
|
255
|
+
def _map_computed_field_info(
|
|
256
|
+
self, cf_info: ComputedFieldInfo
|
|
257
|
+
) -> tuple[type[Any] | None, FieldInfo]:
|
|
258
|
+
return (
|
|
259
|
+
cf_info.return_type,
|
|
260
|
+
FieldInfo(
|
|
261
|
+
annotation=cf_info.return_type,
|
|
262
|
+
alias=cf_info.alias,
|
|
263
|
+
default=None,
|
|
264
|
+
alias_priority=cf_info.alias_priority,
|
|
265
|
+
serialization_alias=cf_info.title,
|
|
266
|
+
title=cf_info.title,
|
|
267
|
+
description=cf_info.title,
|
|
268
|
+
examples=cf_info.examples,
|
|
269
|
+
discriminator=cf_info.title,
|
|
270
|
+
deprecated=cf_info.title,
|
|
271
|
+
json_schema_extra=cf_info.json_schema_extra,
|
|
272
|
+
repr=cf_info.repr,
|
|
273
|
+
),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def BuilderCreate(suffix: str = "Create") -> Builder:
|
|
278
|
+
return Builder(
|
|
279
|
+
suffix,
|
|
280
|
+
access_modes=(
|
|
281
|
+
AccessMode.READ_AND_WRITE,
|
|
282
|
+
AccessMode.WRITE_ONLY,
|
|
283
|
+
AccessMode.WRITE_ONLY_ON_CREATION,
|
|
284
|
+
),
|
|
285
|
+
hide_default_null=True,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def BuilderCreateResult(suffix: str = "CreateResult") -> Builder:
|
|
290
|
+
return Builder(
|
|
291
|
+
suffix,
|
|
292
|
+
access_modes=(
|
|
293
|
+
AccessMode.READ_AND_WRITE,
|
|
294
|
+
AccessMode.READ_ONLY,
|
|
295
|
+
AccessMode.READ_ONLY_ON_CREATION,
|
|
296
|
+
),
|
|
297
|
+
include_computed_fields=True,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def BuilderUpdate(suffix: str = "Update") -> Builder:
|
|
302
|
+
return Builder(
|
|
303
|
+
suffix,
|
|
304
|
+
access_modes=(AccessMode.READ_AND_WRITE, AccessMode.WRITE_ONLY),
|
|
305
|
+
all_optional=True,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def BuilderLoad(suffix: str = "Load") -> Builder:
|
|
310
|
+
return Builder(
|
|
311
|
+
suffix,
|
|
312
|
+
access_modes=(
|
|
313
|
+
AccessMode.READ_AND_WRITE,
|
|
314
|
+
AccessMode.READ_ONLY,
|
|
315
|
+
),
|
|
316
|
+
include_computed_fields=True,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def ensure_model_views[T: BaseModel](model: type[T]):
|
|
321
|
+
try:
|
|
322
|
+
if (
|
|
323
|
+
manager := cast(Manager[T], getattr(model, "model_views"))
|
|
324
|
+
) and manager.model == model:
|
|
325
|
+
return manager
|
|
326
|
+
except AttributeError:
|
|
327
|
+
pass
|
|
328
|
+
|
|
329
|
+
manager = Manager(model)
|
|
330
|
+
setattr(
|
|
331
|
+
model,
|
|
332
|
+
"model_views",
|
|
333
|
+
manager,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
return manager
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING
|
|
2
|
+
from weakref import ref
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
from .view import View
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from .builder import Builder
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Manager[TModel: BaseModel]:
|
|
13
|
+
__slots__ = ("_model", "_views")
|
|
14
|
+
|
|
15
|
+
def __init__(self, model: type[TModel]):
|
|
16
|
+
self._model = ref(model)
|
|
17
|
+
self._views: dict[str, type["View[TModel] | TModel"]] = {}
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def model(self) -> type[TModel]:
|
|
21
|
+
result = self._model()
|
|
22
|
+
if not result: # pragma: no cover
|
|
23
|
+
raise RuntimeError("Model class disappeared")
|
|
24
|
+
return result
|
|
25
|
+
|
|
26
|
+
def __getitem__(self, view_name: str) -> type["View[TModel] | TModel"]:
|
|
27
|
+
return self._views[view_name]
|
|
28
|
+
|
|
29
|
+
def __setitem__(self, view_name: str, view: type["View[TModel] | TModel"]):
|
|
30
|
+
self._views[view_name] = view
|
|
31
|
+
|
|
32
|
+
def build_view(self, builder: "Builder") -> type["View[TModel] | TModel"]:
|
|
33
|
+
view = builder.build_from_model(self.model)
|
|
34
|
+
self[builder.suffix] = view
|
|
35
|
+
|
|
36
|
+
return view
|
|
File without changes
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
from typing import Any, ClassVar, cast, overload
|
|
3
|
+
from weakref import ReferenceType
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, RootModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class View[T: BaseModel](BaseModel):
|
|
9
|
+
__model_class_root__: ClassVar[ReferenceType[type[BaseModel]]]
|
|
10
|
+
|
|
11
|
+
model_config = {
|
|
12
|
+
"protected_namespaces": ("view_class_root", "view_build", "view_apply")
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def view_class_root(cls) -> type[T]:
|
|
17
|
+
root = cls.__model_class_root__()
|
|
18
|
+
|
|
19
|
+
if root is None: # pragma: no cover
|
|
20
|
+
raise RuntimeError("Root model disappeared")
|
|
21
|
+
|
|
22
|
+
return cast(type[T], root)
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def view_build_from(cls, model: T):
|
|
26
|
+
return cls.model_validate(model.model_dump(exclude_unset=True, by_alias=True))
|
|
27
|
+
|
|
28
|
+
def view_build_to(self) -> T:
|
|
29
|
+
return self.view_class_root().model_validate(
|
|
30
|
+
self.model_dump(exclude_unset=True, exclude_defaults=True, by_alias=True)
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def view_apply_to(self, model: T) -> T:
|
|
34
|
+
return model_apply(model, self)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RootView[R](View[RootModel[R]], RootModel[R]):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def model_apply[T: BaseModel](orig: T, view: View[T] | T) -> T:
|
|
42
|
+
update_data: dict[str, Any] = {}
|
|
43
|
+
|
|
44
|
+
for field in view.model_fields_set:
|
|
45
|
+
value = _merge_values(
|
|
46
|
+
getattr(orig, field),
|
|
47
|
+
getattr(view, field),
|
|
48
|
+
)
|
|
49
|
+
update_data[field] = getattr(
|
|
50
|
+
orig.__pydantic_validator__.validate_assignment(orig, field, value), field
|
|
51
|
+
)
|
|
52
|
+
return orig.model_copy(
|
|
53
|
+
update=update_data,
|
|
54
|
+
deep=True,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@overload
|
|
59
|
+
def _merge_values[T: BaseModel](
|
|
60
|
+
orig_value: None, new_value: T
|
|
61
|
+
) -> T | dict[str, Any]: ...
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@overload
|
|
65
|
+
def _merge_values[T: BaseModel, A](orig_value: None, new_value: A) -> A: ...
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@overload
|
|
69
|
+
def _merge_values[T: BaseModel, A](
|
|
70
|
+
orig_value: T, new_value: View[T]
|
|
71
|
+
) -> T | dict[str, Any]: ...
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _merge_values[T: BaseModel, A](
|
|
75
|
+
orig_value: T | None, new_value: View[T] | A
|
|
76
|
+
) -> T | A | dict[str, Any]:
|
|
77
|
+
if isinstance(new_value, BaseModel):
|
|
78
|
+
if isinstance(orig_value, BaseModel):
|
|
79
|
+
return cast(T, model_apply(orig_value, new_value))
|
|
80
|
+
if isinstance(new_value, View):
|
|
81
|
+
return cast(View[T], new_value).view_build_to()
|
|
82
|
+
return new_value.model_dump(
|
|
83
|
+
exclude_unset=True, exclude_defaults=True, by_alias=True
|
|
84
|
+
)
|
|
85
|
+
elif isinstance(new_value, Mapping) and isinstance(orig_value, Mapping):
|
|
86
|
+
data: dict[str, Any] = dict(cast(Mapping[str, Any], orig_value))
|
|
87
|
+
for k, v in cast(Mapping[str, Any], new_value).items():
|
|
88
|
+
data[k] = _merge_values(data.get(k), v)
|
|
89
|
+
|
|
90
|
+
return cast(T, cast(Mapping[str, Any], orig_value).__class__(**data))
|
|
91
|
+
else:
|
|
92
|
+
return cast(T, new_value)
|