]> 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:20:14 +0000 (12:20 -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

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 be9742a8df434fb47d4e2a7a175afcabda0d6caa..2a6d9bd231887f0e9ec85245efac5a6e929b2946 100644 (file)
@@ -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:
index c7a75c40dc576c2a50258bae7caad3955b88d077..7aaa65010f41b11ec9487cffd34ab57037df4a8d 100644 (file)
@@ -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(
index c0048b180f449832298a4f2dc6981b9a447f81fb..46780e3806a312d0ea6f4e62868b32b788336d41 100644 (file)
@@ -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):
 
index b6f226073d3c71e650d09569d2d6714fec371253..7724259396b8e7b7f853485e3ef1728d15242901 100644 (file)
@@ -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):