]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
modernize annotationlib approach
authorMichael Bayer <mike_mp@zzzcomputing.com>
Sat, 6 Dec 2025 15:38:30 +0000 (15:38 +0000)
committerMike Bayer <mike_mp@zzzcomputing.com>
Wed, 10 Dec 2025 18:04:51 +0000 (13:04 -0500)
This reverts commit 4ff1d604f9bec061fb1936b80d3ed09979d930e8 which reverted this change originally,
so it restores the change.

Change continues here where we now use py314's built in
annotationlib for get_annotations; issue [1] was fixed long
ago before 3.14.0 was released.

change for now:

A change in the mechanics of how Python dataclasses are applied to classes
that use :class:`.MappedAsDataclass` or
:meth:`.registry.mapped_as_dataclass` to apply ``__annotations__`` that are
as identical as is possible to the original ``__annotations__`` given,
while also adding attributes that SQLAlchemy considers to be part of
dataclass ``__annotations__``, then restoring the previous annotations in
exactly the same format as they were, using patterns that work with
:pep:`649` as closely as possible.

[1] https://github.com/python/cpython/issues/133684

Change-Id: I9073f99bc81b466888000da7d51d98cebf272b81

doc/build/changelog/unreleased_21/13021.rst [new file with mode: 0644]
lib/sqlalchemy/orm/decl_base.py
lib/sqlalchemy/util/__init__.py
lib/sqlalchemy/util/langhelpers.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_21/13021.rst b/doc/build/changelog/unreleased_21/13021.rst
new file mode 100644 (file)
index 0000000..e474391
--- /dev/null
@@ -0,0 +1,12 @@
+.. change::
+    :tags: bug, orm
+    :tickets: 13021
+
+    A change in the mechanics of how Python dataclasses are applied to classes
+    that use :class:`.MappedAsDataclass` or
+    :meth:`.registry.mapped_as_dataclass` to apply ``__annotations__`` that are
+    as identical as is possible to the original ``__annotations__`` given,
+    while also adding attributes that SQLAlchemy considers to be part of
+    dataclass ``__annotations__``, then restoring the previous annotations in
+    exactly the same format as they were, using patterns that work with
+    :pep:`649` as closely as possible.
index 2a6d9bd231887f0e9ec85245efac5a6e929b2946..9b8853f998c77060a4aa029ccaa5cf7a506ee215 100644 (file)
@@ -11,6 +11,7 @@ from __future__ import annotations
 
 import collections
 import dataclasses
+import itertools
 import re
 from typing import Any
 from typing import Callable
