Title: Calling `help` executes @classmethod @property decorated methods
Type: behavior Stage: test needed
Components: Versions: Python 3.11
Status: open Resolution:
Dependencies: Superseder:
Assigned To: rhettinger Nosy List: AlexWaygood, berker.peksag, corona10, randolf.scholz, rhettinger, serhiy.storchaka, terry.reedy, wim.glenn
Priority: normal Keywords:

Created on 2021-10-03 19:45 by randolf.scholz, last changed 2021-10-12 07:32 by randolf.scholz.

File name Uploaded Description Edit randolf.scholz, 2021-10-03 19:45 randolf.scholz, 2021-10-03 20:21 randolf.scholz, 2021-10-11 12:55
Messages (12)
msg403109 - (view) Author: Randolf Scholz (randolf.scholz) Date: 2021-10-03 19:45
I noticed some strange behaviour when calling `help` on a class inheriting from a class or having itself @classmethod @property decorated methods.

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

class MyMetaClass(ABCMeta):
    def expensive_metaclass_property(cls):
        """This may take a while to compute!"""
        print("computing metaclass property"); sleep(3)
        return "Phew, that was a lot of work!"

class MyBaseClass(ABC, metaclass=MyMetaClass):
    def expensive_class_property(cls):
        """This may take a while to compute!"""
        print("computing class property .."); sleep(3)
        return "Phew, that was a lot of work!"
    def expensive_instance_property(self):
        """This may take a while to compute!"""
        print("computing instance property ..."); sleep(3)
        return "Phew, that was a lot of work!"

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

Calling `help(MyClass)` will cause `expensive_class_property` to be executed 4 times (!)

The other two properties, `expensive_instance_property` and `expensive_metaclass_property` are not executed.

Secondly, only `expensive_instance_property` is listed as a read-only property; `expensive_class_property` is listed as a classmethod and `expensive_metaclass_property` is unlisted.

The problem is also present in '3.10.0rc2 (default, Sep 28 2021, 17:57:14) [GCC 10.2.1 20210110]'

Stack Overflow thread:
msg403110 - (view) Author: Randolf Scholz (randolf.scholz) Date: 2021-10-03 20:21
I updated the script with dome more info. The class-property gets actually executed 5 times 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!
msg403111 - (view) Author: Alex Waygood (AlexWaygood) * Date: 2021-10-03 21:03
See also:
msg403504 - (view) Author: Terry J. Reedy (terry.reedy) * (Python committer) Date: 2021-10-08 20:47
Randolf, what specific behaviors do you consider to be bugs that should be fixed.  What would a test of the the changed behavior look like?

This should perhaps be closed as a duplicate of #44904.  Randolf, please check and say what you thing.
msg403505 - (view) Author: Terry J. Reedy (terry.reedy) * (Python committer) Date: 2021-10-08 20:53
On current 3.9, 3.10, 3.11, on Windows running in IDLE, I see
computing class property ..
computing class property ..
computing class property ..
computing class property ..
computing class property ..
Help ...
msg403578 - (view) Author: Randolf Scholz (randolf.scholz) Date: 2021-10-10 09:57
@Terry I think the problem boils down to the fact that `@classmethod @property` decorated methods end up not being real properties.

Calling `MyBaseClass.__dict__` reveals:

mappingproxy({'__module__': '__main__',
              'expensive_class_property': <classmethod at 0x7f893e95dd60>,
              'expensive_instance_property': <property at 0x7f893e8a5860>,
              '__dict__': <attribute '__dict__' of 'MyBaseClass' objects>,
              '__weakref__': <attribute '__weakref__' of 'MyBaseClass' objects>,
              '__doc__': None,
              '__abstractmethods__': frozenset(),
              '_abc_impl': <_abc._abc_data at 0x7f893fb98740>})

Two main issues:

1. Any analytics or documentation tool that has special treatment for properties may not identify 'expensive_class_property' as a property if they simply check via `isinstance(func, property)`. Of course, this could be fixed by the tool-makers by doing a conditional check:

