pydantic-views 0.1.0__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.0/LICENSE +21 -0
- pydantic_views-0.1.0/PKG-INFO +210 -0
- pydantic_views-0.1.0/README.rst +188 -0
- pydantic_views-0.1.0/pyproject.toml +117 -0
- pydantic_views-0.1.0/src/pydantic_views/__init__.py +38 -0
- pydantic_views-0.1.0/src/pydantic_views/annotations.py +47 -0
- pydantic_views-0.1.0/src/pydantic_views/builder.py +410 -0
- pydantic_views-0.1.0/src/pydantic_views/manager.py +64 -0
- pydantic_views-0.1.0/src/pydantic_views/py.typed +0 -0
- pydantic_views-0.1.0/src/pydantic_views/view.py +132 -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,210 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: pydantic-views
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Views for Pydantic models
|
|
5
|
+
Home-page: https://pydantic-views.readthedocs.io/latest/
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: view,pydantic,datamodel,model,REST API
|
|
8
|
+
Author: Alfred Santacatalina
|
|
9
|
+
Author-email: alfred.santacatalinagea@telefonica.com
|
|
10
|
+
Requires-Python: >=3.13,<4.0.0
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Framework :: Pydantic
|
|
13
|
+
Classifier: Framework :: Pydantic :: 2
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Dist: pydantic (>=2.10.6,<3.0.0)
|
|
18
|
+
Project-URL: Documentation, https://pydantic-views.readthedocs.io/latest/
|
|
19
|
+
Project-URL: Issues, https://github.com/alfred82santa/pydantic-views/issues
|
|
20
|
+
Project-URL: Repository, https://github.com/alfred82santa/pydantic-views.git
|
|
21
|
+
Description-Content-Type: text/x-rst
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
.. |docs| image:: https://readthedocs.org/projects/pydantic-views/badge/?version=latest
|
|
25
|
+
:alt: Documentation Status
|
|
26
|
+
:target: https://pydantic-views.readthedocs.io/latest/?badge=latest
|
|
27
|
+
|
|
28
|
+
.. |python-versions| image:: https://img.shields.io/pypi/pyversions/pydantic-views
|
|
29
|
+
:alt: PyPI - Python Version
|
|
30
|
+
|
|
31
|
+
.. |typed| image:: https://img.shields.io/pypi/types/pydantic-views
|
|
32
|
+
:alt: PyPI - Types
|
|
33
|
+
|
|
34
|
+
.. |license| image:: https://img.shields.io/pypi/l/pydantic-views
|
|
35
|
+
:alt: PyPI - License
|
|
36
|
+
|
|
37
|
+
.. |version| image:: https://img.shields.io/pypi/v/pydantic-views
|
|
38
|
+
:alt: PyPI - Version
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|docs| |python-versions| |typed| |license| |version|
|
|
42
|
+
|
|
43
|
+
.. start-doc
|
|
44
|
+
|
|
45
|
+
======================================
|
|
46
|
+
View for Pydantic models documentation
|
|
47
|
+
======================================
|
|
48
|
+
|
|
49
|
+
This package provides a simple way to create `views` from `pydantic <https://docs.pydantic.dev/latest/>`_ models. A view is
|
|
50
|
+
another `pydantic <https://docs.pydantic.dev/latest/>`_ models with some of field of original model. So, for example,
|
|
51
|
+
read only fields does not appears on `Create` or `Update` views.
|
|
52
|
+
|
|
53
|
+
As rest service definition you could do:
|
|
54
|
+
|
|
55
|
+
.. code-block:: python
|
|
56
|
+
|
|
57
|
+
ExampleModelCreate = BuilderCreate().build_view(ExampleModel)
|
|
58
|
+
ExampleModelCreateResult = BuilderCreateResult().build_view(ExampleModel)
|
|
59
|
+
ExampleModelLoad = BuilderLoad().build_view(ExampleModel)
|
|
60
|
+
ExampleModelUpdate = BuilderUpdate().build_view(ExampleModel)
|
|
61
|
+
|
|
62
|
+
def create(input: ExampleModelCreate) -> ExampleModelCreateResult: ...
|
|
63
|
+
def load(model_id: str) -> ExampleModelLoad: ...
|
|
64
|
+
def update(model_id: str, input: ExampleModelUpdate) -> ExampleModelLoad: ...
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
--------
|
|
68
|
+
Features
|
|
69
|
+
--------
|
|
70
|
+
|
|
71
|
+
- Unlimited views per model.
|
|
72
|
+
- Create view for referenced inner models.
|
|
73
|
+
- It is possible to set a view manually.
|
|
74
|
+
- Tested code.
|
|
75
|
+
- Full typed.
|
|
76
|
+
- Opensource.
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
------------
|
|
80
|
+
Installation
|
|
81
|
+
------------
|
|
82
|
+
|
|
83
|
+
Using pip:
|
|
84
|
+
|
|
85
|
+
.. code-block:: bash
|
|
86
|
+
|
|
87
|
+
pip install pydantic-views
|
|
88
|
+
|
|
89
|
+
Using `poetry <https://python-poetry.org/>`_:
|
|
90
|
+
|
|
91
|
+
.. code-block:: bash
|
|
92
|
+
|
|
93
|
+
poetry add pydantic-views
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
----------
|
|
97
|
+
How to use
|
|
98
|
+
----------
|
|
99
|
+
|
|
100
|
+
When you define a pydantic model you must mark the access model for each field. It means
|
|
101
|
+
you should use our `annotations <https://pydantic-views.readthedocs.io/latest/api.html#field-annotations>`_ to define field typing.
|
|
102
|
+
|
|
103
|
+
.. code-block:: python
|
|
104
|
+
|
|
105
|
+
from typing import Annotated
|
|
106
|
+
from pydantic import BaseModel, gt
|
|
107
|
+
from pydantic_views import ReadOnly, ReadOnlyOnCreation, Hidden, AccessMode
|
|
108
|
+
|
|
109
|
+
class ExampleModel(BaseModel):
|
|
110
|
+
|
|
111
|
+
# No marked fields are treated like ReadAndWrite fields.
|
|
112
|
+
field_str: str
|
|
113
|
+
|
|
114
|
+
# Read only fields are removed on view for create and update views.
|
|
115
|
+
field_read_only_str: ReadOnly[str]
|
|
116
|
+
|
|
117
|
+
# Read only on creation fields are removed on view for create, update and load views.
|
|
118
|
+
# But it is shown on create result view.
|
|
119
|
+
field_api_secret: ReadOnlyOnCreation[str]
|
|
120
|
+
|
|
121
|
+
# It is possible to set more than one access mode and to use annotation standard pattern.
|
|
122
|
+
field_int: Annotated[int, AccessMode.READ_ONLY, AccessMode.WRITE_ONLY_ON_CREATION, gt(5)]
|
|
123
|
+
|
|
124
|
+
# Hidden field do not appears in any view.
|
|
125
|
+
field_hidden_int: Hidden[int]
|
|
126
|
+
|
|
127
|
+
# Computed fields only appears on reading views.
|
|
128
|
+
@computed_field
|
|
129
|
+
def field_computed_field(self) -> int:
|
|
130
|
+
return self.field_hidden_int * 5
|
|
131
|
+
|
|
132
|
+
So, in order to build a `Load` view it is so simple:
|
|
133
|
+
|
|
134
|
+
.. code-block:: python
|
|
135
|
+
|
|
136
|
+
from pydantic_views import BuilderLoad
|
|
137
|
+
|
|
138
|
+
ExampleModelLoad = BuilderLoad().build_view(ExampleModel)
|
|
139
|
+
|
|
140
|
+
It is equivalent to:
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
.. code-block:: python
|
|
144
|
+
|
|
145
|
+
from pydantic import gt
|
|
146
|
+
from pydantic_views import View
|
|
147
|
+
|
|
148
|
+
class ExampleModelLoad(View[ExampleModel]):
|
|
149
|
+
field_str: str
|
|
150
|
+
field_int: Annotated[int, gt(5)]
|
|
151
|
+
field_computed_field: int
|
|
152
|
+
|
|
153
|
+
In same way to build a `Update` view you must do:
|
|
154
|
+
|
|
155
|
+
.. code-block:: python
|
|
156
|
+
|
|
157
|
+
from pydantic_views import BuilderUpdate
|
|
158
|
+
|
|
159
|
+
ExampleModelUpdate = BuilderUpdate().build_view(ExampleModel)
|
|
160
|
+
|
|
161
|
+
It is equivalent to:
|
|
162
|
+
|
|
163
|
+
.. code-block:: python
|
|
164
|
+
|
|
165
|
+
from pydantic import PydanticUndefined
|
|
166
|
+
from pydantic_views import View
|
|
167
|
+
|
|
168
|
+
class ExampleModelUpdate(View[ExampleModel]):
|
|
169
|
+
field_str: str = Field(default_factory=lambda: PydanticUndefined)
|
|
170
|
+
|
|
171
|
+
As you can see, on `Update` view all fields has a default factory returning `PydanticUndefined`
|
|
172
|
+
in order to make them optionals. And when an update view is applied to a given model, the fields that are
|
|
173
|
+
not set (use default value) will not be applied to the model.
|
|
174
|
+
|
|
175
|
+
.. code-block:: python
|
|
176
|
+
|
|
177
|
+
original_model = ExampleModel(
|
|
178
|
+
field_str="anything"
|
|
179
|
+
field_read_only_str="anything"
|
|
180
|
+
field_api_secret="anything"
|
|
181
|
+
field_int=10
|
|
182
|
+
field_hidden_int=33
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
update = ExampleModelUpdate(field_str="new_data")
|
|
186
|
+
|
|
187
|
+
updated_model = update.view_apply_to(original_model)
|
|
188
|
+
|
|
189
|
+
assert isinstance(updated_model, ExampleModel)
|
|
190
|
+
assert updated_model.field_str == "new_data"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
But if a field is not set on update view, the original value is kept.
|
|
194
|
+
|
|
195
|
+
.. code-block:: python
|
|
196
|
+
|
|
197
|
+
original_model = ExampleModel(
|
|
198
|
+
field_str="anything"
|
|
199
|
+
field_read_only_str="anything"
|
|
200
|
+
field_api_secret="anything"
|
|
201
|
+
field_int=10
|
|
202
|
+
field_hidden_int=33
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
update = ExampleModelUpdate()
|
|
206
|
+
|
|
207
|
+
updated_model = update.view_apply_to(original_model)
|
|
208
|
+
|
|
209
|
+
assert isinstance(updated_model, ExampleModel)
|
|
210
|
+
assert updated_model.field_str == "anything"
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
|
|
2
|
+
.. |docs| image:: https://readthedocs.org/projects/pydantic-views/badge/?version=latest
|
|
3
|
+
:alt: Documentation Status
|
|
4
|
+
:target: https://pydantic-views.readthedocs.io/latest/?badge=latest
|
|
5
|
+
|
|
6
|
+
.. |python-versions| image:: https://img.shields.io/pypi/pyversions/pydantic-views
|
|
7
|
+
:alt: PyPI - Python Version
|
|
8
|
+
|
|
9
|
+
.. |typed| image:: https://img.shields.io/pypi/types/pydantic-views
|
|
10
|
+
:alt: PyPI - Types
|
|
11
|
+
|
|
12
|
+
.. |license| image:: https://img.shields.io/pypi/l/pydantic-views
|
|
13
|
+
:alt: PyPI - License
|
|
14
|
+
|
|
15
|
+
.. |version| image:: https://img.shields.io/pypi/v/pydantic-views
|
|
16
|
+
:alt: PyPI - Version
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|docs| |python-versions| |typed| |license| |version|
|
|
20
|
+
|
|
21
|
+
.. start-doc
|
|
22
|
+
|
|
23
|
+
======================================
|
|
24
|
+
View for Pydantic models documentation
|
|
25
|
+
======================================
|
|
26
|
+
|
|
27
|
+
This package provides a simple way to create `views` from `pydantic <https://docs.pydantic.dev/latest/>`_ models. A view is
|
|
28
|
+
another `pydantic <https://docs.pydantic.dev/latest/>`_ models with some of field of original model. So, for example,
|
|
29
|
+
read only fields does not appears on `Create` or `Update` views.
|
|
30
|
+
|
|
31
|
+
As rest service definition you could do:
|
|
32
|
+
|
|
33
|
+
.. code-block:: python
|
|
34
|
+
|
|
35
|
+
ExampleModelCreate = BuilderCreate().build_view(ExampleModel)
|
|
36
|
+
ExampleModelCreateResult = BuilderCreateResult().build_view(ExampleModel)
|
|
37
|
+
ExampleModelLoad = BuilderLoad().build_view(ExampleModel)
|
|
38
|
+
ExampleModelUpdate = BuilderUpdate().build_view(ExampleModel)
|
|
39
|
+
|
|
40
|
+
def create(input: ExampleModelCreate) -> ExampleModelCreateResult: ...
|
|
41
|
+
def load(model_id: str) -> ExampleModelLoad: ...
|
|
42
|
+
def update(model_id: str, input: ExampleModelUpdate) -> ExampleModelLoad: ...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
--------
|
|
46
|
+
Features
|
|
47
|
+
--------
|
|
48
|
+
|
|
49
|
+
- Unlimited views per model.
|
|
50
|
+
- Create view for referenced inner models.
|
|
51
|
+
- It is possible to set a view manually.
|
|
52
|
+
- Tested code.
|
|
53
|
+
- Full typed.
|
|
54
|
+
- Opensource.
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
------------
|
|
58
|
+
Installation
|
|
59
|
+
------------
|
|
60
|
+
|
|
61
|
+
Using pip:
|
|
62
|
+
|
|
63
|
+
.. code-block:: bash
|
|
64
|
+
|
|
65
|
+
pip install pydantic-views
|
|
66
|
+
|
|
67
|
+
Using `poetry <https://python-poetry.org/>`_:
|
|
68
|
+
|
|
69
|
+
.. code-block:: bash
|
|
70
|
+
|
|
71
|
+
poetry add pydantic-views
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
----------
|
|
75
|
+
How to use
|
|
76
|
+
----------
|
|
77
|
+
|
|
78
|
+
When you define a pydantic model you must mark the access model for each field. It means
|
|
79
|
+
you should use our `annotations <https://pydantic-views.readthedocs.io/latest/api.html#field-annotations>`_ to define field typing.
|
|
80
|
+
|
|
81
|
+
.. code-block:: python
|
|
82
|
+
|
|
83
|
+
from typing import Annotated
|
|
84
|
+
from pydantic import BaseModel, gt
|
|
85
|
+
from pydantic_views import ReadOnly, ReadOnlyOnCreation, Hidden, AccessMode
|
|
86
|
+
|
|
87
|
+
class ExampleModel(BaseModel):
|
|
88
|
+
|
|
89
|
+
# No marked fields are treated like ReadAndWrite fields.
|
|
90
|
+
field_str: str
|
|
91
|
+
|
|
92
|
+
# Read only fields are removed on view for create and update views.
|
|
93
|
+
field_read_only_str: ReadOnly[str]
|
|
94
|
+
|
|
95
|
+
# Read only on creation fields are removed on view for create, update and load views.
|
|
96
|
+
# But it is shown on create result view.
|
|
97
|
+
field_api_secret: ReadOnlyOnCreation[str]
|
|
98
|
+
|
|
99
|
+
# It is possible to set more than one access mode and to use annotation standard pattern.
|
|
100
|
+
field_int: Annotated[int, AccessMode.READ_ONLY, AccessMode.WRITE_ONLY_ON_CREATION, gt(5)]
|
|
101
|
+
|
|
102
|
+
# Hidden field do not appears in any view.
|
|
103
|
+
field_hidden_int: Hidden[int]
|
|
104
|
+
|
|
105
|
+
# Computed fields only appears on reading views.
|
|
106
|
+
@computed_field
|
|
107
|
+
def field_computed_field(self) -> int:
|
|
108
|
+
return self.field_hidden_int * 5
|
|
109
|
+
|
|
110
|
+
So, in order to build a `Load` view it is so simple:
|
|
111
|
+
|
|
112
|
+
.. code-block:: python
|
|
113
|
+
|
|
114
|
+
from pydantic_views import BuilderLoad
|
|
115
|
+
|
|
116
|
+
ExampleModelLoad = BuilderLoad().build_view(ExampleModel)
|
|
117
|
+
|
|
118
|
+
It is equivalent to:
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
.. code-block:: python
|
|
122
|
+
|
|
123
|
+
from pydantic import gt
|
|
124
|
+
from pydantic_views import View
|
|
125
|
+
|
|
126
|
+
class ExampleModelLoad(View[ExampleModel]):
|
|
127
|
+
field_str: str
|
|
128
|
+
field_int: Annotated[int, gt(5)]
|
|
129
|
+
field_computed_field: int
|
|
130
|
+
|
|
131
|
+
In same way to build a `Update` view you must do:
|
|
132
|
+
|
|
133
|
+
.. code-block:: python
|
|
134
|
+
|
|
135
|
+
from pydantic_views import BuilderUpdate
|
|
136
|
+
|
|
137
|
+
ExampleModelUpdate = BuilderUpdate().build_view(ExampleModel)
|
|
138
|
+
|
|
139
|
+
It is equivalent to:
|
|
140
|
+
|
|
141
|
+
.. code-block:: python
|
|
142
|
+
|
|
143
|
+
from pydantic import PydanticUndefined
|
|
144
|
+
from pydantic_views import View
|
|
145
|
+
|
|
146
|
+
class ExampleModelUpdate(View[ExampleModel]):
|
|
147
|
+
field_str: str = Field(default_factory=lambda: PydanticUndefined)
|
|
148
|
+
|
|
149
|
+
As you can see, on `Update` view all fields has a default factory returning `PydanticUndefined`
|
|
150
|
+
in order to make them optionals. And when an update view is applied to a given model, the fields that are
|
|
151
|
+
not set (use default value) will not be applied to the model.
|
|
152
|
+
|
|
153
|
+
.. code-block:: python
|
|
154
|
+
|
|
155
|
+
original_model = ExampleModel(
|
|
156
|
+
field_str="anything"
|
|
157
|
+
field_read_only_str="anything"
|
|
158
|
+
field_api_secret="anything"
|
|
159
|
+
field_int=10
|
|
160
|
+
field_hidden_int=33
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
update = ExampleModelUpdate(field_str="new_data")
|
|
164
|
+
|
|
165
|
+
updated_model = update.view_apply_to(original_model)
|
|
166
|
+
|
|
167
|
+
assert isinstance(updated_model, ExampleModel)
|
|
168
|
+
assert updated_model.field_str == "new_data"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
But if a field is not set on update view, the original value is kept.
|
|
172
|
+
|
|
173
|
+
.. code-block:: python
|
|
174
|
+
|
|
175
|
+
original_model = ExampleModel(
|
|
176
|
+
field_str="anything"
|
|
177
|
+
field_read_only_str="anything"
|
|
178
|
+
field_api_secret="anything"
|
|
179
|
+
field_int=10
|
|
180
|
+
field_hidden_int=33
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
update = ExampleModelUpdate()
|
|
184
|
+
|
|
185
|
+
updated_model = update.view_apply_to(original_model)
|
|
186
|
+
|
|
187
|
+
assert isinstance(updated_model, ExampleModel)
|
|
188
|
+
assert updated_model.field_str == "anything"
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pydantic-views"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Views for Pydantic models"
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "Alfred Santacatalina",email = "alfred.santacatalinagea@telefonica.com"}
|
|
7
|
+
]
|
|
8
|
+
license = "MIT"
|
|
9
|
+
readme = "README.rst"
|
|
10
|
+
requires-python = ">=3.13,<4.0.0"
|
|
11
|
+
dependencies = ["pydantic (>=2.10.6,<3.0.0)"]
|
|
12
|
+
keywords = ["view", "pydantic", "datamodel", "model", "REST API"]
|
|
13
|
+
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Framework :: Pydantic",
|
|
17
|
+
"Framework :: Pydantic :: 2",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Typing :: Typed"
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://pydantic-views.readthedocs.io/latest/"
|
|
25
|
+
Repository = "https://github.com/alfred82santa/pydantic-views.git"
|
|
26
|
+
Documentation = "https://pydantic-views.readthedocs.io/latest/"
|
|
27
|
+
Issues = "https://github.com/alfred82santa/pydantic-views/issues"
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
31
|
+
build-backend = "poetry.core.masonry.api"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
[tool.poetry]
|
|
35
|
+
requires-poetry = ">=2.0"
|
|
36
|
+
packages = [{ from = "src", include = "pydantic_views" }]
|
|
37
|
+
|
|
38
|
+
[tool.poetry.group.dev.dependencies]
|
|
39
|
+
flake8 = "^7.1.2"
|
|
40
|
+
pytest-cov = "^6.0.0"
|
|
41
|
+
isort = "^5.13.2"
|
|
42
|
+
absolufy-imports = "^0.3.1"
|
|
43
|
+
ruff = "^0.9.9"
|
|
44
|
+
mypy = "^1.15.0"
|
|
45
|
+
pytest = "^8.3.5"
|
|
46
|
+
flake8-pyproject = "^1.2.3"
|
|
47
|
+
autoflake = "^2.3.1"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
[tool.poetry.group.docs.dependencies]
|
|
51
|
+
sphinx = "^8.2.3"
|
|
52
|
+
autodoc-pydantic = "^2.2.0"
|
|
53
|
+
|
|
54
|
+
[tool.ruff]
|
|
55
|
+
exclude = [".venv/*"]
|
|
56
|
+
|
|
57
|
+
[tool.flake8]
|
|
58
|
+
exclude = [".venv/*"]
|
|
59
|
+
max-line-length = 120
|
|
60
|
+
extend-ignore = "E251"
|
|
61
|
+
|
|
62
|
+
[tool.isort]
|
|
63
|
+
profile = "black"
|
|
64
|
+
src_paths = ["src", "tests"]
|
|
65
|
+
skip_glob = [".venv/*"]
|
|
66
|
+
reverse_relative = true
|
|
67
|
+
split_on_trailing_comma = true
|
|
68
|
+
multi_line_output = 3
|
|
69
|
+
include_trailing_comma = true
|
|
70
|
+
force_grid_wrap = 0
|
|
71
|
+
use_parentheses = true
|
|
72
|
+
ensure_newline_before_comments = true
|
|
73
|
+
|
|
74
|
+
[tool.mypy]
|
|
75
|
+
files = ["src", "tests"]
|
|
76
|
+
exclude = [".venv/.*", "docs/source/.*"]
|
|
77
|
+
disable_error_code="valid-type,import-untyped"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
[tool.pydantic-mypy]
|
|
81
|
+
init_forbid_extra = true
|
|
82
|
+
init_typed = true
|
|
83
|
+
warn_required_dynamic_aliases = true
|
|
84
|
+
warn_untyped_fields = true
|
|
85
|
+
|
|
86
|
+
[tool.coverage.run]
|
|
87
|
+
omit = [".venv/*"]
|
|
88
|
+
branch = true
|
|
89
|
+
relative_files = false
|
|
90
|
+
|
|
91
|
+
[tool.coverage.report]
|
|
92
|
+
# Regexes for lines to exclude from consideration
|
|
93
|
+
exclude_also = [
|
|
94
|
+
# Dont complain about missing debug-only code:
|
|
95
|
+
"def __repr__",
|
|
96
|
+
"if self\\.debug",
|
|
97
|
+
|
|
98
|
+
# Don't complain if tests don't hit defensive assertion code:
|
|
99
|
+
"raise AssertionError",
|
|
100
|
+
"raise NotImplementedError",
|
|
101
|
+
|
|
102
|
+
# Don't complain if non-runnable code isn't run:
|
|
103
|
+
"if 0:",
|
|
104
|
+
"if __name__ == .__main__.:",
|
|
105
|
+
|
|
106
|
+
# Don't complain about abstract methods, they aren't run:
|
|
107
|
+
"@(abc\\.)?abstractmethod",
|
|
108
|
+
|
|
109
|
+
# Don't complain type checking imports, they aren't run:
|
|
110
|
+
"if TYPE_CHECKING",
|
|
111
|
+
|
|
112
|
+
# Don't complain overloads, they aren't run:
|
|
113
|
+
"@overload"
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
[tool.coverage.paths]
|
|
117
|
+
source = ["src/"]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from .annotations import (
|
|
2
|
+
AccessMode,
|
|
3
|
+
Hidden,
|
|
4
|
+
ReadAndWrite,
|
|
5
|
+
ReadOnly,
|
|
6
|
+
ReadOnlyOnCreation,
|
|
7
|
+
WriteOnly,
|
|
8
|
+
WriteOnlyOnCreation,
|
|
9
|
+
)
|
|
10
|
+
from .builder import (
|
|
11
|
+
Builder,
|
|
12
|
+
BuilderCreate,
|
|
13
|
+
BuilderCreateResult,
|
|
14
|
+
BuilderLoad,
|
|
15
|
+
BuilderUpdate,
|
|
16
|
+
ensure_model_views,
|
|
17
|
+
)
|
|
18
|
+
from .manager import Manager
|
|
19
|
+
from .view import RootView, View
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"AccessMode",
|
|
23
|
+
"ReadOnly",
|
|
24
|
+
"ReadAndWrite",
|
|
25
|
+
"ReadOnlyOnCreation",
|
|
26
|
+
"WriteOnly",
|
|
27
|
+
"WriteOnlyOnCreation",
|
|
28
|
+
"Hidden",
|
|
29
|
+
"Builder",
|
|
30
|
+
"BuilderCreate",
|
|
31
|
+
"BuilderCreateResult",
|
|
32
|
+
"BuilderUpdate",
|
|
33
|
+
"BuilderLoad",
|
|
34
|
+
"ensure_model_views",
|
|
35
|
+
"Manager",
|
|
36
|
+
"View",
|
|
37
|
+
"RootView",
|
|
38
|
+
]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from enum import Enum, auto
|
|
2
|
+
from typing import Annotated, TypeAlias, TypeVar
|
|
3
|
+
|
|
4
|
+
T = TypeVar("T")
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AccessMode(Enum):
|
|
8
|
+
"""
|
|
9
|
+
Field access mode.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
#: Read and write mark.
|
|
13
|
+
READ_AND_WRITE = auto()
|
|
14
|
+
|
|
15
|
+
#: Read only mark.
|
|
16
|
+
READ_ONLY = auto()
|
|
17
|
+
|
|
18
|
+
#: Write only mark.
|
|
19
|
+
WRITE_ONLY = auto()
|
|
20
|
+
|
|
21
|
+
#: Read only on creation mark.
|
|
22
|
+
READ_ONLY_ON_CREATION = auto()
|
|
23
|
+
|
|
24
|
+
#: Write only on creation mark.
|
|
25
|
+
WRITE_ONLY_ON_CREATION = auto()
|
|
26
|
+
|
|
27
|
+
#: Hidden mark.
|
|
28
|
+
HIDDEN = auto()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
#: Read and write field annotation. Field could be read and written always.
|
|
32
|
+
ReadAndWrite: TypeAlias = Annotated[T, AccessMode.READ_AND_WRITE]
|
|
33
|
+
|
|
34
|
+
#: Read only field annotation. Field could be read always but never written.
|
|
35
|
+
ReadOnly = Annotated[T, AccessMode.READ_ONLY]
|
|
36
|
+
|
|
37
|
+
#: Write only field annotation. Field could be written always but never read.
|
|
38
|
+
WriteOnly = Annotated[T, AccessMode.WRITE_ONLY]
|
|
39
|
+
|
|
40
|
+
#: Read only on creation field annotation. Field could be read only after creation, and never again.
|
|
41
|
+
ReadOnlyOnCreation = Annotated[T, AccessMode.READ_ONLY_ON_CREATION]
|
|
42
|
+
|
|
43
|
+
#: Write only on creation field annotation. Field could be written only after creation, and never again.
|
|
44
|
+
WriteOnlyOnCreation = Annotated[T, AccessMode.WRITE_ONLY_ON_CREATION]
|
|
45
|
+
|
|
46
|
+
#: Hidden field annotation. Field could not be read or written.
|
|
47
|
+
Hidden = Annotated[T, AccessMode.HIDDEN]
|
|
@@ -0,0 +1,410 @@
|
|
|
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
|
|
19
|
+
from .manager import Manager
|
|
20
|
+
from .view import RootView, View
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Builder:
|
|
24
|
+
"""
|
|
25
|
+
View builder. It create a view classes from models following given criterias.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
view_name: str,
|
|
31
|
+
access_modes: tuple[AccessMode, ...],
|
|
32
|
+
all_optional: bool = False,
|
|
33
|
+
all_nullable: bool = False,
|
|
34
|
+
hide_default_null: bool = False,
|
|
35
|
+
include_computed_fields: bool = False,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""
|
|
38
|
+
:param view_name: View name.
|
|
39
|
+
:param access_modes: Access modes to filter for.
|
|
40
|
+
:param all_optional: Make all fields optionals. On updates it allows to send just fields you want to change.
|
|
41
|
+
:param all_nullable: Make all fields nulleables. On some kinds of updates it could meant set default value.
|
|
42
|
+
:param hide_default_null: Hide :obj:`None` as default value. It produces better examples.
|
|
43
|
+
:param include_computed_fields: Whether computed fields must be included on view or not.
|
|
44
|
+
"""
|
|
45
|
+
self.view_name = view_name
|
|
46
|
+
self.access_modes = access_modes
|
|
47
|
+
self.all_optional = all_optional
|
|
48
|
+
self.all_nullable = all_nullable
|
|
49
|
+
self.hide_default_null = hide_default_null
|
|
50
|
+
self.include_computed_fields = include_computed_fields
|
|
51
|
+
self._views: dict[type[BaseModel], type[View[BaseModel]] | ForwardRef] = {}
|
|
52
|
+
|
|
53
|
+
def build_view[T: BaseModel](self, model: type[T]) -> type[View[T] | T]:
|
|
54
|
+
"""
|
|
55
|
+
Builds a view from model if it does not exist, otherwise it return already created one.
|
|
56
|
+
|
|
57
|
+
:param model: Model class.
|
|
58
|
+
:returns: View of model class.
|
|
59
|
+
"""
|
|
60
|
+
manager = ensure_model_views(model)
|
|
61
|
+
try:
|
|
62
|
+
result: type[View[T] | T] = manager[self.view_name]
|
|
63
|
+
except (KeyError, TypeError):
|
|
64
|
+
result = manager.build_view(self)
|
|
65
|
+
|
|
66
|
+
[
|
|
67
|
+
v.model_rebuild() # type: ignore
|
|
68
|
+
for v in self._views.values()
|
|
69
|
+
if not isinstance(v, ForwardRef)
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
return result
|
|
73
|
+
|
|
74
|
+
def get_view_ref[T: BaseModel](
|
|
75
|
+
self, model: type[T]
|
|
76
|
+
) -> type[View[T] | T] | ForwardRef:
|
|
77
|
+
"""
|
|
78
|
+
Returns a view of model or a forward reference to it.
|
|
79
|
+
|
|
80
|
+
:param model: Model class.
|
|
81
|
+
:type model: type[T]
|
|
82
|
+
:returns: View of model class or reference to it.
|
|
83
|
+
:rtype: type[View[T] | T] | ForwardRef
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
return cast(type[View[T]] | ForwardRef, self._views[model])
|
|
87
|
+
except KeyError:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
manager = ensure_model_views(model)
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
view: type[View[T] | T] = manager[self.view_name]
|
|
94
|
+
except KeyError:
|
|
95
|
+
view = manager.build_view(self)
|
|
96
|
+
|
|
97
|
+
self._views[model] = cast(type[View[BaseModel]], view)
|
|
98
|
+
|
|
99
|
+
return view
|
|
100
|
+
|
|
101
|
+
def _filter_field(self, f_info: FieldInfo):
|
|
102
|
+
am = {m for m in f_info.metadata if isinstance(m, AccessMode)}
|
|
103
|
+
return len((am & set(self.access_modes))) == 0 and len(am) > 0
|
|
104
|
+
|
|
105
|
+
def _iter_fields[T: BaseModel](self, model: type[T]):
|
|
106
|
+
for f_name, f_info in model.model_fields.items():
|
|
107
|
+
if self._filter_field(f_info):
|
|
108
|
+
continue
|
|
109
|
+
yield f_name, f_info
|
|
110
|
+
|
|
111
|
+
def _filter_computed_field(self, f_info: ComputedFieldInfo) -> bool:
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
def _iter_computed_fields[T: BaseModel](self, model: type[T]):
|
|
115
|
+
for f_name, cf_info in model.model_computed_fields.items():
|
|
116
|
+
if self._filter_computed_field(
|
|
117
|
+
cf_info
|
|
118
|
+
): # [TODO] does it have sense? # pragma: no cover
|
|
119
|
+
continue
|
|
120
|
+
yield f_name, cf_info
|
|
121
|
+
|
|
122
|
+
def build_from_model[T: BaseModel](self, model: type[T]) -> type[View[T] | T]:
|
|
123
|
+
"""
|
|
124
|
+
Builds a view from model
|
|
125
|
+
|
|
126
|
+
:param model: Model class.
|
|
127
|
+
:returns: View of model class.
|
|
128
|
+
"""
|
|
129
|
+
from pydantic._internal._config import ConfigWrapper
|
|
130
|
+
|
|
131
|
+
view_name = model.__name__ + self.view_name[0].upper() + self.view_name[1:]
|
|
132
|
+
try:
|
|
133
|
+
view_cache = self._views[model]
|
|
134
|
+
if not isinstance(view_cache, ForwardRef):
|
|
135
|
+
return cast(type[View[T] | T], view_cache)
|
|
136
|
+
except KeyError:
|
|
137
|
+
self._views[model] = ForwardRef(view_name, module=model.__module__)
|
|
138
|
+
|
|
139
|
+
view: type[View[T] | T]
|
|
140
|
+
|
|
141
|
+
manager = ensure_model_views(model)
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
view = manager[self.view_name]
|
|
145
|
+
self._views[model] = cast(type[View[BaseModel]], view)
|
|
146
|
+
except KeyError:
|
|
147
|
+
model_fields: dict[str, tuple[type[Any] | None, FieldInfo]] = {}
|
|
148
|
+
for f_name, f_info in self._iter_fields(model):
|
|
149
|
+
model_fields[f_name] = self._map_field_info(
|
|
150
|
+
f_info.annotation,
|
|
151
|
+
f_info,
|
|
152
|
+
ignore_nullable=issubclass(model, RootModel),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
if self.include_computed_fields:
|
|
156
|
+
for f_name, cf_info in self._iter_computed_fields(model):
|
|
157
|
+
model_fields[f_name] = self._map_computed_field_info(cf_info)
|
|
158
|
+
|
|
159
|
+
base_view: type[View[T]] | type[RootView[T]]
|
|
160
|
+
|
|
161
|
+
if issubclass(model, RootModel):
|
|
162
|
+
|
|
163
|
+
class _RootView(RootView[T]):
|
|
164
|
+
model_config = deepcopy(model.model_config)
|
|
165
|
+
|
|
166
|
+
__model_class_root__ = ref(cast(type[T], model))
|
|
167
|
+
|
|
168
|
+
base_view = _RootView
|
|
169
|
+
|
|
170
|
+
else:
|
|
171
|
+
|
|
172
|
+
class _View(View[T]):
|
|
173
|
+
model_config = deepcopy(model.model_config)
|
|
174
|
+
|
|
175
|
+
__model_class_root__ = ref(cast(type[T], model))
|
|
176
|
+
|
|
177
|
+
_View.model_config["protected_namespaces"] = tuple(
|
|
178
|
+
{
|
|
179
|
+
*ConfigWrapper(model.model_config).protected_namespaces,
|
|
180
|
+
*ConfigWrapper(View.model_config).protected_namespaces,
|
|
181
|
+
}
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
base_view = _View
|
|
185
|
+
|
|
186
|
+
params: dict[str, Any] = {
|
|
187
|
+
"__module__": model.__module__,
|
|
188
|
+
"__base__": base_view,
|
|
189
|
+
"__doc__": (
|
|
190
|
+
f"View `{self.view_name}` "
|
|
191
|
+
f"of model :class:`~{model.__module__}.{model.__qualname__}`"
|
|
192
|
+
),
|
|
193
|
+
**model_fields,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
view = cast(
|
|
197
|
+
type[View[T]],
|
|
198
|
+
create_model(
|
|
199
|
+
view_name,
|
|
200
|
+
**params,
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
self._views[model] = cast(type[View[BaseModel]], view)
|
|
205
|
+
|
|
206
|
+
return view
|
|
207
|
+
|
|
208
|
+
def _map_field_info(
|
|
209
|
+
self,
|
|
210
|
+
annotation: type[Any] | None,
|
|
211
|
+
f_info: FieldInfo,
|
|
212
|
+
*,
|
|
213
|
+
ignore_nullable: bool = False,
|
|
214
|
+
) -> tuple[type[Any] | None, FieldInfo]:
|
|
215
|
+
f_info = FieldInfo.merge_field_infos(
|
|
216
|
+
f_info,
|
|
217
|
+
annotation=self._map_annotation(
|
|
218
|
+
f_info.annotation, ignore_nullable=ignore_nullable
|
|
219
|
+
),
|
|
220
|
+
metadata=[m for m in f_info.metadata if not isinstance(m, AccessMode)],
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
if self.all_optional:
|
|
224
|
+
f_info = FieldInfo.merge_field_infos(
|
|
225
|
+
f_info,
|
|
226
|
+
default_factory=lambda: PydanticUndefined,
|
|
227
|
+
)
|
|
228
|
+
f_info.default = PydanticUndefined
|
|
229
|
+
|
|
230
|
+
if self.hide_default_null and f_info.default is None:
|
|
231
|
+
f_info = FieldInfo.merge_field_infos(
|
|
232
|
+
f_info,
|
|
233
|
+
default_factory=lambda: PydanticUndefined,
|
|
234
|
+
)
|
|
235
|
+
f_info.default = PydanticUndefined
|
|
236
|
+
|
|
237
|
+
return f_info.annotation, f_info
|
|
238
|
+
|
|
239
|
+
def _map_annotation(
|
|
240
|
+
self, annotation: type[Any] | None, *, ignore_nullable: bool = False
|
|
241
|
+
) -> type[Any] | ForwardRef | None | UnionType:
|
|
242
|
+
def finish_annotation(a: type[Any] | None) -> type[Any] | None | UnionType:
|
|
243
|
+
if not ignore_nullable and self.all_nullable and a is not Ellipsis: # type: ignore
|
|
244
|
+
return Union[a, None] # type: ignore
|
|
245
|
+
|
|
246
|
+
return a
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
if annotation and issubclass(annotation, BaseModel):
|
|
250
|
+
return finish_annotation(self.get_view_ref(annotation)) # type: ignore
|
|
251
|
+
except TypeError:
|
|
252
|
+
pass
|
|
253
|
+
|
|
254
|
+
origin = get_origin(annotation)
|
|
255
|
+
type_args = get_args(annotation)
|
|
256
|
+
|
|
257
|
+
if origin is None:
|
|
258
|
+
return finish_annotation(annotation)
|
|
259
|
+
|
|
260
|
+
if origin is not Union and not issubclass(origin, UnionType): # type: ignore
|
|
261
|
+
if issubclass(origin, Mapping):
|
|
262
|
+
return finish_annotation(
|
|
263
|
+
origin[ # type: ignore
|
|
264
|
+
self._map_annotation(type_args[0], ignore_nullable=True),
|
|
265
|
+
self._map_annotation(type_args[1]),
|
|
266
|
+
]
|
|
267
|
+
)
|
|
268
|
+
elif issubclass(origin, Iterable):
|
|
269
|
+
return finish_annotation(
|
|
270
|
+
origin[ # type: ignore
|
|
271
|
+
*(
|
|
272
|
+
self._map_annotation(
|
|
273
|
+
t, ignore_nullable=issubclass(origin, (list, set))
|
|
274
|
+
)
|
|
275
|
+
for t in type_args
|
|
276
|
+
)
|
|
277
|
+
]
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
return Union[ # type: ignore
|
|
281
|
+
*(
|
|
282
|
+
self._map_annotation(a)
|
|
283
|
+
for a in type_args
|
|
284
|
+
if a is not NoneType or not self.hide_default_null
|
|
285
|
+
)
|
|
286
|
+
]
|
|
287
|
+
|
|
288
|
+
def _map_computed_field_info(
|
|
289
|
+
self, cf_info: ComputedFieldInfo
|
|
290
|
+
) -> tuple[type[Any] | None, FieldInfo]:
|
|
291
|
+
return (
|
|
292
|
+
cf_info.return_type,
|
|
293
|
+
FieldInfo(
|
|
294
|
+
annotation=cf_info.return_type,
|
|
295
|
+
alias=cf_info.alias,
|
|
296
|
+
default=None,
|
|
297
|
+
alias_priority=cf_info.alias_priority,
|
|
298
|
+
serialization_alias=cf_info.title,
|
|
299
|
+
title=cf_info.title,
|
|
300
|
+
description=cf_info.title,
|
|
301
|
+
examples=cf_info.examples,
|
|
302
|
+
discriminator=cf_info.title,
|
|
303
|
+
deprecated=cf_info.title,
|
|
304
|
+
json_schema_extra=cf_info.json_schema_extra,
|
|
305
|
+
repr=cf_info.repr,
|
|
306
|
+
),
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def BuilderCreate(view_name: str = "Create") -> Builder:
|
|
311
|
+
"""
|
|
312
|
+
Default builder for `Create` view. Views created by it keep fields with
|
|
313
|
+
:obj:`access mode <pydantic_views.AccessMode>` :obj:`~pydantic_views.AccessMode.READ_AND_WRITE`,
|
|
314
|
+
:obj:`~pydantic_views.AccessMode.WRITE_ONLY` and :obj:`~pydantic_views.AccessMode.WRITE_ONLY_ON_CREATION`.
|
|
315
|
+
And hide default :obj:`None` value. It produces a better schema examples.
|
|
316
|
+
|
|
317
|
+
:param view_name: View name.
|
|
318
|
+
:returns: Builder configured for `Create` views.
|
|
319
|
+
"""
|
|
320
|
+
return Builder(
|
|
321
|
+
view_name,
|
|
322
|
+
access_modes=(
|
|
323
|
+
AccessMode.READ_AND_WRITE,
|
|
324
|
+
AccessMode.WRITE_ONLY,
|
|
325
|
+
AccessMode.WRITE_ONLY_ON_CREATION,
|
|
326
|
+
),
|
|
327
|
+
hide_default_null=True,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def BuilderCreateResult(view_name: str = "CreateResult") -> Builder:
|
|
332
|
+
"""
|
|
333
|
+
Default builder for `CreateResult` view. Views created by it keep fields with
|
|
334
|
+
:obj:`access mode <pydantic_views.AccessMode>` :obj:`~pydantic_views.AccessMode.READ_AND_WRITE`,
|
|
335
|
+
:obj:`~pydantic_views.AccessMode.READ_ONLY` and :obj:`~pydantic_views.AccessMode.READ_ONLY_ON_CREATION`.
|
|
336
|
+
And includes computed fields.
|
|
337
|
+
|
|
338
|
+
:param view_name: View name.
|
|
339
|
+
:returns: Builder configured for `CreateResult` views.
|
|
340
|
+
"""
|
|
341
|
+
return Builder(
|
|
342
|
+
view_name,
|
|
343
|
+
access_modes=(
|
|
344
|
+
AccessMode.READ_AND_WRITE,
|
|
345
|
+
AccessMode.READ_ONLY,
|
|
346
|
+
AccessMode.READ_ONLY_ON_CREATION,
|
|
347
|
+
),
|
|
348
|
+
include_computed_fields=True,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def BuilderUpdate(view_name: str = "Update") -> Builder:
|
|
353
|
+
"""
|
|
354
|
+
Default builder for `Update` view. Views created by it keep fields with
|
|
355
|
+
:obj:`access mode <pydantic_views.AccessMode>` :obj:`~pydantic_views.AccessMode.READ_AND_WRITE`
|
|
356
|
+
and :obj:`~pydantic_views.AccessMode.WRITE_ONLY`. And make all fields optional.
|
|
357
|
+
|
|
358
|
+
:param view_name: View name.
|
|
359
|
+
:returns: Builder configured for `Update` views.
|
|
360
|
+
"""
|
|
361
|
+
return Builder(
|
|
362
|
+
view_name,
|
|
363
|
+
access_modes=(AccessMode.READ_AND_WRITE, AccessMode.WRITE_ONLY),
|
|
364
|
+
all_optional=True,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def BuilderLoad(view_name: str = "Load") -> Builder:
|
|
369
|
+
"""
|
|
370
|
+
Default builder for `Load` view. Views created by it keep fields with
|
|
371
|
+
:obj:`access mode <pydantic_views.AccessMode>` :obj:`~pydantic_views.AccessMode.READ_AND_WRITE`
|
|
372
|
+
and :obj:`~pydantic_views.AccessMode.READ_ONLY`. And includes computed fields.
|
|
373
|
+
|
|
374
|
+
:param view_name: View name.
|
|
375
|
+
:returns: Builder configured for `Load` views.
|
|
376
|
+
"""
|
|
377
|
+
return Builder(
|
|
378
|
+
view_name,
|
|
379
|
+
access_modes=(
|
|
380
|
+
AccessMode.READ_AND_WRITE,
|
|
381
|
+
AccessMode.READ_ONLY,
|
|
382
|
+
),
|
|
383
|
+
include_computed_fields=True,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def ensure_model_views[T: BaseModel](model: type[T]):
|
|
388
|
+
"""
|
|
389
|
+
Ensures model has a view manager and returns it.
|
|
390
|
+
|
|
391
|
+
:param model: Model class.
|
|
392
|
+
:returns: Views manager for model class.
|
|
393
|
+
"""
|
|
394
|
+
|
|
395
|
+
try:
|
|
396
|
+
if (
|
|
397
|
+
manager := cast(Manager[T], getattr(model, "model_views"))
|
|
398
|
+
) and manager.model == model:
|
|
399
|
+
return manager
|
|
400
|
+
except AttributeError:
|
|
401
|
+
pass
|
|
402
|
+
|
|
403
|
+
manager = Manager(model)
|
|
404
|
+
setattr(
|
|
405
|
+
model,
|
|
406
|
+
"model_views",
|
|
407
|
+
manager,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
return manager
|
|
@@ -0,0 +1,64 @@
|
|
|
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
|
+
"""
|
|
14
|
+
Views manager for a given model class.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
__slots__ = ("_model", "_views")
|
|
18
|
+
|
|
19
|
+
def __init__(self, model: type[TModel]):
|
|
20
|
+
""" """
|
|
21
|
+
self._model = ref(model)
|
|
22
|
+
self._views: dict[str, type["View[TModel] | TModel"]] = {}
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def model(self) -> type[TModel]:
|
|
26
|
+
"""
|
|
27
|
+
Associated model class.
|
|
28
|
+
|
|
29
|
+
:returns: Model class associated.
|
|
30
|
+
"""
|
|
31
|
+
result = self._model()
|
|
32
|
+
if not result: # pragma: no cover
|
|
33
|
+
raise RuntimeError("Model class disappeared")
|
|
34
|
+
return result
|
|
35
|
+
|
|
36
|
+
def __getitem__(self, view_name: str) -> type["View[TModel] | TModel"]:
|
|
37
|
+
"""
|
|
38
|
+
Get a model view.
|
|
39
|
+
|
|
40
|
+
:param view_name: Name of view to get.
|
|
41
|
+
:returns: View of model class.
|
|
42
|
+
"""
|
|
43
|
+
return self._views[view_name]
|
|
44
|
+
|
|
45
|
+
def __setitem__(self, view_name: str, view: type["View[TModel] | TModel"]):
|
|
46
|
+
"""
|
|
47
|
+
Set a model view.
|
|
48
|
+
|
|
49
|
+
:param view_name: Name of view to get.
|
|
50
|
+
:param view: View of model class.
|
|
51
|
+
"""
|
|
52
|
+
self._views[view_name] = view
|
|
53
|
+
|
|
54
|
+
def build_view(self, builder: "Builder") -> type["View[TModel] | TModel"]:
|
|
55
|
+
"""
|
|
56
|
+
Build view class for Manager's model.
|
|
57
|
+
|
|
58
|
+
:param builder: Builder to use to make the view of model.
|
|
59
|
+
:returns: View of model class.
|
|
60
|
+
"""
|
|
61
|
+
view = builder.build_from_model(self.model)
|
|
62
|
+
self[builder.view_name] = view
|
|
63
|
+
|
|
64
|
+
return view
|
|
File without changes
|
|
@@ -0,0 +1,132 @@
|
|
|
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
|
+
"""
|
|
10
|
+
View of model.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
__model_class_root__: ClassVar[ReferenceType[type[BaseModel]]]
|
|
14
|
+
|
|
15
|
+
model_config = {
|
|
16
|
+
"protected_namespaces": (
|
|
17
|
+
"view_class_root",
|
|
18
|
+
"view_build_to",
|
|
19
|
+
"view_apply_to",
|
|
20
|
+
"view_build_from",
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def view_class_root(cls) -> type[T]:
|
|
26
|
+
"""
|
|
27
|
+
Returns the class object associated to the view.
|
|
28
|
+
|
|
29
|
+
:returns: Associated model class.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
root = cls.__model_class_root__()
|
|
33
|
+
|
|
34
|
+
if root is None: # pragma: no cover
|
|
35
|
+
raise RuntimeError("Root model disappeared")
|
|
36
|
+
|
|
37
|
+
return cast(type[T], root)
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def view_build_from(cls, model: T):
|
|
41
|
+
"""
|
|
42
|
+
Build view from given model.
|
|
43
|
+
|
|
44
|
+
:param model: Model class to build view from.
|
|
45
|
+
:returns: View of model class.
|
|
46
|
+
"""
|
|
47
|
+
return cls.model_validate(model.model_dump(exclude_unset=True, by_alias=True))
|
|
48
|
+
|
|
49
|
+
def view_build_to(self) -> T:
|
|
50
|
+
"""
|
|
51
|
+
Build associated model from view data.
|
|
52
|
+
|
|
53
|
+
:returns: Associated model instance with view data.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
return self.view_class_root().model_validate(
|
|
57
|
+
self.model_dump(exclude_unset=True, exclude_defaults=True, by_alias=True)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def view_apply_to(self, model: T) -> T:
|
|
61
|
+
"""
|
|
62
|
+
Apply view data to associated model.
|
|
63
|
+
|
|
64
|
+
:param model: Model instance to use as base..
|
|
65
|
+
:returns: Associated model instance with view data merged to given model data.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
return model_apply(model, self)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class RootView[R](View[RootModel[R]], RootModel[R]):
|
|
72
|
+
"""
|
|
73
|
+
View for root models.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def model_apply[T: BaseModel](orig: T, view: View[T] | T) -> T:
|
|
78
|
+
"""
|
|
79
|
+
Merge view or model into model
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
update_data: dict[str, Any] = {}
|
|
83
|
+
|
|
84
|
+
for field in view.model_fields_set:
|
|
85
|
+
value = _merge_values(
|
|
86
|
+
getattr(orig, field),
|
|
87
|
+
getattr(view, field),
|
|
88
|
+
)
|
|
89
|
+
update_data[field] = getattr(
|
|
90
|
+
orig.__pydantic_validator__.validate_assignment(orig, field, value), field
|
|
91
|
+
)
|
|
92
|
+
return orig.model_copy(
|
|
93
|
+
update=update_data,
|
|
94
|
+
deep=True,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@overload
|
|
99
|
+
def _merge_values[T: BaseModel](
|
|
100
|
+
orig_value: None, new_value: T
|
|
101
|
+
) -> T | dict[str, Any]: ...
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@overload
|
|
105
|
+
def _merge_values[T: BaseModel, A](orig_value: None, new_value: A) -> A: ...
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@overload
|
|
109
|
+
def _merge_values[T: BaseModel, A](
|
|
110
|
+
orig_value: T, new_value: View[T]
|
|
111
|
+
) -> T | dict[str, Any]: ...
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _merge_values[T: BaseModel, A](
|
|
115
|
+
orig_value: T | None, new_value: View[T] | A
|
|
116
|
+
) -> T | A | dict[str, Any]:
|
|
117
|
+
if isinstance(new_value, BaseModel):
|
|
118
|
+
if isinstance(orig_value, BaseModel):
|
|
119
|
+
return cast(T, model_apply(orig_value, new_value))
|
|
120
|
+
if isinstance(new_value, View):
|
|
121
|
+
return cast(View[T], new_value).view_build_to()
|
|
122
|
+
return new_value.model_dump(
|
|
123
|
+
exclude_unset=True, exclude_defaults=True, by_alias=True
|
|
124
|
+
)
|
|
125
|
+
elif isinstance(new_value, Mapping) and isinstance(orig_value, Mapping):
|
|
126
|
+
data: dict[str, Any] = dict(cast(Mapping[str, Any], orig_value))
|
|
127
|
+
for k, v in cast(Mapping[str, Any], new_value).items():
|
|
128
|
+
data[k] = _merge_values(data.get(k), v)
|
|
129
|
+
|
|
130
|
+
return cast(T, cast(Mapping[str, Any], orig_value).__class__(**data))
|
|
131
|
+
else:
|
|
132
|
+
return cast(T, new_value)
|