From 5571516ffd2a417e2bef866d3f9f95f9d83608b8 Mon Sep 17 00:00:00 2001 From: redruin1 Date: Fri, 2 May 2025 13:02:05 -0400 Subject: [PATCH 1/4] add `to_field()` to `Attribute` with additional `inherited` kwarg to preserve inherited class order --- src/attr/__init__.pyi | 1 + src/attr/_make.py | 74 ++++++++++++++- src/attr/_next_gen.py | 8 ++ tests/test_make.py | 203 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 283 insertions(+), 3 deletions(-) diff --git a/src/attr/__init__.pyi b/src/attr/__init__.pyi index 133e50105..715edbc98 100644 --- a/src/attr/__init__.pyi +++ b/src/attr/__init__.pyi @@ -139,6 +139,7 @@ class Attribute(Generic[_T]): alias: str | None def evolve(self, **changes: Any) -> "Attribute[Any]": ... + def to_field(self) -> Any: ... # NOTE: We had several choices for the annotation to use for type arg: # 1) Type[_T] diff --git a/src/attr/_make.py b/src/attr/_make.py index e84d9792a..c96b1db74 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -118,6 +118,7 @@ def attrib( order=None, on_setattr=None, alias=None, + inherited=False, ): """ Create a new field / attribute on a class. @@ -206,6 +207,7 @@ def attrib( order_key=order_key, on_setattr=on_setattr, alias=alias, + inherited=inherited, ) @@ -434,17 +436,33 @@ 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 = [ + base_attr + if base_attr.name not in own_attr_map + else own_attr_map[base_attr.name] + 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: @@ -2501,7 +2519,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, @@ -2532,6 +2550,35 @@ def evolve(self, **changes): return new + def to_field(self): + """ + Converts this attribute back into a raw :py:class:`_CountingAttr` 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 state. + + .. versionadded:: 25.4.0 + """ + return _CountingAttr( + default=self.default, + 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, + eq_key=self.eq_key, + order=self.order, + order_key=self.order_key, + on_setattr=self.on_setattr, + alias=self.alias, + inherited=self.inherited, + ) + # Don't use _add_pickle since fields(Attribute) doesn't work def __getstate__(self): """ @@ -2608,6 +2655,7 @@ class _CountingAttr: "eq", "eq_key", "hash", + "inherited", "init", "kw_only", "metadata", @@ -2646,6 +2694,7 @@ class _CountingAttr: "init", "on_setattr", "alias", + "inherited", ) ), Attribute( @@ -2665,6 +2714,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 @@ -2686,6 +2752,7 @@ def __init__( order_key, on_setattr, alias, + inherited, ): _CountingAttr.cls_counter += 1 self.counter = _CountingAttr.cls_counter @@ -2704,6 +2771,7 @@ def __init__( self.kw_only = kw_only self.on_setattr = on_setattr self.alias = alias + self.inherited = inherited def validator(self, meth): """ diff --git a/src/attr/_next_gen.py b/src/attr/_next_gen.py index 9290664b2..fc20825be 100644 --- a/src/attr/_next_gen.py +++ b/src/attr/_next_gen.py @@ -429,6 +429,7 @@ def field( order=None, on_setattr=None, alias=None, + inherited=None, ): """ Create a new :term:`field` / :term:`attribute` on a class. @@ -565,6 +566,11 @@ def field( ``__init__`` method. If left None, default to ``name`` stripped of leading underscores. See `private-attributes`. + inherited (bool | None): + 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 @@ -572,6 +578,7 @@ def field( .. 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:: @@ -592,6 +599,7 @@ def field( order=order, on_setattr=on_setattr, alias=alias, + inherited=inherited, ) diff --git a/tests/test_make.py b/tests/test_make.py index 80c00662b..9c2214a3f 100644 --- a/tests/test_make.py +++ b/tests/test_make.py @@ -834,6 +834,209 @@ class SubClass(BaseClass): assert hash(ba) == hash(sa) +class TestEvolveAttributes: + """ + Test reusing/evolving attributes from already `define`d attrs classes when + constructing new ones. + """ + + def test_make_class(self): + """ + `to_field` should permit users to construct new classes with existing + attributes using `make_class`. + """ + + @attr.define + class Example: + a: int + + ExampleCopy = attr.make_class( + "ExampleCopy", attrs={"a": attr.fields(Example).a.to_field()} + ) + + assert int is attr.fields(ExampleCopy).a.type + assert 1 == ExampleCopy(1).a + assert "ExampleCopy(a=1)" == repr(ExampleCopy(1)) + + def test_these(self): + """ + `to_field` should permit users to construct new classes with existing + attributes using `these`. + """ + + @attr.define + class Example: + a: int + + @attr.define(these={"a": attr.fields(Example).a.to_field()}) + class ExampleCopy: + pass + + assert int is attr.fields(ExampleCopy).a.type + assert 1 == ExampleCopy(1).a + assert "ExampleCopy(a=1)" == repr(ExampleCopy(1)) + + def test_evolve_unrelated(self): + """ + You can use `to_field()` to set fields from entirely unrelated classes. + """ + + @attr.define + class A: + x: int = attr.ib(default=1) + + @attr.define + class B: + y = attr.fields(A).x.evolve(default=100).to_field() + + assert int is attr.fields(B).y.type + assert 100 == attr.fields(B).y.default + + def test_evolve_inherit_order(self): + """ + Ensure that by specifying `inherited=True` we preserve the order of + attributes from the parent class. + """ + + @attr.s + class BaseClass: + x: int = attr.ib(default=1) + y: int = attr.ib(default=2) + + # "Normal" ordering + @attr.s + class SubClass(BaseClass): + x = attr.fields(BaseClass).x.evolve(default=3).to_field() + + assert "SubClass(y=2, x=3)" == repr(SubClass()) + + # Inherited ordering + @attr.s + class SubClass(BaseClass): + x = ( + attr.fields(BaseClass) + .x.evolve(default=3, inherited=True) + .to_field() + ) + + assert "SubClass(x=3, y=2)" == repr(SubClass()) + + def test_inherited_on_attr_not_in_parent(self): + """ + Specifying `inherited` on a field which does not have a matching field + in it's parent classes has no effect - the field is simply considered an + attribute of the child. + """ + + @attr.define + class BaseClass: + x: int = 1 + y: int = 2 + + @attr.define + class SubClass(BaseClass): + z = ( + attr.fields(BaseClass) + .x.evolve( + default=3, + alias="z", # This is populated with "x" from BaseClass, attrs + # complains if we don't specify it properly here + inherited=True, + ) + .to_field() + ) + + assert "SubClass(x=1, y=2, z=3)" == repr(SubClass()) + + def test_evolve_defaults(self): + """ + Ensure you can change the defaults of attributes defined in parent + classes. The syntax is a little clunky, but functional. + """ + + @attr.s + class BaseClass: + x: int = attr.ib(default=1) + + # Static default + @attr.s + class SubClass(BaseClass): + x = attr.fields(BaseClass).x.evolve(default=2).to_field() + + assert 2 == SubClass().x + + # Factory(takes_self=False) + @attr.s + class SubClass(BaseClass): + x = ( + attr.fields(BaseClass) + .x.evolve(default=Factory(lambda: 3)) + .to_field() + ) + + assert 3 == SubClass().x + + # Factory(takes_self=True) + @attr.s + class SubClass(BaseClass): + x = ( + attr.fields(BaseClass) + .x.evolve(default=attr.NOTHING) + .to_field() + ) + + @x.default + def x_default(self): + return 4 + + assert 4 == SubClass().x + + def test_evolve_validators(self): + """ + Ensure that you can add or replace validators of parent attributes from + subclasses. + """ + validators_called = 0 + + @attr.s + class BaseClass: + x: int = attr.ib(default=None) + + @x.validator + def x_validator(self, attr, value): + nonlocal validators_called + validators_called += 1 + + # Run BaseClass and SubClass validators + @attr.s + class SubClass(BaseClass): + # Bring x into scope so we can attach more validators to it + x = attr.fields(BaseClass).x.to_field() + + @x.validator + def x_validator(self, _attr, _value): + nonlocal validators_called + validators_called += 1 + + SubClass() + assert 2 == validators_called + + # Only run SubClass validators + validators_called = 0 + + @attr.s + class SubClass(BaseClass): + x = attr.fields(BaseClass).x.evolve(validator=None).to_field() + + @x.validator + def x_validator(self, _attr, _value): + nonlocal validators_called + validators_called += 1 + + SubClass() + assert 1 == validators_called + + class TestKeywordOnlyAttributes: """ Tests for keyword-only attributes. From bf46813ec9e41144716e409835515d6664483fad Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 2 May 2025 17:56:03 +0000 Subject: [PATCH 2/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/attr/_next_gen.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/attr/_next_gen.py b/src/attr/_next_gen.py index fc20825be..e3ffc6ac5 100644 --- a/src/attr/_next_gen.py +++ b/src/attr/_next_gen.py @@ -568,7 +568,7 @@ def field( inherited (bool | None): Ensure this attribute inherits the ordering of the parent attribute - with the same name. If no parent attribute with the same name + with the same name. If no parent attribute with the same name exists, this field is treated as normal. .. versionadded:: 20.1.0 From ac8df61b987b9f958c4a868a1c2a66f07667e6c0 Mon Sep 17 00:00:00 2001 From: redruin1 Date: Wed, 7 May 2025 20:03:13 -0400 Subject: [PATCH 3/4] changed to `reuse` and docs first pass * Added material in "Other goodies" section in `examples.md` * docstrings for `field`, `attrib`, and `reuse` *Added towncrier changelog message --- changelog.d/pr1429.change.md | 50 +++++++++++++++++++++++++ docs/api.rst | 2 +- docs/examples.md | 47 +++++++++++++++++++++++ src/attr/__init__.pyi | 13 ++++++- src/attr/_make.py | 70 +++++++++++++++++++++++------------ src/attr/_next_gen.py | 4 +- tests/test_make.py | 72 ++++++++++++++++++------------------ 7 files changed, 194 insertions(+), 64 deletions(-) create mode 100644 changelog.d/pr1429.change.md diff --git a/changelog.d/pr1429.change.md b/changelog.d/pr1429.change.md new file mode 100644 index 000000000..65382ad79 --- /dev/null +++ b/changelog.d/pr1429.change.md @@ -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)" +``` \ No newline at end of file diff --git a/docs/api.rst b/docs/api.rst index cd5df2d94..6d7a4f1f4 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -38,7 +38,7 @@ Core .. autofunction:: field .. autoclass:: Attribute - :members: evolve + :members: evolve, reuse For example: diff --git a/docs/examples.md b/docs/examples.md index 2393decf4..9823f9957 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -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`. diff --git a/src/attr/__init__.pyi b/src/attr/__init__.pyi index 715edbc98..0638361a7 100644 --- a/src/attr/__init__.pyi +++ b/src/attr/__init__.pyi @@ -139,7 +139,14 @@ class Attribute(Generic[_T]): alias: str | None def evolve(self, **changes: Any) -> "Attribute[Any]": ... - def to_field(self) -> 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] @@ -182,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 @@ -206,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. @@ -229,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 @@ -252,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)) diff --git a/src/attr/_make.py b/src/attr/_make.py index c96b1db74..9bf90573e 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -157,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 @@ -2550,34 +2551,55 @@ def evolve(self, **changes): return new - def to_field(self): + def reuse( + self, + **field_kwargs, + ): """ - Converts this attribute back into a raw :py:class:`_CountingAttr` 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 state. + 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 """ - return _CountingAttr( - default=self.default, - 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, - eq_key=self.eq_key, - order=self.order, - order_key=self.order_key, - on_setattr=self.on_setattr, - alias=self.alias, - inherited=self.inherited, - ) + 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): diff --git a/src/attr/_next_gen.py b/src/attr/_next_gen.py index e3ffc6ac5..394175493 100644 --- a/src/attr/_next_gen.py +++ b/src/attr/_next_gen.py @@ -429,7 +429,7 @@ def field( order=None, on_setattr=None, alias=None, - inherited=None, + inherited=False, ): """ Create a new :term:`field` / :term:`attribute` on a class. @@ -566,7 +566,7 @@ def field( ``__init__`` method. If left None, default to ``name`` stripped of leading underscores. See `private-attributes`. - inherited (bool | None): + 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. diff --git a/tests/test_make.py b/tests/test_make.py index 9c2214a3f..093f974a3 100644 --- a/tests/test_make.py +++ b/tests/test_make.py @@ -834,7 +834,7 @@ class SubClass(BaseClass): assert hash(ba) == hash(sa) -class TestEvolveAttributes: +class TestReuseAttributes: """ Test reusing/evolving attributes from already `define`d attrs classes when constructing new ones. @@ -851,7 +851,7 @@ class Example: a: int ExampleCopy = attr.make_class( - "ExampleCopy", attrs={"a": attr.fields(Example).a.to_field()} + "ExampleCopy", attrs={"a": attr.fields(Example).a.reuse()} ) assert int is attr.fields(ExampleCopy).a.type @@ -866,15 +866,23 @@ def test_these(self): @attr.define class Example: - a: int - - @attr.define(these={"a": attr.fields(Example).a.to_field()}) + a: int = 1 + b: int = attr.field(factory=lambda: 2) + + @attr.define( + these={ + "a": attr.fields(Example).a.reuse(), + "b": attr.fields(Example).b.reuse(), + } + ) class ExampleCopy: pass assert int is attr.fields(ExampleCopy).a.type - assert 1 == ExampleCopy(1).a - assert "ExampleCopy(a=1)" == repr(ExampleCopy(1)) + assert 1 == ExampleCopy().a + assert int is attr.fields(ExampleCopy).b.type + assert 2 == ExampleCopy().b + assert "ExampleCopy(a=1, b=2)" == repr(ExampleCopy()) def test_evolve_unrelated(self): """ @@ -887,7 +895,7 @@ class A: @attr.define class B: - y = attr.fields(A).x.evolve(default=100).to_field() + y = attr.fields(A).x.reuse(default=100) assert int is attr.fields(B).y.type assert 100 == attr.fields(B).y.default @@ -906,18 +914,14 @@ class BaseClass: # "Normal" ordering @attr.s class SubClass(BaseClass): - x = attr.fields(BaseClass).x.evolve(default=3).to_field() + x = attr.fields(BaseClass).x.reuse(default=3) assert "SubClass(y=2, x=3)" == repr(SubClass()) # Inherited ordering @attr.s class SubClass(BaseClass): - x = ( - attr.fields(BaseClass) - .x.evolve(default=3, inherited=True) - .to_field() - ) + x = attr.fields(BaseClass).x.reuse(default=3, inherited=True) assert "SubClass(x=3, y=2)" == repr(SubClass()) @@ -935,15 +939,11 @@ class BaseClass: @attr.define class SubClass(BaseClass): - z = ( - attr.fields(BaseClass) - .x.evolve( - default=3, - alias="z", # This is populated with "x" from BaseClass, attrs - # complains if we don't specify it properly here - inherited=True, - ) - .to_field() + z = attr.fields(BaseClass).x.reuse( + default=3, + alias="z", # This is populated with "x" from BaseClass, + # behavior is incorrect if we don't specify it here + inherited=True, ) assert "SubClass(x=1, y=2, z=3)" == repr(SubClass()) @@ -961,29 +961,21 @@ class BaseClass: # Static default @attr.s class SubClass(BaseClass): - x = attr.fields(BaseClass).x.evolve(default=2).to_field() + x = attr.fields(BaseClass).x.reuse(default=2) assert 2 == SubClass().x # Factory(takes_self=False) @attr.s class SubClass(BaseClass): - x = ( - attr.fields(BaseClass) - .x.evolve(default=Factory(lambda: 3)) - .to_field() - ) + x = attr.fields(BaseClass).x.reuse(factory=lambda: 3) assert 3 == SubClass().x # Factory(takes_self=True) @attr.s class SubClass(BaseClass): - x = ( - attr.fields(BaseClass) - .x.evolve(default=attr.NOTHING) - .to_field() - ) + x = attr.fields(BaseClass).x.reuse(default=attr.NOTHING) @x.default def x_default(self): @@ -991,6 +983,14 @@ def x_default(self): assert 4 == SubClass().x + # Test uninherited + @attr.s + class Different: + x = attr.fields(BaseClass).x.reuse(factory=lambda: 5) + + print(attr.fields(Different)) + assert 5 == Different().x + def test_evolve_validators(self): """ Ensure that you can add or replace validators of parent attributes from @@ -1011,7 +1011,7 @@ def x_validator(self, attr, value): @attr.s class SubClass(BaseClass): # Bring x into scope so we can attach more validators to it - x = attr.fields(BaseClass).x.to_field() + x = attr.fields(BaseClass).x.reuse() @x.validator def x_validator(self, _attr, _value): @@ -1026,7 +1026,7 @@ def x_validator(self, _attr, _value): @attr.s class SubClass(BaseClass): - x = attr.fields(BaseClass).x.evolve(validator=None).to_field() + x = attr.fields(BaseClass).x.reuse(validator=None) @x.validator def x_validator(self, _attr, _value): From d4f45876b991c7313d8e21ed58327bc2a33e5495 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 8 May 2025 00:03:26 +0000 Subject: [PATCH 4/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- changelog.d/pr1429.change.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog.d/pr1429.change.md b/changelog.d/pr1429.change.md index 65382ad79..8830c1764 100644 --- a/changelog.d/pr1429.change.md +++ b/changelog.d/pr1429.change.md @@ -27,7 +27,7 @@ class ChildOrder(Parent): assert repr(ChildOrder()) == "ChildOrder(y=2, x=3, z=4)" -@define +@define class ParentOrder(Parent): x = fields(Parent).y.reuse(default=3, inherited=True) z = fields(ChildOrder).z.reuse(inherited=True) # `inherited` does nothing here @@ -47,4 +47,4 @@ class Child(Parent): a: str = field(default="test", inherited=True) assert repr(Child()) == "Child(a='test', b=10)" -``` \ No newline at end of file +```