-
-
Notifications
You must be signed in to change notification settings - Fork 31.8k
bpo-41905: added abc.update_abstractmethods #22485
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
Conversation
Hello, and thanks for your contribution! I'm a bot set up to make sure that the project can legally accept this contribution by verifying everyone involved has signed the PSF contributor agreement (CLA). Recognized GitHub usernameWe couldn't find a bugs.python.org (b.p.o) account corresponding to the following GitHub usernames: This might be simply due to a missing "GitHub Name" entry in one's b.p.o account settings. This is necessary for legal reasons before we can look at this contribution. Please follow the steps outlined in the CPython devguide to rectify this issue. You can check yourself to see if the CLA has been received. Thanks again for the contribution, we look forward to reviewing it! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Although I'm no expert on ABC's, this looks good to me, with a few noted tiny changes. I'll do some more research before signing off on this, though.
My only real concern is that dataclasses now imports abc. We've jumped through hoops to make dataclasses not import typing, because it's slow to import. I don't know if abc brings the same issues, but I'll try to do some investigating. The trick it uses for typing is to check if 'typing' is in sys.modules before checking for typing.<somthing_or_other>. I'm not sure such trickery is warranted here. Probably not, since just starting the REPL looks like it imports abc (but not typing).
A Python core developer has requested some changes be made to your pull request before we can consider merging it. If you could please address their requests along with any other requests in other reviews from core developers that would be appreciated. Once you have made the requested changes, please leave a comment on this pull request containing the phrase |
I have made the requested changes; please review again |
Thanks for making the requested changes! @ericvsmith: please review the changes made to this pull request. |
|
||
B() | ||
self.assertEqual(B.__abstractmethods__, set()) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you need this test case to cover the first loop:
import abc
class FooABC(metaclass=abc.ABCMeta):
@abc.abstractmethod
def bar(self):
pass
del FooABC.bar
assert ('bar' in FooABC.__abstractmethods__)
assert ('bar' not in FooABC.__dict__)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@iritkatriel What does that test case accomplish other than showing that FooABC is now in an inconsistent state?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@gvanrossum In this comment I left out the update_abstractmethods, see Ben's version which he committed as test_update_del. Without this test the first loop in update_abstractmethods was not exercised.
Since |
Lib/abc.py
Outdated
abstracts = set() | ||
# check the existing abstract methods, keep only the ones that are | ||
# still abstract | ||
for name in cls.__abstractmethods__: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since you didn't check in this function that it's an ABC, it's not guaranteed that cls.__abstractmethods__
works:
>>> class FooABC: pass
...
>>> FooABC.__abstractmethods__
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: __abstractmethods__
Maybe add a test for when this function is called for a non-ABC.
Change looks good. Thanks. |
If this does prove to be an issue, then we can change the |
Lib/functools.py
Outdated
# Find user-defined comparisons (not those inherited from object or abstract). | ||
roots = {op for op in _convert | ||
if getattr(cls, op, None) is not getattr(object, op, None) | ||
and not getattr(getattr(cls, op, None), '__isabstractmethod__', False)} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The nested getattr() isn't very clear.
Also, since this is in the inner loop for a dynamic decorator, it will slow the class creation process. I didn't used to think that was important but experience with named tuples show that people care a great deal about the amount of computation that occurs during an import.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
would using a walrus operator be okay here?
roots = {...
if (root:=getattr(cls, op, None)) is not getattr(object, op, None)
and not getattr(root, '__isabstractmethod__', False)}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That seems fine to me.
for name in getattr(scls, '__abstractmethods__', ()): | ||
value = getattr(cls, name, None) | ||
if getattr(value, "__isabstractmethod__", False): | ||
abstracts.add(name) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add tests for deeper inheritance - I think iterating over __bases__
may not be enough:
>>> class A: pass
...
>>> class B(A): pass
...
>>> class C(B): pass
...
>>> C.__bases__
(<class '__main__.B'>,)
>>> B.__bases__
(<class '__main__.A'>,)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It actually is- we are assured that the parent's __abstractmethods__
are correct since, after a class is subclassed, its abstraction status cannot change:
class A(ABC):
@abstractmethod
def foo(self): pass
class B(A):
pass
# after this point, A.__abstractmethods__ will never change, so we can rely on it always being correct when checking B's Abstraction status
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I added a test case regardless
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see, interesting. How about this case? I think you might need to break after the first hit for the method:
class A(metaclass=abc.ABCMeta):
@abc.abstractmethod
def foo(self):
pass
class B(metaclass=abc.ABCMeta):
@abc.abstractmethod
def foo(self):
pass
class C(A, B):
pass
A.foo = lambda self: None
assert(C.foo is not abstract)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is expected (albeit confusing) behavior. Since abstraction status cannot change after subclassing, A is still considered abstract, even though its method is implemented. However, since namespace resolution occurs through the __dict__
attribute, The method is defined as far as C is concerned. Indeed, if you were to call update_abstractmethods(C)
after this code, C would become concrete and usable, since its foo
method implemented.
In essence, abstract methods shouldn't be changed after the class is used, so this use case is not handled by this implementation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well, I disagree with the check for empty __subclasses__()
, and I think in Irit's last example the right thing to do is to call abc.update_abstractmethods(A)
and then abc.update_abstractmethods(C)
.
Note that it's not technically true that the abstractness cannot change once subclassed -- it may be undocumented but one could just write A.__abstractmethods__ = ()
.
FWIW I do agree that the code should assume that the abstractness of the bases is correct. If some base lies about it, well, Garbage In, Garbage Out.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think abc.update_abstractmethods(A) would have any impact here, A never changed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry, I was commenting on your earlier example which has A.foo = lambda ...
-- in that example it certainly would have an effect. :-)
My disagreement was with this quote from @bentheiii:
In essence, abstract methods shouldn't be changed after the class is used, so this use case is not handled by this implementation.
I agree with @iritkatriel that adding that test would be useful (I didn't verify -- maybe it's already added?).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry, I was commenting on your earlier example which has
A.foo = lambda ...
-- in that example it certainly would have an effect. :-)
Ah, in that case it raises an exception that A has been subclasses so you can't update it anymore. I think there's a test for that already but if not then there should be.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see now - you're suggesting to remove that exception. In that case this test is probably needed.
Lib/abc.py
Outdated
"""Repopulate the abstract methods of an abstract class, or a subclass of | ||
an abstract class. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would like the summary to fit on one line.
"""Repopulate the abstract methods of an abstract class, or a subclass of | |
an abstract class. | |
"""Recalculate the set of abstract methods of an abstract class. |
Lib/abc.py
Outdated
if cls.__subclasses__(): | ||
raise RuntimeError("cannot update abstract methods of class after" | ||
" subclassing") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure I like this. This function is potentially expensive. It could even be detrimental -- I could imagine some very dynamic code that's messing around dynamically with a base class and then calls update_abstractmethods()
for all subclasses. So I propose to remove this check (and the documentation about it).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That might be okay, However, removing this check means that we have to call update_abstractmethods
on all of cls
's subclasses, meaning that cls.__subclasses__()
will still be called, so performance will not improve.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Only if you're not sure that there are no subclasses. In @dataclass
we can assume there are no subclasses, and that's so in most class decorators (which are only applicable to freshly created classes).
I recommend that update_abstractmethods()
should not try to fix up subclasses -- it's just the case that an ambitious dynamic class hacker could take two documented features (this and __subclasses__()
) and create something that automatically updates affected subclasses.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that could cause a problem if user chain two decorators: one that updates subclasses and one that doesn't
def decoratorA(cls):
"""implements methods and creates a subclass, calling
update_abstractmethods on cls and its subclasses
"""
...
def decoratorB(cls):
"""implements methods only, not updating subclasses
"""
...
@decoratorB
@decoratorA
class A(SomeABC):
...
# A's subclass is now out of sync with decoratorB's changes
Thinking about it, the previous implementation was indeed faulty since, even if a decorator that needed to subclass cls
was responsible enough to delay subclassing until after calling update_abstractmethods
, that would meant that follow-up decorators simply couldn't call it.
The only sensible solution is to call update_abstractmethods on all of the class's subclasses with each call (@dataclass
can't be certain that the subject class wasn't subclassed in a previous mixin decorator).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you do that, you get what you deserve.
Please keep it simple.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's implemented in the latest commit, but I must say I'm not sure what you mean. Who "gets what they deserve"?
- Whoever implemented
decoratorA
(which may well be@dataclass
) was right to assume there are no subclasses, since it did not create them, and wanted to avoid unnecessary checks. - Whoever implemented
decoratorB
did their due diligence by making sure it returned the class and its subclasses in a synced state. - Whoever wrote
class A
may be totally unaware thatdecoratorB
creates subclasses or thatdecoratorA
doesn't take them into account. Even if they did, they would need to implement a fix themselves, which would require at the least to iterate throughA.__subclasses__()
. Even that won't keep them 100% safe, however, since these subclasses can have subclasses of their own. Very far fetched, perhaps, but that's exactly what very dynamic code might produce.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Truth be told, if we do assume that decorators and tools are allowed to subclass (and perhaps other permanent operations like instancing) before calling update_abstractmethods
, I'm leaning closer and closer to an automatic solution as proposed by Bar and Reymond.
for name in getattr(scls, '__abstractmethods__', ()): | ||
value = getattr(cls, name, None) | ||
if getattr(value, "__isabstractmethod__", False): | ||
abstracts.add(name) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well, I disagree with the check for empty __subclasses__()
, and I think in Irit's last example the right thing to do is to call abc.update_abstractmethods(A)
and then abc.update_abstractmethods(C)
.
Note that it's not technically true that the abstractness cannot change once subclassed -- it may be undocumented but one could just write A.__abstractmethods__ = ()
.
FWIW I do agree that the code should assume that the abstractness of the bases is correct. If some base lies about it, well, Garbage In, Garbage Out.
|
||
B() | ||
self.assertEqual(B.__abstractmethods__, set()) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@iritkatriel What does that test case accomplish other than showing that FooABC is now in an inconsistent state?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I just don't care enough about the purely hypothetical scenario you're describing to want to complicate this function. The automatic solution also has its problems (to begin, it would require C code).
Just noted this:
I looked into this, dataclasses already imports functools which already imports abc, so there's no new module being loaded. Also, abc.py is pretty compact and backed by C code -- most of the Python code is in So I don't think there's a worry. @ericvsmith, all your other comments seem to have been taken care of this -- can you sign off? (Unless you want to jump into the debate about subclasses.) |
@gvanrossum: Agreed about importing abc. I'll let you and the others work out the subclass issue. |
Co-authored-by: Guido van Rossum <guido@python.org>
Co-authored-by: Guido van Rossum <guido@python.org>
Co-authored-by: Guido van Rossum <guido@python.org>
Of course, I defer judgement to the dev team, but I'd be remiss if I didn't reflect the implications of this behavior: When this behavior is implemented, it will effectively split all class decorators into one of three categories:
An important consequence is, that decorators can only be reliably stacked on top of each other only if they are of the same category. And while Of course, the mere existence of a decorator of the third category is a case so far fetched that I doubt it even exists across all python code. This is a valid reason to discount it, but the implication is that if were to ever exist, it could not be used with virtually any other decorator. |
Importing abc is not a concern: it will already be imported via functools.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can consider us amply warned.
@rhettinger any final word? @iritkatriel any comments? |
A very pedantic one, which should not block this PR being merged. The doc says "It does not update any subclasses", but no test asserts this. |
Thanks! All merged. |
* origin/master: (147 commits) Fix the attribute names in the docstring of GenericAlias (pythonGH-22594) bpo-39337: Add a test case for normalizing of codec names (pythonGH-19069) bpo-41557: Update Windows installer to use SQLite 3.33.0 (pythonGH-21960) bpo-41976: Fix the fallback to gcc of ctypes.util.find_library when using gcc>9 (pythonGH-22598) bpo-41306: Allow scale value to not be rounded (pythonGH-21715) bpo-41970: Avoid test failure in test_lib2to3 if the module is already imported (pythonGH-22595) bpo-41376: Fix the documentation of `site.getusersitepackages()` (pythonGH-21602) Revert "bpo-26680: Incorporate is_integer in all built-in and standard library numeric types (pythonGH-6121)" (pythonGH-22584) bpo-41923: PEP 613: Add TypeAlias to typing module (python#22532) Fix comment about PyObject_IsTrue. (pythonGH-22343) bpo-38605: Make 'from __future__ import annotations' the default (pythonGH-20434) bpo-41905: Add abc.update_abstractmethods() (pythonGH-22485) bpo-41944: No longer call eval() on content received via HTTP in the UnicodeNames tests (pythonGH-22575) bpo-41944: No longer call eval() on content received via HTTP in the CJK codec tests (pythonGH-22566) Post 3.10.0a1 Python 3.10.0a1 bpo-41584: clarify when the reflected method of a binary arithemtic operator is called (python#22505) bpo-41939: Fix test_site.test_license_exists_at_url() (python#22559) bpo-41774: Tweak new programming FAQ entry (pythonGH-22562) bpo-41936. Remove macros Py_ALLOW_RECURSION/Py_END_ALLOW_RECURSION (pythonGH-22552) ...
This function recomputes `cls.__abstractmethods__`. Also update `@dataclass` to use it.
relevant python-ideas thread: https://mail.python.org/archives/list/python-ideas@python.org/thread/6BNJ3YSEBPHEPGXSAZGBW3TJ64ZGZIHE/
https://bugs.python.org/issue41905