From: Mike Bayer Date: Tue, 3 Feb 2026 13:53:53 +0000 (-0500) Subject: forwards-port cpython issue 141560 for getfullargspec X-Git-Tag: rel_2_0_47~10 X-Git-Url: http://git.ipfire.org/gitweb/?a=commitdiff_plain;h=8234f83f6a13cd3f49ccce818d3ecad66258caab;p=thirdparty%2Fsqlalchemy%2Fsqlalchemy.git forwards-port cpython issue 141560 for getfullargspec Fixed issue when using ORM mappings with Python 3.14's :pep:`649` feature that no longer requires "future annotations", where the ORM's introspection of the ``__init__`` method of mapped classes would fail if non-present identifiers in annotations were present. The vendored ``getfullargspec()`` method has been amended to use ``Format.FORWARDREF`` under Python 3.14 to prevent resolution of names that aren't present. Fixes: #13104 References: https://github.com/python/cpython/issues/141560 Change-Id: I6af8026a07131d4a1e28cd7fc2e90509194ae957 (cherry picked from commit be37eb8d3061a316d82051d1a7eb670fc65b5fb0) --- diff --git a/doc/build/changelog/unreleased_20/13104.rst b/doc/build/changelog/unreleased_20/13104.rst new file mode 100644 index 0000000000..2571485078 --- /dev/null +++ b/doc/build/changelog/unreleased_20/13104.rst @@ -0,0 +1,11 @@ +.. change:: + :tags: bug, orm + :tickets: 13104 + + Fixed issue when using ORM mappings with Python 3.14's :pep:`649` feature + that no longer requires "future annotations", where the ORM's introspection + of the ``__init__`` method of mapped classes would fail if non-present + identifiers in annotations were present. The vendored ``getfullargspec()`` + method has been amended to use ``Format.FORWARDREF`` under Python 3.14 to + prevent resolution of names that aren't present. + diff --git a/lib/sqlalchemy/testing/requirements.py b/lib/sqlalchemy/testing/requirements.py index df630503fc..69f95cb2de 100644 --- a/lib/sqlalchemy/testing/requirements.py +++ b/lib/sqlalchemy/testing/requirements.py @@ -1656,6 +1656,11 @@ class SuiteRequirements(Requirements): lambda: util.py314, "Python 3.14 or above not supported" ) + @property + def pep649(self): + """pep649 deferred evaluation of annotations without future mode""" + return self.python314 + @property def cpython(self): return exclusions.only_if( diff --git a/lib/sqlalchemy/util/__init__.py b/lib/sqlalchemy/util/__init__.py index 9c8e3cda4e..fcaa54d637 100644 --- a/lib/sqlalchemy/util/__init__.py +++ b/lib/sqlalchemy/util/__init__.py @@ -57,6 +57,7 @@ from .compat import dataclass_fields as dataclass_fields from .compat import decode_backslashreplace as decode_backslashreplace from .compat import dottedgetter as dottedgetter from .compat import freethreading as freethreading +from .compat import get_annotations as get_annotations from .compat import has_refcount_gc as has_refcount_gc from .compat import inspect_getfullargspec as inspect_getfullargspec from .compat import is64bit as is64bit @@ -109,7 +110,6 @@ from .langhelpers import format_argspec_init as format_argspec_init from .langhelpers import format_argspec_plus as format_argspec_plus from .langhelpers import generic_fn_descriptor as generic_fn_descriptor from .langhelpers import generic_repr as generic_repr -from .langhelpers import get_annotations as get_annotations from .langhelpers import get_callable_argspec as get_callable_argspec from .langhelpers import get_cls_kwargs as get_cls_kwargs from .langhelpers import get_func_kwargs as get_func_kwargs diff --git a/lib/sqlalchemy/util/compat.py b/lib/sqlalchemy/util/compat.py index 9e48b9c6d8..9476242251 100644 --- a/lib/sqlalchemy/util/compat.py +++ b/lib/sqlalchemy/util/compat.py @@ -21,6 +21,7 @@ import sysconfig import typing from typing import Any from typing import Callable +from typing import cast from typing import Dict from typing import Iterable from typing import List @@ -57,6 +58,110 @@ dottedgetter = operator.attrgetter _T_co = TypeVar("_T_co", covariant=True) +if py314: + # vendor a minimal form of get_annotations per + # https://github.com/python/cpython/issues/133684#issuecomment-2863841891 + + from annotationlib import call_annotate_function # type: ignore[import-not-found,unused-ignore] # noqa: E501 + from annotationlib import Format + + def _get_and_call_annotate(obj, format): # noqa: A002 + annotate = getattr(obj, "__annotate__", None) + if annotate is not None: + ann = call_annotate_function(annotate, format, owner=obj) + if not isinstance(ann, dict): + raise ValueError(f"{obj!r}.__annotate__ returned a non-dict") + return ann + return None + + # this is ported from py3.13.0a7 + _BASE_GET_ANNOTATIONS = type.__dict__["__annotations__"].__get__ + + def _get_dunder_annotations(obj): + if isinstance(obj, type): + try: + ann = _BASE_GET_ANNOTATIONS(obj) + except AttributeError: + # For static types, the descriptor raises AttributeError. + return {} + else: + ann = getattr(obj, "__annotations__", None) + if ann is None: + return {} + + if not isinstance(ann, dict): + raise ValueError( + f"{obj!r}.__annotations__ is neither a dict nor None" + ) + return dict(ann) + + def _vendored_get_annotations( + obj: Any, *, format: Format # noqa: A002 + ) -> Mapping[str, Any]: + """A sparse implementation of annotationlib.get_annotations()""" + + try: + ann = _get_dunder_annotations(obj) + except Exception: + pass + else: + if ann is not None: + return dict(ann) + + # But if __annotations__ threw a NameError, we try calling __annotate__ + ann = _get_and_call_annotate(obj, format) + if ann is None: + # If that didn't work either, we have a very weird object: + # evaluating + # __annotations__ threw NameError and there is no __annotate__. + # In that case, + # we fall back to trying __annotations__ again. + ann = _get_dunder_annotations(obj) + + if ann is None: + if isinstance(obj, type) or callable(obj): + return {} + raise TypeError(f"{obj!r} does not have annotations") + + if not ann: + return {} + + return dict(ann) + + def get_annotations(obj: Any) -> Mapping[str, Any]: + # FORWARDREF has the effect of giving us ForwardRefs and not + # actually trying to evaluate the annotations. We need this so + # that the annotations act as much like + # "from __future__ import annotations" as possible, which is going + # away in future python as a separate mode + return _vendored_get_annotations(obj, format=Format.FORWARDREF) + +elif py310: + + def get_annotations(obj: Any) -> Mapping[str, Any]: + return inspect.get_annotations(obj) + +else: + + def get_annotations(obj: Any) -> Mapping[str, Any]: + # it's been observed that cls.__annotations__ can be non present. + # it's not clear what causes this, running under tox py37/38 it + # happens, running straight pytest it doesnt + + # https://docs.python.org/3/howto/annotations.html#annotations-howto + if isinstance(obj, type): + ann = obj.__dict__.get("__annotations__", None) + else: + ann = getattr(obj, "__annotations__", None) + + from . import _collections + + if ann is None: + return _collections.EMPTY_DICT + else: + return cast("Mapping[str, Any]", ann) + + class FullArgSpec(typing.NamedTuple): args: List[str] varargs: Optional[str] @@ -64,7 +169,7 @@ class FullArgSpec(typing.NamedTuple): defaults: Optional[Tuple[Any, ...]] kwonlyargs: List[str] kwonlydefaults: Optional[Dict[str, Any]] - annotations: Dict[str, Any] + annotations: Mapping[str, Any] def inspect_getfullargspec(func: Callable[..., Any]) -> FullArgSpec: @@ -101,7 +206,7 @@ def inspect_getfullargspec(func: Callable[..., Any]) -> FullArgSpec: func.__defaults__, kwonlyargs, func.__kwdefaults__, - func.__annotations__, + get_annotations(func), ) diff --git a/lib/sqlalchemy/util/langhelpers.py b/lib/sqlalchemy/util/langhelpers.py index 77fd225838..cb72771e37 100644 --- a/lib/sqlalchemy/util/langhelpers.py +++ b/lib/sqlalchemy/util/langhelpers.py @@ -32,7 +32,6 @@ from typing import FrozenSet from typing import Generic from typing import Iterator from typing import List -from typing import Mapping from typing import NoReturn from typing import Optional from typing import overload @@ -60,108 +59,6 @@ _HP = TypeVar("_HP", bound="hybridproperty[Any]") _HM = TypeVar("_HM", bound="hybridmethod[Any]") -if compat.py314: - # vendor a minimal form of get_annotations per - # https://github.com/python/cpython/issues/133684#issuecomment-2863841891 - - from annotationlib import call_annotate_function # type: ignore[import-not-found,unused-ignore] # noqa: E501 - from annotationlib import Format - - def _get_and_call_annotate(obj, format): # noqa: A002 - annotate = getattr(obj, "__annotate__", None) - if annotate is not None: - ann = call_annotate_function(annotate, format, owner=obj) - if not isinstance(ann, dict): - raise ValueError(f"{obj!r}.__annotate__ returned a non-dict") - return ann - return None - - # this is ported from py3.13.0a7 - _BASE_GET_ANNOTATIONS = type.__dict__["__annotations__"].__get__ - - def _get_dunder_annotations(obj): - if isinstance(obj, type): - try: - ann = _BASE_GET_ANNOTATIONS(obj) - except AttributeError: - # For static types, the descriptor raises AttributeError. - return {} - else: - ann = getattr(obj, "__annotations__", None) - if ann is None: - return {} - - if not isinstance(ann, dict): - raise ValueError( - f"{obj!r}.__annotations__ is neither a dict nor None" - ) - return dict(ann) - - def _vendored_get_annotations( - obj: Any, *, format: Format # noqa: A002 - ) -> Mapping[str, Any]: - """A sparse implementation of annotationlib.get_annotations()""" - - try: - ann = _get_dunder_annotations(obj) - except Exception: - pass - else: - if ann is not None: - return dict(ann) - - # But if __annotations__ threw a NameError, we try calling __annotate__ - ann = _get_and_call_annotate(obj, format) - if ann is None: - # If that didn't work either, we have a very weird object: - # evaluating - # __annotations__ threw NameError and there is no __annotate__. - # In that case, - # we fall back to trying __annotations__ again. - ann = _get_dunder_annotations(obj) - - if ann is None: - if isinstance(obj, type) or callable(obj): - return {} - raise TypeError(f"{obj!r} does not have annotations") - - if not ann: - return {} - - return dict(ann) - - def get_annotations(obj: Any) -> Mapping[str, Any]: - # FORWARDREF has the effect of giving us ForwardRefs and not - # actually trying to evaluate the annotations. We need this so - # that the annotations act as much like - # "from __future__ import annotations" as possible, which is going - # away in future python as a separate mode - return _vendored_get_annotations(obj, format=Format.FORWARDREF) - -elif compat.py310: - - def get_annotations(obj: Any) -> Mapping[str, Any]: - return inspect.get_annotations(obj) - -else: - - def get_annotations(obj: Any) -> Mapping[str, Any]: - # it's been observed that cls.__annotations__ can be non present. - # it's not clear what causes this, running under tox py37/38 it - # happens, running straight pytest it doesnt - - # https://docs.python.org/3/howto/annotations.html#annotations-howto - if isinstance(obj, type): - ann = obj.__dict__.get("__annotations__", None) - else: - ann = getattr(obj, "__annotations__", None) - - if ann is None: - return _collections.EMPTY_DICT - else: - return cast("Mapping[str, Any]", ann) - - def md5_hex(x: Any) -> str: x = x.encode("utf-8") m = compat.md5_not_for_security() diff --git a/test/base/test_typing_utils.py b/test/base/test_typing_utils.py index 58ad10fa95..2cf95689d2 100644 --- a/test/base/test_typing_utils.py +++ b/test/base/test_typing_utils.py @@ -8,6 +8,7 @@ from typing import cast import typing_extensions from sqlalchemy import Column +from sqlalchemy import util from sqlalchemy.testing import eq_ from sqlalchemy.testing import fixtures from sqlalchemy.testing import is_ @@ -656,3 +657,24 @@ class TestTyping(fixtures.TestBase): types.add(lt) is_(lt in ti, True) eq_(len(ti), len(types), k) + + @requires.pep649 + def test_pep649_getfullargspec(self): + """test for #13104""" + + def foo(x: Frobnizzle): # type: ignore # noqa: F821 + pass + + anno = util.get_annotations(foo) + eq_( + util.inspect_getfullargspec(foo), + util.compat.FullArgSpec( + args=["x"], + varargs=None, + varkw=None, + defaults=None, + kwonlyargs=[], + kwonlydefaults=None, + annotations=anno, + ), + ) diff --git a/test/orm/declarative/test_tm_future_annotations_sync.py b/test/orm/declarative/test_tm_future_annotations_sync.py index 6ad125eaa4..04d98b20a7 100644 --- a/test/orm/declarative/test_tm_future_annotations_sync.py +++ b/test/orm/declarative/test_tm_future_annotations_sync.py @@ -4580,6 +4580,31 @@ class AllYourFavoriteHitsTest(fixtures.TestBase, testing.AssertsCompiledSQL): else: inh_type.fail() + @requires.pep649 + def test_pep649_init(self, decl_base): + + class A(decl_base): + __tablename__ = "a" + + id: Mapped[int] = mapped_column( + BigInteger, Identity(always=True), primary_key=True + ) + + bs: Mapped[list[B]] = relationship(back_populates="a") + + def __init__(self, bs: list[B], *args, **kwargs): + super().__init__(*args, bs=bs, **kwargs) + + class B(decl_base): + __tablename__ = "b" + + id: Mapped[int] = mapped_column( + BigInteger, Identity(always=True), primary_key=True + ) + + a_id: Mapped[int] = mapped_column(ForeignKey("a.id")) + a: Mapped[A] = relationship(back_populates="bs") + class WriteOnlyRelationshipTest(fixtures.TestBase): def _assertions(self, A, B, lazy): diff --git a/test/orm/declarative/test_typed_mapping.py b/test/orm/declarative/test_typed_mapping.py index da06972701..3daf8e4e9b 100644 --- a/test/orm/declarative/test_typed_mapping.py +++ b/test/orm/declarative/test_typed_mapping.py @@ -4571,6 +4571,31 @@ class AllYourFavoriteHitsTest(fixtures.TestBase, testing.AssertsCompiledSQL): else: inh_type.fail() + @requires.pep649 + def test_pep649_init(self, decl_base): + + class A(decl_base): + __tablename__ = "a" + + id: Mapped[int] = mapped_column( + BigInteger, Identity(always=True), primary_key=True + ) + + bs: Mapped[list[B]] = relationship(back_populates="a") + + def __init__(self, bs: list[B], *args, **kwargs): + super().__init__(*args, bs=bs, **kwargs) + + class B(decl_base): + __tablename__ = "b" + + id: Mapped[int] = mapped_column( + BigInteger, Identity(always=True), primary_key=True + ) + + a_id: Mapped[int] = mapped_column(ForeignKey("a.id")) + a: Mapped[A] = relationship(back_populates="bs") + class WriteOnlyRelationshipTest(fixtures.TestBase): def _assertions(self, A, B, lazy):