Skip to content

Commit

Permalink
Raise a deprecation warning when a field is annotated as final with a…
Browse files Browse the repository at this point in the history
… default value (pydantic#11168)
  • Loading branch information
Viicos authored Dec 27, 2024
1 parent daa0732 commit d823d8c
Show file tree
Hide file tree
Showing 4 changed files with 52 additions and 23 deletions.
6 changes: 6 additions & 0 deletions pydantic/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@
PydanticDeprecatedSince20,
PydanticDeprecatedSince26,
PydanticDeprecatedSince29,
PydanticDeprecatedSince210,
PydanticDeprecatedSince211,
PydanticDeprecationWarning,
PydanticExperimentalWarning,
)
Expand Down Expand Up @@ -215,6 +217,8 @@
'PydanticDeprecatedSince20',
'PydanticDeprecatedSince26',
'PydanticDeprecatedSince29',
'PydanticDeprecatedSince210',
'PydanticDeprecatedSince211',
'PydanticDeprecationWarning',
'PydanticExperimentalWarning',
# annotated handlers
Expand Down Expand Up @@ -370,6 +374,8 @@
'PydanticDeprecatedSince20': (__spec__.parent, '.warnings'),
'PydanticDeprecatedSince26': (__spec__.parent, '.warnings'),
'PydanticDeprecatedSince29': (__spec__.parent, '.warnings'),
'PydanticDeprecatedSince210': (__spec__.parent, '.warnings'),
'PydanticDeprecatedSince211': (__spec__.parent, '.warnings'),
'PydanticDeprecationWarning': (__spec__.parent, '.warnings'),
'PydanticExperimentalWarning': (__spec__.parent, '.warnings'),
# annotated handlers
Expand Down
48 changes: 29 additions & 19 deletions pydantic/_internal/_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from pydantic_core import PydanticUndefined
from typing_extensions import TypeIs

from pydantic import PydanticDeprecatedSince211
from pydantic.errors import PydanticUserError

from . import _typing_extra
Expand Down Expand Up @@ -158,7 +159,18 @@ def collect_model_fields( # noqa: C901
if _typing_extra.is_classvar_annotation(ann_type):
class_vars.add(ann_name)
continue
if _is_finalvar_with_default_val(ann_type, getattr(cls, ann_name, PydanticUndefined)):

assigned_value = getattr(cls, ann_name, PydanticUndefined)

if _is_finalvar_with_default_val(ann_type, assigned_value):
warnings.warn(
f'Annotation {ann_name!r} is marked as final and has a default value. Pydantic treats {ann_name!r} as a '
'class variable, but it will be considered as a normal field in V3 to be aligned with dataclasses. If you '
f'still want {ann_name!r} to be considered as a class variable, annotate it as: `ClassVar[<type>] = <default>.`',
category=PydanticDeprecatedSince211,
# Incorrect when `create_model` is used, but the chance that final with a default is used is low in that case:
stacklevel=4,
)
class_vars.add(ann_name)
continue
if not is_valid_field_name(ann_name):
Expand Down Expand Up @@ -195,11 +207,7 @@ def collect_model_fields( # noqa: C901
UserWarning,
)

try:
default = getattr(cls, ann_name, PydanticUndefined)
if default is PydanticUndefined:
raise AttributeError
except AttributeError:
if assigned_value is PydanticUndefined:
if ann_name in annotations:
field_info = FieldInfo_.from_annotation(ann_type)
field_info.evaluated = evaluated
Expand All @@ -218,14 +226,15 @@ def collect_model_fields( # noqa: C901
field_info.evaluated = evaluated
else:
_warn_on_nested_alias_in_annotation(ann_type, ann_name)
if isinstance(default, FieldInfo_) and ismethoddescriptor(default.default):
# the `getattr` call above triggers a call to `__get__` for descriptors, so we do
# the same if the `= field(default=...)` form is used. Note that we only do this
# for method descriptors for now, we might want to extend this to any descriptor
# in the future (by simply checking for `hasattr(default.default, '__get__')`).
default.default = default.default.__get__(None, cls)

field_info = FieldInfo_.from_annotated_attribute(ann_type, default)
if isinstance(assigned_value, FieldInfo_) and ismethoddescriptor(assigned_value.default):
# `assigned_value` was fetched using `getattr`, which triggers a call to `__get__`
# for descriptors, so we do the same if the `= field(default=...)` form is used.
# Note that we only do this for method descriptors for now, we might want to
# extend this to any descriptor in the future (by simply checking for
# `hasattr(assigned_value.default, '__get__')`).
assigned_value.default = assigned_value.default.__get__(None, cls)

field_info = FieldInfo_.from_annotated_attribute(ann_type, assigned_value)
field_info.evaluated = evaluated
# attributes which are fields are removed from the class namespace:
# 1. To match the behaviour of annotation-only fields
Expand Down Expand Up @@ -266,14 +275,15 @@ def _warn_on_nested_alias_in_annotation(ann_type: type[Any], ann_name: str) -> N
return


def _is_finalvar_with_default_val(type_: type[Any], val: Any) -> bool:
def _is_finalvar_with_default_val(ann_type: type[Any], assigned_value: Any) -> bool:
if assigned_value is PydanticUndefined:
return False

FieldInfo = import_cached_field_info()

if not _typing_extra.is_finalvar(type_):
return False
elif val is PydanticUndefined:
if isinstance(assigned_value, FieldInfo) and assigned_value.is_required():
return False
elif isinstance(val, FieldInfo) and (val.default is PydanticUndefined and val.default_factory is None):
elif not _typing_extra.is_finalvar(ann_type):
return False
else:
return True
Expand Down
12 changes: 11 additions & 1 deletion pydantic/warnings.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@

__all__ = (
'PydanticDeprecatedSince20',
'PydanticDeprecationWarning',
'PydanticDeprecatedSince26',
'PydanticDeprecatedSince29',
'PydanticDeprecatedSince210',
'PydanticDeprecatedSince211',
'PydanticDeprecationWarning',
'PydanticExperimentalWarning',
)

Expand Down Expand Up @@ -74,6 +77,13 @@ def __init__(self, message: str, *args: object) -> None:
super().__init__(message, *args, since=(2, 10), expected_removal=(3, 0))


class PydanticDeprecatedSince211(PydanticDeprecationWarning):
"""A specific `PydanticDeprecationWarning` subclass defining functionality deprecated since Pydantic 2.11."""

def __init__(self, message: str, *args: object) -> None:
super().__init__(message, *args, since=(2, 11), expected_removal=(3, 0))


class GenericBeforeBaseModelWarning(Warning):
pass

Expand Down
9 changes: 6 additions & 3 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
Field,
GetCoreSchemaHandler,
PrivateAttr,
PydanticDeprecatedSince211,
PydanticUndefinedAnnotation,
PydanticUserError,
SecretStr,
Expand Down Expand Up @@ -2010,9 +2011,11 @@ class Model(BaseModel):
[Final, Final[int]],
ids=['no-arg', 'with-arg'],
)
def test_final_field_decl_with_default_val(ann):
class Model(BaseModel):
a: ann = 10
def test_deprecated_final_field_decl_with_default_val(ann):
with pytest.warns(PydanticDeprecatedSince211):

class Model(BaseModel):
a: ann = 10

assert 'a' in Model.__class_vars__
assert 'a' not in Model.model_fields
Expand Down

0 comments on commit d823d8c

Please sign in to comment.