From ceeac6d91ddab3bf7121a1ea4646abadc3b8c98f Mon Sep 17 00:00:00 2001 From: Michael Bayer Date: Sat, 6 Dec 2025 15:38:30 +0000 Subject: [PATCH] modernize annotationlib approach 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 | 12 + lib/sqlalchemy/orm/decl_base.py | 151 ++++---- lib/sqlalchemy/util/__init__.py | 1 + lib/sqlalchemy/util/langhelpers.py | 117 +++---- test/orm/declarative/test_dc_transforms.py | 321 +++++++++++++++++- .../test_dc_transforms_future_anno_sync.py | 321 +++++++++++++++++- 6 files changed, 764 insertions(+), 159 deletions(-) create mode 100644 doc/build/changelog/unreleased_21/13021.rst diff --git a/doc/build/changelog/unreleased_21/13021.rst b/doc/build/changelog/unreleased_21/13021.rst new file mode 100644 index 0000000000..e474391463 --- /dev/null +++ b/doc/build/changelog/unreleased_21/13021.rst @@ -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. diff --git a/lib/sqlalchemy/orm/decl_base.py b/lib/sqlalchemy/orm/decl_base.py index 2a6d9bd231..9b8853f998 100644 --- a/lib/sqlalchemy/orm/decl_base.py +++ b/lib/sqlalchemy/orm/decl_base.py @@ -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 = { diff --git a/lib/sqlalchemy/util/__init__.py b/lib/sqlalchemy/util/__init__.py index a61d980378..cdef97350e 100644 --- a/lib/sqlalchemy/util/__init__.py +++ b/lib/sqlalchemy/util/__init__.py @@ -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 diff --git a/lib/sqlalchemy/util/langhelpers.py b/lib/sqlalchemy/util/langhelpers.py index 7bd293d40c..38f8530dc6 100644 --- a/lib/sqlalchemy/util/langhelpers.py +++ b/lib/sqlalchemy/util/langhelpers.py @@ -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: diff --git a/test/orm/declarative/test_dc_transforms.py b/test/orm/declarative/test_dc_transforms.py index e00d89f2b5..6bb07dec0d 100644 --- a/test/orm/declarative/test_dc_transforms.py +++ b/test/orm/declarative/test_dc_transforms.py @@ -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", " + r"bs: \"Mapped" + r"\[List\['B'\]\]\" = " + r"\)", + ) + eq_regex( + B.__doc__, + r"B\(data: 'Mapped\[str\]', " + r"x: 'Mapped" + r"\[(?:Optional\[int\]|int \| None)\]' = " + r"\)", + ) + 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", " + r"bs: sqlalchemy.orm.base.Mapped" + r"\[typing.List\[ForwardRef\('B'\)\]\] = " + r"\)", + ) + 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"\)", + ) + 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 = ( 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 3406d81cdb..5f7da5e5b7 100644 --- a/test/orm/declarative/test_dc_transforms_future_anno_sync.py +++ b/test/orm/declarative/test_dc_transforms_future_anno_sync.py @@ -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", " + r"bs: \"Mapped" + r"\[List\['B'\]\]\" = " + r"\)", + ) + eq_regex( + B.__doc__, + r"B\(data: 'Mapped\[str\]', " + r"x: 'Mapped" + r"\[(?:Optional\[int\]|int \| None)\]' = " + r"\)", + ) + 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", " + r"bs: sqlalchemy.orm.base.Mapped" + r"\[typing.List\[ForwardRef\('B'\)\]\] = " + r"\)", + ) + 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"\)", + ) + 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 = ( -- 2.47.3