Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add eval_type_backport to handle union operator and builtin generic subscripting in older Pythons #8209

Merged
merged 34 commits into from Jan 16, 2024
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
75986a9
Add eval_type_backport to handle union operator in older Pythons
alexmojaki Oct 29, 2023
ca60caa
Use modified get_type_hints in test_config
alexmojaki Oct 29, 2023
a163ea3
unskip a couple more tests in older pythons
alexmojaki Oct 29, 2023
8033556
Use pipe operator in a bunch of tests
alexmojaki Oct 29, 2023
0495f0f
various misc tidying up: use default localns=None, handle None values…
alexmojaki Nov 4, 2023
780b46a
explain asserts
alexmojaki Nov 4, 2023
4c536b6
type hints
alexmojaki Nov 4, 2023
d7f874b
inline node_to_ref
alexmojaki Nov 4, 2023
09d2e93
is_unsupported_types_for_union_error
alexmojaki Nov 4, 2023
d7b1462
tidying
alexmojaki Nov 4, 2023
c65ae3d
remove more type: ignore comments
alexmojaki Nov 4, 2023
3aa88da
docstrings and tidying
alexmojaki Nov 4, 2023
ca09a03
fix and tighten test_is_union
alexmojaki Nov 4, 2023
4890fc8
Merge branch 'main' of github.com:pydantic/pydantic into eval_type_ba…
alexmojaki Nov 22, 2023
c2d9d22
Use `eval_type_backport` package
alexmojaki Nov 22, 2023
40a41fb
Add test dependency
alexmojaki Nov 22, 2023
7da17e0
Merge branch 'main' of github.com:pydantic/pydantic into eval_type_ba…
alexmojaki Dec 16, 2023
a998d14
upgrade eval_type_backport
alexmojaki Dec 16, 2023
4f465a3
fix pdm.lock
alexmojaki Dec 16, 2023
71ca7cf
upgrade eval_type_backport to handle fussy typing._type_check
alexmojaki Dec 16, 2023
f060876
update is_backport_fixable_error and move down, update eval_type_back…
alexmojaki Dec 16, 2023
2b42d65
raise helpful error if eval_type_backport isn't installed. ensure tes…
alexmojaki Dec 17, 2023
dcbdd28
Restore skip, add another test for combination of backport and Pydant…
alexmojaki Dec 17, 2023
959c755
Test that eval_type_backport is being called in the right places
alexmojaki Dec 17, 2023
71c912e
test calling backport from get_type_hints
alexmojaki Dec 17, 2023
9847058
upgrade eval_type_backport to handle working union operator
alexmojaki Dec 17, 2023
6beab5b
unskip tests that can now pass in 3.8
alexmojaki Dec 17, 2023
b79692b
revert scattered test changes
alexmojaki Dec 17, 2023
6bc0ee4
unskip more tests
alexmojaki Dec 17, 2023
f423659
upgrade eval_type_backport to copy ForwardRef attributes, allowing un…
alexmojaki Dec 17, 2023
d3d5584
Merge branch 'main' of github.com:pydantic/pydantic into eval_type_ba…
alexmojaki Jan 15, 2024
aa21092
revert moving part of pyproject.toml
alexmojaki Jan 15, 2024
8da4294
Refine and test error raised when eval_type_backport isn't installed
alexmojaki Jan 16, 2024
60aa70f
use a type annotation that's unsupported in 3.9, not just 3.8
alexmojaki Jan 16, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
12 changes: 11 additions & 1 deletion pdm.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pydantic/_internal/_generate_schema.py
Expand Up @@ -638,7 +638,7 @@ def _resolve_forward_ref(self, obj: Any) -> Any:
# class Model(BaseModel):
# x: SomeImportedTypeAliasWithAForwardReference
try:
obj = _typing_extra.evaluate_fwd_ref(obj, globalns=self._types_namespace)
obj = _typing_extra.eval_type_backport(obj, globalns=self._types_namespace)
except NameError as e:
raise PydanticUndefinedAnnotation.from_name_error(e) from e

