Skip to content

Commit

Permalink
Add support for deprecated fields (#8237)
Browse files Browse the repository at this point in the history
Co-authored-by: sydney-runkle <sydneymarierunkle@gmail.com>
  • Loading branch information
Viicos and sydney-runkle committed Feb 29, 2024
1 parent 14e0b7d commit 0eb31fc
Show file tree
Hide file tree
Showing 7 changed files with 460 additions and 13 deletions.
106 changes: 106 additions & 0 deletions docs/concepts/fields.md
Expand Up @@ -692,6 +692,93 @@ print(user.model_dump()) # (1)!

See the [Serialization] section for more details.

## Deprecated fields

The `deprecated` parameter can be used to mark a field as being deprecated. Doing so will result in:

* a runtime deprecation warning emitted when accessing the field.
* `"deprecated": true` being set in the generated JSON schema.

You can set the `deprecated` parameter as one of:

* A string, which will be used as the deprecation message.
* An instance of the `warnings.deprecated` decorator (or the `typing_extensions` backport).
* A boolean, which will be used to mark the field as deprecated without a default `'deprecated'` deprecation message.

### `deprecated` as a string

```py
from typing_extensions import Annotated

from pydantic import BaseModel, Field


class Model(BaseModel):
deprecated_field: Annotated[int, Field(deprecated='This is deprecated')]


print(Model.model_json_schema()['properties']['deprecated_field'])
#> {'deprecated': True, 'title': 'Deprecated Field', 'type': 'integer'}
```

### `deprecated` via the `warnings.deprecated` decorator

```py test="skip"
from typing_extensions import Annotated, deprecated

from pydantic import BaseModel, Field


class Model(BaseModel):
deprecated_field: Annotated[int, deprecated('This is deprecated')]

# Or explicitly using `Field`:
alt_form: Annotated[int, Field(deprecated=deprecated('This is deprecated'))]
```

### `deprecated` as a boolean

```py
from typing_extensions import Annotated

from pydantic import BaseModel, Field


class Model(BaseModel):
deprecated_field: Annotated[int, Field(deprecated=True)]


print(Model.model_json_schema()['properties']['deprecated_field'])
#> {'deprecated': True, 'title': 'Deprecated Field', 'type': 'integer'}
```


!!! note "Support for `category` and `stacklevel`"
The current implementation of this feature does not take into account the `category` and `stacklevel`
arguments to the `deprecated` decorator. This might land in a future version of Pydantic.

!!! warning "Accessing a deprecated field in validators"
When accessing a deprecated field inside a validator, the deprecation warning will be emitted. You can use
[`catch_warnings`][warnings.catch_warnings] to explicitly ignore it:

```py
import warnings

from typing_extensions import Self

from pydantic import BaseModel, Field, model_validator


class Model(BaseModel):
deprecated_field: int = Field(deprecated='This is deprecated')

@model_validator(mode='after')
def validate_model(self) -> Self:
with warnings.catch_warnings():
warnings.simplefilter('ignore', DeprecationWarning)
self.deprecated_field = self.deprecated_field * 2
```

## Customizing JSON Schema

Some field parameters are used exclusively to customize the generated JSON schema. The parameters in question are:
Expand Down Expand Up @@ -733,6 +820,25 @@ print(b.model_dump())
#> {'width': 1.0, 'height': 2.0, 'depth': 3.0, 'volume': 6.0}
```

As with regular fields, computed fields can be marked as being deprecated:

```py
from typing_extensions import deprecated

from pydantic import BaseModel, computed_field


class Box(BaseModel):
width: float
height: float
depth: float

@computed_field
@deprecated("'volume' is deprecated")
def volume(self) -> float:
return self.width * self.height * self.depth
```


[JSON Schema Draft 2020-12]: https://json-schema.org/understanding-json-schema/reference/numeric.html#numeric-types
[Discriminated Unions]: ../concepts/unions.md#discriminated-unions
Expand Down
4 changes: 4 additions & 0 deletions pydantic/_internal/_generate_schema.py
Expand Up @@ -1135,6 +1135,7 @@ def set_discriminator(schema: CoreSchema) -> CoreSchema:
json_schema_updates = {
'title': field_info.title,
'description': field_info.description,
'deprecated': bool(field_info.deprecated) or field_info.deprecated == '' or None,
'examples': to_jsonable_python(field_info.examples),
}
json_schema_updates = {k: v for k, v in json_schema_updates.items() if v is not None}
Expand Down Expand Up @@ -1731,6 +1732,9 @@ def set_computed_field_metadata(schema: CoreSchemaOrField, handler: GetJsonSchem
if description is not None:
json_schema['description'] = description

if d.info.deprecated or d.info.deprecated == '':
json_schema['deprecated'] = True

examples = d.info.examples
if examples is not None:
json_schema['examples'] = to_jsonable_python(examples)
Expand Down
61 changes: 59 additions & 2 deletions pydantic/_internal/_model_construction.py
@@ -1,14 +1,15 @@
"""Private logic for creating models."""
from __future__ import annotations as _annotations

import builtins
import operator
import typing
import warnings
import weakref
from abc import ABCMeta
from functools import partial
from types import FunctionType
from typing import Any, Callable, Generic
from typing import Any, Callable, Generic, NoReturn

import typing_extensions
from pydantic_core import PydanticUndefined, SchemaSerializer
Expand All @@ -18,7 +19,7 @@
from ..plugin._schema_validator import create_schema_validator
from ..warnings import GenericBeforeBaseModelWarning, PydanticDeprecatedSince20
from ._config import ConfigWrapper
from ._decorators import DecoratorInfos, PydanticDescriptorProxy, get_attribute_from_bases
from ._decorators import DecoratorInfos, PydanticDescriptorProxy, get_attribute_from_bases, unwrap_wrapped_function
from ._fields import collect_model_fields, is_valid_field_name, is_valid_privateattr_name
from ._generate_schema import GenerateSchema
from ._generics import PydanticGenericMetadata, get_model_typevars_map
Expand Down Expand Up @@ -211,6 +212,8 @@ def wrapped_model_post_init(self: BaseModel, __context: Any) -> None:
# the generic computed fields return type is set to PydanticUndefined
cls.model_computed_fields = {k: v.info for k, v in cls.__pydantic_decorators__.computed_fields.items()}

set_deprecated_descriptors(cls)

# using super(cls, cls) on the next line ensures we only call the parent class's __pydantic_init_subclass__
# I believe the `type: ignore` is only necessary because mypy doesn't realize that this code branch is
# only hit for _proper_ subclasses of BaseModel
Expand Down Expand Up @@ -571,6 +574,60 @@ def complete_model_class(
return True


def set_deprecated_descriptors(cls: type[BaseModel]) -> None:
"""Set data descriptors on the class for deprecated fields."""
for field, field_info in cls.model_fields.items():
if (msg := field_info.deprecation_message) is not None:
desc = _DeprecatedFieldDescriptor(msg)
desc.__set_name__(cls, field)
setattr(cls, field, desc)

for field, computed_field_info in cls.model_computed_fields.items():
if (
(msg := computed_field_info.deprecation_message) is not None
# Avoid having two warnings emitted:
and not hasattr(unwrap_wrapped_function(computed_field_info.wrapped_property), '__deprecated__')
):
desc = _DeprecatedFieldDescriptor(msg, computed_field_info.wrapped_property)
desc.__set_name__(cls, field)
setattr(cls, field, desc)


class _DeprecatedFieldDescriptor:
"""Data descriptor used to emit a runtime deprecation warning before accessing a deprecated field.
Attributes:
msg: The deprecation message to be emitted.
wrapped_property: The property instance if the deprecated field is a computed field, or `None`.
field_name: The name of the field being deprecated.
"""

field_name: str

def __init__(self, msg: str, wrapped_property: property | None = None) -> None:
self.msg = msg
self.wrapped_property = wrapped_property

def __set_name__(self, cls: type[BaseModel], name: str) -> None:
self.field_name = name

def __get__(self, obj: BaseModel | None, obj_type: type[BaseModel] | None = None) -> Any:
if obj is None:
raise AttributeError(self.field_name)

warnings.warn(self.msg, builtins.DeprecationWarning, stacklevel=2)

if self.wrapped_property is not None:
return self.wrapped_property.__get__(obj, obj_type)
return obj.__dict__[self.field_name]

# Defined to take precedence over the instance's dictionary
# Note that it will not be called when setting a value on a model instance
# as `BaseModel.__setattr__` is defined and takes priority.
def __set__(self, obj: Any, value: Any) -> NoReturn:
raise AttributeError(self.field_name)


class _PydanticWeakRef:
"""Wrapper for `weakref.ref` that enables `pickle` serialization.
Expand Down
12 changes: 11 additions & 1 deletion pydantic/_internal/_typing_extra.py
Expand Up @@ -5,12 +5,13 @@
import sys
import types
import typing
import warnings
from collections.abc import Callable
from functools import partial
from types import GetSetDescriptorType
from typing import TYPE_CHECKING, Any, Final

from typing_extensions import Annotated, Literal, TypeAliasType, TypeGuard, get_args, get_origin
from typing_extensions import Annotated, Literal, TypeAliasType, TypeGuard, deprecated, get_args, get_origin

if TYPE_CHECKING:
from ._dataclasses import StandardDataclass
Expand Down Expand Up @@ -62,6 +63,11 @@ def origin_is_union(tp: type[Any] | None) -> bool:
if hasattr(typing, 'Literal'):
LITERAL_TYPES.add(typing.Literal) # type: ignore

# Check if `deprecated` is a type to prevent errors when using typing_extensions < 4.9.0
DEPRECATED_TYPES: tuple[Any, ...] = (deprecated,) if isinstance(deprecated, type) else ()
if hasattr(warnings, 'deprecated'):
DEPRECATED_TYPES = (*DEPRECATED_TYPES, warnings.deprecated) # type: ignore

NONE_TYPES: tuple[Any, ...] = (None, NoneType, *(tp[None] for tp in LITERAL_TYPES))


Expand All @@ -80,6 +86,10 @@ def is_literal_type(type_: type[Any]) -> bool:
return Literal is not None and get_origin(type_) in LITERAL_TYPES


def is_deprecated_instance(instance: Any) -> TypeGuard[deprecated]:
return isinstance(instance, DEPRECATED_TYPES)


def literal_values(type_: type[Any]) -> tuple[Any, ...]:
return get_args(type_)

Expand Down

0 comments on commit 0eb31fc

Please sign in to comment.