isinstance(func, property) or (`isinstance(func, classmethod)` and `isinstance(func.__func__, property)`

2. `expensive_class_property` does not implement `getter`, `setter`, `deleter`. This seems to be the real dealbreaker, for example, if we do

MyBaseClass.expensive_class_property = 2
MyBaseClass().expensive_instance_property = 2

Then the first line erroneously executes, such that MyBaseClass.__dict__['expensive_class_property'] is now `int` instead of `classmethod`, while the second line correctly raises `AttributeError: can't set attribute` since the setter method is not implemented.
msg403582 - (view) Author: Randolf Scholz (randolf.scholz) Date: 2021-10-10 10:37
If fact, in the current state it seem that it is impossible to implement real class-properties, for a simple reason: 

descriptor.__set__ is only called when setting the attribute of an instance, but not of a class!!

import math

class TrigConst: 
    const = math.pi
    def __get__(self, obj, objtype=None):
        print("__get__ called")
        return self.const
    def __set__(self, obj, value):
        print("__set__ called")
        self.const = value

class Trig:
    const = TrigConst()              # Descriptor instance

Trig().const             # calls TrigConst.__get__
Trig().const = math.tau  # calls TrigConst.__set__
Trig.const               # calls TrigConst.__get__
Trig.const = math.pi     # overwrites TrigConst attribute with float.
msg403611 - (view) Author: Raymond Hettinger (rhettinger) * (Python committer) Date: 2021-10-10 22:17
I'm merging issue 44904 into this one because they are related.
msg403613 - (view) Author: Raymond Hettinger (rhettinger) * (Python committer) Date: 2021-10-10 22:38
It may have been a mistake to allow this kind of decorator chaining.  

* As Randolf and Alex have noticed, it invalidates assumptions made elsewhere in the standard library and presumably in third-party code as well.  

* And as Randolf noted in his last post, the current descriptor logic doesn't make it possible to implement classmethod properties with setter logic. 

* In issue 44973, we found that staticmethod and property also don't compose well, nor does abstract method.

We either need to undo the Python 3.9 feature from issue 19072, or we need to put serious thought into making all these descriptors composable in a way that would match people's expectations.
msg403653 - (view) Author: Randolf Scholz (randolf.scholz) Date: 2021-10-11 12:55
Dear Raymond,

I think decorator chaining is a very useful concept. At the very least, if all decorators are functional (following the `functools.wraps` recipe), things work out great -- we even get associativity of function composition if things are done properly!

The question is: can we get similar behaviour when allowing decoration with stateful objects, i.e. classes? This seems a lot more difficult. At the very least, in the case of `@classmethod` I think one can formulate a straightforward desiderata:

### Expectation

- `classmethod(property)` should still be a `property`!
- More generally: `classmethod(decorated)` should always be a subclass of `decorated`!

Using your pure-python versions of property / classmethod from <>, I was able to write down a variant of `classmethod` that works mostly as expected in conjunction with `property`. The main idea is rewrite the `classmethod` to dynamically be a subclass of whatever it wrapped; roughly:

def ClassMethod(func):
  class Wrapped(type(func)):
      def __get__(self, obj, cls=None):
          if cls is None:
              cls = type(obj)
          if hasattr(type(self.__func__), '__get__'):
              return self.__func__.__get__(cls)
          return MethodType(self.__func__, cls)
  return Wrapped(func)

I attached a full MWE. Unfortunately, this doesn't fix the `help` bug, though it is kind of weird because the decorated class-property now correctly shows up under the "Readonly properties" section. Maybe `help` internally checks `isinstance(cls.func, property)` at some point instead of `isinstance(cls.__dict__["func"], property)`?

### Some further Proposals / Ideas

1. Decorators could always have an attribute that points to whatever object they wrapped. For the sake of argument, let's take `__func__`.
   ⟹ raise Error when typing `@decorator` if `not hasattr(decorated, "__func__")`
   ⟹ Regular functions/methods should probably by default have `__func__` as a pointer to themselves?
   ⟹ This could hae several subsidiary benefits, for example, currently, how would you implement a pair of decorators `@print_args_and_kwargs` and `@time_execution` such that both of them only apply to the base function, no matter the order in which they are decorating it? The proposed `__func__` convention would make this very easy, almost trivial.
2. `type.__setattr__` could support checking if `attr` already exists and has `__set__` implemented.
  ⟹ This would allow true class-properties with `getter`, `setter` and `deleter`. I provide a MWE here: <>
3. I think an argument can be made that it would be really, really cool if `@` could become a general purpose function composition operator?
  ⟹ This is already kind of what it is doing with decorators
  ⟹ This is already exacltly what it is doing in numpy -- matrix multiplication \*is\* the composition of linear functions.
  ⟹ In fact this is a frequently requested feature on python-ideas!
  ⟹ But here is probably the wrong place to discuss this.
msg403699 - (view) Author: Alex Waygood (AlexWaygood) * Date: 2021-10-11 22:43
Some thoughts from me, as an unqualified but interested party:

Like Randolph, I very much like having class properties in the language, and have used them in several projects since their introduction in 3.9. I find they're especially useful with Enums. However, while the bug in doctest, for example, is relatively trivial to fix (see my PR in #44904), it seems to me plausible that bugs might crop up in other standard library modules as well. As such, leaving class properties in the language might mean that several more bugfixes relating to this feature would have to be made in the future. So, I can see the argument for removing them.

It seems to me that Randolph's idea of changing `classmethod` from a class into a function would break a lot of existing code. As an alternative, one small adjustment that could be made would be to special-case `isinstance()` when it comes to class properties. In pure Python, you could achieve this like this:

oldproperty = property

class propertymeta(type):
    def __instancecheck__(cls, obj):
        return super().__instancecheck__(obj) or (
            isinstance(obj, classmethod)
            and super().__instancecheck__(obj.__func__)

class property(oldproperty, metaclass=propertymeta): pass

This would at least mean that `isinstance(classmethod(property(lambda cls: 42)), property)` and `isinstance(classmethod(property(lambda cls: 42)), classmethod)` would both evaluate to `True`. This would be a bit of a lie (an instance of `classmethod` isn't an instance of `property`), but would at least warn the caller that the object they were dealing with had some propertylike qualities to it.

Note that this change wouldn't fix the bugs in abc and doctest, nor does it fix the issue with class-property setter logic that Randolph identified.

With regards to the point that `@staticmethod` cannot be stacked on top of `@property`: it strikes me that this feature isn't really needed. You can achieve the same effect just by stacking `@classmethod` on top of `@property` and not using the `cls` parameter.
msg403713 - (view) Author: Randolf Scholz (randolf.scholz) Date: 2021-10-12 07:32
@Alex Regarding my proposal, the crucial part is the desiderata, not the hacked up implementation I presented.

And I really believe that at least that part I got hopefully right. After all, what does `@classmethod` functionally do? It makes the first argument of the function receive the class type instead of the object instance and makes it possible to call it directly from the class without instantiating it. I would still expect `MyClass.__dict__["themethod"]` to behave, as an object, much the same, regardless if it was decorated with `@classmethod` or not.

Regarding code breakage - yes that is a problem, but code is already broken and imo Python needs to make a big decision going forward: 

1. Embrace decorator chaining and try hard to make it work universally (which afaik was never intended originally when decorators were first introduced). As a mathematician I would love this, also adding `@` as a general purpose function composition operator would add quite some useful functional programming aspects to python.
2. Revert changes from 3.9 and generally discourage decorator chaining.

At this point however I want to emphasize that I am neither a CS major nor a python expert (I only started working with the language 3 years ago), so take everything I say as what it is - the opinion of some random stranger from the internet (:
Date User Action Args
2021-10-12 07:32:22randolf.scholzsetmessages: + msg403713
2021-10-11 22:43:14AlexWaygoodsetmessages: + msg403699
2021-10-11 12:55:14randolf.scholzsetfiles: +

messages: + msg403653
2021-10-10 22:38:26rhettingersetassignee: rhettinger

messages: + msg403613
nosy: + berker.peksag
2021-10-10 22:18:34rhettingerlinkissue44904 superseder
2021-10-10 22:17:30rhettingersetnosy: + rhettinger
messages: + msg403611
2021-10-10 19:08:12serhiy.storchakasetnosy: + serhiy.storchaka
2021-10-10 10:37:36randolf.scholzsetmessages: + msg403582
2021-10-10 09:57:52randolf.scholzsetmessages: + msg403578
2021-10-08 20:53:12terry.reedysetmessages: + msg403505
versions: + Python 3.11, - Python 3.9, Python 3.10
2021-10-08 20:47:43terry.reedysetnosy: + terry.reedy

messages: + msg403504
stage: test needed
2021-10-04 20:14:40wim.glennsetnosy: + wim.glenn
2021-10-04 07:26:14corona10setnosy: + corona10
2021-10-03 21:03:34AlexWaygoodsetnosy: + AlexWaygood
messages: + msg403111
2021-10-03 20:21:20randolf.scholzsetfiles: +

messages: + msg403110
2021-10-03 19:45:55randolf.scholzcreate