From 835b55d4d2945998040616e50ae7a0c194b13714 Mon Sep 17 00:00:00 2001 From: Oli Russell Date: Wed, 5 Aug 2026 15:52:33 +0100 Subject: [PATCH] Add `dataclass_compatible` flag Short of having some [stdlib fields interface](https://discuss.python.org/t/add-a-fields-interface/108364), the de facto standard for marking a class as "some non-opaque class that you can iterate over the fields of" is the presence of `__dataclass_fields__`. One example of this de facto standard (and the motivation for this PR) is in Pydantic. Pydantic will happily use dataclass definitions for (de)serialization, both in the case of nested definitions, and when using `pydantic.TypeAdapter(T)`. The only way it can tell if something is a dataclass is using `dataclasses.is_dataclass`, which itself just checks for the presence of `__dataclass_fields__`. An `attrs` class masquerading as a dataclass via `__dataclass_fields__` works just fine. Using `pydantic.TypeAdapter` at the edges and normal `attrs` in the core application avoids @tinche's concerns around things like: > Is it really necessary to re-validate all your objects while reading > them from a trusted database? that this PR's author [largely agrees with](https://leontrolski.github.io/pydantic-wrong.html). Another example would be a "map over everything" function like the following: ```python def map_[T](o: T, f: Callable[[T], T]) -> T: if isinstance(o, list): return [map_(v, f) for v in o] # ditto for tuple, set, frozenset, dict, then, if has_fields(o): kwargs = {k: map_(getattr(o, field.name), f) for field in dataclasses.fields(o)} return copy.replace(o, **kwargs) return o ``` With `dataclass_compatible=True` set, functions like this will work without having to muck around checking for the installation of `attrs` and using the subtly different `attrs.fields` function. --- docs/api.rst | 2 +- src/attr/_make.py | 39 +++++++++++++ src/attr/_next_gen.py | 7 +++ src/attrs/__init__.pyi | 4 ++ tests/test_dataclass_compatible.py | 88 ++++++++++++++++++++++++++++++ tests/test_make.py | 5 ++ tests/test_next_gen.py | 2 + 7 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 tests/test_dataclass_compatible.py 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, )