diff --git a/docs/api.rst b/docs/api.rst index c8ab29a41..12db06940 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -202,7 +202,7 @@ Helpers ... class CInspect: ... pass >>> attrs.inspect(CInspect) # doctest: +ELLIPSIS - ClassProps(is_exception=False, is_slotted=True, has_weakref_slot=True, is_frozen=False, kw_only=, collected_fields_by_mro=True, added_init=True, added_repr=True, added_eq=True, added_ordering=False, hashability=, added_match_args=True, added_str=False, added_pickling=True, on_setattr_hook=.wrapped_pipe at ...>, field_transformer=None) + ClassProps(is_exception=False, is_slotted=True, has_weakref_slot=True, is_frozen=False, kw_only=, collected_fields_by_mro=True, added_init=True, added_repr=True, added_eq=True, added_ordering=False, hashability=, added_match_args=True, added_str=False, added_pickling=True, added_dataclass_fields=False, on_setattr_hook=.wrapped_pipe at ...>, field_transformer=None) .. autoclass:: attrs.ClassProps .. autoclass:: attrs.ClassProps.Hashability diff --git a/src/attr/_make.py b/src/attr/_make.py index afbca4635..458b9e9a2 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -1022,6 +1022,32 @@ def __str__(self): self._cls_dict["__str__"] = self._add_method_dunders(__str__) return self + def add_dataclass_fields(self): + import dataclasses + + dataclass_fields = {} + for attribute in self._attrs: + field = dataclasses.field() + field._field_type = dataclasses._FIELD + + field.name = attribute.name + if attribute.type is None: + msg = "__dataclass_fields__ can only be generated if all attributes are annotated." + raise ValueError(msg) + field.type = attribute.type + field.kw_only = attribute.kw_only + field.metadata = attribute.metadata + if attribute.default is not NOTHING: + if isinstance(attribute.default, Factory): + field.default_factory = attribute.default.factory + else: + field.default = attribute.default + + dataclass_fields[field.name] = field + + self._cls_dict["__dataclass_fields__"] = dataclass_fields + return self + def _make_getstate_setstate(self): """ Create custom __setstate__ and __getstate__ methods. @@ -1374,6 +1400,7 @@ def attrs( on_setattr=None, field_transformer=None, match_args=True, + dataclass_compatible=False, unsafe_hash=None, force_kw_only=True, ): @@ -1557,6 +1584,7 @@ def wrap(cls): ("__getstate__", "__setstate__"), default=slots, ), + added_dataclass_fields=dataclass_compatible, on_setattr_hook=on_setattr, field_transformer=field_transformer, ) @@ -1606,6 +1634,9 @@ def wrap(cls): if match_args and not _has_own_attribute(cls, "__match_args__"): builder.add_match_args() + if props.added_dataclass_fields: + builder.add_dataclass_fields() + return builder.build_class() # maybe_cls's type depends on the usage of the decorator. It's a class @@ -2959,12 +2990,17 @@ class ClassProps: Whether the class has *attrs*-generated ``__getstate__`` and ``__setstate__`` methods for `pickle`. + added_dataclass_fields (bool): + Whether the class has an *attrs*-generated ``__dataclass_fields__`` + attribute. + on_setattr_hook (Callable[[Any, Attribute[Any], Any], Any] | None): The class's ``__setattr__`` hook. field_transformer (Callable[[Attribute[Any]], Attribute[Any]] | None): The class's `field transformers `. + .. versionadded:: 25.4.0 """ @@ -3013,6 +3049,7 @@ class KeywordOnly(enum.Enum): "added_match_args", "added_str", "added_pickling", + "added_dataclass_fields", "on_setattr_hook", "field_transformer", ) @@ -3033,6 +3070,7 @@ def __init__( added_match_args, added_str, added_pickling, + added_dataclass_fields, on_setattr_hook, field_transformer, ): @@ -3050,6 +3088,7 @@ def __init__( self.added_match_args = added_match_args self.added_str = added_str self.added_pickling = added_pickling + self.added_dataclass_fields = added_dataclass_fields self.on_setattr_hook = on_setattr_hook self.field_transformer = field_transformer diff --git a/src/attr/_next_gen.py b/src/attr/_next_gen.py index fc473608b..604cb9e2a 100644 --- a/src/attr/_next_gen.py +++ b/src/attr/_next_gen.py @@ -43,6 +43,7 @@ def define( on_setattr=None, field_transformer=None, match_args=True, + dataclass_compatible=False, force_kw_only=False, ): r""" @@ -243,6 +244,11 @@ def define( :pep:`634` (*Structural Pattern Matching*). It is a tuple of all non-keyword-only ``__init__`` parameter names. + dataclass_compatible (bool): + If True, add ``__dataclass_fields__`` to the class. This enables + compatibility with various dataclass functions, notably + `dataclasses.fields`. Only works if all of the fields are annotated. + force_kw_only (bool): A back-compat flag for restoring pre-25.4.0 behavior. If True and ``kw_only=True``, all attributes are made keyword-only, including @@ -384,6 +390,7 @@ def do_it(cls, auto_attribs): field_transformer=field_transformer, match_args=match_args, force_kw_only=force_kw_only, + dataclass_compatible=dataclass_compatible, ) def wrap(cls): diff --git a/src/attrs/__init__.pyi b/src/attrs/__init__.pyi index 0c694ee61..1be62b533 100644 --- a/src/attrs/__init__.pyi +++ b/src/attrs/__init__.pyi @@ -183,6 +183,7 @@ def define( on_setattr: _OnSetAttrArgType | None = ..., field_transformer: _FieldTransformer | None = ..., match_args: bool = ..., + dataclass_compatible: bool = ..., ) -> _C: ... @overload @dataclass_transform(field_specifiers=(attrib, field)) @@ -209,6 +210,7 @@ def define( on_setattr: _OnSetAttrArgType | None = ..., field_transformer: _FieldTransformer | None = ..., match_args: bool = ..., + dataclass_compatible: bool = ..., ) -> Callable[[_C], _C]: ... mutable = define @@ -288,6 +290,7 @@ class ClassProps: added_match_args: bool added_str: bool added_pickling: bool + added_dataclass_fields: bool on_setattr_hook: _OnSetAttrType | None field_transformer: Callable[[Attribute[Any]], Attribute[Any]] | None @@ -309,6 +312,7 @@ class ClassProps: added_match_args: bool, added_str: bool, added_pickling: bool, + added_dataclass_fields: bool, on_setattr_hook: _OnSetAttrType, field_transformer: Callable[[Attribute[Any]], Attribute[Any]], ) -> None: ... diff --git a/tests/test_dataclass_compatible.py b/tests/test_dataclass_compatible.py new file mode 100644 index 000000000..e536357fb --- /dev/null +++ b/tests/test_dataclass_compatible.py @@ -0,0 +1,88 @@ +# SPDX-License-Identifier: MIT + +""" +Tests for types with dataclass compatibility added. +""" + +import dataclasses + +from typing import Annotated, Any + +import pytest + +import attrs + + +def _fields_as_tuple(o: Any) -> list[tuple[object, ...]]: + return [ + ( + f._field_type, + f.name, + f.type, + f.kw_only, + f.metadata, + f.default, + f.default_factory, + ) + for f in dataclasses.fields(o) + ] + + +class TestDataclassCompatible: + """ + Tests for types with dataclass compatibility added. + """ + + def test_dataclass_compatible_fields(self): + """ + Check that setting `dataclass_compatible` makes a dataclass-y type. + """ + + @attrs.define(dataclass_compatible=True) + class C: + x: Annotated[int, "some-annotation"] + y: "float" = 3.14 + z: str = attrs.field(metadata={"foo": "bar"}, default="baz") + my_list: list[int] = attrs.field(factory=list) + + def __attrs_post_init__(self) -> None: + self.x += 1 + + @dataclasses.dataclass + class D: + x: Annotated[int, "some-annotation"] + y: "float" = 3.14 + z: str = dataclasses.field(metadata={"foo": "bar"}, default="baz") + my_list: list[int] = dataclasses.field(default_factory=list) + + def __post_init__(self) -> None: + self.x += 1 + + # Assert at the class level + assert _fields_as_tuple(C) == _fields_as_tuple(D) + + # Assert at the instance level + assert _fields_as_tuple(C(1)) == _fields_as_tuple(D(1)) + + # Check high level dataclasses functions + assert dataclasses.is_dataclass(C) == dataclasses.is_dataclass(D) + assert dataclasses.is_dataclass(C(1)) == dataclasses.is_dataclass(D(1)) + assert dataclasses.asdict(C(1)) == dataclasses.asdict(D(1)) + assert dataclasses.astuple(C(1)) == dataclasses.astuple(D(1)) + assert dataclasses.asdict( + dataclasses.replace(C(1), x=2) + ) == dataclasses.asdict(dataclasses.replace(D(1), x=2)) + + def test_raises_on_missing_type(self): + """ + Raises ValueError if type is missing. + """ + with pytest.raises(ValueError) as e: + + @attrs.define(dataclass_compatible=True) + class C: + x = attrs.field() + + assert ( + "__dataclass_fields__ can only be generated if all attributes are annotated.", + ) == e.value.args diff --git a/tests/test_make.py b/tests/test_make.py index b32f1054e..118c80025 100644 --- a/tests/test_make.py +++ b/tests/test_make.py @@ -591,6 +591,7 @@ class C: collected_fields_by_mro=False, added_str=True, added_pickling=True, + added_dataclass_fields=False, on_setattr_hook=None, field_transformer=None, ) == attrs.inspect(C) @@ -620,6 +621,7 @@ class CDef: collected_fields_by_mro=False, added_str=False, added_pickling=False, + added_dataclass_fields=False, on_setattr_hook=None, field_transformer=None, ) == attrs.inspect(CDef) @@ -2090,6 +2092,7 @@ class C: collected_fields_by_mro=True, added_str=False, added_pickling=True, + added_dataclass_fields=False, on_setattr_hook=None, field_transformer=None, ), @@ -2125,6 +2128,7 @@ class C: collected_fields_by_mro=True, added_str=False, added_pickling=True, + added_dataclass_fields=False, on_setattr_hook=None, field_transformer=None, ), @@ -2226,6 +2230,7 @@ def our_hasattr(obj, name, /) -> bool: collected_fields_by_mro=True, added_str=False, added_pickling=True, + added_dataclass_fields=False, on_setattr_hook=None, field_transformer=None, ), diff --git a/tests/test_next_gen.py b/tests/test_next_gen.py index 955e8120b..9a303bb0c 100644 --- a/tests/test_next_gen.py +++ b/tests/test_next_gen.py @@ -493,6 +493,7 @@ class C: collected_fields_by_mro=True, added_str=True, added_pickling=False, # because slots=False + added_dataclass_fields=False, on_setattr_hook=None, field_transformer=None, ) @@ -524,6 +525,7 @@ class C: collected_fields_by_mro=True, added_str=False, added_pickling=True, + added_dataclass_fields=False, on_setattr_hook=None, field_transformer=None, )