From: Michael Bayer Date: Sat, 6 Dec 2025 15:38:30 +0000 (+0000) Subject: modernize annotationlib approach X-Git-Url: http://git.ipfire.org/?a=commitdiff_plain;h=ceeac6d91ddab3bf7121a1ea4646abadc3b8c98f;p=thirdparty%2Fsqlalchemy%2Fsqlalchemy.git 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 --- 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 = (