Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

allow Config.field to update a Field #2461

Merged
merged 5 commits into from Mar 3, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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'}