0

Since python 3.9, it is supported to stack the @property and @classmethod decorators. However, I struggle to create a class-property that shows up in help under the Readonly properties section. The solutions presented in [1],[2],[3],[4],[5] didn't solve my problem. Consider:

from time import sleep
from abc import ABC, ABCMeta, abstractmethod

def compute(obj, s):
    print(f"Computing {s} of {obj} ...", end="")
    sleep(3)
    print("DONE!")
    return "Phew, that was a lot of work!"


class MyMetaClass(ABCMeta):
    @property
    def expensive_metaclass_property(cls):
        """This may take a while to compute!""" 
        return compute(cls, "metaclass property")

    
class MyBaseClass(ABC, metaclass=MyMetaClass):
    @classmethod
    @property
    def expensive_class_property(cls):
        """This may take a while to compute!"""
        return compute(cls, "class property")
    
    @property
    def expensive_instance_property(self):
        """This may take a while to compute!"""   
        return compute(self, "instance property")

class MyClass(MyBaseClass):
    """Some subclass of MyBaseClass"""
    
help(MyClass)

The issue is that calling help(MyBaseClass) executes expensive_class_property multiple times. This caused issues with documentation in the past where for example sphinx would also end up executing the property code.

Using the metaclass avoids this issue, but has the disadvantage that expensive_metaclass_property neither shows up in dir(MyClass) nor help(MyClass) nor in documentation of MyClass. How can I get a class-property that shows up in help(MyClass) under the Readonly properties section?

Here's what happens when calling help(MyClass):

Computing class property of <class '__main__.MyClass'> ...DONE!
Computing class property of <class '__main__.MyClass'> ...DONE!
Computing class property of <class '__main__.MyClass'> ...DONE!
Computing class property of <class '__main__.MyBaseClass'> ...DONE!
Computing class property of <class '__main__.MyClass'> ...DONE!

Help on class MyClass in module __main__:

class MyClass(MyBaseClass)
 |  Some subclass of MyBaseClass
 |  
 |  Method resolution order:
 |      MyClass
 |      MyBaseClass
 |      abc.ABC
 |      builtins.object
 |  
 |  Data and other attributes defined here:
 |  
 |  __abstractmethods__ = frozenset()
 |  
 |  ----------------------------------------------------------------------
 |  Class methods inherited from MyBaseClass:
 |  
 |  expensive_class_property = 'Phew, that was a lot of work!'
 |  ----------------------------------------------------------------------
 |  Readonly properties inherited from MyBaseClass:
 |  
 |  expensive_instance_property
 |      This may take a while to compute!
 |  
 |  ----------------------------------------------------------------------
 |  Data descriptors inherited from MyBaseClass:
 |  
 |  __dict__
 |      dictionary for instance variables (if defined)
 |  
 |  __weakref__
 |      list of weak references to the object (if defined)
Hyperplane
  • 1,422
  • 1
  • 14
  • 28
  • Is it acceptable to cache the computed result? Or must it be recomputed on every invocation? – wim Oct 03 '21 at 19:37
  • @wim I am already combining it with a cache decorator later on. However, that means it still gets executed at least once when it should get executed 0 times just like the `expensive_instance_property`. – Hyperplane Oct 03 '21 at 19:57
  • OK so the main problem is not really the multiple invocations, but that autodoc invokes the property code at all? – wim Oct 03 '21 at 20:12
  • Bug report from OP: https://bugs.python.org/issue45356 – wim Oct 04 '21 at 20:17

1 Answers1

0

Using the call stack, you can detect if the property was invoked by pydoc and short-circuit in that case:

import inspect

class A:
    @classmethod
    @property
    def expensive_class_property(cls):
        """This may take a while to compute!"""
        fnames = [f.filename for f in inspect.getouterframes(inspect.currentframe())]
        if any(fname.endswith("pydoc.py") for fname in fnames):
            return "Text returned for autodoc"
        print("computing class property")
        return "Phew, that was a lot of work!"

This help will look something like this:

Help on class A in module __main__:

class A(builtins.object)
 |  Class methods defined here:
 |  
 |  expensive_class_property = 'Text returned for autodoc'
 |  ----------------------------------------------------------------------
 |  Data descriptors defined here:
 |  
 |  __dict__
 |      dictionary for instance variables (if defined)
 |  
 |  __weakref__
 |      list of weak references to the object (if defined)

But normal access will be as expected:

>>> A.expensive_class_property
computing class property
'Phew, that was a lot of work!'

It doesn't prevent help(A) from invoking the descriptor multiple times, but you may not be able to prevent this without modifying pydoc.py directly. As long as the short-circuit is fast, then you likely don't need to prevent it anyway.

This approach should be easily extensible to detect invocation by Sphinx. If you don't like adding the short-circuit code directly into the body of the function, this idea could be factored out to a decorator.

wim
  • 338,267
  • 99
  • 616
  • 750