Skip to content

Commit

Permalink
Do not evaluate annotations for private fields (#10962)
Browse files Browse the repository at this point in the history
  • Loading branch information
Viicos authored and sydney-runkle committed Nov 26, 2024
1 parent d6fc7fc commit 7c0ed72
Show file tree
Hide file tree
Showing 4 changed files with 54 additions and 30 deletions.
3 changes: 1 addition & 2 deletions pydantic/_internal/_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ def collect_model_fields( # noqa: C901
if model_fields := getattr(base, '__pydantic_fields__', None):
parent_fields_lookup.update(model_fields)

type_hints = _typing_extra.get_cls_type_hints(cls, ns_resolver=ns_resolver, lenient=True)
type_hints = _typing_extra.get_model_type_hints(cls, ns_resolver=ns_resolver)

# https://docs.python.org/3/howto/annotations.html#accessing-the-annotations-dict-of-an-object-in-python-3-9-and-older
# annotations is only used for finding fields in parent classes
Expand Down Expand Up @@ -216,7 +216,6 @@ def collect_model_fields( # noqa: C901
# Nothing stops us from just creating a new FieldInfo for this type hint, so we do this.
field_info = FieldInfo_.from_annotation(ann_type)
field_info.evaluated = evaluated

else:
_warn_on_nested_alias_in_annotation(ann_type, ann_name)
if isinstance(default, FieldInfo_) and ismethoddescriptor(default.default):
Expand Down
8 changes: 2 additions & 6 deletions pydantic/_internal/_generate_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -1432,9 +1432,7 @@ def _typed_dict_schema(self, typed_dict_cls: Any, origin: Any) -> core_schema.Co
field_docstrings = None

try:
annotations = _typing_extra.get_cls_type_hints(
typed_dict_cls, ns_resolver=self._ns_resolver, lenient=False
)
annotations = _typing_extra.get_cls_type_hints(typed_dict_cls, ns_resolver=self._ns_resolver)
except NameError as e:
raise PydanticUndefinedAnnotation.from_name_error(e) from e

Expand Down Expand Up @@ -1496,9 +1494,7 @@ def _namedtuple_schema(self, namedtuple_cls: Any, origin: Any) -> core_schema.Co
namedtuple_cls = origin

try:
annotations = _typing_extra.get_cls_type_hints(
namedtuple_cls, ns_resolver=self._ns_resolver, lenient=False
)
annotations = _typing_extra.get_cls_type_hints(namedtuple_cls, ns_resolver=self._ns_resolver)
except NameError as e:
raise PydanticUndefinedAnnotation.from_name_error(e) from e
if not annotations:
Expand Down
66 changes: 44 additions & 22 deletions pydantic/_internal/_typing_extra.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import typing
import warnings
from functools import lru_cache, partial
from typing import Any, Callable, Literal, overload
from typing import TYPE_CHECKING, Any, Callable

import typing_extensions
from typing_extensions import TypeIs, deprecated, get_args, get_origin
Expand All @@ -23,6 +23,8 @@
from types import EllipsisType as EllipsisType
from types import NoneType as NoneType

if TYPE_CHECKING:
from pydantic import BaseModel

# See https://typing-extensions.readthedocs.io/en/latest/#runtime-use-of-types:

Expand Down Expand Up @@ -467,34 +469,57 @@ def _type_convert(arg: Any) -> Any:
return arg


@overload
def get_cls_type_hints(
obj: type[Any],
*,
ns_resolver: NsResolver | None = None,
lenient: Literal[True],
) -> dict[str, tuple[Any, bool]]: ...
@overload
def get_cls_type_hints(
obj: type[Any],
def get_model_type_hints(
obj: type[BaseModel],
*,
ns_resolver: NsResolver | None = None,
lenient: Literal[False] = ...,
) -> dict[str, Any]: ...
) -> dict[str, tuple[Any, bool]]:
"""Collect annotations from a Pydantic model class, including those from parent classes.
Args:
obj: The Pydantic model to inspect.
ns_resolver: A namespace resolver instance to use. Defaults to an empty instance.
Returns:
A dictionary mapping annotation names to a two-tuple: the first element is the evaluated
type or the original annotation if a `NameError` occurred, the second element is a boolean
indicating if whether the evaluation succeeded.
"""
hints: dict[str, Any] | dict[str, tuple[Any, bool]] = {}
ns_resolver = ns_resolver or NsResolver()

for base in reversed(obj.__mro__):
ann: dict[str, Any] | None = base.__dict__.get('__annotations__')
if not ann or isinstance(ann, types.GetSetDescriptorType):
continue
with ns_resolver.push(base):
globalns, localns = ns_resolver.types_namespace
for name, value in ann.items():
if name.startswith('_'):
# For private attributes, we only need the annotation to detect the `ClassVar` special form.
# For this reason, we still try to evaluate it, but we also catch any possible exception (on
# top of the `NameError`s caught in `try_eval_type`) that could happen so that users are free
# to use any kind of forward annotation for private fields (e.g. circular imports, new typing
# syntax, etc).
try:
hints[name] = try_eval_type(value, globalns, localns)
except Exception:
hints[name] = (value, False)
else:
hints[name] = try_eval_type(value, globalns, localns)
return hints


def get_cls_type_hints(
obj: type[Any],
*,
ns_resolver: NsResolver | None = None,
lenient: bool = False,
) -> dict[str, Any] | dict[str, tuple[Any, bool]]:
) -> dict[str, Any]:
"""Collect annotations from a class, including those from parent classes.
Args:
obj: The class to inspect.
ns_resolver: A namespace resolver instance to use. Defaults to an empty instance.
lenient: Whether to keep unresolvable annotations as is or re-raise the `NameError` exception.
If lenient, an extra boolean flag is set for each annotation value to indicate whether the
evaluation succeeded or not. Default: re-raise.
"""
hints: dict[str, Any] | dict[str, tuple[Any, bool]] = {}
ns_resolver = ns_resolver or NsResolver()
Expand All @@ -506,10 +531,7 @@ def get_cls_type_hints(
with ns_resolver.push(base):
globalns, localns = ns_resolver.types_namespace
for name, value in ann.items():
if lenient:
hints[name] = try_eval_type(value, globalns, localns)
else:
hints[name] = eval_type(value, globalns, localns)
hints[name] = eval_type(value, globalns, localns)
return hints


Expand Down
7 changes: 7 additions & 0 deletions tests/test_forward_ref.py
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,13 @@ class Model(BaseModel):
assert module.Model.__private_attributes__ == {}


def test_private_attr_annotation_not_evaluated() -> None:
class Model(BaseModel):
_a: 'UnknownAnnotation'

assert '_a' in Model.__private_attributes__


def test_json_encoder_str(create_module):
module = create_module(
# language=Python
Expand Down

0 comments on commit 7c0ed72

Please sign in to comment.