Skip to content

Commit 62bb2ad

Browse files
authored
allow Config.field to update a Field (pydantic#2461)
* allow Config.field to update a Field, fix pydantic#2426 * move logic to update_from_config, work with Annotated * fix flake8 erroneous warnings * test for allow_mutation * better support for allow_mutation
1 parent 3f84d14 commit 62bb2ad

File tree

7 files changed

+132
-9
lines changed

7 files changed

+132
-9
lines changed

changes/2461-samuelcolvin.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
fix: allow elements of `Config.field` to update elements of a `Field`

pydantic/fields.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,8 @@ class FieldInfo(Representation):
106106
'extra',
107107
)
108108

109-
__field_constraints__ = { # field constraints with the default value
109+
# field constraints with the default value, it's also used in update_from_config below
110+
__field_constraints__ = {
110111
'min_length': None,
111112
'max_length': None,
112113
'regex': None,
@@ -153,6 +154,20 @@ def get_constraints(self) -> Set[str]:
153154
"""
154155
return {attr for attr, default in self.__field_constraints__.items() if getattr(self, attr) != default}
155156

157+
def update_from_config(self, from_config: Dict[str, Any]) -> None:
158+
"""
159+
Update this FieldInfo based on a dict from get_field_info, only fields which have not been set are dated.
160+
"""
161+
for attr_name, value in from_config.items():
162+
try:
163+
current_value = getattr(self, attr_name)
164+
except AttributeError:
165+
# attr_name is not an attribute of FieldInfo, it should therefore be added to extra
166+
self.extra[attr_name] = value
167+
else:
168+
if current_value is self.__field_constraints__.get(attr_name, None):
169+
setattr(self, attr_name, value)
170+
156171
def _validate(self) -> None:
157172
if self.default not in (Undefined, Ellipsis) and self.default_factory is not None:
158173
raise ValueError('cannot specify both default and default_factory')
@@ -354,17 +369,20 @@ def _get_field_info(
354369
raise ValueError(f'cannot specify multiple `Annotated` `Field`s for {field_name!r}')
355370
field_info = next(iter(field_infos), None)
356371
if field_info is not None:
372+
field_info.update_from_config(field_info_from_config)
357373
if field_info.default not in (Undefined, Ellipsis):
358374
raise ValueError(f'`Field` default cannot be set in `Annotated` for {field_name!r}')
359375
if value not in (Undefined, Ellipsis):
360376
field_info.default = value
377+
361378
if isinstance(value, FieldInfo):
362379
if field_info is not None:
363380
raise ValueError(f'cannot specify `Annotated` and value `Field`s together for {field_name!r}')
364381
field_info = value
365-
if field_info is None:
382+
field_info.update_from_config(field_info_from_config)
383+
elif field_info is None:
366384
field_info = FieldInfo(value, **field_info_from_config)
367-
field_info.alias = field_info.alias or field_info_from_config.get('alias')
385+
368386
value = None if field_info.default_factory is not None else field_info.default
369387
field_info._validate()
370388
return field_info, value

pydantic/main.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,10 @@ class BaseConfig:
142142

143143
@classmethod
144144
def get_field_info(cls, name: str) -> Dict[str, Any]:
145+
"""
146+
Get properties of FieldInfo from the `fields` property of the config class.
147+
"""
148+
145149
fields_value = cls.fields.get(name)
146150

147151
if isinstance(fields_value, str):

tests/test_annotated.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,10 @@
22
from typing import get_type_hints
33

44
import pytest
5+
from typing_extensions import Annotated
56

67
from pydantic import BaseModel, Field
78
from pydantic.fields import Undefined
8-
from pydantic.typing import Annotated
9-
10-
pytestmark = pytest.mark.skipif(not Annotated, reason='typing_extensions not installed')
119

1210

1311
@pytest.mark.parametrize(
@@ -26,12 +24,12 @@
2624
),
2725
# Test valid Annotated Field uses
2826
pytest.param(
29-
lambda: Annotated[int, Field(description='Test')],
27+
lambda: Annotated[int, Field(description='Test')], # noqa: F821
3028
5,
3129
id='annotated-field-value-default',
3230
),
3331
pytest.param(
34-
lambda: Annotated[int, Field(default_factory=lambda: 5, description='Test')],
32+
lambda: Annotated[int, Field(default_factory=lambda: 5, description='Test')], # noqa: F821
3533
Undefined,
3634
id='annotated-field-default_factory',
3735
),
@@ -132,3 +130,15 @@ class AnnotatedModel(BaseModel):
132130
one: Annotated[int, field]
133131

134132
assert AnnotatedModel(one=1).dict() == {'one': 1}
133+
134+
135+
def test_config_field_info():
136+
class Foo(BaseModel):
137+
a: Annotated[int, Field(foobar='hello')] # noqa: F821
138+
139+
class Config:
140+
fields = {'a': {'description': 'descr'}}
141+
142+
assert Foo.schema(by_alias=True)['properties'] == {
143+
'a': {'title': 'A', 'description': 'descr', 'foobar': 'hello', 'type': 'integer'},
144+
}

tests/test_create_model.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import pytest
22

3-
from pydantic import BaseModel, Extra, ValidationError, create_model, errors, validator
3+
from pydantic import BaseModel, Extra, Field, ValidationError, create_model, errors, validator
44

55

66
def test_create_model():
@@ -194,3 +194,14 @@ class A(BaseModel):
194194

195195
for field_name in ('x', 'y', 'z'):
196196
assert A.__fields__[field_name].default == DynamicA.__fields__[field_name].default
197+
198+
199+
def test_config_field_info_create_model():
200+
class Config:
201+
fields = {'a': {'description': 'descr'}}
202+
203+
m1 = create_model('M1', __config__=Config, a=(str, ...))
204+
assert m1.schema()['properties'] == {'a': {'title': 'A', 'description': 'descr', 'type': 'string'}}
205+
206+
m2 = create_model('M2', __config__=Config, a=(str, Field(...)))
207+
assert m2.schema()['properties'] == {'a': {'title': 'A', 'description': 'descr', 'type': 'string'}}

tests/test_dataclasses.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -901,3 +901,22 @@ class Config:
901901
# ensure the restored dataclass is still a pydantic dataclass
902902
with pytest.raises(ValidationError, match='value\n +value is not a valid integer'):
903903
restored_obj.dataclass.value = 'value of a wrong type'
904+
905+
906+
def test_config_field_info_create_model():
907+
# works
908+
class A1(BaseModel):
909+
a: str
910+
911+
class Config:
912+
fields = {'a': {'description': 'descr'}}
913+
914+
assert A1.schema()['properties'] == {'a': {'title': 'A', 'description': 'descr', 'type': 'string'}}
915+
916+
@pydantic.dataclasses.dataclass(config=A1.Config)
917+
class A2:
918+
a: str
919+
920+
assert A2.__pydantic_model__.schema()['properties'] == {
921+
'a': {'title': 'A', 'description': 'descr', 'type': 'string'}
922+
}

tests/test_edge_cases.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1779,3 +1779,63 @@ class MyModel(BaseModel):
17791779
y: str = 'a'
17801780

17811781
assert list(MyModel()._iter(by_alias=True)) == [('x', 1), ('y', 'a')]
1782+
1783+
1784+
def test_config_field_info():
1785+
class Foo(BaseModel):
1786+
a: str = Field(...)
1787+
1788+
class Config:
1789+
fields = {'a': {'description': 'descr'}}
1790+
1791+
assert Foo.schema(by_alias=True)['properties'] == {'a': {'title': 'A', 'description': 'descr', 'type': 'string'}}
1792+
1793+
1794+
def test_config_field_info_alias():
1795+
class Foo(BaseModel):
1796+
a: str = Field(...)
1797+
1798+
class Config:
1799+
fields = {'a': {'alias': 'b'}}
1800+
1801+
assert Foo.schema(by_alias=True)['properties'] == {'b': {'title': 'B', 'type': 'string'}}
1802+
1803+
1804+
def test_config_field_info_merge():
1805+
class Foo(BaseModel):
1806+
a: str = Field(..., foo='Foo')
1807+
1808+
class Config:
1809+
fields = {'a': {'bar': 'Bar'}}
1810+
1811+
assert Foo.schema(by_alias=True)['properties'] == {
1812+
'a': {'bar': 'Bar', 'foo': 'Foo', 'title': 'A', 'type': 'string'}
1813+
}
1814+
1815+
1816+
def test_config_field_info_allow_mutation():
1817+
class Foo(BaseModel):
1818+
a: str = Field(...)
1819+
1820+
class Config:
1821+
validate_assignment = True
1822+
1823+
assert Foo.__fields__['a'].field_info.allow_mutation is True
1824+
1825+
f = Foo(a='x')
1826+
f.a = 'y'
1827+
assert f.dict() == {'a': 'y'}
1828+
1829+
class Bar(BaseModel):
1830+
a: str = Field(...)
1831+
1832+
class Config:
1833+
fields = {'a': {'allow_mutation': False}}
1834+
validate_assignment = True
1835+
1836+
assert Bar.__fields__['a'].field_info.allow_mutation is False
1837+
1838+
b = Bar(a='x')
1839+
with pytest.raises(TypeError):
1840+
b.a = 'y'
1841+
assert b.dict() == {'a': 'x'}

0 commit comments

Comments
 (0)