]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
ensure util.get_annotations() is used
authorMike Bayer <mike_mp@zzzcomputing.com>
Fri, 31 Oct 2025 15:51:37 +0000 (11:51 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Fri, 31 Oct 2025 16:21:01 +0000 (12:21 -0400)
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
(cherry picked from commit 03dbd830e174685964191f739ea784f51c97d6b3)

doc/build/changelog/unreleased_20/12952.rst [new file with mode: 0644]
lib/sqlalchemy/orm/decl_base.py
lib/sqlalchemy/testing/requirements.py
test/orm/declarative/test_dc_transforms.py
test/orm/declarative/test_dc_transforms_future_anno_sync.py

diff --git a/doc/build/changelog/unreleased_20/12952.rst b/doc/build/changelog/unreleased_20/12952.rst
new file mode 100644 (file)
index 0000000..fa3ae9e
--- /dev/null
@@ -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.
index 418e312787cb51aa0bbe35d09063cfe08ab13746..b2ea17319ad10825d6a408c94f99d420efb59d03 100644 (file)
@@ -1218,7 +1218,7 @@ class _ClassScanMapperConfig(_MapperConfig):
             # 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
@@ -1249,7 +1249,7 @@ class _ClassScanMapperConfig(_MapperConfig):
                 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:
index 5b541b3ab437614bcbcf1d201a90c6c4fa41a70d..0a96ffcce3ef0afe05d641189aa2710173018ca3 100644 (file)
@@ -1634,6 +1634,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(
index b727430a70f4752351f23161abf3d3ccc6cb2dd2..06c92f038916b2253310d301c62281624490a89b 100644 (file)
@@ -893,6 +893,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):
     def test_list(self, dc_decl_base: Type[MappedAsDataclass]):
index 22f5c3082d37a8ba4367bf678c4c166c2ab07318..023b67a026eb4149a9a23f2d22fde74db92be31f 100644 (file)
@@ -906,6 +906,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):
     def test_list(self, dc_decl_base: Type[MappedAsDataclass]):