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
Show file tree
Hide file tree
Changes from 8 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
23 changes: 18 additions & 5 deletions Doc/library/abc.rst
Original file line number Diff line number Diff line change
Expand Up @@ -174,10 +174,11 @@ The :mod:`abc` module also provides the following decorator:
to declare abstract methods for properties and descriptors.

Dynamically adding abstract methods to a class, or attempting to modify the
abstraction status of a method or class once it is created, are not
supported. The :func:`abstractmethod` only affects subclasses derived using
regular inheritance; "virtual subclasses" registered with the ABC's
:meth:`register` method are not affected.
abstraction status of a method or class once it is created, are only
supported using the :func:`update_abstractmethods` function. The
:func:`abstractmethod` only affects subclasses derived using regular
inheritance; "virtual subclasses" registered with the ABC's :meth:`register`
method are not affected.

When :func:`abstractmethod` is applied in combination with other method
descriptors, it should be applied as the innermost decorator, as shown in
Expand Down Expand Up @@ -235,7 +236,6 @@ The :mod:`abc` module also provides the following decorator:
super-call in a framework that uses cooperative
multiple-inheritance.


The :mod:`abc` module also supports the following legacy decorators:

.. decorator:: abstractclassmethod
Expand Down Expand Up @@ -335,6 +335,19 @@ The :mod:`abc` module also provides the following functions:

.. versionadded:: 3.4

.. function:: update_abstractmethods(cls)
A function to recalculate an abstract class's abstraction status. This
function should be called if a class's abstract methods have been
implemented or changed after it was created. Usually, this function should
be called from within a class decorator.

Returns *cls*, to allow usage as a class decorator.

If *cls* has any subclasses, raises a :exc:`RuntimeError`.

If *cls* is not an instance of ABCMeta, raises a :exc:`TypeError`.

.. versionadded:: 3.10

.. rubric:: Footnotes

Expand Down
42 changes: 42 additions & 0 deletions Lib/abc.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,48 @@ def _abc_caches_clear(cls):
"""Clear the caches (for debugging or testing)."""
_reset_caches(cls)

def update_abstractmethods(cls):
"""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.


If a class has had one of its abstract methods implemented after the
class was created, the method will not be considered implemented until
this function is called. Alternatively, if a new abstract method has been
added to the class, it will only be considered an abstract method of the
class after this function is called.

This function should be called before any use is made of the class,
usually in class decorators that add methods to the subject class.

Returns cls, to allow usage as a class decorator.

If cls is has any subclasses, raises a RuntimeError.

If cls is not an instance of ABCMeta, raises a TypeError.
"""
if not hasattr(cls, '__abstractmethods__'):
# We check for __abstractmethods__ here because cls might by a C
# implementation or a python implementation (especially during
# testing), and we want to handle both cases.
raise TypeError('cls must be an abstract class or subclass of an abstract'
' class')
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.


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.

value = getattr(cls, name, None)
if getattr(value, "__isabstractmethod__", False):
abstracts.add(name)
# Also add any other newly added abstract methods.
for name, value in cls.__dict__.items():
if getattr(value, "__isabstractmethod__", False):
abstracts.add(name)
cls.__abstractmethods__ = frozenset(abstracts)
return cls

