GeezerVison is showing github.com — Calling `help` executes @classmethod @property decorated methods · Issue #89519 · python/cpython
Calling help executes @classmethod @property decorated methods #89519Description
| BPO |
45356 |
| Nosy |
@rhettinger, @terryjreedy, @ambv, @berkerpeksag, @serhiy-storchaka, @wimglenn, @corona10, @pablogsal, @akulakov, @sirosen, @randolf-scholz, @AlexWaygood |
| PRs |
gh-89519: fix incorrect access of class property in pydoc and inspect #29239 |
| Files |
classmethod_property.pyclassmethod_property.pyClassPropertyIdea.py |
Note: these values reflect the state of the issue at the time it was migrated and might not reflect the current state.
Show more details
GitHub fields:
assignee = 'https://github.com/rhettinger'
closed_at = None
created_at = <Date 2021-10-03.19:45:55.963>
labels = ['type-bug', '3.11']
title = 'Calling `help` executes @classmethod @property decorated methods'
updated_at = <Date 2022-02-17.20:03:40.349>
user = 'https://github.com/randolf-scholz'
bugs.python.org fields:
activity = <Date 2022-02-17.20:03:40.349>
actor = 'rhettinger'
assignee = 'rhettinger'
closed = False
closed_date = None
closer = None
components = []
creation = <Date 2021-10-03.19:45:55.963>
creator = 'randolf.scholz'
dependencies = []
files = ['50325', '50326', '50344']
hgrepos = []
issue_num = 45356
keywords = ['patch']
message_count = 23.0
messages = ['403109', '403110', '403111', '403504', '403505', '403578', '403582', '403611', '403613', '403653', '403699', '403713', '405100', '405115', '405121', '405142', '405143', '406600', '406605', '406606', '407493', '407499', '413451']
nosy_count = 13.0
nosy_names = ['rhettinger', 'terry.reedy', 'grahamd', 'lukasz.langa', 'berker.peksag', 'serhiy.storchaka', 'wim.glenn', 'corona10', 'pablogsal', 'andrei.avk', 'sirosen0', 'randolf.scholz', 'AlexWaygood']
pr_nums = ['29239']
priority = 'normal'
resolution = None
stage = 'patch review'
status = 'open'
superseder = None
type = 'behavior'
url = 'https://bugs.python.org/issue45356'
versions = ['Python 3.11']
Linked PRs
Activityrandolf-scholz commented on Oct 3, 2021 MannequinAuthor More actions 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):
@classmethod
@property
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):
@classmethod
@property
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!"
@property
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"""
help(MyClass)
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: https://stackoverflow.com/questions/69426309 randolf-scholz commented on Oct 3, 2021 MannequinAuthor More actions 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!
AlexWaygood commented on Oct 3, 2021 terryjreedy commented on Oct 8, 2021 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 bpo-44904. Randolf, please check and say what you thing. terryjreedy commented on Oct 8, 2021 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 ... randolf-scholz commented on Oct 10, 2021 MannequinAuthor More actions @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:
- 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)`
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. randolf-scholz commented on Oct 10, 2021 MannequinAuthor More actions 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. Reacted by Ole Jørgen Brønner, 5j9 and Austin rhettinger commented on Oct 10, 2021 I'm merging bpo-44904 into this one because they are related. ReHoss commented on May 29, 2024
In Python 3.10, classmethod() added a wrapped attribute. Presumably, any use case for implicit chaining can now be accomplished in an explicit and controlled manner.
@rhettinger - Could you share an example of how you would do this as a replacement for chaining @classmethod and @property?
@zduvall Here is an implementation for a read-only class property that is backwards/forwards compatible:
import sys
from typing import Generic, Optional, TypeVar
if sys.version_info < (3, 9):
from typing import Callable, Type
else:
from builtins import type as Type
from collections.abc import Callable
T = TypeVar("T")
RT = TypeVar("RT")
class classproperty(Generic[T, RT]):
"""
Class property attribute (read-only).
Same usage as @property, but taking the class as the first argument.
class C:
@classproperty
def x(cls):
return 0
print(C.x) # 0
print(C().x) # 0
"""
def __init__(self, func: Callable[[Type[T]], RT]) -> None:
# For using `help(...)` on instances in Python >= 3.9.
self.__doc__ = func.__doc__
self.__module__ = func.__module__
self.__name__ = func.__name__
self.__qualname__ = func.__qualname__
# Consistent use of __wrapped__ for wrapping functions.
self.__wrapped__: Callable[[Type[T]], RT] = func
def __set_name__(self, owner: Type[T], name: str) -> None:
# Update based on class context.
self.__module__ = owner.__module__
self.__name__ = name
self.__qualname__ = owner.__qualname__ + "." + name
def __get__(self, instance: Optional[T], owner: Optional[Type[T]] = None) -> RT:
if owner is None:
owner = type(instance)
return self.__wrapped__(owner)
Additionally, as shown in this issue thread, running help(...) on things like this will not work as expected. In the example shown in help(classproperty), running help(C) will show x = 0 instead of the documentation for the class property, if any. This is the same behavior as the current @classmethod @property solution, nothing fancy there. As such, you may want to simplify the __init__ and remove the __set_name__ as being completely unnecessary.
Note also that as shown by @Paebbels, the descriptor protocol ONLY supports class getters, it does not support class setters or class deleters. As such, only read-only class properties are possible.
This isn't to say you can't have mutable class properties however, only that you can't have them follow the descriptor protocol for the nicer syntax. You can instead have the class getter return objects with .get, .set, and .delete methods as a workaround:
import sys
from types import MethodType
from typing import Any, Callable, Generic, Optional, TypeVar, Union
if sys.version_info < (3, 9):
from typing import Type
else:
from builtins import type as Type
if sys.version_info < (3, 11):
Self = TypeVar("Self", bound="classproperty")
else:
from typing import Self
T = TypeVar("T")
class BoundClassDescriptor(Generic[T]):
def __init__(
self,
owner: Type[T],
descriptor: property,
module: Optional[str],
name: Optional[str],
qualname: Optional[str],
) -> None:
self.__doc__ = descriptor.__doc__
self.__module__ = module
self.__name__ = name
self.__qualname__ = qualname
self.__wrapped__: property = descriptor
self.get: Callable[[], Any]
self.set: Callable[[Any], object]
self.delete: Callable[[], object]
if descriptor.fget is not None:
self.get = MethodType(descriptor.fget, owner)
if descriptor.fset is not None:
self.set = MethodType(descriptor.fset, owner)
if descriptor.fdel is not None:
self.delete = MethodType(descriptor.fdel, owner)
class classproperty(Generic[T]):
def __init__(
self,
fget: Optional[Callable[[Type[T]], Any]] = None,
fset: Optional[Callable[[Type[T], Any], object]] = None,
fdel: Optional[Callable[[Type[T]], object]] = None,
doc: Optional[str] = None,
) -> None:
self.__module__ = None
self.__name__ = None
self.__qualname__ = None
self.__wrapped__: property = property(fget, fset, fdel, doc)
def __set_name__(self, owner: Type[T], name: str) -> None:
self.__module__ = owner.__module__
self.__name__ = name
self.__qualname__ = owner.__qualname__ + "." + name
def __get__(
self, instance: Optional[T], owner: Optional[Type[T]] = None
) -> BoundClassDescriptor[T]:
if owner is None:
owner = type(instance)
return BoundClassDescriptor(
owner,
self.__wrapped__,
self.__module__,
self.__name__,
self.__qualname__,
)
def getter(self: Self, func: Callable[[Type[T]], Any]) -> Self:
self.__wrapped__ = self.__wrapped__.getter(func)
return self
def setter(self: Self, func: Callable[[Type[T], Any], object]) -> Self:
self.__wrapped__ = self.__wrapped__.setter(func)
return self
def deleter(self: Self, func: Callable[[Type[T]], object]) -> Self:
self.__wrapped__ = self.__wrapped__.deleter(func)
return self
Example:
class A:
_foo = 0
@classproperty
def foo(cls):
"""Description of foo class property."""
return cls._foo
@foo.setter
def foo(cls, value):
cls._foo = value
class B(A):
pass
Usage:
>>> # Getters/setters/deleters work as you'd expect using `A._foo` directly.
>>> A.foo.get()
0
>>> B.foo.get()
0
>>>
>>> A.foo.set(1)
>>> A.foo.get()
1
>>> B.foo.get()
1
>>>
>>> B.foo.set(2)
>>> A.foo.get()
1
>>> B.foo.get()
2
>>>
>>> help(A.foo) # Instance documentation works in Python >= 3.9.
Help on BoundClassDescriptor in A:
A.foo = <BoundClassDescriptor object>
Description of foo class property.
>>> help(A.foo.get)
Help on method foo in module __main__:
foo() method of builtins.type instance
Description of foo class property.
>>> help(A.foo.set) # You could have custom setter documentation.
Help on method foo in module __main__:
foo(value) method of builtins.type instance
>>> help(A.foo.delete) # Only has a delete method if one was set.
Traceback (most recent call last):
...
AttributeError: 'BoundClassDescriptor' object has no attribute 'delete'
Of course the documentation won't work on static analyzers, but you'll at least be able to dynamically use help(...) and get what you'd expect.
Hi @SimpleArt ,
The pyright type checker for the instruction return self.__wrapped__(owner) returns:
Argument of type "type[T@classproperty] | type[object]* | None; cannot be assigned to parameter of type ";type[T@classproperty]"; Type ";type[T@classproperty] | type[object]* | None"; is incompatible with type ";type[T@classproperty]"; (reportArgumentType)
for this slightly modified version of the __get__ method (modified such that type(None) does not happen)
def __get__(self, instance: Optional[T], owner: Type[T] | None = None) -> RT:
if owner is None:
if instance is not None:
owner = type(instance)
else:
owner = None
return self.__wrapped__(owner) # pyright triggers warning
also this assignment
self.__module__ = func.__module__
gives
Cannot find reference 'module' in '(T) -> RT'
Moreover, could you provide a simple example of using abstract class properties for Python>=3.13 ? STofone commented on May 10, 2025 classproperty breaks the type checker.
While Metaclass cannot be generic.
Makes this code impossible to work fine
from functools import cached_property
from typing import Callable, Literal, LiteralString, Type
class SetFunc[T: LiteralString]:
def __init__(self, id: T):
self.id = id
def __call__(self, func: Callable):
...
return func
class META_A[T: LiteralString](type):
@cached_property
def set_func(cls) -> Type[SetFunc[T]]:
return SetFunc
class A[T: LiteralString](metaclass=META_A[T]):
pass
class B(A[Literal["1", "2", "3"]]):
pass
# type checking for id does not work
@B.set_func("1")
def func():
pass
python/mypy#11672
MetadataMetadataDevelopmentNo branches or pull requestsIssue actions
|