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

Add warnings/errors for non-annotated fields #5263

Merged
merged 6 commits into from Mar 28, 2023
Merged
Show file tree
Hide file tree
Changes from 2 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
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.
**`non_field_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
47 changes: 32 additions & 15 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)
dmontagu marked this conversation as resolved.
Show resolved Hide resolved
object_setattr = object.__setattr__


Expand All @@ -47,15 +46,24 @@ 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],
non_field_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_non_field_types = non_field_types + IGNORED_TYPES

private_attributes: dict[str, ModelPrivateAttr] = {}
raw_annotations = namespace.get('__annotations__', {})
for var_name, value in list(namespace.items()):
if var_name == 'model_config':
continue
if isinstance(value, ModelPrivateAttr):
if var_name.startswith('__'):
raise NameError(
Expand All @@ -69,19 +77,28 @@ 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 and not isinstance(value, all_non_field_types):
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 suppress this '
f'warning by annotating it as a ClassVar or updating model_config["non_field_types"].',
dmontagu marked this conversation as resolved.
Show resolved Hide resolved
)

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):
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??
non_field_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=(),
non_field_types=(),
json_loads=json.loads,
json_dumps=json.dumps,
json_encoders={},
Expand Down
30 changes: 23 additions & 7 deletions pydantic/main.py
Expand Up @@ -11,7 +11,7 @@
from enum import Enum
from functools import partial
from inspect import getdoc
from types import prepare_class, resolve_bases
from types import FunctionType, prepare_class, resolve_bases
from typing import Any, Generic, TypeVar, overload

import typing_extensions
Expand Down Expand Up @@ -56,6 +56,8 @@
# the `BaseModel` class, since that's defined immediately after the metaclass.
_base_class_defined = False

PYDANTIC_NON_FIELD_TYPES = (property, type, classmethod, staticmethod, FunctionType)


@typing_extensions.dataclass_transform(kw_only_default=True, field_specifiers=(Field,))
class ModelMetaclass(ABCMeta):
Expand All @@ -71,9 +73,20 @@ def __new__(
) -> type:
if _base_class_defined:
class_vars: set[str] = set()
base_private_attributes: dict[str, ModelPrivateAttr] = {}
dmontagu marked this conversation as resolved.
Show resolved Hide resolved
base_field_names: set[str] = set()
for base in bases:
if _base_class_defined and issubclass(base, BaseModel) and base != BaseModel:
class_vars.update(base.__class_vars__)
base_private_attributes.update(base.__private_attributes__)
# model_fields might not be defined yet in the case of generics, so we use getattr
base_field_names.update(getattr(base, 'model_fields', {}).keys())

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('non_field_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 +106,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 +142,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
dmontagu marked this conversation as resolved.
Show resolved Hide resolved
# 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
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['non_field_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,
dmontagu marked this conversation as resolved.
Show resolved Hide resolved
# 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