--- /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 no longer modify the
+ `__annotations__` collection that's on the class, instead manipulating
+ regular class-bound attributes in order to satisfy the class requirements
+ for the dataclass creation function. This works around an issue that has
+ appeared in Python 3.14.1, provides for a much simpler implementation, and
+ also maintains accurate typing information about the attributes as the
+ dataclass is built.
code="dcmx",
)
- annotations = {}
- defaults = {}
- for item in field_list:
- if len(item) == 2:
- name, tp = item
- elif len(item) == 3:
- name, tp, spec = item
- defaults[name] = spec
- else:
- assert False
- annotations[name] = tp
+ 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
- revert_dict = {}
+ anno = util.get_annotations(self.cls)
- for k, v in defaults.items():
- if k in self.cls.__dict__:
- revert_dict[k] = self.cls.__dict__[k]
- setattr(self.cls, k, v)
+ # ensure the class has attributes for only those keys
+ # where an annotation is present.
+ for item in field_list:
+ name = item[0]
+ if name not in anno and name in self.cls.__dict__:
+ delattr(self.cls, name)
+ elif name in self.cls.__dict__ and len(item) == 3:
+ setattr(self.cls, name, item[2])
- 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
+
+ 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)
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 = {
),
)
+ 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: {"name": Mapped[str]},
+ },
)
else:
dc_type.fail()
__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: {"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}
+ if dataclass_scope.on_mixin:
+ expected_annotations[Book] = {"id": Mapped[int]}
+ else:
+ 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:
),
)
+ 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: {"name": Mapped[str]},
+ },
)
else:
dc_type.fail()
__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: {"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}
+ if dataclass_scope.on_mixin:
+ expected_annotations[Book] = {"id": Mapped[int]}
+ else:
+ 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: