Skip to content

Commit

Permalink
allow Config.field to update a Field (#2461)
Browse files Browse the repository at this point in the history
* allow Config.field to update a Field, fix #2426

* move logic to update_from_config, work with Annotated

* fix flake8 erroneous warnings

* test for allow_mutation

* better support for allow_mutation
  • Loading branch information
samuelcolvin committed Mar 3, 2021
1 parent 3f84d14 commit 62bb2ad
Show file tree
Hide file tree
Showing 7 changed files with 132 additions and 9 deletions.
1 change: 1 addition & 0 deletions changes/2461-samuelcolvin.md
@@ -0,0 +1 @@
fix: allow elements of `Config.field` to update elements of a `Field`
24 changes: 21 additions & 3 deletions pydantic/fields.py
Expand Up @@ -106,7 +106,8 @@ class FieldInfo(Representation):
'extra',
)

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

def update_from_config(self, from_config: Dict[str, Any]) -> None:
"""
Update this FieldInfo based on a dict from get_field_info, only fields which have not been set are dated.
"""
for attr_name, value in from_config.items():
try:
current_value = getattr(self, attr_name)
except AttributeError:
# attr_name is not an attribute of FieldInfo, it should therefore be added to extra
self.extra[attr_name] = value
else:
if current_value is self.__field_constraints__.get(attr_name, None):
setattr(self, attr_name, value)

