]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
forwards-port cpython issue 141560 for getfullargspec
authorMike Bayer <mike_mp@zzzcomputing.com>
Tue, 3 Feb 2026 13:53:53 +0000 (08:53 -0500)
committerMike Bayer <mike_mp@zzzcomputing.com>
Tue, 3 Feb 2026 22:12:37 +0000 (17:12 -0500)
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)

doc/build/changelog/unreleased_20/13104.rst [new file with mode: 0644]
lib/sqlalchemy/testing/requirements.py
lib/sqlalchemy/util/__init__.py
lib/sqlalchemy/util/compat.py
lib/sqlalchemy/util/langhelpers.py
test/base/test_typing_utils.py
test/orm/declarative/test_tm_future_annotations_sync.py
test/orm/declarative/test_typed_mapping.py

diff --git a/doc/build/changelog/unreleased_20/13104.rst b/doc/build/changelog/unreleased_20/13104.rst
new file mode 100644 (file)
index 0000000..2571485
--- /dev/null
@@ -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.
+
index df630503fc89234739e287fe1b28c508e2e85f45..69f95cb2de27ce2c8d93c448fa4fa1b03e0edd05 100644 (file)
@@ -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(
index 9c8e3cda4e64c76dd14c4261a7ee39bc16c250a7..fcaa54d637a43b4320d25535d7a702584989ea7d 100644 (file)
@@ -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
index 9e48b9c6d8e3a15ec17598e0ccd1d0ecb973453c..94762422513fa6a4e3f13f3580cfdb2451890c86 100644 (file)
@@ -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),
     )
 
 
index 77fd22583835d695d8982f35b2572bd6fa7f7403..cb72771e37b9913a409d0b047e4c2d7133da486a 100644 (file)
@@ -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()
index 58ad10fa95c4498d8b4621583d4d4937dd63ebd3..2cf95689d2224b2c39e3b9e148edd0f6405ad3ab 100644 (file)
@@ -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,
+            ),
+        )
index 6ad125eaa4542a3d56d3bc5322a82778aef7f7b3..04d98b20a735df37cc14f9dfc4ebe18677c19df7 100644 (file)
@@ -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):
index da06972701ee1a2c1f589db37564792046406fb9..3daf8e4e9b142056343d9779b59a34d8d38c5ea5 100644 (file)
@@ -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):