Skip to content

Commit

Permalink
Add warnings/errors for non-annotated fields
Browse files Browse the repository at this point in the history
  • Loading branch information
dmontagu committed Mar 28, 2023
1 parent 98427b3 commit 7d29c6d
Show file tree
Hide file tree
Showing 10 changed files with 198 additions and 112 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.
**`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
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)
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"].',
)

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] = {}
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
# 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,
# 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 7d29c6d

Please sign in to comment.