Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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=<KeywordOnly.NO: 'no'>, collected_fields_by_mro=True, added_init=True, added_repr=True, added_eq=True, added_ordering=False, hashability=<Hashability.UNHASHABLE: 'unhashable'>, added_match_args=True, added_str=False, added_pickling=True, on_setattr_hook=<function pipe.<locals>.wrapped_pipe at ...>, field_transformer=None)
ClassProps(is_exception=False, is_slotted=True, has_weakref_slot=True, is_frozen=False, kw_only=<KeywordOnly.NO: 'no'>, collected_fields_by_mro=True, added_init=True, added_repr=True, added_eq=True, added_ordering=False, hashability=<Hashability.UNHASHABLE: 'unhashable'>, added_match_args=True, added_str=False, added_pickling=True, added_dataclass_fields=False, on_setattr_hook=<function pipe.<locals>.wrapped_pipe at ...>, field_transformer=None)

.. autoclass:: attrs.ClassProps
.. autoclass:: attrs.ClassProps.Hashability
Expand Down
39 changes: 39 additions & 0 deletions src/attr/_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
):
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <transform-fields>`.


.. versionadded:: 25.4.0
"""

Expand Down Expand Up @@ -3013,6 +3049,7 @@ class KeywordOnly(enum.Enum):
"added_match_args",
"added_str",
"added_pickling",
"added_dataclass_fields",
"on_setattr_hook",
"field_transformer",
)
Expand All @@ -3033,6 +3070,7 @@ def __init__(
added_match_args,
added_str,
added_pickling,
added_dataclass_fields,
on_setattr_hook,
field_transformer,
):
Expand All @@ -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

Expand Down
7 changes: 7 additions & 0 deletions src/attr/_next_gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ def define(
on_setattr=None,
field_transformer=None,
match_args=True,
dataclass_compatible=False,
force_kw_only=False,
):
r"""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
4 changes: 4 additions & 0 deletions src/attrs/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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: ...
Expand Down
88 changes: 88 additions & 0 deletions tests/test_dataclass_compatible.py
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions tests/test_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
),
Expand Down Expand Up @@ -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,
),
Expand Down Expand Up @@ -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,
),
Expand Down
2 changes: 2 additions & 0 deletions tests/test_next_gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
)
Expand Down