class ABC(metaclass=ABCMeta):
"""Helper class that provides a standard way to create an ABC using
Expand Down
5 changes: 5 additions & 0 deletions Lib/dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import keyword
import builtins
import functools
import abc
import _thread
from types import GenericAlias

Expand Down Expand Up @@ -992,6 +993,10 @@ def _process_class(cls, init, repr, eq, order, unsafe_hash, frozen):
cls.__doc__ = (cls.__name__ +
str(inspect.signature(cls)).replace(' -> None', ''))

# Update the abstract methods of the class, if it is abstract.
if isinstance(cls, abc.ABCMeta):
abc.update_abstractmethods(cls)

return cls


Expand Down
11 changes: 8 additions & 3 deletions Lib/functools.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
'partial', 'partialmethod', 'singledispatch', 'singledispatchmethod',
'cached_property']

from abc import get_cache_token
from abc import ABCMeta, get_cache_token, update_abstractmethods
from collections import namedtuple
# import types, weakref # Deferred to single_dispatch()
from reprlib import recursive_repr
Expand Down Expand Up @@ -187,15 +187,20 @@ def _lt_from_ge(self, other, NotImplemented=NotImplemented):

def total_ordering(cls):
"""Class decorator that fills in missing ordering methods"""
# Find user-defined comparisons (not those inherited from object).
roots = {op for op in _convert if getattr(cls, op, None) is not getattr(object, op, None)}
# 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.

if not roots:
raise ValueError('must define at least one ordering operation: < > <= >=')
root = max(roots) # prefer __lt__ to __le__ to __gt__ to __ge__
for opname, opfunc in _convert[root]:
if opname not in roots:
opfunc.__name__ = opname
setattr(cls, opname, opfunc)
# update the abstract methods of the class, if it is abstract
if isinstance(cls, ABCMeta):
update_abstractmethods(cls)
return cls


Expand Down
81 changes: 81 additions & 0 deletions Lib/test/test_abc.py
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,87 @@ class C(with_metaclass(abc_ABCMeta, A, B)):
pass
self.assertEqual(C.__class__, abc_ABCMeta)

def test_update_del(self):
class A(metaclass=abc_ABCMeta):
@abc.abstractmethod
def foo(self):
pass

del A.foo
self.assertEqual(A.__abstractmethods__, {'foo'})
self.assertFalse(hasattr(A, 'foo'))

abc.update_abstractmethods(A)

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


def test_update_new_abstractmethods(self):
class A(metaclass=abc_ABCMeta):
@abc.abstractmethod
def bar(self):
pass

@abc.abstractmethod
def updated_foo(self):
pass

A.foo = updated_foo
abc.update_abstractmethods(A)
msg = "class A with abstract methods bar, foo"
self.assertEqual(A.__abstractmethods__, {'foo', 'bar'})
self.assertRaisesRegex(TypeError, msg, A)

def test_update_implementation(self):
class A(metaclass=abc_ABCMeta):
@abc.abstractmethod
def foo(self):
pass

class B(A):
pass

msg = "class B with abstract method foo"
self.assertRaisesRegex(TypeError, msg, B)
self.assertEqual(B.__abstractmethods__, {'foo'})

B.foo = lambda self: None

abc.update_abstractmethods(B)

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.

def test_update_as_decorator(self):
class A(metaclass=abc_ABCMeta):
@abc.abstractmethod
def foo(self):
pass

def class_decorator(cls):
cls.foo = lambda self: None
return cls

@abc.update_abstractmethods
@class_decorator
class B(A):
pass

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

def test_update_non_abc(self):
class A:
pass

@abc.abstractmethod
def updated_foo(self):
pass

A.foo = updated_foo
self.assertRaises(TypeError, abc.update_abstractmethods, A)


class TestABCWithInitSubclass(unittest.TestCase):
def test_works_with_init_subclass(self):
Expand Down
37 changes: 37 additions & 0 deletions Lib/test/test_dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from dataclasses import *

import abc
import pickle
import inspect
import builtins
Expand Down Expand Up @@ -3332,6 +3333,42 @@ class C:

## replace(c, x=5)

class TestAbstract(unittest.TestCase):
def test_abc_implementation(self):
class Ordered(abc.ABC):
@abc.abstractmethod
def __lt__(self, other):
pass

@abc.abstractmethod
def __le__(self, other):
pass

@dataclass(order=True)
class Date(Ordered):
year: int
month: 'Month'
day: 'int'

self.assertFalse(inspect.isabstract(Date))
self.assertGreater(Date(2020,12,25), Date(2020,8,31))

def test_maintain_abc(self):
class A(abc.ABC):
@abc.abstractmethod
def foo(self):
pass

@dataclass
class Date(A):
year: int
month: 'Month'
day: 'int'

self.assertTrue(inspect.isabstract(Date))
msg = 'class Date with abstract method foo'
self.assertRaisesRegex(TypeError, msg, Date)


if __name__ == '__main__':
unittest.main()
14 changes: 14 additions & 0 deletions Lib/test/test_functools.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import collections
import collections.abc
import copy
import inspect
from itertools import permutations
import pickle
from random import choice
Expand Down Expand Up @@ -1157,6 +1158,19 @@ def test_pickle(self):
method_copy = pickle.loads(pickle.dumps(method, proto))
self.assertIs(method_copy, method)

def test_abc_impl(self):
class A(abc.ABC):
@abc.abstractmethod
def __gt__(self, other):
pass

class B(A):
def __lt__(self, other):
return True
B = functools.total_ordering(B)
B()
self.assertFalse(inspect.isabstract(B))

@functools.total_ordering
class Orderable_LT:
def __init__(self, value):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A new function in abc: *update_abstractmethods* to re-calculate an abstract class's abstract status. In addition, *dataclass* and *total_ordering* have been changed to call this function. Finally, *total_ordering* was patched so that it would override abstract methods.