From a96851254b7335b33a6de5335d6917f7ed8da8cc Mon Sep 17 00:00:00 2001 From: David Montague <35119617+dmontagu@users.noreply.github.com> Date: Tue, 28 Mar 2023 11:03:56 -0600 Subject: [PATCH] Add warnings/errors for non-annotated fields (#5263) * 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 --- docs/usage/model_config.md | 7 +- docs/usage/models.md | 53 +++++++++------ pydantic/_internal/_model_construction.py | 62 ++++++++++++----- pydantic/config.py | 4 +- pydantic/main.py | 33 +++++++-- pydantic/mypy.py | 2 +- tests/test_aliases.py | 12 +--- tests/test_create_model.py | 82 ++++++++++++++--------- tests/test_edge_cases.py | 66 ++++++++++-------- tests/test_generics.py | 16 +++-- tests/test_main.py | 54 ++++++++++++--- tests/test_private_attributes.py | 21 ++++++ 12 files changed, 278 insertions(+), 134 deletions(-) diff --git a/docs/usage/model_config.md b/docs/usage/model_config.md index a169bf0a9a..3df2ee66bb 100644 --- a/docs/usage/model_config.md +++ b/docs/usage/model_config.md @@ -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) diff --git a/docs/usage/models.md b/docs/usage/models.md index 6142dbb014..6647f91358 100644 --- a/docs/usage/models.md +++ b/docs/usage/models.md @@ -140,8 +140,8 @@ class Foo(BaseModel): class Bar(BaseModel): - apple = 'x' - banana = 'y' + apple: str = 'x' + banana: str = 'y' class Spam(BaseModel): @@ -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). @@ -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): @@ -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 @@ -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: @@ -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', + }, ] """ ``` @@ -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']) @@ -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 @@ -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): @@ -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) #> 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. @@ -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 diff --git a/pydantic/_internal/_model_construction.py b/pydantic/_internal/_model_construction.py index 667abde370..e6d00eeb32 100644 --- a/pydantic/_internal/_model_construction.py +++ b/pydantic/_internal/_model_construction.py @@ -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 @@ -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 @@ -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__ @@ -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; ' @@ -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 diff --git a/pydantic/config.py b/pydantic/config.py index 2d9c3b9194..e9cbde6863 100644 --- a/pydantic/config.py +++ b/pydantic/config.py @@ -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 @@ -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={}, diff --git a/pydantic/main.py b/pydantic/main.py index e561d01708..95f33d66d1 100644 --- a/pydantic/main.py +++ b/pydantic/main.py @@ -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() @@ -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} @@ -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, @@ -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 diff --git a/pydantic/mypy.py b/pydantic/mypy.py index e05f23d188..592d8e3e96 100644 --- a/pydantic/mypy.py +++ b/pydantic/mypy.py @@ -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 diff --git a/tests/test_aliases.py b/tests/test_aliases.py index 413c9c3088..ff575a2d6d 100644 --- a/tests/test_aliases.py +++ b/tests/test_aliases.py @@ -37,16 +37,6 @@ class MyModel(BaseModel): assert str(e.value) == IsStr(regex="alias_generator must return str, not ") -@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') @@ -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' diff --git a/tests/test_create_model.py b/tests/test_create_model.py index d2b4576117..213383e7fb 100644 --- a/tests/test_create_model.py +++ b/tests/test_create_model.py @@ -3,24 +3,27 @@ import pytest from pydantic import BaseModel, ConfigDict, Extra, Field, ValidationError, create_model, errors -from pydantic.decorators import field_validator +from pydantic.decorators import field_validator, validator from pydantic.fields import ModelPrivateAttr -@pytest.mark.xfail(reason='working on V2') def test_create_model(): - model = create_model('FooModel', foo=(str, ...), bar=123) + model = create_model('FooModel', foo=(str, ...), bar=(int, 123)) assert issubclass(model, BaseModel) assert model.model_config == BaseModel.model_config assert model.__name__ == 'FooModel' assert model.model_fields.keys() == {'foo', 'bar'} - assert model.__validators__ == {} + + assert not model.__pydantic_decorators__.validator + assert not model.__pydantic_decorators__.root_validator + assert not model.__pydantic_decorators__.field_validator + assert not model.__pydantic_decorators__.serializer + assert model.__module__ == 'pydantic.main' -@pytest.mark.xfail(reason='working on V2') def test_create_model_usage(): - model = create_model('FooModel', foo=(str, ...), bar=123) + model = create_model('FooModel', foo=(str, ...), bar=(int, 123)) m = model(foo='hello') assert m.foo == 'hello' assert m.bar == 123 @@ -42,7 +45,7 @@ def module(): from pydantic import create_model - FooModel = create_model('FooModel', foo=(str, ...), bar=123, __module__=__name__) + FooModel = create_model('FooModel', foo=(str, ...), bar=(int, 123), __module__=__name__) m = FooModel(foo='hello') d = pickle.dumps(m) @@ -69,11 +72,10 @@ def test_config_and_base(): create_model('FooModel', __config__=BaseModel.model_config, __base__=BaseModel) -@pytest.mark.xfail(reason='working on V2') def test_inheritance(): class BarModel(BaseModel): - x = 1 - y = 2 + x: int = 1 + y: int = 2 model = create_model('FooModel', foo=(str, ...), bar=(int, 123), __base__=BarModel) assert model.model_fields.keys() == {'foo', 'bar', 'x', 'y'} @@ -119,7 +121,6 @@ def test_custom_config_extras(): model(bar=654) -@pytest.mark.xfail(reason='working on V2') def test_inheritance_validators(): class BarModel(BaseModel): @field_validator('a', check_fields=False) @@ -129,14 +130,14 @@ def check_a(cls, v): raise ValueError('"foobar" not found in a') return v - model = create_model('FooModel', a='cake', __base__=BarModel) + model = create_model('FooModel', a=(str, 'cake'), __base__=BarModel) assert model().a == 'cake' assert model(a='this is foobar good').a == 'this is foobar good' with pytest.raises(ValidationError): model(a='something else') -@pytest.mark.xfail(reason='working on V2') +@pytest.mark.xfail(reason='validators do not support always=True') def test_inheritance_validators_always(): class BarModel(BaseModel): @field_validator('a', check_fields=False, always=True) @@ -146,7 +147,7 @@ def check_a(cls, v): raise ValueError('"foobar" not found in a') return v - model = create_model('FooModel', a='cake', __base__=BarModel) + model = create_model('FooModel', a=(str, 'cake'), __base__=BarModel) with pytest.raises(ValidationError): model() assert model(a='this is foobar good').a == 'this is foobar good' @@ -154,19 +155,19 @@ def check_a(cls, v): model(a='something else') -@pytest.mark.xfail(reason='working on V2') def test_inheritance_validators_all(): - class BarModel(BaseModel): - @field_validator('*') - @classmethod - def check_all(cls, v): - return v * 2 + with pytest.warns(DeprecationWarning, match='Pydantic V1 style `@validator` validators are deprecated'): + + class BarModel(BaseModel): + @validator('*') + @classmethod + def check_all(cls, v): + return v * 2 model = create_model('FooModel', a=(int, ...), b=(int, ...), __base__=BarModel) assert model(a=2, b=6).model_dump() == {'a': 4, 'b': 12} -@pytest.mark.xfail(reason='working on V2') def test_funky_name(): model = create_model('FooModel', **{'this-is-funky': (int, ...)}) m = model(**{'this-is-funky': '123'}) @@ -174,29 +175,28 @@ def test_funky_name(): with pytest.raises(ValidationError) as exc_info: model() assert exc_info.value.errors() == [ - {'loc': ('this-is-funky',), 'msg': 'field required', 'type': 'value_error.missing'} + {'input': {}, 'loc': ('this-is-funky',), 'msg': 'Field required', 'type': 'missing'} ] -@pytest.mark.xfail(reason='working on V2') def test_repeat_base_usage(): class Model(BaseModel): a: str assert Model.model_fields.keys() == {'a'} - model = create_model('FooModel', b=1, __base__=Model) + model = create_model('FooModel', b=(int, 1), __base__=Model) assert Model.model_fields.keys() == {'a'} assert model.model_fields.keys() == {'a', 'b'} - model2 = create_model('Foo2Model', c=1, __base__=Model) + model2 = create_model('Foo2Model', c=(int, 1), __base__=Model) assert Model.model_fields.keys() == {'a'} assert model.model_fields.keys() == {'a', 'b'} assert model2.model_fields.keys() == {'a', 'c'} - model3 = create_model('Foo2Model', d=1, __base__=model) + model3 = create_model('Foo2Model', d=(int, 1), __base__=model) assert Model.model_fields.keys() == {'a'} assert model.model_fields.keys() == {'a', 'b'} @@ -216,21 +216,29 @@ class A(BaseModel): assert A.model_fields[field_name].default == DynamicA.model_fields[field_name].default -@pytest.mark.xfail(reason='working on V2') def test_config_field_info_create_model(): # TODO fields doesn't exist anymore, remove test? # class Config: # fields = {'a': {'description': 'descr'}} - config = ConfigDict() + ConfigDict() - m1 = create_model('M1', __config__=config, a=(str, ...)) - assert m1.model_json_schema()['properties'] == {'a': {'title': 'A', 'description': 'descr', 'type': 'string'}} + m1 = create_model('M1', __config__={'title': 'abc'}, a=(str, ...)) + assert m1.model_json_schema() == { + 'properties': {'a': {'title': 'A', 'type': 'string'}}, + 'required': ['a'], + 'title': 'abc', + 'type': 'object', + } - m2 = create_model('M2', __config__=config, a=(str, Field(...))) - assert m2.model_json_schema()['properties'] == {'a': {'title': 'A', 'description': 'descr', 'type': 'string'}} + m2 = create_model('M2', __config__={}, a=(str, Field(description='descr'))) + assert m2.model_json_schema() == { + 'properties': {'a': {'description': 'descr', 'title': 'A', 'type': 'string'}}, + 'required': ['a'], + 'title': 'M2', + 'type': 'object', + } -@pytest.mark.xfail(reason='working on V2') @pytest.mark.parametrize('base', [ModelPrivateAttr, object]) def test_set_name(base): calls = [] @@ -270,3 +278,11 @@ def test_create_model_with_slots(): model = create_model('PartialPet', **field_definitions) assert model.model_fields.keys() == {'foobar'} + + +def test_create_model_non_annotated(): + with pytest.raises( + TypeError, + match='A non-annotated attribute was detected: `bar = 123`. All model fields require a type annotation', + ): + create_model('FooModel', foo=(str, ...), bar=123) diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py index 0eeb7d28eb..124930aefa 100644 --- a/tests/test_edge_cases.py +++ b/tests/test_edge_cases.py @@ -926,11 +926,29 @@ def test_inheritance(): class Foo(BaseModel): a: float = ... - class Bar(Foo): + with pytest.raises( + TypeError, + match=( + "Field 'a' defined on a base class was overridden by a non-annotated attribute. " + 'All field definitions, including overrides, require a type annotation.' + ), + ): + + class Bar(Foo): + x: float = 12.3 + a = 123.0 + + class Bar2(Foo): + x: float = 12.3 + a: float = 123.0 + + assert Bar2().model_dump() == {'x': 12.3, 'a': 123.0} + + class Bar3(Foo): x: float = 12.3 - a = 123.0 + a: float = Field(default=123.0) - assert Bar().model_dump() == {'x': 12.3, 'a': 123.0} + assert Bar3().model_dump() == {'x': 12.3, 'a': 123.0} def test_inheritance_subclass_default(): @@ -949,7 +967,7 @@ class Base(BaseModel): y: str class Sub(Base): - x = MyStr('test') + x: str = MyStr('test') y: MyStr = MyStr('test') # force subtype model_config = dict(arbitrary_types_allowed=True) @@ -1058,18 +1076,13 @@ class Child(Parent): ] -@pytest.mark.xfail(reason='working on V2') def test_annotation_inheritance(): class A(BaseModel): integer: int = 1 class B(A): - integer = 2 + integer: int = 2 - if sys.version_info < (3, 10): - assert B.__annotations__['integer'] == int - else: - assert B.__annotations__ == {} assert B.model_fields['integer'].annotation == int class C(A): @@ -1078,20 +1091,17 @@ class C(A): assert C.__annotations__['integer'] == str assert C.model_fields['integer'].annotation == str - with pytest.raises(TypeError) as exc_info: + with pytest.raises( + TypeError, + match=( + "Field 'integer' defined on a base class was overridden by a non-annotated attribute. " + "All field definitions, including overrides, require a type annotation." + ), + ): class D(A): integer = 'G' - # TODO: Do we want any changes to this behavior in v2? (Currently, no error is raised) - # "I think it should be an error to redefine any field without an annotation - that way we - # don't need to start trying to infer the type of the default value." - # https://github.com/pydantic/pydantic/pull/5151#discussion_r1130681812 - assert str(exc_info.value) == ( - 'The type of D.integer differs from the new default value; ' - 'if you wish to change the type of this field, please use a type annotation' - ) - def test_string_none(): class Model(BaseModel): @@ -1167,19 +1177,19 @@ class InvalidValidatorModel(BaseModel): x: InvalidValidator = ... -@pytest.mark.xfail(reason='working on V2') def test_unable_to_infer(): - with pytest.raises(errors.PydanticUserError) as exc_info: + with pytest.raises( + errors.PydanticUserError, + match=re.escape( + "A non-annotated attribute was detected: `x = None`. All model fields require a type annotation; " + "if 'x' is not meant to be a field, you may be able to resolve this error by annotating it as a " + "ClassVar or updating model_config[\"ignored_types\"]" + ), + ): class InvalidDefinitionModel(BaseModel): x = None - # TODO: Do we want any changes to this behavior in v2? (Currently, no error is raised) - # "x definitely shouldn't be a field, I guess an error would be best, - # but might be hard to identify 'non-field attributes reliable'?" - # https://github.com/pydantic/pydantic/pull/5151#discussion_r1130682562 - assert exc_info.value.args[0] == 'unable to infer type for attribute "x"' - def test_multiple_errors(): class Model(BaseModel): diff --git a/tests/test_generics.py b/tests/test_generics.py index 80233dab29..5869f8ac72 100644 --- a/tests/test_generics.py +++ b/tests/test_generics.py @@ -37,6 +37,7 @@ Field, Json, PositiveInt, + PydanticUserError, ValidationError, ValidationInfo, root_validator, @@ -217,15 +218,22 @@ class Result(BaseModel, Generic[T]): assert 'other' not in Result.model_fields -# TODO: Replace this test with a test that ensures the same warning message about non-annotated fields is raised -# for generic and non-generic models. Requires https://github.com/pydantic/pydantic/issues/5014 -@pytest.mark.xfail(reason='working on V2 - non-annotated fields - issue #5014') def test_non_annotated_field(): T = TypeVar('T') + with pytest.raises(PydanticUserError, match='A non-annotated attribute was detected: `other = True`'): + + class Result(BaseModel, Generic[T]): + data: T + other = True + + +def test_non_generic_field(): + T = TypeVar('T') + class Result(BaseModel, Generic[T]): data: T - other = True + other: bool = True assert 'other' in Result.model_fields assert 'other' in Result[int].model_fields diff --git a/tests/test_main.py b/tests/test_main.py index 94c6c5dc1b..54ddec2174 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,4 +1,5 @@ import platform +import re import sys from collections import defaultdict from copy import deepcopy @@ -24,7 +25,17 @@ import pytest from typing_extensions import Final, Literal -from pydantic import BaseModel, ConfigDict, Extra, Field, PrivateAttr, SecretStr, ValidationError, constr +from pydantic import ( + BaseModel, + ConfigDict, + Extra, + Field, + PrivateAttr, + PydanticUserError, + SecretStr, + ValidationError, + constr, +) def test_success(): @@ -698,12 +709,12 @@ class BadModel(BaseModel): def test_value_field_name_shadows_attribute(): - class BadModel(BaseModel): - model_json_schema = ( - 'abc' # This conflicts with the BaseModel's model_json_schema() class method, but has no annotation - ) + with pytest.raises(PydanticUserError, match="A non-annotated attribute was detected: `model_json_schema = 'abc'`"): - assert len(BadModel.model_fields) == 0 + class BadModel(BaseModel): + model_json_schema = ( + 'abc' # This conflicts with the BaseModel's model_json_schema() class method, but has no annotation + ) def test_class_var(): @@ -840,7 +851,7 @@ class MyModel(BaseModel): assert m.model_dump(by_alias=True) == {'alias_a': None, 'extra_key': 'extra'} -def test_untouched_types(): +def test_ignored_types(): from pydantic import BaseModel class _ClassPropertyDescriptor: @@ -853,7 +864,7 @@ def __get__(self, instance, owner): classproperty = _ClassPropertyDescriptor class Model(BaseModel): - model_config = ConfigDict(keep_untouched=(classproperty,)) + model_config = ConfigDict(ignored_types=(classproperty,)) @classproperty def class_name(cls) -> str: @@ -1232,6 +1243,31 @@ class Child(Parent): assert actual == expected, 'Unexpected model export result' +def test_untyped_fields_warning(): + with pytest.raises( + PydanticUserError, + match=re.escape( + "A non-annotated attribute was detected: `x = 1`. All model fields require a type annotation; " + "if 'x' is not meant to be a field, you may be able to resolve this error by annotating it " + "as a ClassVar or updating model_config[\"ignored_types\"]." + ), + ): + + class WarningModel(BaseModel): + x = 1 + + # Prove that annotating with ClassVar prevents the warning + class NonWarningModel(BaseModel): + x: ClassVar = 1 + + +def test_untyped_fields_error(): + with pytest.raises(TypeError, match="Field 'a' requires a type annotation"): + + class Model(BaseModel): + a = Field('foobar') + + def test_custom_init_subclass_params(): class DerivedModel(BaseModel): def __init_subclass__(cls, something): @@ -1243,7 +1279,7 @@ def __init_subclass__(cls, something): # to allow the special method __init_subclass__ to be defined with custom # parameters on extended BaseModel classes. class NewModel(DerivedModel, something=2): - something = 1 + something: ClassVar = 1 assert NewModel.something == 2 diff --git a/tests/test_private_attributes.py b/tests/test_private_attributes.py index 95d24071ae..cf3c91d829 100644 --- a/tests/test_private_attributes.py +++ b/tests/test_private_attributes.py @@ -299,3 +299,24 @@ def test_private_attributes_not_dunder() -> None: class MyModel(BaseModel): __foo__ = PrivateAttr({'private'}) + + +def test_ignored_types_are_ignored() -> None: + class IgnoredType: + pass + + class MyModel(BaseModel): + model_config = ConfigDict(ignored_types=(IgnoredType,)) + + _a = IgnoredType() + _b: int = IgnoredType() + _c: IgnoredType + _d: IgnoredType = IgnoredType() + + # The following are included to document existing behavior, and can be updated + # if the current behavior does not match the desired behavior + _e: int + _f: int = 1 + _g = 1 # Note: this is completely ignored, in keeping with v1 + + assert sorted(MyModel.__private_attributes__.keys()) == ['_e', '_f']