From e0f13cb6c1c3f44ebd110e8e32147d9f853146d4 Mon Sep 17 00:00:00 2001 From: Mike Bayer Date: Tue, 3 Feb 2026 08:53:53 -0500 Subject: [PATCH] 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 --- doc/build/changelog/unreleased_20/13104.rst | 11 ++++++++ lib/sqlalchemy/testing/requirements.py | 5 ++++ lib/sqlalchemy/util/__init__.py | 2 +- lib/sqlalchemy/util/compat.py | 19 ++++++++++++-- lib/sqlalchemy/util/langhelpers.py | 15 ----------- test/base/test_typing_utils.py | 22 ++++++++++++++++ .../test_tm_future_annotations_sync.py | 25 +++++++++++++++++++ test/orm/declarative/test_typed_mapping.py | 25 +++++++++++++++++++ 8 files changed, 106 insertions(+), 18 deletions(-) create mode 100644 doc/build/changelog/unreleased_20/13104.rst 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 63ad553abf..2dd4bf6bcf 100644 --- a/lib/sqlalchemy/testing/requirements.py +++ b/lib/sqlalchemy/testing/requirements.py @@ -1688,6 +1688,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 d4dcde409f..5d55a691d5 100644 --- a/lib/sqlalchemy/util/__init__.py +++ b/lib/sqlalchemy/util/__init__.py @@ -56,6 +56,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 @@ -105,7 +106,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 8f8cc97677..ddf042a474 100644 --- a/lib/sqlalchemy/util/compat.py +++ b/lib/sqlalchemy/util/compat.py @@ -73,6 +73,21 @@ else: return iter(self._parts) +if py314: + + import annotationlib + + def get_annotations(obj: Any) -> Mapping[str, Any]: + return annotationlib.get_annotations( + obj, format=annotationlib.Format.FORWARDREF + ) + +else: + + def get_annotations(obj: Any) -> Mapping[str, Any]: + return inspect.get_annotations(obj) + + class FullArgSpec(typing.NamedTuple): args: List[str] varargs: Optional[str] @@ -80,7 +95,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: @@ -117,7 +132,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 8acc352468..6b95979bfd 100644 --- a/lib/sqlalchemy/util/langhelpers.py +++ b/lib/sqlalchemy/util/langhelpers.py @@ -35,7 +35,6 @@ from typing import Generic from typing import Iterator from typing import List from typing import Literal -from typing import Mapping from typing import NoReturn from typing import Optional from typing import overload @@ -58,20 +57,6 @@ _F = TypeVar("_F", bound=Callable[..., Any]) _MA = TypeVar("_MA", bound="HasMemoized.memoized_attribute[Any]") _M = TypeVar("_M", bound=ModuleType) -if compat.py314: - - import annotationlib - - def get_annotations(obj: Any) -> Mapping[str, Any]: - return annotationlib.get_annotations( - obj, format=annotationlib.Format.FORWARDREF - ) - -else: - - def get_annotations(obj: Any) -> Mapping[str, Any]: - return inspect.get_annotations(obj) - def restore_annotations( cls: type, new_annotations: dict[str, Any] diff --git a/test/base/test_typing_utils.py b/test/base/test_typing_utils.py index 58aa15391b..e66431066f 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_ @@ -646,3 +647,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 87f7858169..e01805ce70 100644 --- a/test/orm/declarative/test_tm_future_annotations_sync.py +++ b/test/orm/declarative/test_tm_future_annotations_sync.py @@ -4519,6 +4519,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 9f67bf9474..d5d5298a9f 100644 --- a/test/orm/declarative/test_typed_mapping.py +++ b/test/orm/declarative/test_typed_mapping.py @@ -4510,6 +4510,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): -- 2.47.3