Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Calling help executes @classmethod @property decorated methods #89519

Open
randolf-scholz mannequin opened this issue Oct 3, 2021 · 40 comments
Open

Calling help executes @classmethod @property decorated methods #89519

randolf-scholz mannequin opened this issue Oct 3, 2021 · 40 comments
Assignees
Labels
3.11 bug and security fixes type-bug An unexpected behavior, bug, or error

Comments

@randolf-scholz
Copy link
Mannequin

randolf-scholz mannequin commented Oct 3, 2021

BPO 45356
Nosy @rhettinger, @terryjreedy, @ambv, @berkerpeksag, @serhiy-storchaka, @wimglenn, @corona10, @pablogsal, @akulakov, @sirosen, @randolf-scholz, @AlexWaygood
PRs
  • bpo-45356: 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

    @randolf-scholz
    Copy link
    Mannequin Author

    randolf-scholz mannequin commented Oct 3, 2021

    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 randolf-scholz mannequin added 3.9 only security fixes 3.10 only security fixes type-bug An unexpected behavior, bug, or error labels Oct 3, 2021
    @randolf-scholz
    Copy link
    Mannequin Author

    randolf-scholz mannequin commented Oct 3, 2021

    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
    Copy link
    Member

    See also: https://bugs.python.org/issue44904

    @terryjreedy
    Copy link
    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.

    @terryjreedy
    Copy link
    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 ...

    @terryjreedy terryjreedy added 3.11 bug and security fixes and removed 3.9 only security fixes 3.10 only security fixes labels Oct 8, 2021
    @randolf-scholz
    Copy link
    Mannequin Author

    randolf-scholz mannequin commented Oct 10, 2021

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

    @randolf-scholz
    Copy link
    Mannequin Author

    randolf-scholz mannequin commented Oct 10, 2021

    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.

    @rhettinger
    Copy link
    Contributor

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

    @rhettinger
    Copy link
    Contributor

    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 bpo-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 bpo-19072, or we need to put serious thought into making all these descriptors composable in a way that would match people's expectations.

    @randolf-scholz
    Copy link
    Mannequin Author

    randolf-scholz mannequin commented Oct 11, 2021

    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 https://docs.python.org/3.9/howto/descriptor.html, 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: https://mail.google.com/mail/u/0/#label/Python+Ideas/FMfcgzGlkPRbJVRkHHtkRPhMCxNsFHpl
    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.

    @AlexWaygood
    Copy link
    Member

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

    @randolf-scholz
    Copy link
    Mannequin Author

    randolf-scholz mannequin commented Oct 12, 2021

    @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 (:

    @akulakov
    Copy link
    Contributor

    I've put up a PR; I'm not sure it's the best way to fix it. I will look more into it and will try to post some details about the PR later today.

    @akulakov
    Copy link
    Contributor

    I missed that this is assigned to Raymond, hope we didn't duplicate any effort (it only took me a short while to do the PR). Apologies..

    @akulakov
    Copy link
    Contributor

    I've looked more into it, the issue is that even before an object can be tested with isinstance(), both inspect.classify_class_attrs and in pydoc, classdoc local function spill() use a getattr() call, which triggers the property.

    So I think my PR is going in the right direction, adding guards in the inspect function and in two spill() functions. If isinstance() can be fixed to detect class properties correctly, these guards can be simplified but they still need to be there, I think.

    @wimglenn
    Copy link
    Mannequin

    wimglenn mannequin commented Oct 28, 2021

    added Graham Dumpleton to nosy list in case he has some useful insight here, I think the PR from bpo-19072 may have been adapted from grahamd's patch originally?

    @grahamd
    Copy link
    Mannequin

    grahamd mannequin commented Oct 28, 2021

    Too much to grok right now.

    There is already a convention for what a decorator wraps. It is __wrapped__.

    wrapper.__wrapped__ = wrapped

    Don't use __func__ as that has other defined meaning in Python related to bound methods and possibly other things as well and overloading on that will break other stuff.

    In part I suspect a lot of the problems here are because things like classmethod and functools style decorators are not proper transparent object proxies, which is the point of what the wrapt package was trying to solve so that accessing stuff on the wrapper behaves as much as possible as if it was done on what was wrapped, including things like isinstance checks.

    @rhettinger
    Copy link
    Contributor

    Also see: https://bugs.python.org/issue42073

    The classmethod pass through broke some existing code and the "fix" for it looks dubious:

                if hasattr(type(self.f), '__get__'):
                    return self.f.__get__(cls, cls)

    @rhettinger
    Copy link
    Contributor

    I propose deprecating classmethod chaining. It has become clear that it doesn't really do what people wanted and can't easily be made to work.

    By even suggesting that some stateful decorators are composable, we've ventured onto thin ice. Wrapping property in a classmethod doesn't produce something that behaves like a real property. Mixing staticmethod and property doesn't work at all. Putting abstractmethod in the mix doesn't work well either. The ecosystem of code inspection tools, like help() in this issue, is wholly unprepared for recognizing and working around these combinations. The latest "fix" for classmethod chaining looks weird and worriesome as well: self.f.__get__(cls, cls).

    Classmethod chaining is relatively new, so we will affect very little code by deprecating it. Any of the possible use cases can be served in other ways like the wrapt package or by explicit code in __getattribute__.

    @AlexWaygood
    Copy link
    Member

    It makes me sad that the stdlib will no longer provide a way to compose classmethods with other descriptors. However, I agree that deprecating classmethod chaining is probably the correct course of action, given the complications this feature has caused, and the backwards-compatibility issues it raises.

    This is probably a conversation for another BPO issue or the python-ideas mailing list, but I hope some consideration can be given in the future as to whether a new classmethod-like feature could possibly be added to functools that would enable this kind of decorator chaining without the same code-breakage concerns that this feature has had.

    @sirosen
    Copy link
    Mannequin

    sirosen mannequin commented Dec 1, 2021

    Probably >90% of the use-cases for chaining classmethod are a read-only class property.
    It's important enough that some tools (e.g. sphinx) even have special-cased support for classmethod(property(...)).

    Perhaps the general case of classmethod(descriptor(...)) should be treated separately from the common case?

    I propose deprecating classmethod chaining. It has become clear that it doesn't really do what people wanted and can't easily be made to work.

    If classmethod(property(f)) is going to be removed, can an implementation of classproperty be considered as a replacement?

    Or perhaps support via the other ordering, property(classmethod(f))?

    @serhiy-storchaka
    Copy link
    Member

    Seems the problem with the deprecation is that Python functions are descriptors, so in case of normal class methods we pass the same path as for properties. Am I right?

    I think that instead of emitting a deprecation warning when read a class property, it should be emitted when the class property is created. We can start with emitting warning if the classmethod() argument is a property instance. Or if it is a data descriptor (because setting and deleting the class attribute are broken anyway).

    @Paebbels
    Copy link

    @randolf-scholz I also did experiments on class properties, but with some differences:

    • no chaining of classmethod and property
    • my usecase includes getters and setters

    My observations are:

    1. The descriptor protocol implementation is broken on classes. As you already described, setters don't work because __set__ is not called.
    2. By writing to a read-only class property, the descriptor is replaced by a value. This allows code to overwrite internal data's protecting code (getters).

    Expectations from user side are:

    • Everything in Python are objects of classes. Thus when objects have properties, classes must be able to have them too, because classes have meta classes.
    • The descriptor protocol is not limited in any way, so it must work on instances and classes with same behavior.
    • Even if the descriptor protocol would be limited to be read-only on classes, then it's shouldn't be allowed to replace a descriptor.

    The following code demonstrates how a descriptor is replaced by an integer:

    from typing import Callable, Any
    
    
    def classproperty(method):
    	"""A decorator adding properties to classes."""
    
    	class ClassPropertyDescriptor:
    		"""A descriptor for class properties.."""
    
    		_name: str
    		_getter: Callable
    		_setter: Callable
    		_deleter: Callable
    
    		def __init__(self, getter: Callable = None, setter: Callable = None, deleter: Callable = None, doc: str = None):
    			self._getter = getter
    			self._setter = setter
    			self._deleter = deleter
    			self._name = ''
    
    			if doc is None and getter is not None:
    				doc = getter.__doc__
    			self.__doc__ = doc
    
    		def __set_name__(self, owner: Any, name: str):
    			self._name = name
    
    		def __get__(self, instance: Any, objtype: type = None):
    			if self._getter is None:
    				raise AttributeError(f'unreadable attribute {self._name}')
    
    			return self #._getter(objtype)
    
    		def __set__(self, instance: Any, value: Any):
    			print(f"__set__: {value}")
    			if self._setter is None:
    				raise AttributeError(f"can't set attribute {self._name}")
    
    			self._setter(instance, value)
    
    		def __delete__(self, instance: Any):
    			if self._deleter is None:
    				raise AttributeError(f"can't delete attribute {self._name}")
    
    			self._deleter(type(instance))
    
    		def getter(self, getter: Callable):
    			prop = type(self)(getter, self._setter, self._deleter, self.__doc__)
    			prop._name = self._name
    
    			return prop
    
    		def setter(self, setter: Callable):
    			prop = type(self)(self._getter, setter, self._deleter, self.__doc__)
    			prop._name = self._name
    
    			print(f"setter: self: {id(self)}; prop:{id(prop)}")
    			return prop
    
    		def deleter(self, deleter: Callable):
    			prop = type(self)(self._getter, self._setter, deleter, self.__doc__)
    			prop._name = self._name
    
    			return prop
    
    	return ClassPropertyDescriptor(method)
    
    
    class TestClass:
    	_member: int = 1
    
    	@classproperty
    	def Member(cls) -> int:
    		"""Class_1.Member"""
    		return cls._member
    
    	@Member.setter
    	def Member(cls, value: int) -> None:
    		print(f"value: {value} {cls} {id(cls)}")
    		cls._member = value
    
    
    def main():
    	print(f"TestClass.Member: {TestClass.Member}   {id(TestClass.Member)}")
    
    	TestClass.Member.__set__(TestClass, 12)
    	TestClass.Member = 13
    	TestClass.Member.__set__(TestClass, 14)
    	print(f"TestClass.Member: {TestClass.Member}   {id(TestClass.Member)}")
    
    
    if __name__ == "__main__":
    	main()

    Output:

    setter: self: 1674684726480; prop:1674684726384
    TestClass.Member: <__main__.classproperty.<locals>.ClassPropertyDescriptor object at 0x00000185EAFCFC70>   1674684726384
    __set__: 12
    value: 12 <class '__main__.TestClass'> 1674666598048
    

    Error message:

    AttributeError: 'int' object has no attribute '__set__'. Did you mean: '__eq__'?
    

    By changing how the descriptor works for __get__, the descriptor itself can be exposed and __set__ can be called manually. In the first case, the value 12 can be set. Then 13 is assigned which replaces the descriptor. Now setting 14 causes an exception because the descriptor was replaced.


    Tested with Python 3.10.

    @zduvall
    Copy link

    zduvall commented Sep 2, 2022

    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?

    @KevinMGranger
    Copy link

    KevinMGranger commented Oct 6, 2022

    It's really disappointing that this behavior would be changed without the standard deprecation cycle. Surely there should be an alternative introduced so people have time to transition?

    In addition, would this break the pattern described as an alternative to abc.abstractclassmethod?

    @MattF-NSIDC
    Copy link

    MattF-NSIDC commented Oct 17, 2022

    Wrapping abc.abstractmethod with classmethod is still documented as a valid approach in 3.11 docs: https://docs.python.org/3.11/library/abc.html#abc.abstractclassmethod , but the classmethod docs indicate this wouldn't work. How should I write code that won't break under 3.11 here?

    Since classmethod docs indicate the wrapping functionality will be disabled in 3.11, it sounds like abc.abstractclassmethod is the way to go -- it may be deprecated as of 3.10, but I don't see any suggestion it will be removed in 3.11, and I don't see an alternative.

    @Prometheus3375
    Copy link

    Prometheus3375 commented Nov 7, 2022

    @KevinMGranger @MattF-NSIDC classmethod docs says that classmethod cannot wrap other descriptors. You can still apply decorators to a function and then wrap it with classmethod. In other words, you can use as many decorators before classmethod as you want while all of them return non-descriptor objects.

    abstractmethod does not return a descriptor, so its chaining with classmethod works in 3.11. In fact, abstractmethod just adds an attribute to passed function and then returns it.

    def abstractmethod(funcobj):
        """A decorator indicating abstract methods.
    
        Requires that the metaclass is ABCMeta or derived from it.  A
        class that has a metaclass derived from ABCMeta cannot be
        instantiated unless all of its abstract methods are overridden.
        The abstract methods can be called using any of the normal
        'super' call mechanisms.  abstractmethod() may be used to declare
        abstract methods for properties and descriptors.
    
        Usage:
    
            class C(metaclass=ABCMeta):
                @abstractmethod
                def my_abstract_method(self, ...):
                    ...
        """
        funcobj.__isabstractmethod__ = True
        return funcobj

    supakeen added a commit to supakeen/osbuild that referenced this issue Nov 28, 2022
    supakeen added a commit to supakeen/osbuild that referenced this issue Nov 30, 2022
    supakeen added a commit to supakeen/osbuild that referenced this issue Dec 1, 2022
    @SimpleArt
    Copy link

    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.

    @SimpleArt
    Copy link

    SimpleArt commented Jan 19, 2023

    One minor detail concerning abstract properties is that removing classmethod chaining no longer allows one to use abstract class properties:

    from abc import ABC, abstractmethod
    
    
    class C(ABC):
    
        @property
        @classmethod
        @abstractmethod
        def expensive_class_property(cls):
            ...

    I don't think anyone's going to miss this though, and if you're going so far then the workaround is to use typing.Protocol instead with expensive_class_property: classproperty from one of the above implementations, along with mixin classes instead of the abstract class.

    Correction:

    You'll need to wrap it with typing.ClassVar[...], and the whole classproperty thing is completely unnecessary if you can get away with not using descriptors like:

    from typing import ClassVar, Protocol
    
    
    class C(Protocol):
        class_property: ClassVar[int]
    
    
    class D(C):
        class_property = 5

    Otherwise you'll need to use typing.ClassVar[typing.Union[Descriptor, Value]]:

    from typing import ClassVar, Protocol, TypeVar, Union
    
    T = TypeVar("T")
    
    
    class Attribute(Protocol[T]):
    
        def __get__(self, instance, owner=None) -> T:
            ...
    
        def __set__(self, instance, value: int) -> None:
            ...
    
    
    class C(Protocol):
        foo: ClassVar[Union[Attribute[int], int]]
    
    
    class C1(C):
        pass
    
    
    class C2(C):
        foo = 3
    
    
    C1()  # Cannot instantiate abstract class "D" with abstract attribute "foo"
    C2()  # Works fine.
    reveal_type(C2.foo)    # builtins.int
    reveal_type(C2().foo)  # builtins.int

    @Paebbels
    Copy link

    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.

    @SimpleArt
    This statement is not fully correct. A read-only property prevents write access. The current implementation of Python doesn't do this, so third parties can "destroy" a descriptor for the class property getter.

    Thus:

    1. Python has a bug
    2. Has a vulnerability problem
    3. Python is inconsistent, because the descriptor protocol is not a generic implementation working on any object. Also types (classes) are objects.

    /cc @skoehler

    @SimpleArt
    Copy link

    @Paebbels To clarify, the inconsistency is assuming "read-only property" translates to "read-only class property" or "read-only descriptor". There's simply no such thing as an actual "read-only" thing, all a read-only property does is define a __set__ that raises an error when accessed on an instance.

    Quite frankly, changing it so that __set__ is called when a descriptor is accessed on the class itself just sounds more trouble than it's worth because it makes meta usage more confusing and introduce more potential bugs. That is to say, there are cases where one may need to apply changes to a class after its creation, like with class decorators.

    @SimpleArt
    Copy link

    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?

    After writing the previous comment, I realized that there's a much simpler alternative that avoids all of the complicated code. Metaclasses.

    class MetaC(type):
    
        @property
        def expensive_class_property(self):
            ...
    
    
    class C(metaclass=MetaC):
        pass

    Unlike properties which are defined within C, you cannot use C().expensive_class_property. This will however allow you to create setters on classes which "actually work" e.g. you can use C.expensive_class_property += 1.

    Just be wary of metaclass conflicts.

    @SimpleArt
    Copy link

    I can't help but wonder, not being very familiar with the C side of things here to know how help is implemented, since when and why did using help(...) ever try to run descriptors at all, and if not if that could be changed.

    Indeed this original problem is not necessarily the fault of classmethod and fails on ANY descriptor:

    class Descriptor:
        """Description of this descriptor."""
    
        def __get__(self, instance, owner=None):
            print("expensive descriptor getter")
    
    
    class Foo:
        foo = Descriptor()

    Running help(Foo) produces the following:

    expensive descriptor getter
    expensive descriptor getter
    expensive descriptor getter
    expensive descriptor getter
    Help ...
    

    I assume the fact that the getter is ran 4 times instead of once is definitely a bug that is getting fixed. I also assume the point of running the getter is so that the help documentation can display the value, as it does at runtime:

    Help on class Foo in module __main__:
    
    class Foo(builtins.object)
     |  Methods defined here:
     |
     |  foo = None
     |  ----------------------------------------------------------------------
     |  Data descriptors defined here:
     |
     |  __dict__
     |      dictionary for instance variables (if defined)
     |
     |  __weakref__
     |      list of weak references to the object (if defined)
    

    However, compare the documentation to that of @property:

    >>> class Bar:
    ...     @property
    ...     def foo():
    ...         """Description of this property."""
    ... 
    >>> help(Bar)
    Help on class Bar in module __main__:
    
    class Bar(builtins.object)
     |  Readonly properties defined here:
     |
     |  foo
     |      Description of this property.
     |
     |  ----------------------------------------------------------------------
     |  Data descriptors defined here:
     |
     |  __dict__
     |      dictionary for instance variables (if defined)
     |
     |  __weakref__
     |      list of weak references to the object (if defined)

    From the documentation specifically, the way I see it is that there are two differences:

    • Properties are correctly placed under Readonly properties defined here:, while descriptors are misplaced under Methods defined here:.
    • Properties (not class properties) display their documentation and do not compute their value, while descriptors compute their value and display only it. Even with return object_with_doc for the descriptor, no documentation is shown.

    Personally, I don't find displaying the value very useful. At the point that it's something like a descriptor, there's a reason it's not just a value. It may not be the same value every time or I may not want to calculate it if I don't have to. My expectation is that using help(Foo) should show up something like this:

    Help on class Foo in module __main__:
    
    class Foo(builtins.object)
     |  Descriptors defined here:
     |
     |  foo = <__main__.Descriptor object at ...>
     |      Description of this descriptor.
     |
     |  ----------------------------------------------------------------------
     |  Data descriptors defined here:
     |
     |  __dict__
     |      dictionary for instance variables (if defined)
     |
     |  __weakref__
     |      list of weak references to the object (if defined)
    

    This approach would also let one display a value if they want by implementing Descriptor.__repr__, so maybe it would show something like this:

    Help on class Foo in module __main__:
    
    class Foo(builtins.object)
     |  Descriptors defined here:
     |
     |  cached_foo = Descriptor(value=3)
     |      Description of this descriptor.
     |
     |  uncached_foo = Descriptor()
     |      Description of this descriptor.
     |
     |  ----------------------------------------------------------------------
     |  Data descriptors defined here:
     |
     |  __dict__
     |      dictionary for instance variables (if defined)
     |
     |  __weakref__
     |      list of weak references to the object (if defined)
    

    @SimpleArt
    Copy link

    Concerning the chaining of @property and @classmethod, I did notice that there is potential to have both in a way which does not cause significant confusion by introducing a cget value which should roughly look like this:

    class property:
    
        def __init__(self, fget=None, fset=None, fdel=None, doc=None, cget=None):
            if doc is None and fget is not None:
                doc = fget.__doc__
            if isinstance(fget, classmethod):
                if cget is not None:
                    raise TypeError("got class getter with classmethod fget")
                cget = fget
                fget = None
            self.fget = fget
            self.fset = fset
            self.fdel = fdel
            self.__doc__ = doc
            self.cget = cget
    
        def __get__(self, instance, owner=None):
            if instance is None:
                return self.cget(owner)
            elif self.cget is None:  # The default behavior is to return the property object.
                return self
            else:
                return self.fget(owner)
    
        def getter(self, fget):
            if isinstance(fget, classmethod):
                return property(self.fget, self.fset, self.fdel, self.__doc__, fget)
            else:
                return property(fget, self.fset, self.fdel, self.__doc__, self.cget)

    Example usage:

    class C:
    
        @property
        @classmethod
        def is_instance(cls) -> bool:
            return False
    
        @is_instance.getter
        def is_instance(self) -> bool:
            return True
    
    
    print(C.is_instance)    # False
    print(C().is_instance)  # True

    Although I can't imagine a case where you'd want to use both, this would definitely let you consistently use each approach individually. The drawback that I see from this however is the fact that people might get confused and think that you can use @property.setter with @classmethod. An error could get raised at this point, but people might try to remove the @classmethod to avoid the error. I think that linters should then complain that the setter should take self instead of cls, really cementing the fact that a writable class property shouldn't work. Take it as you will.

    @sobolevn
    Copy link
    Member

    Now 3.13 is in the works, so this can be changed according to the TODO item in 3.11.rst:

    Pending Removal in Python 3.13
    ------------------------------
    
    * :class:`classmethod` descriptor chaining (:gh:`89519`)
    

    @ProgramRipper
    Copy link

    I notice that this feature will be removed by #110163 in Python 3.13. So are there any workaround? If so, that may be great if it could be added to the Descriptor HowTo Guide, because it has been commonly used.

    @Prometheus3375
    Copy link

    Here is the sample of the implementation. You can also use a metaclass to add a property.

    Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
    Labels
    3.11 bug and security fixes type-bug An unexpected behavior, bug, or error
    Projects
    None yet
    Development

    No branches or pull requests