Skip to content

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

Merged
merged 21 commits into from
Oct 6, 2020
Merged

Conversation

bentheiii
Copy link
Contributor

@bentheiii bentheiii commented Oct 1, 2020

@the-knights-who-say-ni
Copy link

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 username

We couldn't find a bugs.python.org (b.p.o) account corresponding to the following GitHub usernames:

@bentheiii

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!

Copy link
Member

@ericvsmith ericvsmith left a 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).

@bedevere-bot
Copy link

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. I will then notify any core developers who have left a review that you're ready for them to take another look at this pull request.

@bentheiii
Copy link
Contributor Author

I have made the requested changes; please review again

@bedevere-bot
Copy link

Thanks for making the requested changes!

@ericvsmith: please review the changes made to this pull request.


B()
self.assertEqual(B.__abstractmethods__, set())

Copy link
Member

@iritkatriel iritkatriel Oct 1, 2020

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__)

Copy link
Member

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?

Copy link
Member

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.

@ericvsmith
Copy link
Member

Since update_abstractmethods is documented as being usable as a decorator, there should be a test for that. I realize it's trivially obvious that it works, but still. Although I guess you'll need another decorator that adds an abstract method (like dataclasses used to do) in order to test it. And then use them together.

Lib/abc.py Outdated
abstracts = set()
# check the existing abstract methods, keep only the ones that are
# still abstract
for name in cls.__abstractmethods__:
Copy link
Member

@iritkatriel iritkatriel Oct 1, 2020

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.

@ericvsmith
Copy link
Member

Since update_abstractmethods is documented as being usable as a decorator, there should be a test for that. I realize it's trivially obvious that it works, but still. Although I guess you'll need another decorator that adds an abstract method (like dataclasses used to do) in order to test it. And then use them together.

Change looks good. Thanks.

@bentheiii
Copy link
Contributor Author

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).

If this does prove to be an issue, then we can change the isinstance(cls, ABCMeta) checking to hasattr(cls, '__abstractmethods__') (no sys.modules check necessary), and then import abc.update_abstractmethods before calling it inside the "if" block.

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)}
Copy link
Contributor

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.

Copy link
Contributor Author

@bentheiii bentheiii Oct 2, 2020

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)}

Copy link
Member

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)
Copy link
Member

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'>,)

Copy link
Contributor Author

@bentheiii bentheiii Oct 4, 2020

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

Copy link
Contributor Author

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

Copy link
Member

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)

Copy link
Contributor Author

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.

Copy link
Member

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.

Copy link
Member

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.

Copy link
Member

@gvanrossum gvanrossum Oct 4, 2020

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?).

Copy link
Member

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.

Copy link
Member

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
Comment on lines 125 to 126
"""Repopulate the abstract methods of an abstract class, or a subclass of
an abstract class.
Copy link
Member

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.

Suggested change
"""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
Comment on lines 149 to 151
if cls.__subclasses__():
raise RuntimeError("cannot update abstract methods of class after"
" subclassing")
Copy link
Member

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).

Copy link
Contributor Author

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.

Copy link
Member

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.

Copy link
Contributor Author

@bentheiii bentheiii Oct 4, 2020

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).

Copy link
Member

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.

Copy link
Contributor Author

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 that decoratorB creates subclasses or that decoratorA 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 through A.__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.

Copy link
Contributor Author

@bentheiii bentheiii Oct 4, 2020

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)
Copy link
Member

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())

Copy link
Member

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?

Copy link
Member

@gvanrossum gvanrossum left a 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).

@gvanrossum
Copy link
Member

Just noted this:

@ericvsmith

My only real concern is that dataclasses now imports abc.

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 _py_abc.py which is only loaded if _abc (the C code) cannot be loaded for some reason.

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.)

@ericvsmith
Copy link
Member

@gvanrossum: Agreed about importing abc. I'll let you and the others work out the subclass issue.

bentheiii and others added 3 commits October 5, 2020 12:56
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>
@bentheiii
Copy link
Contributor Author

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:

  1. ABC-agnostic, who don't consider abstract superclasses as a use case. total_ordering will be of this category.
  2. ABC-aware, subclass-agnostic, who accept the possibility of an abstract superclass, and thus call update_abstractmethods. dataclass will be of this of this category, as well as, update_abstractmethods, techinally.
  3. ABC-aware, subclass-aware, who accept the possibility of an abstract superclass, and call update_abstractmethods, and also accept possibility of it having subclasses, so they also call update_abstractmethods on its subclasses (perhaps even recursively).

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 update_abstractmethods can be used to adapt a category 1 decorator to category 2, there are no simple means to adapt to category 3.

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.

@ericvsmith ericvsmith dismissed their stale review October 5, 2020 14:14

Importing abc is not a concern: it will already be imported via functools.

Copy link
Member

@gvanrossum gvanrossum left a 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.

@gvanrossum
Copy link
Member

@rhettinger any final word? @iritkatriel any comments?

@iritkatriel
Copy link
Member

@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.

@gvanrossum gvanrossum merged commit bef7d29 into python:master Oct 6, 2020
@gvanrossum
Copy link
Member

Thanks! All merged.

shihai1991 added a commit to shihai1991/cpython that referenced this pull request Oct 9, 2020
* 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)
  ...
xzy3 pushed a commit to xzy3/cpython that referenced this pull request Oct 18, 2020
This function recomputes `cls.__abstractmethods__`.
Also update `@dataclass` to use it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

8 participants