--- /dev/null
+.. 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.
import collections
import dataclasses
+import itertools
import re
from typing import Any
from typing import Callable
for key, anno, mapped_container in (
(
key,
- mapped_anno if mapped_anno else raw_anno,
+ raw_anno,
mapped_container,
)
for key, (
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
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,
)
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 = {
)
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
_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:
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
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
),
)
+ 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),
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()
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:
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()
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"""
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):
__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
# 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\)")
)
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(
)
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)
)
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:
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"""
),
)
+ @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 = (
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
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
),
)
+ 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),
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()
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:
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()
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"""
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):
__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
# 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\)")
)
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(
)
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)
)
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:
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"""
),
)
+ @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 = (