Skip to content

Add reuse() to Attribute for field evolution #1429

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
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
50 changes: 50 additions & 0 deletions changelog.d/pr1429.change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
Added `Attribute.reuse(**kwargs)`, which allows you to reuse (and simultaneously `evolve()`) attribute definitions from already defined classes:

```py
@define
class A:
a: int = 100

@define
class B(A):
a = fields(B).a.reuse(default=200)

assert B().a == 200
```

To preserve attribute ordering of `reuse`d attributes, `inherited` was exposed as a public keyword argument to `attrib` and friends. Setting `inherited=True` manually on a *attrs* field definition simply acts as a flag to tell *attrs* to use the parent class ordering, *if* it can find an attribute in the parent MRO with the same name. Otherwise, the field is simply added to the class's attribute list as if `inherited=False`.

```py
@define
class Parent:
x: int = 1
y: int = 2

@define
class ChildOrder(Parent):
x = fields(Parent).x.reuse(default=3)
z: int = 4

assert repr(ChildOrder()) == "ChildOrder(y=2, x=3, z=4)"

@define
class ParentOrder(Parent):
x = fields(Parent).y.reuse(default=3, inherited=True)
z = fields(ChildOrder).z.reuse(inherited=True) # `inherited` does nothing here

assert repr(ParentOrder()) == "ParentOrder(x=3, y=2, z=4)"
```

Incidentally, because this behavior was added to `field`, this gives you a little more control of attribute ordering even when not using `reuse()`:

```py
@define
class Parent:
a: int
b: int = 10

class Child(Parent):
a: str = field(default="test", inherited=True)

assert repr(Child()) == "Child(a='test', b=10)"
```
2 changes: 1 addition & 1 deletion docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Core
.. autofunction:: field

.. autoclass:: Attribute
:members: evolve
:members: evolve, reuse

For example:

Expand Down
47 changes: 47 additions & 0 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,53 @@ If you need to dynamically make a class with {func}`~attrs.make_class` and it ne
True
```

In certain situations, you might want to reuse part (or all) of an existing attribute instead of entirely redefining it. This pattern is common in inheritance schemes, where you might just want to change one parameter of an attribute (i.e. default value) while leaving all other parts unchanged. For this purpose, *attrs* offers {meth}`.Attribute.reuse`:

```{doctest}
>>> @define
... class A:
... a: int = field(default=10)
...
... @a.validator
... def very_complex_validator(self, attr, value):
... print("Validator runs!")

>>> @define
... class B(A):
... a = fields(A).a.reuse(default=20)

>>> B()
Validator runs!
B(a=20)
```

This method inherits all of the keyword arguments from {func}`~attrs.field`, which works identically to defining a new attribute with the same parameters.

While this feature is mostly intended for making working with inherited classes easier, there's nothing requiring `reuse`d attributes actually be part of a parent class:

```{doctest}
>>> @define
... class C: # does not inherit class `A`
... a = fields(A).a.reuse(factory=lambda: 100)
... # And now that `a` is in scope of `C`, field decorators work again:
... @a.validator
... def my_new_validator_func(self, attr, value):
... print("Another validator function!")