@@ -522,7 +523,7 @@ class _ClassScanAbstractConfig(_ORMClassConfigurator):
             for key, anno, mapped_container in (
                 (
                     key,
-                    mapped_anno if mapped_anno else raw_anno,
+                    raw_anno,
                     mapped_container,
                 )
                 for key, (
@@ -569,8 +570,28 @@ class _ClassScanAbstractConfig(_ORMClassConfigurator):
                     code="dcmx",
                 )
 
-        annotations = {}
+        if revert:
+            # the "revert" case is used only by an unmapped mixin class
+            # that is nonetheless using Mapped construct and needs to
+            # itself be a dataclass
+            revert_dict = {
+                name: self.cls.__dict__[name]
+                for name in (item[0] for item in field_list)
+                if name in self.cls.__dict__
+            }
+        else:
+            revert_dict = None
+
+        # get original annotations using ForwardRef for symbols that
+        # are unresolvable
+        orig_annotations = util.get_annotations(self.cls)
+
+        # build a new __annotations__ dict from the fields we have.
+        # this has to be done carefully since we have to maintain
+        # the correct order! wow
+        swap_annotations = {}
         defaults = {}
+
         for item in field_list:
             if len(item) == 2:
                 name, tp = item
@@ -579,25 +600,71 @@ class _ClassScanAbstractConfig(_ORMClassConfigurator):
                 defaults[name] = spec
             else:
                 assert False
-            annotations[name] = tp
 
-        revert_dict = {}
+            # add the annotation to the new dict we are creating.
+            # note that if name is in orig_annotations, we expect
+            # tp and orig_annotations[name] to be identical.
+            swap_annotations[name] = orig_annotations.get(name, tp)
 
         for k, v in defaults.items():
-            if k in self.cls.__dict__:
-                revert_dict[k] = self.cls.__dict__[k]
             setattr(self.cls, k, v)
 
-        self._apply_dataclasses_to_any_class(
-            dataclass_setup_arguments, self.cls, annotations
-        )
+        self._assert_dc_arguments(dataclass_setup_arguments)
 
-        if revert:
-            # used for mixin dataclasses; we have to restore the
-            # mapped_column(), relationship() etc. to the class so these
-            # take place for a mapped class scan
-            for k, v in revert_dict.items():
-                setattr(self.cls, k, v)
+        dataclass_callable = dataclass_setup_arguments["dataclass_callable"]
+        if dataclass_callable is _NoArg.NO_ARG:
+            dataclass_callable = dataclasses.dataclass
+
+        # create a merged __annotations__ dictionary, maintaining order
+        # as best we can:
+
+        # 1. merge all keys in orig_annotations that occur before
+        # we see any of our mapped fields (this can be attributes like
+        # __table_args__ etc.)
+        new_annotations = {
+            k: orig_annotations[k]
+            for k in itertools.takewhile(
+                lambda k: k not in swap_annotations, orig_annotations
+            )
+        }
+
+        # 2. then put in all the dataclass annotations we have
+        new_annotations |= swap_annotations
+
+        # 3. them merge all of orig_annotations which will add remaining
+        # keys
+        new_annotations |= orig_annotations
+
+        # 4. this becomes the new class annotations.
+        restore_anno = util.restore_annotations(self.cls, new_annotations)
+
+        try:
+            dataclass_callable(  # type: ignore[call-overload]
+                self.cls,
+                **{  # type: ignore[call-overload,unused-ignore]
+                    k: v
+                    for k, v in dataclass_setup_arguments.items()
+                    if v is not _NoArg.NO_ARG
+                    and k not in ("dataclass_callable",)
+                },
+            )
+        except (TypeError, ValueError) as ex:
+            raise exc.InvalidRequestError(
+                f"Python dataclasses error encountered when creating "
+                f"dataclass for {self.cls.__name__!r}: "
+                f"{ex!r}. Please refer to Python dataclasses "
+                "documentation for additional information.",
+                code="dcte",
+            ) from ex
+        finally:
+            if revert and revert_dict:
+                # used for mixin dataclasses; we have to restore the
+                # mapped_column(), relationship() etc. to the class so these
+                # take place for a mapped class scan
+                for k, v in revert_dict.items():
+                    setattr(self.cls, k, v)
+
+            restore_anno()
 
     def _collect_annotation(
         self,
@@ -671,60 +738,6 @@ class _ClassScanAbstractConfig(_ORMClassConfigurator):
         )
         return ca
 
-    @classmethod
-    def _apply_dataclasses_to_any_class(
-        cls,
-        dataclass_setup_arguments: _DataclassArguments,
-        klass: Type[_O],
-        use_annotations: Mapping[str, _AnnotationScanType],
-    ) -> None:
-        cls._assert_dc_arguments(dataclass_setup_arguments)
-
-        dataclass_callable = dataclass_setup_arguments["dataclass_callable"]
-        if dataclass_callable is _NoArg.NO_ARG:
-            dataclass_callable = dataclasses.dataclass
-
-        restored: Optional[Any]
-
-        if use_annotations:
-            # apply constructed annotations that should look "normal" to a
-            # dataclasses callable, based on the fields present.  This
-            # means remove the Mapped[] container and ensure all Field
-            # entries have an annotation
-            restored = util.get_annotations(klass)
-            klass.__annotations__ = cast("Dict[str, Any]", use_annotations)
-        else:
-            restored = None
-
-        try:
-            dataclass_callable(  # type: ignore[call-overload]
-                klass,
-                **{  # type: ignore[call-overload,unused-ignore]
-                    k: v
-                    for k, v in dataclass_setup_arguments.items()
-                    if v is not _NoArg.NO_ARG
-                    and k not in ("dataclass_callable",)
-                },
-            )
-        except (TypeError, ValueError) as ex:
-            raise exc.InvalidRequestError(
-                f"Python dataclasses error encountered when creating "
-                f"dataclass for {klass.__name__!r}: "
-                f"{ex!r}. Please refer to Python dataclasses "
-                "documentation for additional information.",
-                code="dcte",
-            ) from ex
-        finally:
-            # restore original annotations outside of the dataclasses
-            # process; for mixins and __abstract__ superclasses, SQLAlchemy
-            # Declarative will need to see the Mapped[] container inside the
-            # annotations in order to map subclasses
-            if use_annotations:
-                if restored is None:
-                    del klass.__annotations__
-                else:
-                    klass.__annotations__ = restored  # type: ignore[assignment]  # noqa: E501
-
     @classmethod
     def _assert_dc_arguments(cls, arguments: _DataclassArguments) -> None:
         allowed = {
index a61d980378c14f7ca73fc53f355b789ca58c7e46..cdef97350e002105f793ecb07188fce8c5a8bf8e 100644 (file)
@@ -136,6 +136,7 @@ from .langhelpers import (
 )
 from .langhelpers import PluginLoader as PluginLoader
 from .langhelpers import quoted_token_parser as quoted_token_parser
+from .langhelpers import restore_annotations as restore_annotations
 from .langhelpers import ro_memoized_property as ro_memoized_property
 from .langhelpers import ro_non_memoized_property as ro_non_memoized_property
 from .langhelpers import rw_hybridproperty as rw_hybridproperty
index 7bd293d40c470ea414b2bde1f8e89072c5b8c4a9..38f8530dc65b26f9cd3a94237bd4c50460a479ed 100644 (file)
@@ -59,87 +59,60 @@ _MA = TypeVar("_MA", bound="HasMemoized.memoized_attribute[Any]")
 _M = TypeVar("_M", bound=ModuleType)
 
 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()"""
+    import annotationlib
 
-        try:
-            ann = _get_dunder_annotations(obj)
-        except Exception:
-            pass
-        else:
-            if ann is not None:
-                return dict(ann)
+    def get_annotations(obj: Any) -> Mapping[str, Any]:
+        return annotationlib.get_annotations(
+            obj, format=annotationlib.Format.FORWARDREF
+        )
 
-        # 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)
+else:
 
-        if ann is None:
-            if isinstance(obj, type) or callable(obj):
-                return {}
-            raise TypeError(f"{obj!r} does not have annotations")
+    def get_annotations(obj: Any) -> Mapping[str, Any]:
+        return inspect.get_annotations(obj)
 
-        if not ann:
-            return {}
 
-        return dict(ann)
+def restore_annotations(
+    cls: type, new_annotations: dict[str, Any]
+) -> Callable[[], None]:
+    """apply alternate annotations to a class, with a callable to restore
+    the pristine state of the former.
+    This is used strictly to provide dataclasses on a mapped class, where
+    in some cases where are making dataclass fields based on an attribute
+    that is actually a python descriptor on a superclass which we called
+    to get a value.
+    if dataclasses were to give us a way to achieve this without swapping
+    __annotations__, that would be much better.
+    """
+    delattr_ = object()
+
+    # pep-649 means classes have "__annotate__", and it's a callable. if it's
+    # there and is None, we're in "legacy future mode", where it's python 3.14
+    # or higher and "from __future__ import annotations" is set. in "legacy
+    # future mode" we have to do the same steps we do for older pythons,
+    # __annotate__ can be ignored
+    is_pep649 = hasattr(cls, "__annotate__") and cls.__annotate__ is not None
+
+    if is_pep649:
+        memoized = {
+            "__annotate__": getattr(cls, "__annotate__", delattr_),
+        }
+    else:
+        memoized = {
+            "__annotations__": getattr(cls, "__annotations__", delattr_)
+        }
 
-    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)
+    cls.__annotations__ = new_annotations
 
-else:
+    def restore():
+        for k, v in memoized.items():
+            if v is delattr_:
+                delattr(cls, k)
+            else:
+                setattr(cls, k, v)
 
-    def get_annotations(obj: Any) -> Mapping[str, Any]:
-        return inspect.get_annotations(obj)
+    return restore
 
 
 def md5_hex(x: Any) -> str:
index e00d89f2b5827b3b818aec9d517b460edb8231ce..6bb07dec0d59d5f680c8a40c6e7021d9071fea98 100644 (file)
@@ -3,6 +3,7 @@ import dataclasses
 from dataclasses import InitVar
 import inspect as pyinspect
 from itertools import product
+import sys
 from typing import Annotated
 from typing import Any
 from typing import ClassVar
@@ -27,6 +28,7 @@ from sqlalchemy import select
 from sqlalchemy import String
 from sqlalchemy import Table
 from sqlalchemy import testing
+from sqlalchemy import util
 from sqlalchemy.ext.associationproxy import association_proxy
 from sqlalchemy.orm import column_property
 from sqlalchemy.orm import composite
@@ -148,6 +150,49 @@ class DCTransformsTest(AssertsCompiledSQL, fixtures.TestBase):
             ),
         )
 
+        use_future_mode = False
+        # anno only: use_future_mode = True
+
+        # new docstrings change as of #12168 (adds DONT_SET as default value)
+        # and #13021 (maintains Mapped[type] as the type when dataclass is
+        # created)
+        if use_future_mode:
+            eq_regex(
+                A.__doc__,
+                r"A\(data: 'Mapped\[str\]', "
+                r"x: 'Mapped"
+                r"\[(?:Optional\[int\]|int \| None)\]' = "
+                r"<LoaderCallableStatus.DONT_SET: 5>, "
+                r"bs: \"Mapped"
+                r"\[List\['B'\]\]\" = "
+                r"<LoaderCallableStatus.DONT_SET: 5>\)",
+            )
+            eq_regex(
+                B.__doc__,
+                r"B\(data: 'Mapped\[str\]', "
+                r"x: 'Mapped"
+                r"\[(?:Optional\[int\]|int \| None)\]' = "
+                r"<LoaderCallableStatus.DONT_SET: 5>\)",
+            )
+        else:
+            eq_regex(
+                A.__doc__,
+                r"A\(data: sqlalchemy.orm.base.Mapped\[str\], "
+                r"x: sqlalchemy.orm.base.Mapped"
+                r"\[(?:typing.Optional\[int\]|int \| None)\] = "
+                r"<LoaderCallableStatus.DONT_SET: 5>, "
+                r"bs: sqlalchemy.orm.base.Mapped"
+                r"\[typing.List\[ForwardRef\('B'\)\]\] = "
+                r"<LoaderCallableStatus.DONT_SET: 5>\)",
+            )
+            eq_regex(
+                B.__doc__,
+                r"B\(data: sqlalchemy.orm.base.Mapped\[str\], "
+                r"x: sqlalchemy.orm.base.Mapped"
+                r"\[(?:typing.Optional\[int\]|int \| None)\] = "
+                r"<LoaderCallableStatus.DONT_SET: 5>\)",
+            )
+
         a2 = A("10", x=5, bs=[B("data1"), B("data2", x=12)])
         eq_(
             repr(a2),
@@ -327,7 +372,10 @@ class DCTransformsTest(AssertsCompiledSQL, fixtures.TestBase):
                 id: Mapped[int] = mapped_column(primary_key=True)
                 name: Mapped[str]
 
-            eq_(annotations, {MappedClass: {"id": int, "name": str}})
+            eq_(
+                annotations,
+                {MappedClass: {"id": Mapped[int], "name": Mapped[str]}},
+            )
 
         elif dc_type.decorator:
             reg = registry()
@@ -339,7 +387,10 @@ class DCTransformsTest(AssertsCompiledSQL, fixtures.TestBase):
                 id: Mapped[int] = mapped_column(primary_key=True)
                 name: Mapped[str]
 
-            eq_(annotations, {MappedClass: {"id": int, "name": str}})
+            eq_(
+                annotations,
+                {MappedClass: {"id": Mapped[int], "name": Mapped[str]}},
+            )
 
         elif dc_type.superclass:
 
@@ -355,7 +406,10 @@ class DCTransformsTest(AssertsCompiledSQL, fixtures.TestBase):
 
             eq_(
                 annotations,
-                {Mixin: {"id": int}, MappedClass: {"id": int, "name": str}},
+                {
+                    Mixin: {"id": Mapped[int]},
+                    MappedClass: {"id": Mapped[int], "name": Mapped[str]},
+                },
             )
         else:
             dc_type.fail()
@@ -924,6 +978,73 @@ class DCTransformsTest(AssertsCompiledSQL, fixtures.TestBase):
         eq_(fields["id"].metadata, {})
         eq_(fields["value"].metadata, {"meta_key": "meta_value"})
 
+    @testing.requires.python314
+    def test_restore_annotations_langhelper(self):
+        """robust tests for the annotation replace/restore feature under
+        all pep-649 modes (absent, disabled, enabled)
+
+        """
+
+        class Thing:
+            data: Mapped[int]
+            x: str
+
+        import annotationlib
+
+        is_pep649 = (
+            hasattr(Thing, "__annotate__") and Thing.__annotate__ is not None
+        )
+
+        def assert_pristine_annotations():
+            if is_pep649:
+                eq_(
+                    annotationlib.get_annotations(Thing),
+                    {"data": Mapped[int], "x": str},
+                )
+                eq_(
+                    annotationlib.get_annotations(
+                        Thing, format=annotationlib.Format.VALUE
+                    ),
+                    {"data": Mapped[int], "x": str},
+                )
+            else:
+                # from __future__ import annotations is in effect
+                eq_(
+                    annotationlib.get_annotations(Thing),
+                    {"data": "Mapped[int]", "x": "str"},
+                )
+                # in __future__ annotations (which means, *past* annotations
+                # mode), Format.VALUE is not effective; you're getting your
+                # strings back
+                eq_(
+                    annotationlib.get_annotations(
+                        Thing, format=annotationlib.Format.VALUE
+                    ),
+                    {"data": "Mapped[int]", "x": "str"},
+                )
+
+            eq_(
+                annotationlib.get_annotations(
+                    Thing, format=annotationlib.Format.STRING
+                ),
+                {"data": "Mapped[int]", "x": "str"},
+            )
+
+        assert_pristine_annotations()
+
+        restore = util.restore_annotations(
+            Thing, {"data": float, "x": str, "y": int}
+        )
+
+        eq_(
+            annotationlib.get_annotations(Thing),
+            {"data": float, "x": str, "y": int},
+        )
+
+        restore()
+
+        assert_pristine_annotations()
+
     @testing.requires.python314
     def test_apply_dc_deferred_annotations(self, dc_decl_base):
         """test for #12952"""
@@ -955,6 +1076,28 @@ class DCTransformsTest(AssertsCompiledSQL, fixtures.TestBase):
         is_true("user_id" in sig.parameters)
         is_true("user" in sig.parameters)
 
+        expect_fail = True
+        # anno only: expect_fail = False
+
+        # annotations are restored exactly
+        if expect_fail:
+            # so in in pure pep649 mode the NameError raises again
+            with expect_raises_message(
+                NameError, "'UnavailableUser' is not defined"
+            ):
+                Message.__annotations__
+        else:
+            # in __future__ annotations mode, we get strings
+            eq_(
+                Message.__annotations__,
+                {
+                    "id": "Mapped[int]",
+                    "content": "Mapped[str]",
+                    "user_id": "Mapped[int]",
+                    "user": "Mapped[UnavailableUser]",
+                },
+            )
+
 
 class RelationshipDefaultFactoryTest(fixtures.TestBase):
 
@@ -1343,7 +1486,10 @@ class DataclassesForNonMappedClassesTest(fixtures.TestBase):
             __tablename__ = "child"
             c: Mapped[int] = mapped_column(primary_key=True)
 
-        eq_(collected_annotations, {Mixin: {"b": int}, Child: {"c": int}})
+        eq_(
+            collected_annotations,
+            {Mixin: {"b": int}, Child: {"c": Mapped[int]}},
+        )
         eq_regex(repr(Child(6, 7)), r".*\.Child\(b=6, c=7\)")
 
     # TODO: get this test to work with future anno mode as well
@@ -1379,7 +1525,10 @@ class DataclassesForNonMappedClassesTest(fixtures.TestBase):
             # dataclasses collection.
             eq_(
                 collected_annotations,
-                {Mixin: {"b": int}, Child: {"b": int, "c": int}},
+                {
+                    Mixin: {"b": Mapped[int]},
+                    Child: {"b": Mapped[int], "c": Mapped[int]},
+                },
             )
         eq_regex(repr(Child(6, 7)), r".*\.Child\(b=6, c=7\)")
 
@@ -1588,7 +1737,10 @@ class DataclassesForNonMappedClassesTest(fixtures.TestBase):
                 )
 
         if MappedAsDataclass in Book.__mro__:
-            expected_annotations[Book] = {"id": int, "polymorphic_type": str}
+            expected_annotations[Book] = {
+                "id": Mapped[int],
+                "polymorphic_type": Mapped[str],
+            }
 
         class Novel(Book):
             id: Mapped[int] = mapped_column(
@@ -1598,7 +1750,10 @@ class DataclassesForNonMappedClassesTest(fixtures.TestBase):
             )
             description: Mapped[Optional[str]]
 
-        expected_annotations[Novel] = {"id": int, "description": Optional[str]}
+        expected_annotations[Novel] = {
+            "id": Mapped[int],
+            "description": Mapped[Optional[str]],
+        }
 
         if test_alternative_callable:
             eq_(collected_annotations, expected_annotations)
@@ -1677,8 +1832,14 @@ class DataclassesForNonMappedClassesTest(fixtures.TestBase):
             )
             description: Mapped[Optional[str]]
 
