From: Mike Bayer Date: Fri, 31 Oct 2025 15:51:37 +0000 (-0400) Subject: ensure util.get_annotations() is used X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=4fd9fbe73f648ed7e134dddbc43241aac1a75723;p=thirdparty%2Fsqlalchemy%2Fsqlalchemy.git ensure util.get_annotations() is used Fixed issue in Python 3.14 where dataclass transformation would fail when a mapped class using :class:`.MappedAsDataclass` included a :func:`.relationship` referencing a class that was not available at runtime (e.g., within a ``TYPE_CHECKING`` block). This occurred when using Python 3.14's :pep:`649` deferred annotations feature, which is the default behavior without a ``from __future__ import annotations`` directive. Fixes: #12952 Change-Id: I32f0adba00c32f5bf98fe2880dda1b96a7774d07 --- diff --git a/doc/build/changelog/unreleased_20/12952.rst b/doc/build/changelog/unreleased_20/12952.rst new file mode 100644 index 0000000000..fa3ae9e3f5 --- /dev/null +++ b/doc/build/changelog/unreleased_20/12952.rst @@ -0,0 +1,11 @@ +.. change:: + :tags: bug, orm + :tickets: 12952 + + Fixed issue in Python 3.14 where dataclass transformation would fail when + a mapped class using :class:`.MappedAsDataclass` included a + :func:`.relationship` referencing a class that was not available at + runtime (e.g., within a ``TYPE_CHECKING`` block). This occurred when using + Python 3.14's :pep:`649` deferred annotations feature, which is the + default behavior without a ``from __future__ import annotations`` + directive. diff --git a/lib/sqlalchemy/orm/decl_base.py b/lib/sqlalchemy/orm/decl_base.py index be9742a8df..2a6d9bd231 100644 --- a/lib/sqlalchemy/orm/decl_base.py +++ b/lib/sqlalchemy/orm/decl_base.py @@ -691,7 +691,7 @@ class _ClassScanAbstractConfig(_ORMClassConfigurator): # dataclasses callable, based on the fields present. This # means remove the Mapped[] container and ensure all Field # entries have an annotation - restored = getattr(klass, "__annotations__", None) + restored = util.get_annotations(klass) klass.__annotations__ = cast("Dict[str, Any]", use_annotations) else: restored = None @@ -723,7 +723,7 @@ class _ClassScanAbstractConfig(_ORMClassConfigurator): if restored is None: del klass.__annotations__ else: - klass.__annotations__ = restored + klass.__annotations__ = restored # type: ignore[assignment] # noqa: E501 @classmethod def _assert_dc_arguments(cls, arguments: _DataclassArguments) -> None: diff --git a/lib/sqlalchemy/testing/requirements.py b/lib/sqlalchemy/testing/requirements.py index c7a75c40dc..7aaa65010f 100644 --- a/lib/sqlalchemy/testing/requirements.py +++ b/lib/sqlalchemy/testing/requirements.py @@ -1646,6 +1646,12 @@ class SuiteRequirements(Requirements): lambda: util.py312, "Python 3.12 or above required" ) + @property + def python314(self): + return exclusions.only_if( + lambda: util.py314, "Python 3.14 or above required" + ) + @property def fail_python314b1(self): return exclusions.fails_if( diff --git a/test/orm/declarative/test_dc_transforms.py b/test/orm/declarative/test_dc_transforms.py index c0048b180f..46780e3806 100644 --- a/test/orm/declarative/test_dc_transforms.py +++ b/test/orm/declarative/test_dc_transforms.py @@ -924,6 +924,36 @@ class DCTransformsTest(AssertsCompiledSQL, fixtures.TestBase): eq_(fields["id"].metadata, {}) eq_(fields["value"].metadata, {"meta_key": "meta_value"}) + @testing.requires.python314 + def test_apply_dc_deferred_annotations(self, dc_decl_base): + """test for #12952""" + + class Message(dc_decl_base): + __tablename__ = "message" + + id: Mapped[int] = mapped_column(primary_key=True) + content: Mapped[str] + user_id: Mapped[int] = mapped_column(ForeignKey("user.id")) + + # annotation is unquoted and refers to nonexistent class (and if + # this is test_dc_transforms.py, __future__ annotations is not + # turned on), so would be rejected by any python interpreter < 3.14 + # up front. with python 3.14, the dataclass scan takes place + # and has to fetch the annotations using get_annotations() + # so that refs are turned into FwdRef without being resolved + user: Mapped[UnavailableUser] = relationship( # type: ignore # noqa + back_populates="messages" + ) + + # The key assertion: Message should be a dataclass + is_true(dataclasses.is_dataclass(Message)) + + # Verify the dataclass has proper __init__ signature + sig = pyinspect.signature(Message.__init__) + is_true("id" in sig.parameters) + is_true("content" in sig.parameters) + is_true("user_id" in sig.parameters) + class RelationshipDefaultFactoryTest(fixtures.TestBase): diff --git a/test/orm/declarative/test_dc_transforms_future_anno_sync.py b/test/orm/declarative/test_dc_transforms_future_anno_sync.py index b6f226073d..7724259396 100644 --- a/test/orm/declarative/test_dc_transforms_future_anno_sync.py +++ b/test/orm/declarative/test_dc_transforms_future_anno_sync.py @@ -937,6 +937,36 @@ class DCTransformsTest(AssertsCompiledSQL, fixtures.TestBase): eq_(fields["id"].metadata, {}) eq_(fields["value"].metadata, {"meta_key": "meta_value"}) + @testing.requires.python314 + def test_apply_dc_deferred_annotations(self, dc_decl_base): + """test for #12952""" + + class Message(dc_decl_base): + __tablename__ = "message" + + id: Mapped[int] = mapped_column(primary_key=True) + content: Mapped[str] + user_id: Mapped[int] = mapped_column(ForeignKey("user.id")) + + # annotation is unquoted and refers to nonexistent class (and if + # this is test_dc_transforms.py, __future__ annotations is not + # turned on), so would be rejected by any python interpreter < 3.14 + # up front. with python 3.14, the dataclass scan takes place + # and has to fetch the annotations using get_annotations() + # so that refs are turned into FwdRef without being resolved + user: Mapped[UnavailableUser] = relationship( # type: ignore # noqa + back_populates="messages" + ) + + # The key assertion: Message should be a dataclass + is_true(dataclasses.is_dataclass(Message)) + + # Verify the dataclass has proper __init__ signature + sig = pyinspect.signature(Message.__init__) + is_true("id" in sig.parameters) + is_true("content" in sig.parameters) + is_true("user_id" in sig.parameters) + class RelationshipDefaultFactoryTest(fixtures.TestBase):