>>> C()
Validator runs!
Another validator function!
C(a=100)
```

This in combination with {func}`~attrs.make_class` makes a very powerful suite of *attrs* class manipulation tools both before and after class creation:

```{doctest}
>>> C3 = make_class("C3", {"x": fields(C2).x.reuse(), "y": fields(C2).y.reuse()})
>>> fields(C2) == fields(C3)
True
```

Sometimes, you want to have your class's `__init__` method do more than just
the initialization, validation, etc. that gets done for you automatically when
using `@define`.
Expand Down
12 changes: 12 additions & 0 deletions src/attr/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,14 @@ class Attribute(Generic[_T]):
alias: str | None

def evolve(self, **changes: Any) -> "Attribute[Any]": ...
@overload
def reuse(self, **changes: Any) -> Any: ...
@overload
def reuse(self, type: _T, **changes: Any) -> _T: ...
@overload
def reuse(self, default: _T, **changes: Any) -> _T: ...
@overload
def reuse(self, type: _T | None, **changes: Any) -> _T: ...

# NOTE: We had several choices for the annotation to use for type arg:
# 1) Type[_T]
Expand Down Expand Up @@ -181,6 +189,7 @@ def attrib(
order: _EqOrderType | None = ...,
on_setattr: _OnSetAttrArgType | None = ...,
alias: str | None = ...,
inherited: bool | None = ...,
) -> Any: ...

# This form catches an explicit None or no default and infers the type from the
Expand All @@ -205,6 +214,7 @@ def attrib(
order: _EqOrderType | None = ...,
on_setattr: _OnSetAttrArgType | None = ...,
alias: str | None = ...,
inherited: bool | None = ...,
) -> _T: ...

# This form catches an explicit default argument.
Expand All @@ -228,6 +238,7 @@ def attrib(
order: _EqOrderType | None = ...,
on_setattr: _OnSetAttrArgType | None = ...,
alias: str | None = ...,
inherited: bool | None = ...,
) -> _T: ...

# This form covers type=non-Type: e.g. forward references (str), Any
Expand All @@ -251,6 +262,7 @@ def attrib(
order: _EqOrderType | None = ...,
on_setattr: _OnSetAttrArgType | None = ...,
alias: str | None = ...,
inherited: bool | None = ...,
) -> Any: ...
@overload
@dataclass_transform(order_default=True, field_specifiers=(attrib, field))
Expand Down
93 changes: 90 additions & 3 deletions src/attr/_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ def attrib(
order=None,
on_setattr=None,
alias=None,
inherited=False,
):
"""
Create a new field / attribute on a class.
Expand Down Expand Up @@ -156,6 +157,7 @@ def attrib(
*eq*, *order*, and *cmp* also accept a custom callable
.. versionchanged:: 21.1.0 *cmp* undeprecated
.. versionadded:: 22.2.0 *alias*
.. versionadded:: 25.4.0 *inherited*
"""
eq, eq_key, order, order_key = _determine_attrib_eq_order(
cmp, eq, order, True
Expand Down Expand Up @@ -206,6 +208,7 @@ def attrib(
order_key=order_key,
on_setattr=on_setattr,
alias=alias,
inherited=inherited,
)


Expand Down Expand Up @@ -434,17 +437,30 @@ def _transform_attrs(

if collect_by_mro:
base_attrs, base_attr_map = _collect_base_attrs(
cls, {a.name for a in own_attrs}
cls, {a.name for a in own_attrs if a.inherited is False}
)
else:
base_attrs, base_attr_map = _collect_base_attrs_broken(
cls, {a.name for a in own_attrs}
cls, {a.name for a in own_attrs if a.inherited is False}
)

if kw_only:
own_attrs = [a.evolve(kw_only=True) for a in own_attrs]
base_attrs = [a.evolve(kw_only=True) for a in base_attrs]

own_attr_map = {attr.name: attr for attr in own_attrs}

# Overwrite explicitly inherited attributes in `base` with their versions in `own`
base_attrs = [
own_attr_map.get(base_attr.name, base_attr) for base_attr in base_attrs
]
# Strip explicitly inherited attributes from `own`, as they now live in `base`
own_attrs = [
own_attr
for own_attr in own_attrs
if own_attr.name not in base_attr_map
]

attrs = base_attrs + own_attrs

if field_transformer is not None:
Expand Down Expand Up @@ -2501,7 +2517,7 @@ def from_counting_attr(cls, name: str, ca: _CountingAttr, type=None):
None,
ca.hash,
ca.init,
False,
ca.inherited,
ca.metadata,
type,
ca.converter,
Expand Down Expand Up @@ -2532,6 +2548,56 @@ def evolve(self, **changes):

return new

def reuse(
self,
**field_kwargs,
):
"""
Converts this attribute back into a raw :py:func:`attrs.field` object,
such that it can be used to annotate newly ``@define``-d classes. This
is useful if you want to reuse part (or all) of fields defined in other
attrs classes that have already been resolved into their finalized
:py:class:`.Attribute`.

Args:
field_kwargs: Any valid keyword argument to :py:func:`attrs.field`.

.. versionadded:: 25.4.0
"""
args = {
"validator": self.validator,
"repr": self.repr,
"cmp": None,
"hash": self.hash,
"init": self.init,
"converter": self.converter,
"metadata": self.metadata,
"type": self.type,
"kw_only": self.kw_only,
"eq": self.eq,
"order": self.order,
"on_setattr": self.on_setattr,
"alias": self.alias,
"inherited": self.inherited,
}

# Map the single "validator" object back down to it's aliased pair.
# Additionally, we help the user out a little bit by automatically
# overwriting the compliment `default` or `factory` function when
# overriding; so if a field already has a `default=3`, using
# `reuse(factory=lambda: 3)` won't complain about having both kinds of
# defaults defined.
if "default" not in field_kwargs and "factory" not in field_kwargs:
if isinstance(self.default, Factory):
field_kwargs["factory"] = self.default.factory
else:
field_kwargs["default"] = self.default

args.update(field_kwargs)

# Send through attrib so we reuse the same errors + syntax sugar
return attrib(**args)

# Don't use _add_pickle since fields(Attribute) doesn't work
def __getstate__(self):
"""
Expand Down Expand Up @@ -2608,6 +2674,7 @@ class _CountingAttr:
"eq",
"eq_key",
"hash",
"inherited",
"init",
"kw_only",
"metadata",
Expand Down Expand Up @@ -2646,6 +2713,7 @@ class _CountingAttr:
"init",
"on_setattr",
"alias",
"inherited",
)
),
Attribute(
Expand All @@ -2665,6 +2733,23 @@ class _CountingAttr:
inherited=False,
on_setattr=None,
),
# Attribute(
# name="inherited",
# alias="inherited",
# default=None,
# validator=None,
# repr=True,
# cmp=None,
# hash=True,
# init=True,
# kw_only=False,
# eq=True,
# eq_key=None,
# order=False,
# order_key=None,
# inherited=False,
# on_setattr=None,
# ),
)
cls_counter = 0

Expand All @@ -2686,6 +2771,7 @@ def __init__(
order_key,
on_setattr,
alias,
inherited,
):
_CountingAttr.cls_counter += 1
self.counter = _CountingAttr.cls_counter
Expand All @@ -2704,6 +2790,7 @@ def __init__(
self.kw_only = kw_only
self.on_setattr = on_setattr
self.alias = alias
self.inherited = inherited

def validator(self, meth):
"""
Expand Down
8 changes: 8 additions & 0 deletions src/attr/_next_gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,7 @@ def field(
order=None,
on_setattr=None,
alias=None,
inherited=False,
):
"""
Create a new :term:`field` / :term:`attribute` on a class.
Expand Down Expand Up @@ -565,13 +566,19 @@ def field(
``__init__`` method. If left None, default to ``name`` stripped
of leading underscores. See `private-attributes`.

inherited (bool):
Ensure this attribute inherits the ordering of the parent attribute
with the same name. If no parent attribute with the same name
exists, this field is treated as normal.

.. versionadded:: 20.1.0
.. versionchanged:: 21.1.0
*eq*, *order*, and *cmp* also accept a custom callable
.. versionadded:: 22.2.0 *alias*
.. versionadded:: 23.1.0
The *type* parameter has been re-added; mostly for `attrs.make_class`.
Please note that type checkers ignore this metadata.
.. versionadded:: 25.4.0 *inherited*

.. seealso::

Expand All @@ -592,6 +599,7 @@ def field(
order=order,
on_setattr=on_setattr,
alias=alias,
inherited=inherited,
)


Expand Down
Loading