Skip to content

Commit 9234946

Browse files
authored
Merge pull request #25 from joshorr/josho/excluded-auto-partial-fields
feat: add way to exclude specific fields from auto partials.
2 parents e55fe68 + 420b686 commit 9234946

File tree

9 files changed

+268
-12
lines changed

9 files changed

+268
-12
lines changed

README.md

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111

1212
An easy way to add or create partials for Pydantic models.
1313

14-
![PythonSupport](https://img.shields.io/static/v1?label=python&message=%203.10|%203.11|%203.12&color=blue?style=flat-square&logo=python)
15-
![PyPI version](https://badge.fury.io/py/xmodel.svg?)
14+
[![PythonSupport](https://img.shields.io/static/v1?label=python&message=%203.10|%203.11|%203.12&color=blue?style=flat-square&logo=python)](https://pypi.org/project/pydantic-partials/)
15+
[![PyPI version](https://badge.fury.io/py/pydantic_partials.svg?)](https://pypi.org/project/pydantic-partials/)
1616

1717
## Documentation
1818

@@ -136,8 +136,61 @@ Notice that if a field has a default value, it's used instead of marking it as `
136136
Also, the `Missing` sentinel value is a separate value vs `None`, allowing one to easily
137137
know if a value is truly just missing or is `None`/`Null`.
138138

139+
### Exclude Fields From Auto Partials
139140

140-
### Automatic Partials Configuration
141+
You can exclude specific fields from the automatic partials via these means:
142+
143+
- `AutoPartialExclude[...]`
144+
- This puts a special `Annotated` item on field to mark it as excluded.
145+
- `class PartialRequired(PartialModel, auto_partials_exclude={'id', 'created_at'}):`
146+
- This way provides them via class argument `auto_partials_exclude`
147+
- Or via the standard `model_config`
148+
- `model_config = {'auto_partials_exclude': {'id', 'created_at'}}`
149+
- A dict, using `auto_partials_exclude` as the key and a set of field names as the value.
150+
151+
Any of these methods are inheritable.
152+
You can override an excluded value by explicitly marking a field as Partial via `some_field: Partial[str]`
153+
154+
Here is an example using the `AutoPartialExclude` method, also showing how it can inherit.
155+
156+
```python
157+
from pydantic_partials import PartialModel, AutoPartialExclude, Missing
158+
from pydantic import BaseModel, ValidationError
159+
from datetime import datetime
160+
import pytest
161+
162+
class PartialRequired(PartialModel):
163+
id: AutoPartialExclude[str]
164+
created_at: AutoPartialExclude[datetime]
165+
166+
class TestModel(BaseModel):
167+
id: str
168+
created_at: datetime
169+
name: str
170+
value: str
171+
some_null_by_default_field: str | None = None
172+
173+
class PartialTestModel(TestModel, PartialRequired):
174+
pass
175+
176+
# Will raise validation error for the two fields excluded from auto-partials
177+
with pytest.raises(
178+
ValidationError,
179+
match=r'2 validation errors[\w\W]*'
180+
r'id[\w\W]*Field required[\w\W]*'
181+
r'created_at[\w\W]*Field required'
182+
):
183+
PartialTestModel()
184+
185+
# If we give them values, we get no ValidationError
186+
obj = PartialTestModel(id='some-value', created_at=datetime.now())
187+
188+
# And fields have the expected values.
189+
assert obj.id == 'some-value'
190+
assert obj.name is Missing
191+
```
192+
193+
### Auto Partials Configuration
141194

142195
You can turn off automatically applying partials to all non-defaulted fields
143196
via `auto_partials` class argument or modeL_config option:

pydantic_partials/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
from .partial import PartialModel, Partial
1+
from .partial import PartialModel, Partial, AutoPartialExclude
22
from .config import PartialConfigDict
33
from .sentinels import Missing, MissingType

pydantic_partials/config.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,16 @@ class PartialConfigDict(ConfigDict):
2626
unless the user already a default value defined by user for field.
2727
This ensures that Pydantic won't require the field be assigned a value.
2828
"""
29+
30+
auto_partials_exclude: set[str]
31+
""" A set of field names to exclude from the `auto_partials` functionality.
32+
33+
All parent classes `partials_exclude` string sets will be combined
34+
together for a final exclusion list.
35+
36+
Any partials set manually/directly via the fields annotation will still be a partial field
37+
regardless if it's referenced in this `auto_partials_exclude` set or not. This only effects
38+
the way automatic partials are applied.
39+
40+
You can also use `pydantic_partials.partial.AutoPartialExclude` to more easily mark fields as excluded.
41+
"""

pydantic_partials/meta.py

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import typing
2-
from typing import Any, get_args, get_origin, TypeVar
2+
from typing import Any, get_args, get_origin, TypeVar, Iterable
33

44
from pydantic import BaseModel
55

@@ -9,7 +9,7 @@
99
from xsentinels.default import DefaultType
1010

1111
from .config import PartialConfigDict
12-
from .sentinels import Missing, MissingType
12+
from .sentinels import Missing, MissingType, AutoPartialExcludeMarker
1313

1414
from logging import getLogger
1515

@@ -46,6 +46,7 @@ def __new__(
4646
*,
4747

4848
auto_partials: bool | DefaultType = Default,
49+
auto_partials_exclude: Iterable[str] | DefaultType = Default,
4950

5051
# A private/internal detail for generic base subclasses that want to also change the fields,
5152
# this prevents having to rebuild the class a second time; if this is True then the subclass
@@ -62,6 +63,13 @@ def __new__(
6263
If `True` (default): Will automatically make all fields on the model `Partial`.
6364
If `False`: User needs to mark individual fields as `Partial` where they want.
6465
66+
auto_partials_exclude: A set of strings of field names to exclude from automatic partials.
67+
If you explicitly mark a field as a Partial, this won't effect that. THis only effects
68+
automatically applied partial fields.
69+
70+
You can also use `pydantic_partials.partial.AutoPartialExclude` to more easily mark fields as excluded.
71+
For more details see `pydantic_partials.config.PartialConfigDict.auto_partials_exclude`.
72+
6573
**kwargs: Passed along other class arguments to Pydantic and any __init_subclass__ methods.
6674
"""
6775
# Create the class first...
@@ -74,19 +82,41 @@ def __new__(
7482
if auto_partials is not Default:
7583
cls.model_config['auto_partials'] = auto_partials
7684

85+
if auto_partials_exclude:
86+
cls.model_config['auto_partials_exclude'] = set(auto_partials_exclude)
87+
88+
final_auto_exclude = cls.model_config.get('auto_partials_exclude', set())
89+
for c in cls.__mro__:
90+
parent_config = getattr(c, 'model_config', set())
91+
if not parent_config:
92+
continue
93+
94+
final_auto_exclude.update(parent_config.get('auto_partials_exclude', set()))
95+
7796
need_rebuild = False
7897

7998
partial_fields = set()
8099
for k, v in cls.model_fields.items():
81100
field_type = v.annotation
82-
if get_origin(field_type) is None:
101+
origin = get_origin(field_type)
102+
if origin is None:
83103
if field_type is MissingType:
84104
partial_fields.add(k)
85105

106+
# TODO: Check that `field_type` is a union?
86107
for arg_type in get_args(field_type):
87108
if arg_type is MissingType:
88109
partial_fields.add(k)
89110

111+
for mdv in v.metadata:
112+
# If we find the marker AND we are not already marked as a partial_field,
113+
# add field to auto-exclude list.
114+
if mdv is AutoPartialExcludeMarker and k not in partial_fields:
115+
final_auto_exclude.add(k)
116+
117+
if final_auto_exclude:
118+
cls.model_config['auto_partials_exclude'] = final_auto_exclude
119+
90120
final_partial_auto = cls.model_config.get('auto_partials', True)
91121
if final_partial_auto is not False:
92122
# I'll be putting in more options for `final_partial_auto` in the near future,
@@ -99,6 +129,10 @@ def __new__(
99129
# The field is already a Partial
100130
continue
101131

132+
if k in final_auto_exclude:
133+
# The field is excluded.
134+
continue
135+
102136
if v.default is PydanticUndefined and v.default_factory is None:
103137
v.annotation = v.annotation | MissingType
104138
partial_fields.add(k)

pydantic_partials/partial.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import typing
2-
from typing import Any, get_args, get_origin, TypeVar
2+
from typing import Any, get_args, get_origin, TypeVar, Annotated
33

44
from pydantic import BaseModel, model_serializer, JsonValue
55

66
from .meta import PartialMeta
7-
from .sentinels import Missing, MissingType
7+
from .sentinels import Missing, MissingType, AutoPartialExcludeMarker
88

99
from logging import getLogger
1010

@@ -17,6 +17,9 @@
1717
assigned to it.
1818
"""
1919

20+
PME = TypeVar('PME')
21+
AutoPartialExclude = Annotated[PME, AutoPartialExcludeMarker]
22+
2023

2124
class PartialModel(
2225
BaseModel,

pydantic_partials/sentinels.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any, Type, TypeVar
1+
from typing import Any, Type, TypeVar, Annotated
22

33
from pydantic import GetCoreSchemaHandler
44
from pydantic_core import core_schema, PydanticOmit
@@ -54,5 +54,12 @@ def _serialize(value: Any) -> 'MissingType':
5454
""" Returned as attribute value when attribute value is missing. Can also be set on attribute to indicate it's missing.
5555
"""
5656

57-
T = TypeVar('T')
5857

58+
class AutoPartialExcludeMarkerType(Sentinel):
59+
pass
60+
61+
62+
AutoPartialExcludeMarker = AutoPartialExcludeMarkerType()
63+
""" Used by `pydantic_partials.partial.AutoPartialExclude` to mark a field as excluded from automatic partials.
64+
See `pydantic_partials.config.PartialConfigDict.auto_partials_exclude` for more details.
65+
"""

setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[pycodestyle]
22
ignore = E121,E123,E126,E203,E226,E242,E704,E731,W391,W503,W504,C0103
3-
max-line-length = 99
3+
max-line-length = 120
44
statistics = True
55
exclude = setup.py,**/migrations/*,lib/*,.git,__pycache__,node_modules,.venv,.eggs/*,.serverless/**

tests/test_doc_examples.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,40 @@ class PartialTestModel(PartialModel, TestModel):
103103
assert obj.model_dump() == {
104104
'name': 'a-name', 'some_null_by_default_field': None
105105
}
106+
107+
108+
def test_doc_example__auto_exclude__index__4():
109+
from pydantic_partials import PartialModel, AutoPartialExclude, Missing
110+
from pydantic import BaseModel, ValidationError
111+
from datetime import datetime
112+
import pytest
113+
114+
class PartialRequired(PartialModel):
115+
id: AutoPartialExclude[str]
116+
created_at: AutoPartialExclude[datetime]
117+
118+
class TestModel(BaseModel):
119+
id: str
120+
created_at: datetime
121+
name: str
122+
value: str
123+
some_null_by_default_field: str | None = None
124+
125+
class PartialTestModel(TestModel, PartialRequired):
126+
pass
127+
128+
# Will raise validation error for the two fields excluded from auto-partials
129+
with pytest.raises(
130+
ValidationError,
131+
match=r'2 validation errors[\w\W]*'
132+
r'id[\w\W]*Field required[\w\W]*'
133+
r'created_at[\w\W]*Field required'
134+
):
135+
PartialTestModel()
136+
137+
# If we give them values, we get no ValidationError
138+
obj = PartialTestModel(id='some-value', created_at=datetime.now())
139+
140+
# And fields have the expected values.
141+
assert obj.id == 'some-value'
142+
assert obj.name is Missing

tests/test_inheritance.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
from datetime import datetime
2+
3+
import pytest
4+
5+
from pydantic_partials import PartialModel, Missing
6+
from pydantic import ValidationError, BaseModel
7+
8+
from pydantic_partials.partial import AutoPartialExclude
9+
10+
11+
def test_doc_example__index__3():
12+
class TestModel(BaseModel):
13+
name: str
14+
value: str
15+
some_null_by_default_field: str | None = None
16+
17+
class PartialTestModel(PartialModel, TestModel):
18+
pass
19+
20+
try:
21+
# This should produce an error because
22+
# `name` and `value`are required fields.
23+
TestModel()
24+
except ValidationError as e:
25+
print(f'Pydantic will state `name` + `value` are required: {e}')
26+
else:
27+
raise Exception('Pydantic should have required `required_decimal`.')
28+
29+
# We inherit from `TestModel` and add `PartialModel` to the mix.
30+
31+
# `PartialTestModel` can now be allocated without the required fields.
32+
# Any missing required fields will be marked with the `Missing` value
33+
# and won't be serialized out.
34+
obj = PartialTestModel(name='a-name')
35+
36+
assert obj.name == 'a-name'
37+
assert obj.value is Missing
38+
assert obj.some_null_by_default_field is None
39+
40+
# The `None` field value is still serialized out,
41+
# only fields with a `Missing` value assigned are skipped.
42+
assert obj.model_dump() == {
43+
'name': 'a-name', 'some_null_by_default_field': None
44+
}
45+
46+
47+
def test_auto_excluded_inheritance_1():
48+
class TestModel(BaseModel):
49+
name: str
50+
value: str
51+
some_null_by_default_field: str | None = None
52+
53+
class PartialTestModel(PartialModel, TestModel):
54+
value: AutoPartialExclude[str]
55+
56+
with pytest.raises(ValidationError, match=r'1 validation error.*\n *value *\n +Field required') as e_info:
57+
PartialTestModel()
58+
59+
PartialTestModel(value='sss')
60+
61+
62+
def test_auto_excluded_inheritance_w_sibling():
63+
class BaseRequired(BaseModel):
64+
id: AutoPartialExclude[str]
65+
created_at: AutoPartialExclude[datetime]
66+
67+
class TestModel(BaseRequired):
68+
name: str
69+
value: str
70+
some_null_by_default_field: str | None = None
71+
72+
class PartialTestModel(PartialModel, TestModel):
73+
pass
74+
75+
with pytest.raises(
76+
ValidationError,
77+
match=r'2 validation errors[\w\W]*'
78+
r'id[\w\W]*Field required[\w\W]*'
79+
r'created_at[\w\W]*Field required'
80+
):
81+
PartialTestModel()
82+
83+
PartialTestModel(id='sss', created_at=datetime.now())
84+
85+
86+
def test_auto_excluded_inheritance_w_sibling_2():
87+
class PartialRequired(PartialModel, auto_partials_exclude={'id', 'created_at'}):
88+
id: str
89+
created_at: datetime
90+
91+
class TestModel(BaseModel):
92+
id: str
93+
created_at: datetime
94+
name: str
95+
value: str
96+
some_null_by_default_field: str | None = None
97+
98+
class PartialTestModel(TestModel, PartialRequired):
99+
pass
100+
101+
with pytest.raises(
102+
ValidationError,
103+
match=r'2 validation errors[\w\W]*'
104+
r'id[\w\W]*Field required[\w\W]*'
105+
r'created_at[\w\W]*Field required'
106+
):
107+
PartialTestModel()
108+
109+
PartialTestModel(id='sss', created_at=datetime.now())

0 commit comments

Comments
 (0)