From 7e6bd769b44ce51f59fecfaf3701b87c7eb0e451 Mon Sep 17 00:00:00 2001 From: Gyanu Date: Wed, 26 Aug 2026 09:11:54 +0530 Subject: [PATCH] Strip leftover attr.ib() sentinels when these= is passed. these= skips collecting class-body fields, but those attr.ib() objects stayed on the class. Accessing them then returned a private _CountingAttr instead of a missing or real attribute. --- changelog.d/621.change.md | 1 + docs/examples.md | 3 +++ src/attr/_make.py | 7 +++++++ tests/test_make.py | 18 ++++++++++++++++++ 4 files changed, 29 insertions(+) create mode 100644 changelog.d/621.change.md diff --git a/changelog.d/621.change.md b/changelog.d/621.change.md new file mode 100644 index 000000000..8e11b3ccc --- /dev/null +++ b/changelog.d/621.change.md @@ -0,0 +1 @@ +Passing `these=` no longer leaves leftover `attr.ib()` sentinels on the class. diff --git a/docs/examples.md b/docs/examples.md index f48b848bd..8ba85f4c3 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -112,6 +112,9 @@ This is useful in times when you want to enhance classes that are not yours (nic SomethingFromSomeoneElse(x=1) ``` +If *these* is passed, *attrs* does not collect `attr.ib()` / `field()` definitions from the class body. +Leftover sentinels are stripped so they cannot leak as public attributes. + [Subclassing is bad for you](https://www.youtube.com/watch?v=3MNVP9-hglc) (except when doing [strict specialization](https://hynek.me/articles/python-subclassing-redux/)), but *attrs* will still do what you'd hope for: ```{doctest} diff --git a/src/attr/_make.py b/src/attr/_make.py index afbca4635..29bcd473a 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -839,6 +839,13 @@ def _patch_original_class(self): # same name by using only a type annotation. with contextlib.suppress(AttributeError): delattr(cls, name) + else: + # `these=` does not collect class-body `attr.ib()`s as fields, but + # the sentinels would otherwise leak as public attributes (#621). + for name, value in list(vars(cls).items()): + if isinstance(value, _CountingAttr): + with contextlib.suppress(AttributeError): + delattr(cls, name) # Attach our dunder methods. for name, value in self._cls_dict.items(): diff --git a/tests/test_make.py b/tests/test_make.py index b32f1054e..c97407f66 100644 --- a/tests/test_make.py +++ b/tests/test_make.py @@ -384,6 +384,24 @@ class C: assert "C(a=1, b=2)" == repr(C()) + def test_these_strips_leftover_counting_attrs(self): + """ + `these=` does not collect class-body attrs as fields, but leftover + `attr.ib()` sentinels must not leak as public attributes. + + Regression test for #621. + """ + + @attr.s(these={"a": attr.ib()}) + class C: + b = attr.ib(default=0) + + inst = C(5) + + assert inst.a == 5 + assert "b" not in vars(C) + assert not isinstance(getattr(inst, "b", None), _CountingAttr) + def test_multiple_inheritance_old(self): """ Old multiple inheritance attribute collection behavior is retained.