Expand Down
49 changes: 29 additions & 20 deletions pydantic/_internal/_typing_extra.py
Expand Up @@ -8,7 +8,7 @@
from collections.abc import Callable
from functools import partial
from types import GetSetDescriptorType
from typing import TYPE_CHECKING, Any, Final, ForwardRef
from typing import TYPE_CHECKING, Any, Final

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

Expand Down Expand Up @@ -221,12 +221,36 @@ def eval_type_lenient(value: Any, globalns: dict[str, Any] | None, localns: dict
value = _make_forward_ref(value, is_argument=False, is_class=True)

try:
return typing._eval_type(value, globalns, localns) # type: ignore
return eval_type_backport(value, globalns, localns)
except NameError:
# the point of this function is to be tolerant to this case
return value


def is_unsupported_types_for_union_error(e: TypeError) -> bool:
alexmojaki marked this conversation as resolved.
Show resolved Hide resolved
return str(e).startswith('unsupported operand type(s) for |: ')


def eval_type_backport(
value: Any, globalns: dict[str, Any] | None = None, localns: dict[str, Any] | None = None
) -> Any:
"""Like `typing._eval_type`, but falls back to the `eval_type_backport` package if it's
installed to let older Python versions use newer typing features.
Currently this just means that `X | Y` is converted to `Union[X, Y]` if `X | Y` is not supported.
This would also be the place to add support for `list[int]` instead of `List[int]` etc.
alexmojaki marked this conversation as resolved.
Show resolved Hide resolved
"""
try:
return typing._eval_type( # type: ignore
value, globalns, localns
)
except TypeError as e:
if not (isinstance(value, typing.ForwardRef) and is_unsupported_types_for_union_error(e)):
raise
from eval_type_backport import eval_type_backport
alexmojaki marked this conversation as resolved.
Show resolved Hide resolved

return eval_type_backport(value, globalns, localns, try_default=False)


def get_function_type_hints(
function: Callable[..., Any], *, include_keys: set[str] | None = None, types_namespace: dict[str, Any] | None = None
) -> dict[str, Any]:
Expand All @@ -248,7 +272,7 @@ def get_function_type_hints(
elif isinstance(value, str):
value = _make_forward_ref(value)

type_hints[name] = typing._eval_type(value, globalns, types_namespace) # type: ignore
type_hints[name] = eval_type_backport(value, globalns, types_namespace)

return type_hints

Expand Down Expand Up @@ -363,7 +387,7 @@ def get_type_hints( # noqa: C901
if isinstance(value, str):
value = _make_forward_ref(value, is_argument=False, is_class=True)

value = typing._eval_type(value, base_globals, base_locals) # type: ignore
value = eval_type_backport(value, base_globals, base_locals)
hints[name] = value
return (
hints if include_extras else {k: typing._strip_annotations(t) for k, t in hints.items()} # type: ignore
Expand Down Expand Up @@ -403,28 +427,13 @@ def get_type_hints( # noqa: C901
is_argument=not isinstance(obj, types.ModuleType),
is_class=False,
)
value = typing._eval_type(value, globalns, localns) # type: ignore
value = eval_type_backport(value, globalns, localns)
if name in defaults and defaults[name] is None:
value = typing.Optional[value]
hints[name] = value
return hints if include_extras else {k: typing._strip_annotations(t) for k, t in hints.items()} # type: ignore


if sys.version_info < (3, 9):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why don't we need this anymore?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAICT this could always have been replaced with typing._eval_type. Now it has to be, with the backport.


def evaluate_fwd_ref(
ref: ForwardRef, globalns: dict[str, Any] | None = None, localns: dict[str, Any] | None = None
) -> Any:
return ref._evaluate(globalns=globalns, localns=localns)

else:

def evaluate_fwd_ref(
ref: ForwardRef, globalns: dict[str, Any] | None = None, localns: dict[str, Any] | None = None
) -> Any:
return ref._evaluate(globalns=globalns, localns=localns, recursive_guard=frozenset())


def is_dataclass(_cls: type[Any]) -> TypeGuard[type[StandardDataclass]]:
# The dataclasses.is_dataclass function doesn't seem to provide TypeGuard functionality,
# so I created this convenience function
Expand Down
105 changes: 54 additions & 51 deletions pyproject.toml
Expand Up @@ -20,57 +20,6 @@ include = [
'/requirements',
]

[project]
alexmojaki marked this conversation as resolved.
Show resolved Hide resolved
name = 'pydantic'
description = 'Data validation using Python type hints'
authors = [
{name = 'Samuel Colvin', email = 's@muelcolvin.com'},
{name = 'Eric Jolibois', email = 'em.jolibois@gmail.com'},
{name = 'Hasan Ramezani', email = 'hasan.r67@gmail.com'},
{name = 'Adrian Garcia Badaracco', email = '1755071+adriangb@users.noreply.github.com'},
{name = 'Terrence Dorsey', email = 'terry@pydantic.dev'},
{name = 'David Montague', email = 'david@pydantic.dev'},
{name = 'Serge Matveenko', email = 'lig@countzero.co'},
{name = 'Marcelo Trylesinski', email = 'marcelotryle@gmail.com'},
{name = 'Sydney Runkle', email = 'sydneymarierunkle@gmail.com'},
{name = 'David Hewitt', email = 'mail@davidhewitt.io'},
]
license = 'MIT'
classifiers = [
'Development Status :: 5 - Production/Stable',
'Programming Language :: Python',
'Programming Language :: Python :: Implementation :: CPython',
'Programming Language :: Python :: Implementation :: PyPy',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3 :: Only',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11',
'Programming Language :: Python :: 3.12',
'Intended Audience :: Developers',
'Intended Audience :: Information Technology',
'Intended Audience :: System Administrators',
'License :: OSI Approved :: MIT License',
'Operating System :: Unix',
'Operating System :: POSIX :: Linux',
'Environment :: Console',
'Environment :: MacOS X',
'Framework :: Hypothesis',
'Framework :: Pydantic',
'Topic :: Software Development :: Libraries :: Python Modules',
'Topic :: Internet',
]
requires-python = '>=3.8'
dependencies = [
'typing-extensions>=4.6.1',
'annotated-types>=0.4.0',
"pydantic-core==2.14.3",
]
dynamic = ['version', 'readme']

[project.optional-dependencies]
email = ['email-validator>=2.0.0']

[tool.pdm.dev-dependencies]
docs = [
Expand Down Expand Up @@ -105,6 +54,7 @@ testing = [
"faker>=18.13.0",
"pytest-benchmark>=4.0.0",
"pytest-codspeed~=2.2.0",
"eval-type-backport>=0.0.1",
]
testing-extra = [
# used when generate devtools docs example
Expand All @@ -126,13 +76,66 @@ memray = [
# requires Python > 3.8, we only test with 3.8 in CI but because of it won't lock properly
pytest-memray = "1.5.0"

[project]
name = 'pydantic'
description = 'Data validation using Python type hints'
authors = [
{name = 'Samuel Colvin', email = 's@muelcolvin.com'},
{name = 'Eric Jolibois', email = 'em.jolibois@gmail.com'},
{name = 'Hasan Ramezani', email = 'hasan.r67@gmail.com'},
{name = 'Adrian Garcia Badaracco', email = '1755071+adriangb@users.noreply.github.com'},
{name = 'Terrence Dorsey', email = 'terry@pydantic.dev'},
{name = 'David Montague', email = 'david@pydantic.dev'},
{name = 'Serge Matveenko', email = 'lig@countzero.co'},
{name = 'Marcelo Trylesinski', email = 'marcelotryle@gmail.com'},
{name = 'Sydney Runkle', email = 'sydneymarierunkle@gmail.com'},
{name = 'David Hewitt', email = 'mail@davidhewitt.io'},
]
license = 'MIT'
classifiers = [
'Development Status :: 5 - Production/Stable',
'Programming Language :: Python',
'Programming Language :: Python :: Implementation :: CPython',
'Programming Language :: Python :: Implementation :: PyPy',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3 :: Only',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11',
'Programming Language :: Python :: 3.12',
'Intended Audience :: Developers',
'Intended Audience :: Information Technology',
'Intended Audience :: System Administrators',
'License :: OSI Approved :: MIT License',
'Operating System :: Unix',
'Operating System :: POSIX :: Linux',
'Environment :: Console',
'Environment :: MacOS X',
'Framework :: Hypothesis',
'Framework :: Pydantic',
'Topic :: Software Development :: Libraries :: Python Modules',
'Topic :: Internet',
]
requires-python = '>=3.8'
dependencies = [
'typing-extensions>=4.6.1',
'annotated-types>=0.4.0',
"pydantic-core==2.14.3",
]
dynamic = ['version', 'readme']

[project.optional-dependencies]
email = ['email-validator>=2.0.0']

[project.urls]
Homepage = 'https://github.com/pydantic/pydantic'
Documentation = 'https://docs.pydantic.dev'
Funding = 'https://github.com/sponsors/samuelcolvin'
Source = 'https://github.com/pydantic/pydantic'
Changelog = 'https://docs.pydantic.dev/latest/changelog/'


[tool.hatch.metadata.hooks.fancy-pypi-readme]
content-type = 'text/markdown'
# construct the PyPI readme from README.md and HISTORY.md
Expand Down
7 changes: 4 additions & 3 deletions tests/test_config.py
Expand Up @@ -4,7 +4,7 @@
from contextlib import nullcontext as does_not_raise
from decimal import Decimal
from inspect import signature
from typing import Any, ContextManager, Iterable, NamedTuple, Optional, Type, Union, get_type_hints
from typing import Any, ContextManager, Iterable, NamedTuple, Optional, Type, Union

from dirty_equals import HasRepr, IsPartialDict
from pydantic_core import SchemaError, SchemaSerializer, SchemaValidator
Expand All @@ -24,6 +24,7 @@
)
from pydantic._internal._config import ConfigWrapper, config_defaults
from pydantic._internal._mock_val_ser import MockValSer
from pydantic._internal._typing_extra import get_type_hints
from pydantic.config import ConfigDict, JsonValue
from pydantic.dataclasses import dataclass as pydantic_dataclass
from pydantic.errors import PydanticUserError
Expand Down Expand Up @@ -523,7 +524,7 @@ class Child(Mixin, Parent):
assert Child.model_config.get('use_enum_values') is True


@pytest.mark.skipif(sys.version_info < (3, 10), reason='different on older versions')
@pytest.mark.skipif(sys.version_info < (3, 9), reason='different on older versions')
def test_config_wrapper_match():
localns = {'_GenerateSchema': GenerateSchema, 'GenerateSchema': GenerateSchema, 'JsonValue': JsonValue}
config_dict_annotations = [(k, str(v)) for k, v in get_type_hints(ConfigDict, localns=localns).items()]
Expand Down Expand Up @@ -567,7 +568,7 @@ def check_foo(cls, v):
assert src_exc.__notes__[0] == '\nPydantic: cause of loc: foo'


@pytest.mark.skipif(sys.version_info < (3, 10), reason='different on older versions')
@pytest.mark.skipif(sys.version_info < (3, 9), reason='different on older versions')
def test_config_defaults_match():
localns = {'_GenerateSchema': GenerateSchema, 'GenerateSchema': GenerateSchema}
config_dict_keys = sorted(list(get_type_hints(ConfigDict, localns=localns).keys()))
Expand Down
4 changes: 2 additions & 2 deletions tests/test_dataclasses.py
Expand Up @@ -8,7 +8,7 @@
from dataclasses import InitVar
from datetime import date, datetime
from pathlib import Path
from typing import Any, Callable, ClassVar, Dict, FrozenSet, Generic, List, Optional, Set, TypeVar, Union
from typing import Any, Callable, ClassVar, Dict, FrozenSet, Generic, List, Optional, Set, TypeVar

import pytest
from dirty_equals import HasRepr
Expand Down Expand Up @@ -1346,7 +1346,7 @@ class B:

@pydantic.dataclasses.dataclass
class Top:
sub: Union[A, B] = dataclasses.field(metadata=dict(discriminator='l'))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we want to change most tests, just add some new tests specifically for the new behaviour.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've reverted the random test changes, updated existing tests more strategically where it seemed natural (i.e. they already used from __future__ import annotations or they were skipped for Python <= 3.9), and added new tests in various files (all called test_eval_type_backport) to test the different places where eval_type_backport is called.

sub: 'A | B' = dataclasses.field(metadata=dict(discriminator='l'))

t = Top(sub=A(l='a'))
assert isinstance(t, Top)
Expand Down