-        expected_annotations[Book] = {"id": int, "polymorphic_type": str}
-        expected_annotations[Novel] = {"id": int, "description": Optional[str]}
+        expected_annotations[Book] = {
+            "id": Mapped[int],
+            "polymorphic_type": Mapped[str],
+        }
+        expected_annotations[Novel] = {
+            "id": Mapped[int],
+            "description": Mapped[Optional[str]],
+        }
         expected_annotations[Mixin] = {}
 
         if test_alternative_callable:
@@ -1691,6 +1852,10 @@ class DataclassesForNonMappedClassesTest(fixtures.TestBase):
         n1 = Novel("the description")
         eq_(n1.description, "the description")
 
+    @testing.fails_if(
+        lambda: sys.version_info[0:3] == (3, 14, 1),
+        reason="See cpython issue 142214",
+    )
     def test_cpython_142214(self, dc_decl_base):
         """test for the cpython issue shown in issue #13021"""
 
@@ -1739,6 +1904,144 @@ class DataclassesForNonMappedClassesTest(fixtures.TestBase):
             ),
         )
 
+    @testing.variation(
+        "levels",
+        [
+            "one",
+            (
+                "two",
+                testing.fails_if(
+                    lambda: sys.version_info[0:3] == (3, 14, 1),
+                    reason="See cpython issue 142214",
+                ),
+            ),
+        ],
+    )
+    @testing.variation("type_", ["mixin", "abstract"])
+    @testing.variation("kwonly", [True, False])
+    def test_declared_attr_relationships(
+        self,
+        dc_decl_base,
+        kwonly: testing.Variation,
+        levels: testing.Variation,
+        type_: testing.Variation,
+    ):
+        """further tests related to #13021 where we need to support
+        declared_attr on mixins"""
+
+        if kwonly:
+            dc_kwargs = {"kw_only": True}
+        else:
+            dc_kwargs = {}
+
+        class User(dc_decl_base):
+            __tablename__ = "user_account"
+
+            id: Mapped[int] = mapped_column(init=False, primary_key=True)
+            name: Mapped[str]
+
+        if type_.abstract:
+
+            class CreatedByMixin(dc_decl_base, **dc_kwargs):
+                __abstract__ = True
+
+                created_by_fk: Mapped[int] = mapped_column(
+                    ForeignKey("user_account.id"), init=False
+                )
+
+                @declared_attr
+                @classmethod
+                def created_by(cls) -> Mapped[User]:
+                    return relationship(foreign_keys=[cls.created_by_fk])
+
+            bases = (CreatedByMixin,)
+        elif type_.mixin:
+
+            class CreatedByMixin(MappedAsDataclass, **dc_kwargs):
+                created_by_fk: Mapped[int] = mapped_column(
+                    ForeignKey("user_account.id"), init=False
+                )
+
+                @declared_attr
+                @classmethod
+                def created_by(cls) -> Mapped[User]:
+                    return relationship(foreign_keys=[cls.created_by_fk])
+
+            bases = (CreatedByMixin, dc_decl_base)
+
+        else:
+            type_.fail()
+
+        class Item(*bases, **dc_kwargs):
+            __tablename__: ClassVar[str] = "item"
+
+            id: Mapped[int] = mapped_column(init=False, primary_key=True)
+            description: Mapped[str]
+
+        if levels.one:
+            item = Item(
+                description="d1",
+                created_by=User(name="u1"),
+            )
+            eq_(
+                item,
+                Item(
+                    description="d1",
+                    created_by=User(name="u1"),
+                ),
+            )
+
+            # check if annotations were restored in acceptable-enough order,
+            # but also including the descriptor field we got from
+            # CreatedByMixin.   this allows python issue #142214 to work
+            eq_(
+                list(Item.__annotations__),
+                [
+                    "__tablename__",
+                    # created_by_fk came from the superclass, and while we
+                    # added this to local annotations, we restored the old
+                    # ones, so that is also gone
+                    # "created_by_fk",
+                    "id",
+                    "description",
+                    # created_by was added by us, and we are restoring the
+                    # old annotations so it's gone
+                    # "created_by",
+                ],
+            )
+
+        if levels.two:
+
+            class SpecialItem(Item, **dc_kwargs):
+
+                id: Mapped[int] = mapped_column(
+                    ForeignKey("item.id"), init=False, primary_key=True
+                )
+                special_description: Mapped[str]
+
+                __tablename__: ClassVar[str] = "special_item"
+
+            special_item = SpecialItem(
+                special_description="sd1",
+                description="d1",
+                created_by=User(name="u1"),
+            )
+
+            eq_(
+                special_item,
+                SpecialItem(
+                    special_description="sd1",
+                    description="d1",
+                    created_by=User(name="u1"),
+                ),
+            )
+
+            # check if annotations were restored in acceptable-enough order
+            eq_(
+                list(SpecialItem.__annotations__),
+                ["id", "special_description", "__tablename__"],
+            )
+
 
 class DataclassArgsTest(fixtures.TestBase):
     dc_arg_names = (
index 3406d81cdbbcbfd1e68f5350b1f44facdcf988cb..5f7da5e5b73eec948a628a994b6206ffa9502b09 100644 (file)
@@ -12,6 +12,7 @@ import dataclasses
 from dataclasses import InitVar
 import inspect as pyinspect
 from itertools import product
+import sys
 from typing import Annotated
 from typing import Any
 from typing import ClassVar
@@ -36,6 +37,7 @@ from sqlalchemy import select
 from sqlalchemy import String
 from sqlalchemy import Table
 from sqlalchemy import testing
+from sqlalchemy import util
 from sqlalchemy.ext.associationproxy import association_proxy
 from sqlalchemy.orm import column_property
 from sqlalchemy.orm import composite
@@ -157,6 +159,49 @@ class DCTransformsTest(AssertsCompiledSQL, fixtures.TestBase):
             ),
         )
 
