6

I've been trying to see whether one can create an abstract class property by mixing the three decorators (in Python 3.9.6, if that matters), and I noticed some strange behaviour.

Consider the following code:

from abc import ABC, abstractmethod

class Foo(ABC):
    @classmethod
    @property
    @abstractmethod
    def x(cls):
        print(cls)
        return None

class Bar(Foo):
    @classmethod
    @property
    def x(cls):
        print("this is executed")
        return super().x

This outputs

this is executed
<class '__main__.Bar'>

This means that somehow, Bar.x ends up being called.

PyCharm warns me that Property 'self' cannot be deleted. If I reverse the order of @classmethod and @property, Bar.x is not called, but I still get the same warning, and also another one: This decorator will not receive a callable it may expect; the built-in decorator returns a special object (this also appears whenever I put @property above @classmethod).

Removing any of the three decorators (with the appropriate changes: adding () when removing @property or changing cls to self when removing @classmethod) also prevents Bar.x from being called.

I suppose all of this means that it's probably just a bad idea to directly mix those decorators (as indicated by discussion about class properties in other threads here).

Neverthless, I am curious: what is happening here? Why is Bar.x called?

tomasz
  • 180
  • 7
  • What are you trying to achieve when you say a "class property"? You probably should not try to mix `property` and `classmethod`, instead, create your own custom descriptor. That would be much more sane. – juanpa.arrivillaga Aug 12 '21 at 20:01
  • 3
    @juanpa.arrivillaga: Mixing `classmethod` and `property` like that is actually supported now - they changed how `classmethod` interacts with arbitrary descriptors. – user2357112 Aug 12 '21 at 20:02
  • @user2357112supportsMonica ahhh yes. I actually recall seeing this in the new and improved Descriptor HOWTO, basically it checks if the functions has a `__get__` and invokes it with the class. – juanpa.arrivillaga Aug 12 '21 at 20:06
  • @user2357112supportsMonica: The fact that this works without `@abstractmethod` made me think that is the case! Do you remember where this feature/change is documented? I'd like to see the details. – tomasz Aug 12 '21 at 21:01
  • 1
    @tomasz: I'm not sure if there's any detailed documentation. All I have is this note in the [`classmethod` docs](https://docs.python.org/3/library/functions.html#classmethod): "Changed in version 3.9: Class methods can now wrap other descriptors such as property()." – user2357112 Aug 12 '21 at 21:16
  • @user2357112supportsMonica: Thanks. – tomasz Aug 12 '21 at 21:19

2 Answers2

5

This looks like a bug in the logic that checks for inherited abstract methods.

An object in a class dict is considered abstract if retrieving its __isabstractmethod__ attribute produces True. When Bar subclasses Foo, Python needs to determine whether Bar overrides the abstract Foo.x, and if so, whether the override is itself abstract. It should do this by searching the MRO for an 'x' entry in a class dict, so it can examine __isabstractmethod__ on descriptors directly without invoking the descriptor protocol, but instead, it performs a simple Bar.x attribute access.

The Bar.x attribute access invokes the class property. It also returns None instead of the abstract property, and None isn't abstract, so Python gets confused about whether Bar.x is abstract. Python ends up still thinking Bar.x is abstract due to a different check, but if you change the example a bit:

>>> from abc import ABC, abstractmethod
>>> 
>>> class Foo(ABC):
...     @classmethod
...     @property
...     @abstractmethod
...     def x(cls):
...         print(cls)
...         return None
... 
>>> class Bar(Foo): pass
... 
<class '__main__.Bar'>
>>> Bar()
<__main__.Bar object at 0x7f46eca8ab80>

Python ends up thinking Bar is a concrete class, even though the changed example doesn't override x at all.

user2357112
  • 260,549
  • 28
  • 431
  • 505
  • That is very clear and informative, thank you! – tomasz Aug 12 '21 at 21:06
  • 2
    I submitted a bug report based on this thread: https://bugs.python.org/issue44904 – tomasz Aug 12 '21 at 21:17
  • @tomasz: My apologies, I misread the code in the original question. Yup, that's a bug! As you were. – Alex Waygood Aug 13 '21 at 14:34
  • (Specifically, I didn't notice that the property was "called" while the subclassing was taking place, despite no instance of the class ever having been created.) – Alex Waygood Aug 13 '21 at 14:38
  • 1
    @tomasz: ...Yes, I should have said "despite `Bar.x` never having been called on the class or an instance of the class". (I find the idea of calling a `classmethod` `property` on a class, rather than an instance of a class, a little mind-bending, I have to confess.) – Alex Waygood Aug 13 '21 at 14:50
3

You can try raising an exception in Bar.x. This way you can see where it is called.

It should lead you to abc.py in the standard library, specifically the line _abc_init(cls). This function is implemented in C. One of the first things this does is call compute_abstract_methods(self) checks all the abstract methods the class has inherited to see if they're implemented. This means getting Bar.x which invokes the property getter.

Jasmijn
  • 9,370
  • 2
  • 29
  • 43