def _validate(self) -> None:
if self.default not in (Undefined, Ellipsis) and self.default_factory is not None:
raise ValueError('cannot specify both default and default_factory')
Expand Down Expand Up @@ -354,17 +369,20 @@ def _get_field_info(
raise ValueError(f'cannot specify multiple `Annotated` `Field`s for {field_name!r}')
field_info = next(iter(field_infos), None)
if field_info is not None:
field_info.update_from_config(field_info_from_config)
if field_info.default not in (Undefined, Ellipsis):
raise ValueError(f'`Field` default cannot be set in `Annotated` for {field_name!r}')
if value not in (Undefined, Ellipsis):
field_info.default = value

if isinstance(value, FieldInfo):
if field_info is not None:
raise ValueError(f'cannot specify `Annotated` and value `Field`s together for {field_name!r}')
field_info = value
if field_info is None:
field_info.update_from_config(field_info_from_config)
elif field_info is None:
field_info = FieldInfo(value, **field_info_from_config)
field_info.alias = field_info.alias or field_info_from_config.get('alias')

value = None if field_info.default_factory is not None else field_info.default
field_info._validate()
return field_info, value
Expand Down
4 changes: 4 additions & 0 deletions pydantic/main.py
Expand Up @@ -142,6 +142,10 @@ class BaseConfig:

@classmethod
def get_field_info(cls, name: str) -> Dict[str, Any]:
"""
Get properties of FieldInfo from the `fields` property of the config class.
"""

fields_value = cls.fields.get(name)

if isinstance(fields_value, str):
Expand Down
20 changes: 15 additions & 5 deletions tests/test_annotated.py
Expand Up @@ -2,12 +2,10 @@
from typing import get_type_hints

import pytest
from typing_extensions import Annotated

from pydantic import BaseModel, Field
from pydantic.fields import Undefined
from pydantic.typing import Annotated

pytestmark = pytest.mark.skipif(not Annotated, reason='typing_extensions not installed')


@pytest.mark.parametrize(
Expand All @@ -26,12 +24,12 @@
),
# Test valid Annotated Field uses
pytest.param(
lambda: Annotated[int, Field(description='Test')],
lambda: Annotated[int, Field(description='Test')], # noqa: F821
5,
id='annotated-field-value-default',
),
pytest.param(
lambda: Annotated[int, Field(default_factory=lambda: 5, description='Test')],
lambda: Annotated[int, Field(default_factory=lambda: 5, description='Test')], # noqa: F821
Undefined,
id='annotated-field-default_factory',
),
Expand Down Expand Up @@ -132,3 +130,15 @@ class AnnotatedModel(BaseModel):
one: Annotated[int, field]

assert AnnotatedModel(one=1).dict() == {'one': 1}


def test_config_field_info():
class Foo(BaseModel):
a: Annotated[int, Field(foobar='hello')] # noqa: F821

class Config:
fields = {'a': {'description': 'descr'}}

assert Foo.schema(by_alias=True)['properties'] == {
'a': {'title': 'A', 'description': 'descr', 'foobar': 'hello', 'type': 'integer'},
}
13 changes: 12 additions & 1 deletion tests/test_create_model.py
@@ -1,6 +1,6 @@
import pytest

from pydantic import BaseModel, Extra, ValidationError, create_model, errors, validator
from pydantic import BaseModel, Extra, Field, ValidationError, create_model, errors, validator


def test_create_model():
Expand Down Expand Up @@ -194,3 +194,14 @@ class A(BaseModel):

for field_name in ('x', 'y', 'z'):
assert A.__fields__[field_name].default == DynamicA.__fields__[field_name].default


def test_config_field_info_create_model():
class Config:
fields = {'a': {'description': 'descr'}}

m1 = create_model('M1', __config__=Config, a=(str, ...))
assert m1.schema()['properties'] == {'a': {'title': 'A', 'description': 'descr', 'type': 'string'}}

m2 = create_model('M2', __config__=Config, a=(str, Field(...)))
assert m2.schema()['properties'] == {'a': {'title': 'A', 'description': 'descr', 'type': 'string'}}
19 changes: 19 additions & 0 deletions tests/test_dataclasses.py
Expand Up @@ -901,3 +901,22 @@ class Config:
# ensure the restored dataclass is still a pydantic dataclass
with pytest.raises(ValidationError, match='value\n +value is not a valid integer'):
restored_obj.dataclass.value = 'value of a wrong type'


def test_config_field_info_create_model():
# works
class A1(BaseModel):
a: str

class Config:
fields = {'a': {'description': 'descr'}}

assert A1.schema()['properties'] == {'a': {'title': 'A', 'description': 'descr', 'type': 'string'}}

@pydantic.dataclasses.dataclass(config=A1.Config)
class A2:
a: str

assert A2.__pydantic_model__.schema()['properties'] == {
'a': {'title': 'A', 'description': 'descr', 'type': 'string'}
}
60 changes: 60 additions & 0 deletions tests/test_edge_cases.py
Expand Up @@ -1779,3 +1779,63 @@ class MyModel(BaseModel):
y: str = 'a'

assert list(MyModel()._iter(by_alias=True)) == [('x', 1), ('y', 'a')]


def test_config_field_info():
class Foo(BaseModel):
a: str = Field(...)

class Config:
fields = {'a': {'description': 'descr'}}

assert Foo.schema(by_alias=True)['properties'] == {'a': {'title': 'A', 'description': 'descr', 'type': 'string'}}


def test_config_field_info_alias():
class Foo(BaseModel):
a: str = Field(...)

class Config:
fields = {'a': {'alias': 'b'}}

assert Foo.schema(by_alias=True)['properties'] == {'b': {'title': 'B', 'type': 'string'}}


def test_config_field_info_merge():
class Foo(BaseModel):
a: str = Field(..., foo='Foo')

class Config:
fields = {'a': {'bar': 'Bar'}}

assert Foo.schema(by_alias=True)['properties'] == {
'a': {'bar': 'Bar', 'foo': 'Foo', 'title': 'A', 'type': 'string'}
}


def test_config_field_info_allow_mutation():
class Foo(BaseModel):
a: str = Field(...)

class Config:
validate_assignment = True

assert Foo.__fields__['a'].field_info.allow_mutation is True

f = Foo(a='x')
f.a = 'y'
assert f.dict() == {'a': 'y'}

class Bar(BaseModel):
a: str = Field(...)

class Config:
fields = {'a': {'allow_mutation': False}}
validate_assignment = True

assert Bar.__fields__['a'].field_info.allow_mutation is False

b = Bar(a='x')
with pytest.raises(TypeError):
b.a = 'y'
assert b.dict() == {'a': 'x'}

0 comments on commit 62bb2ad

Please sign in to comment.