Skip to content

Calling help executes @classmethod @property decorated methods #89519

Closed

Description

@randolf-scholz
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.py
  • classmethod_property.py
  • ClassPropertyIdea.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

    Activity

    1. randolf-scholz commented on Oct 3, 2021

      randolf-scholzmannequin
      MannequinAuthor

      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

    2. randolf-scholz commented on Oct 3, 2021

      randolf-scholzmannequin
      MannequinAuthor

      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!
      
    3. AlexWaygood commented on Oct 3, 2021

      @AlexWaygood
      Member
    4. terryjreedy commented on Oct 8, 2021

      @terryjreedy
      Member

      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.

    5. terryjreedy commented on Oct 8, 2021

      @terryjreedy
      Member

      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 ...

    6. added
      3.11only security fixes
      and removed on Oct 8, 2021
    7. randolf-scholz commented on Oct 10, 2021

      randolf-scholzmannequin
      MannequinAuthor

      @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)`
      1. 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.

    8. randolf-scholz commented on Oct 10, 2021

      randolf-scholzmannequin
      MannequinAuthor

      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.
    9. rhettinger commented on Oct 10, 2021

      @rhettinger
      Contributor

      I'm merging bpo-44904 into this one because they are related.

    10. 50 remaining items

    11. added a commit that references this issue on Dec 26, 2023
    12. added a commit that references this issue on Jan 22, 2024
    13. added 2 commits that reference this issue on Feb 11, 2024
    14. ReHoss commented on May 29, 2024

      @ReHoss

      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 ?

    15. added 2 commits that reference this issue on Sep 2, 2024
    16. added a commit that references this issue on Apr 9, 2025
    17. STofone commented on May 10, 2025

      @STofone

      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

    Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

    Metadata

    Metadata

    Labels

    3.11only security fixestype-bugAn unexpected behavior, bug, or error

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions