Skip to content

Commit

Permalink
Add warnings/errors for non-annotated fields (#5263)
Browse files Browse the repository at this point in the history
* Add warnings/errors for non-annotated fields

* Fix docs tests

* Change error message

* Change non_field_types to ignored_types

* Update test name

* Address feedback
  • Loading branch information
dmontagu committed Mar 28, 2023
1 parent d26d58d commit a968512
Show file tree
Hide file tree
Showing 12 changed files with 278 additions and 134 deletions.
7 changes: 4 additions & 3 deletions docs/usage/model_config.md
Expand Up @@ -151,9 +151,10 @@ for validation, for use with `from_attributes`; see [Data binding](models.md#dat
**`alias_generator`**
: a callable that takes a field name and returns an alias for it; see [the dedicated section](#alias-generator)

**`keep_untouched`**
: a tuple of types (e.g. descriptors) for a model's default values that should not be changed during model creation and will
not be included in the model schemas. **Note**: this means that attributes on the model with *defaults of this type*, not *annotations of this type*, will be left alone.
**`ignored_types`**
: a tuple of types that may occur as values of class attributes without annotations; this is typically used for
custom descriptors (classes that behave like `property`). If an attribute is set on a class without an annotation
and has a type that is not in this tuple (or otherwise recognized by pydantic), an error will be raised.

**`schema_extra`**
: a `dict` used to extend/update the generated JSON Schema, or a callable to post-process it; see [schema customization](schema.md#schema-customization)
Expand Down
53 changes: 34 additions & 19 deletions docs/usage/models.md
Expand Up @@ -140,8 +140,8 @@ class Foo(BaseModel):


class Bar(BaseModel):
apple = 'x'
banana = 'y'
apple: str = 'x'
banana: str = 'y'


class Spam(BaseModel):
Expand All @@ -151,9 +151,16 @@ class Spam(BaseModel):

m = Spam(foo={'count': 4}, bars=[{'apple': 'x1'}, {'apple': 'x2'}])
print(m)
#> foo=Foo(count=4, size=None) bars=[Bar(), Bar()]
"""
foo=Foo(count=4, size=None) bars=[Bar(apple='x1', banana='y'), Bar(apple='x2', banana='y')]
"""
print(m.model_dump())
#> {'foo': {'count': 4, 'size': None}, 'bars': [{}, {}]}
"""
{
'foo': {'count': 4, 'size': None},
'bars': [{'apple': 'x1', 'banana': 'y'}, {'apple': 'x2', 'banana': 'y'}],
}
"""
```

For self-referencing models, see [postponed annotations](postponed_annotations.md#self-referencing-models).
Expand Down Expand Up @@ -409,8 +416,8 @@ from pydantic import BaseModel, ValidationError, conint


class Location(BaseModel):
lat = 0.1
lng = 10.1
lat: float = 0.1
lng: float = 10.1


class Model(BaseModel):
Expand All @@ -433,7 +440,7 @@ try:
except ValidationError as e:
print(e)
"""
4 validation errors for Location
5 validation errors for Location
is_required
Field required [type=missing, input_value={'list_of_ints': ['1', 2,...ew York'}, 'gt_int': 21}, input_type=dict]
gt_int
Expand All @@ -442,6 +449,8 @@ except ValidationError as e:
Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='bad', input_type=str]
a_float
Input should be a valid number, unable to parse string as an number [type=float_parsing, input_value='not a float', input_type=str]
recursive_model -> lng
Input should be a valid number, unable to parse string as an number [type=float_parsing, input_value='New York', input_type=str]
"""

try:
Expand Down Expand Up @@ -482,6 +491,12 @@ except ValidationError as e:
'msg': 'Input should be a valid number, unable to parse string as an number',
'input': 'not a float',
},
{
'type': 'float_parsing',
'loc': ('recursive_model', 'lng'),
'msg': 'Input should be a valid number, unable to parse string as an number',
'input': 'New York',
},
]
"""
```
Expand Down Expand Up @@ -580,13 +595,13 @@ from pydantic import BaseModel, ValidationError

class User(BaseModel):
id: int
name = 'John Doe'
name: str = 'John Doe'
signup_ts: datetime = None


m = User.model_validate({'id': 123, 'name': 'James'})
print(m)
#> id=123 signup_ts=None
#> id=123 name='James' signup_ts=None

try:
User.model_validate(['not', 'a', 'dict'])
Expand All @@ -600,7 +615,7 @@ except ValidationError as e:
# assumes json as no content type passed
m = User.model_validate_json('{"id": 123, "name": "James"}')
print(m)
#> id=123 signup_ts=None
#> id=123 name='James' signup_ts=None
```

!!! warning
Expand Down Expand Up @@ -902,7 +917,7 @@ the `create_model` method to allow models to be created on the fly.
```py
from pydantic import BaseModel, create_model

DynamicFoobarModel = create_model('DynamicFoobarModel', foo=(str, ...), bar=123)
DynamicFoobarModel = create_model('DynamicFoobarModel', foo=(str, ...), bar=(int, 123))


class StaticFoobarModel(BaseModel):
Expand Down Expand Up @@ -932,14 +947,14 @@ class FooModel(BaseModel):

BarModel = create_model(
'BarModel',
apple='russet',
banana='yellow',
apple=(str, 'russet'),
banana=(str, 'yellow'),
__base__=FooModel,
)
print(BarModel)
#> <class 'pydantic.main.BarModel'>
print(BarModel.model_fields.keys())
#> dict_keys(['foo', 'bar'])
#> dict_keys(['foo', 'bar', 'apple', 'banana'])
```

You can also add validators by passing a dict to the `__validators__` argument.
Expand Down Expand Up @@ -1167,24 +1182,24 @@ from pydantic import BaseModel, ValidationError

class Model(BaseModel):
a: int
b = 2
b: int = 2
c: int = 1
d = 0
d: int = 0
e: float


print(Model.model_fields.keys())
#> dict_keys(['a', 'c', 'e'])
#> dict_keys(['a', 'b', 'c', 'd', 'e'])
m = Model(e=2, a=1)
print(m.model_dump())
#> {'a': 1, 'c': 1, 'e': 2.0}
#> {'a': 1, 'b': 2, 'c': 1, 'd': 0, 'e': 2.0}
try:
Model(a='x', b='x', c='x', d='x', e='x')
except ValidationError as err:
error_locations = [e['loc'] for e in err.errors()]

print(error_locations)
#> [('a',), ('c',), ('e',)]
#> [('a',), ('b',), ('c',), ('d',), ('e',)]
```

!!! warning
Expand Down
62 changes: 45 additions & 17 deletions pydantic/_internal/_model_construction.py
Expand Up @@ -4,7 +4,6 @@
from __future__ import annotations as _annotations

import typing
import warnings
from functools import partial
from types import FunctionType
from typing import Any, Callable
Expand All @@ -15,6 +14,7 @@
from ..fields import FieldInfo, ModelPrivateAttr, PrivateAttr
from ._core_metadata import build_metadata_dict
from ._core_utils import consolidate_refs, define_expected_missing_refs
from ._decorators import PydanticDecoratorMarker
from ._fields import Undefined, collect_fields
from ._forward_ref import PydanticForwardRef
from ._generate_schema import generate_config, get_model_self_schema, model_fields_schema
Expand All @@ -30,8 +30,7 @@

__all__ = 'object_setattr', 'init_private_attributes', 'inspect_namespace', 'complete_model_class', 'MockValidator'


IGNORED_TYPES: tuple[Any, ...] = (FunctionType, property, type, classmethod, staticmethod)
IGNORED_TYPES: tuple[Any, ...] = (FunctionType, property, type, classmethod, staticmethod, PydanticDecoratorMarker)
object_setattr = object.__setattr__


Expand All @@ -47,16 +46,30 @@ def init_private_attributes(self_: Any, _context: Any) -> None:
object_setattr(self_, name, default)


def inspect_namespace(namespace: dict[str, Any]) -> dict[str, ModelPrivateAttr]:
def inspect_namespace( # noqa C901
namespace: dict[str, Any],
ignored_types: tuple[type[Any], ...],
base_class_vars: set[str],
base_class_fields: set[str],
) -> dict[str, ModelPrivateAttr]:
"""
iterate over the namespace and:
* gather private attributes
* check for items which look like fields but are not (e.g. have no annotation) and warn
"""
all_ignored_types = ignored_types + IGNORED_TYPES

private_attributes: dict[str, ModelPrivateAttr] = {}
raw_annotations = namespace.get('__annotations__', {})

ignored_names: set[str] = set()
for var_name, value in list(namespace.items()):
if isinstance(value, ModelPrivateAttr):
if var_name == 'model_config':
continue
elif isinstance(value, all_ignored_types):
ignored_names.add(var_name)
continue
elif isinstance(value, ModelPrivateAttr):
if var_name.startswith('__'):
raise NameError(
f'Private attributes "{var_name}" must not have dunder names; '
Expand All @@ -69,22 +82,37 @@ def inspect_namespace(namespace: dict[str, Any]) -> dict[str, ModelPrivateAttr]:
)
private_attributes[var_name] = value
del namespace[var_name]
elif not single_underscore(var_name):
elif var_name.startswith('__'):
continue
elif var_name.startswith('_'):
if var_name in raw_annotations and is_classvar(raw_annotations[var_name]):
continue
private_attributes[var_name] = PrivateAttr(default=value)
del namespace[var_name]
elif var_name not in raw_annotations and not isinstance(value, IGNORED_TYPES):
warnings.warn(
f'All fields must include a type annotation; '
f'{var_name!r} looks like a field but has no type annotation.',
DeprecationWarning,
)
if var_name in raw_annotations and not is_classvar(raw_annotations[var_name]):
private_attributes[var_name] = PrivateAttr(default=value)
del namespace[var_name]
elif var_name in base_class_vars:
continue
elif var_name not in raw_annotations:
if var_name in base_class_fields:
raise PydanticUserError(
f'Field {var_name!r} defined on a base class was overridden by a non-annotated attribute. '
f'All field definitions, including overrides, require a type annotation.',
)
elif isinstance(value, FieldInfo):
raise PydanticUserError(f'Field {var_name!r} requires a type annotation')
else:
raise PydanticUserError(
f'A non-annotated attribute was detected: `{var_name} = {value!r}`. All model fields require a '
f'type annotation; if {var_name!r} is not meant to be a field, you may be able to resolve this '
f'error by annotating it as a ClassVar or updating model_config["ignored_types"].',
)

for ann_name, ann_type in raw_annotations.items():
if single_underscore(ann_name) and ann_name not in private_attributes and not is_classvar(ann_type):
if (
single_underscore(ann_name)
and ann_name not in private_attributes
and ann_name not in ignored_names
and not is_classvar(ann_type)
and ann_type not in all_ignored_types
):
private_attributes[ann_name] = PrivateAttr()

return private_attributes
Expand Down
4 changes: 2 additions & 2 deletions pydantic/config.py
Expand Up @@ -60,7 +60,7 @@ class _ConfigDict(TypedDict, total=False):
undefined_types_warning: bool # TODO review docs
from_attributes: bool
alias_generator: Callable[[str], str] | None
keep_untouched: tuple[type, ...] # TODO remove??
ignored_types: tuple[type, ...] # TODO remove??
json_loads: Callable[[str], Any] # TODO decide
json_dumps: Callable[..., str] # TODO decide
json_encoders: dict[type[Any] | str | ForwardRef, Callable[..., Any]] # TODO decide
Expand Down Expand Up @@ -112,7 +112,7 @@ def __missing__(self, key: str) -> Any:
undefined_types_warning=True,
from_attributes=False,
alias_generator=None,
keep_untouched=(),
ignored_types=(),
json_loads=json.loads,
json_dumps=json.dumps,
json_encoders={},
Expand Down
33 changes: 26 additions & 7 deletions pydantic/main.py
Expand Up @@ -70,10 +70,13 @@ def __new__(
**kwargs: Any,
) -> type:
if _base_class_defined:
class_vars: set[str] = set()
base_field_names, class_vars, base_private_attributes = _collect_bases_data(bases)

config_new = build_config(cls_name, bases, namespace, kwargs)
namespace['model_config'] = config_new
private_attributes = _model_construction.inspect_namespace(namespace)
private_attributes = _model_construction.inspect_namespace(
namespace, config_new.get('ignored_types', ()), class_vars, base_field_names
)
if private_attributes:
slots: set[str] = set(namespace.get('__slots__', ()))
namespace['__slots__'] = slots | private_attributes.keys()
Expand All @@ -93,11 +96,6 @@ def __pydantic_post_init__(self_: Any, context: Any) -> None:
elif 'model_post_init' in namespace:
namespace['__pydantic_post_init__'] = namespace['model_post_init']

base_private_attributes: dict[str, ModelPrivateAttr] = {}
for base in bases:
if _base_class_defined and issubclass(base, BaseModel) and base != BaseModel:
base_private_attributes.update(base.__private_attributes__)
class_vars.update(base.__class_vars__)
namespace['__class_vars__'] = class_vars
namespace['__private_attributes__'] = {**base_private_attributes, **private_attributes}

Expand Down Expand Up @@ -134,6 +132,14 @@ def hash_func(self_: Any) -> int:
)

cls.__pydantic_model_complete__ = False # Ensure this specific class gets completed

# preserve `__set_name__` protocol defined in https://peps.python.org/pep-0487
# for attributes not in `new_namespace` (e.g. private attributes)
for name, obj in private_attributes.items():
set_name = getattr(obj, '__set_name__', None)
if callable(set_name):
set_name(cls, name)

_model_construction.complete_model_class(
cls,
cls_name,
Expand Down Expand Up @@ -982,3 +988,16 @@ def __init__(self, __type: Any, *, config: CoreConfig | None = None) -> None:

def __call__(self, __input: Any) -> T:
return self._validator.validate_python(__input)


def _collect_bases_data(bases: tuple[type[Any], ...]) -> tuple[set[str], set[str], dict[str, ModelPrivateAttr]]:
field_names: set[str] = set()
class_vars: set[str] = set()
private_attributes: dict[str, ModelPrivateAttr] = {}
for base in bases:
if _base_class_defined and issubclass(base, BaseModel) and base != BaseModel:
# model_fields might not be defined yet in the case of generics, so we use getattr here:
field_names.update(getattr(base, 'model_fields', {}).keys())
class_vars.update(base.__class_vars__)
private_attributes.update(base.__private_attributes__)
return field_names, class_vars, private_attributes
2 changes: 1 addition & 1 deletion pydantic/mypy.py
Expand Up @@ -463,8 +463,8 @@ def collect_field_from_stmt(self, stmt: Statement, model_config: ModelConfigData
and stmt.rvalue.callee.callee.fullname in DECORATOR_FULLNAMES
):
# This is a (possibly-reused) validator or serializer, not a field
# TODO: We may want to inspect whatever replaces keep_untouched in the config
# In particular, it looks something like: my_validator = validator('my_field', allow_reuse=True)(f)
# Eventually, we may want to attempt to respect model_config['ignored_types']
return None

# The assignment does not have an annotation, and it's not anything else we recognize
Expand Down
12 changes: 1 addition & 11 deletions tests/test_aliases.py
Expand Up @@ -37,16 +37,6 @@ class MyModel(BaseModel):
assert str(e.value) == IsStr(regex="alias_generator <function .*> must return str, not <class 'bytes'>")


@pytest.mark.xfail(reason='working on V2')
def test_cannot_infer_type_with_alias():
# TODO: I don't think we've finalized the exact error that should be raised when fields are missing annotations,
# but this test should be made consistent with that once it is finalized
with pytest.raises(TypeError):

class Model(BaseModel):
a = Field('foobar', alias='_a')


def test_basic_alias():
class Model(BaseModel):
a: str = Field('foobar', alias='_a')
Expand Down Expand Up @@ -112,7 +102,7 @@ class Parent(BaseModel):
x: int = Field(alias='x1', gt=0)

class Child(Parent):
x = Field(..., alias='x2')
x: int = Field(..., alias='x2')
y: int = Field(..., alias='y2')

assert Parent.model_fields['x'].alias == 'x1'
Expand Down

0 comments on commit a968512

Please sign in to comment.