+        use_future_mode = False
+        use_future_mode = True
+
+        # new docstrings change as of #12168 (adds DONT_SET as default value)
+        # and #13021 (maintains Mapped[type] as the type when dataclass is
+        # created)
+        if use_future_mode:
+            eq_regex(
+                A.__doc__,
+                r"A\(data: 'Mapped\[str\]', "
+                r"x: 'Mapped"
+                r"\[(?:Optional\[int\]|int \| None)\]' = "
+                r"<LoaderCallableStatus.DONT_SET: 5>, "
+                r"bs: \"Mapped"
+                r"\[List\['B'\]\]\" = "
+                r"<LoaderCallableStatus.DONT_SET: 5>\)",
+            )
+            eq_regex(
+                B.__doc__,
+                r"B\(data: 'Mapped\[str\]', "
+                r"x: 'Mapped"
+                r"\[(?:Optional\[int\]|int \| None)\]' = "
+                r"<LoaderCallableStatus.DONT_SET: 5>\)",
+            )
+        else:
+            eq_regex(
+                A.__doc__,
+                r"A\(data: sqlalchemy.orm.base.Mapped\[str\], "
+                r"x: sqlalchemy.orm.base.Mapped"
+                r"\[(?:typing.Optional\[int\]|int \| None)\] = "
+                r"<LoaderCallableStatus.DONT_SET: 5>, "
+                r"bs: sqlalchemy.orm.base.Mapped"
+                r"\[typing.List\[ForwardRef\('B'\)\]\] = "
+                r"<LoaderCallableStatus.DONT_SET: 5>\)",
+            )
+            eq_regex(
+                B.__doc__,
+                r"B\(data: sqlalchemy.orm.base.Mapped\[str\], "
+                r"x: sqlalchemy.orm.base.Mapped"
+                r"\[(?:typing.Optional\[int\]|int \| None)\] = "
+                r"<LoaderCallableStatus.DONT_SET: 5>\)",
+            )
+
         a2 = A("10", x=5, bs=[B("data1"), B("data2", x=12)])
         eq_(
             repr(a2),
@@ -340,7 +385,10 @@ class DCTransformsTest(AssertsCompiledSQL, fixtures.TestBase):
                 id: Mapped[int] = mapped_column(primary_key=True)
                 name: Mapped[str]
 
-            eq_(annotations, {MappedClass: {"id": int, "name": str}})
+            eq_(
+                annotations,
+                {MappedClass: {"id": Mapped[int], "name": Mapped[str]}},
+            )
 
         elif dc_type.decorator:
             reg = registry()
@@ -352,7 +400,10 @@ class DCTransformsTest(AssertsCompiledSQL, fixtures.TestBase):
                 id: Mapped[int] = mapped_column(primary_key=True)
                 name: Mapped[str]
 
-            eq_(annotations, {MappedClass: {"id": int, "name": str}})
+            eq_(
+                annotations,
+                {MappedClass: {"id": Mapped[int], "name": Mapped[str]}},
+            )
 
         elif dc_type.superclass:
 
@@ -368,7 +419,10 @@ class DCTransformsTest(AssertsCompiledSQL, fixtures.TestBase):
 
             eq_(
                 annotations,
-                {Mixin: {"id": int}, MappedClass: {"id": int, "name": str}},
+                {
+                    Mixin: {"id": Mapped[int]},
+                    MappedClass: {"id": Mapped[int], "name": Mapped[str]},
+                },
             )
         else:
             dc_type.fail()
@@ -937,6 +991,73 @@ class DCTransformsTest(AssertsCompiledSQL, fixtures.TestBase):
         eq_(fields["id"].metadata, {})
         eq_(fields["value"].metadata, {"meta_key": "meta_value"})
 
+    @testing.requires.python314
+    def test_restore_annotations_langhelper(self):
+        """robust tests for the annotation replace/restore feature under
+        all pep-649 modes (absent, disabled, enabled)
+
+        """
+
+        class Thing:
+            data: Mapped[int]
+            x: str
+
+        import annotationlib
+
+        is_pep649 = (
+            hasattr(Thing, "__annotate__") and Thing.__annotate__ is not None
+        )
+
+        def assert_pristine_annotations():
+            if is_pep649:
+                eq_(
+                    annotationlib.get_annotations(Thing),
+                    {"data": Mapped[int], "x": str},
+                )
+                eq_(
+                    annotationlib.get_annotations(
+                        Thing, format=annotationlib.Format.VALUE
+                    ),
+                    {"data": Mapped[int], "x": str},
+                )
+            else:
+                # from __future__ import annotations is in effect
+                eq_(
+                    annotationlib.get_annotations(Thing),
+                    {"data": "Mapped[int]", "x": "str"},
+                )
+                # in __future__ annotations (which means, *past* annotations
+                # mode), Format.VALUE is not effective; you're getting your
+                # strings back
+                eq_(
+                    annotationlib.get_annotations(
+                        Thing, format=annotationlib.Format.VALUE
+                    ),
+                    {"data": "Mapped[int]", "x": "str"},
+                )
+
+            eq_(
+                annotationlib.get_annotations(
+                    Thing, format=annotationlib.Format.STRING
+                ),
+                {"data": "Mapped[int]", "x": "str"},
+            )
+
+        assert_pristine_annotations()
+
+        restore = util.restore_annotations(
+            Thing, {"data": float, "x": str, "y": int}
+        )
+
+        eq_(
+            annotationlib.get_annotations(Thing),
+            {"data": float, "x": str, "y": int},
+        )
+
+        restore()
+
+        assert_pristine_annotations()
+
     @testing.requires.python314
     def test_apply_dc_deferred_annotations(self, dc_decl_base):
         """test for #12952"""
@@ -968,6 +1089,28 @@ class DCTransformsTest(AssertsCompiledSQL, fixtures.TestBase):
         is_true("user_id" in sig.parameters)
         is_true("user" in sig.parameters)
 
+        expect_fail = True
+        expect_fail = False
+
+        # annotations are restored exactly
+        if expect_fail:
+            # so in in pure pep649 mode the NameError raises again
+            with expect_raises_message(
+                NameError, "'UnavailableUser' is not defined"
+            ):
+                Message.__annotations__
+        else:
+            # in __future__ annotations mode, we get strings
+            eq_(
+                Message.__annotations__,
+                {
+                    "id": "Mapped[int]",
+                    "content": "Mapped[str]",
+                    "user_id": "Mapped[int]",
+                    "user": "Mapped[UnavailableUser]",
+                },
+            )
+
 
 class RelationshipDefaultFactoryTest(fixtures.TestBase):
 
@@ -1358,7 +1501,10 @@ class DataclassesForNonMappedClassesTest(fixtures.TestBase):
             __tablename__ = "child"
             c: Mapped[int] = mapped_column(primary_key=True)
 
-        eq_(collected_annotations, {Mixin: {"b": int}, Child: {"c": int}})
+        eq_(
+            collected_annotations,
+            {Mixin: {"b": int}, Child: {"c": Mapped[int]}},
+        )
         eq_regex(repr(Child(6, 7)), r".*\.Child\(b=6, c=7\)")
 
     # TODO: get this test to work with future anno mode as well
@@ -1396,7 +1542,10 @@ class DataclassesForNonMappedClassesTest(fixtures.TestBase):
             # dataclasses collection.
             eq_(
                 collected_annotations,
-                {Mixin: {"b": int}, Child: {"b": int, "c": int}},
+                {
+                    Mixin: {"b": Mapped[int]},
+                    Child: {"b": Mapped[int], "c": Mapped[int]},
+                },
             )
         eq_regex(repr(Child(6, 7)), r".*\.Child\(b=6, c=7\)")
 
@@ -1605,7 +1754,10 @@ class DataclassesForNonMappedClassesTest(fixtures.TestBase):
                 )
 
         if MappedAsDataclass in Book.__mro__:
-            expected_annotations[Book] = {"id": int, "polymorphic_type": str}
+            expected_annotations[Book] = {
+                "id": Mapped[int],
+                "polymorphic_type": Mapped[str],
+            }
 
         class Novel(Book):
             id: Mapped[int] = mapped_column(
@@ -1615,7 +1767,10 @@ class DataclassesForNonMappedClassesTest(fixtures.TestBase):
             )
             description: Mapped[Optional[str]]
 
-        expected_annotations[Novel] = {"id": int, "description": Optional[str]}
+        expected_annotations[Novel] = {
+            "id": Mapped[int],
+            "description": Mapped[Optional[str]],
+        }
 
         if test_alternative_callable:
             eq_(collected_annotations, expected_annotations)
@@ -1694,8 +1849,14 @@ class DataclassesForNonMappedClassesTest(fixtures.TestBase):
             )
             description: Mapped[Optional[str]]
 
-        expected_annotations[Book] = {"id": int, "polymorphic_type": str}
-        expected_annotations[Novel] = {"id": int, "description": Optional[str]}
+        expected_annotations[Book] = {
+            "id": Mapped[int],
+            "polymorphic_type": Mapped[str],
+        }
+        expected_annotations[Novel] = {
+            "id": Mapped[int],
+            "description": Mapped[Optional[str]],
+        }
         expected_annotations[Mixin] = {}
 
         if test_alternative_callable:
@@ -1708,6 +1869,10 @@ class DataclassesForNonMappedClassesTest(fixtures.TestBase):
         n1 = Novel("the description")
         eq_(n1.description, "the description")
 
+    @testing.fails_if(
+        lambda: sys.version_info[0:3] == (3, 14, 1),
+        reason="See cpython issue 142214",
+    )
     def test_cpython_142214(self, dc_decl_base):
         """test for the cpython issue shown in issue #13021"""
 
@@ -1756,6 +1921,144 @@ class DataclassesForNonMappedClassesTest(fixtures.TestBase):
             ),
         )
 
+    @testing.variation(
+        "levels",
+        [
+            "one",
+            (
+                "two",
+                testing.fails_if(
+                    lambda: sys.version_info[0:3] == (3, 14, 1),
+                    reason="See cpython issue 142214",
+                ),
+            ),
+        ],
+    )
+    @testing.variation("type_", ["mixin", "abstract"])
+    @testing.variation("kwonly", [True, False])
+    def test_declared_attr_relationships(
+        self,
+        dc_decl_base,
+        kwonly: testing.Variation,
+        levels: testing.Variation,
+        type_: testing.Variation,
+    ):
+        """further tests related to #13021 where we need to support
+        declared_attr on mixins"""
+
+        if kwonly:
+            dc_kwargs = {"kw_only": True}
+        else:
+            dc_kwargs = {}
+
+        class User(dc_decl_base):
+            __tablename__ = "user_account"
+
+            id: Mapped[int] = mapped_column(init=False, primary_key=True)
+            name: Mapped[str]
+
+        if type_.abstract:
+
+            class CreatedByMixin(dc_decl_base, **dc_kwargs):
+                __abstract__ = True
+
+                created_by_fk: Mapped[int] = mapped_column(
+                    ForeignKey("user_account.id"), init=False
+                )
+
+                @declared_attr
+                @classmethod
+                def created_by(cls) -> Mapped[User]:
+                    return relationship(foreign_keys=[cls.created_by_fk])
+
+            bases = (CreatedByMixin,)
+        elif type_.mixin:
+
+            class CreatedByMixin(MappedAsDataclass, **dc_kwargs):
+                created_by_fk: Mapped[int] = mapped_column(
+                    ForeignKey("user_account.id"), init=False
+                )
+
+                @declared_attr
+                @classmethod
+                def created_by(cls) -> Mapped[User]:
+                    return relationship(foreign_keys=[cls.created_by_fk])
+
+            bases = (CreatedByMixin, dc_decl_base)
+
+        else:
+            type_.fail()
+
+        class Item(*bases, **dc_kwargs):
+            __tablename__: ClassVar[str] = "item"
+
+            id: Mapped[int] = mapped_column(init=False, primary_key=True)
+            description: Mapped[str]
+
+        if levels.one:
+            item = Item(
+                description="d1",
+                created_by=User(name="u1"),
+            )
+            eq_(
+                item,
+                Item(
+                    description="d1",
+                    created_by=User(name="u1"),
+                ),
+            )
+
+            # check if annotations were restored in acceptable-enough order,
+            # but also including the descriptor field we got from
+            # CreatedByMixin.   this allows python issue #142214 to work
+            eq_(
+                list(Item.__annotations__),
+                [
+                    "__tablename__",
+                    # created_by_fk came from the superclass, and while we
+                    # added this to local annotations, we restored the old
+                    # ones, so that is also gone
+                    # "created_by_fk",
+                    "id",
+                    "description",
+                    # created_by was added by us, and we are restoring the
+                    # old annotations so it's gone
+                    # "created_by",
+                ],
+            )
+
+        if levels.two:
+
+            class SpecialItem(Item, **dc_kwargs):
+
+                id: Mapped[int] = mapped_column(
+                    ForeignKey("item.id"), init=False, primary_key=True
+                )
+                special_description: Mapped[str]
+
+                __tablename__: ClassVar[str] = "special_item"
+
+            special_item = SpecialItem(
+                special_description="sd1",
+                description="d1",
+                created_by=User(name="u1"),
+            )
+
+            eq_(
+                special_item,
+                SpecialItem(
+                    special_description="sd1",
+                    description="d1",
+                    created_by=User(name="u1"),
+                ),
+            )
+
+            # check if annotations were restored in acceptable-enough order
+            eq_(
+                list(SpecialItem.__annotations__),
+                ["id", "special_description", "__tablename__"],
+            )
+
 
 class DataclassArgsTest(fixtures.TestBase):
     dc_arg_names = (