Skip to content

Commit

Permalink
gh-89519: Remove classmethod descriptor chaining, deprecated since 3.…
Browse files Browse the repository at this point in the history
…11 (gh-110163)
  • Loading branch information
rhettinger committed Oct 27, 2023
1 parent ee2d22f commit 7f9a99e
Show file tree
Hide file tree
Showing 8 changed files with 25 additions and 193 deletions.
41 changes: 10 additions & 31 deletions Doc/howto/descriptor.rst
Expand Up @@ -1141,6 +1141,16 @@ roughly equivalent to:
obj = self.__self__
return func(obj, *args, **kwargs)
def __getattribute__(self, name):
"Emulate method_getset() in Objects/classobject.c"
if name == '__doc__':
return self.__func__.__doc__
return object.__getattribute__(self, name)

def __getattr__(self, name):
"Emulate method_getattro() in Objects/classobject.c"
return getattr(self.__func__, name)

To support automatic creation of methods, functions include the
:meth:`__get__` method for binding methods during attribute access. This
means that functions are non-data descriptors that return bound methods
Expand Down Expand Up @@ -1420,10 +1430,6 @@ Using the non-data descriptor protocol, a pure Python version of
def __get__(self, obj, cls=None):
if cls is None:
cls = type(obj)
if hasattr(type(self.f), '__get__'):
# This code path was added in Python 3.9
# and was deprecated in Python 3.11.
return self.f.__get__(cls, cls)
return MethodType(self.f, cls)

.. testcode::
Expand All @@ -1436,11 +1442,6 @@ Using the non-data descriptor protocol, a pure Python version of
"Class method that returns a tuple"
return (cls.__name__, x, y)

@ClassMethod
@property
def __doc__(cls):
return f'A doc for {cls.__name__!r}'


.. doctest::
:hide:
Expand All @@ -1453,10 +1454,6 @@ Using the non-data descriptor protocol, a pure Python version of
>>> t.cm(11, 22)
('T', 11, 22)

# Check the alternate path for chained descriptors
>>> T.__doc__
"A doc for 'T'"

# Verify that T uses our emulation
>>> type(vars(T)['cm']).__name__
'ClassMethod'
Expand All @@ -1481,24 +1478,6 @@ Using the non-data descriptor protocol, a pure Python version of
('T', 11, 22)


The code path for ``hasattr(type(self.f), '__get__')`` was added in
Python 3.9 and makes it possible for :func:`classmethod` to support
chained decorators. For example, a classmethod and property could be
chained together. In Python 3.11, this functionality was deprecated.

.. testcode::

class G:
@classmethod
@property
def __doc__(cls):
return f'A doc for {cls.__name__!r}'

.. doctest::

>>> G.__doc__
"A doc for 'G'"

The :func:`functools.update_wrapper` call in ``ClassMethod`` adds a
``__wrapped__`` attribute that refers to the underlying function. Also
it carries forward the attributes necessary to make the wrapper look
Expand Down
2 changes: 1 addition & 1 deletion Doc/library/functions.rst
Expand Up @@ -285,7 +285,7 @@ are always available. They are listed here in alphabetical order.
``__name__``, ``__qualname__``, ``__doc__`` and ``__annotations__``) and
have a new ``__wrapped__`` attribute.

.. versionchanged:: 3.11
.. deprecated-removed:: 3.11 3.13
Class methods can no longer wrap other :term:`descriptors <descriptor>` such as
:func:`property`.

Expand Down
8 changes: 8 additions & 0 deletions Doc/whatsnew/3.13.rst
Expand Up @@ -1228,6 +1228,14 @@ Deprecated
Removed
-------

* Removed chained :class:`classmethod` descriptors (introduced in
:issue:`19072`). This can no longer be used to wrap other descriptors
such as :class:`property`. The core design of this feature was flawed
and caused a number of downstream problems. To "pass-through" a
:class:`classmethod`, consider using the :attr:`!__wrapped__`
attribute that was added in Python 3.10. (Contributed by Raymond
Hettinger in :gh:`89519`.)

* Remove many APIs (functions, macros, variables) with names prefixed by
``_Py`` or ``_PY`` (considered as private API). If your project is affected
by one of these removals and you consider that the removed API should remain
Expand Down
123 changes: 0 additions & 123 deletions Lib/test/test_decorators.py
Expand Up @@ -291,44 +291,6 @@ def bar(): return 42
self.assertEqual(bar(), 42)
self.assertEqual(actions, expected_actions)

def test_wrapped_descriptor_inside_classmethod(self):
class BoundWrapper:
def __init__(self, wrapped):
self.__wrapped__ = wrapped

def __call__(self, *args, **kwargs):
return self.__wrapped__(*args, **kwargs)

class Wrapper:
def __init__(self, wrapped):
self.__wrapped__ = wrapped

def __get__(self, instance, owner):
bound_function = self.__wrapped__.__get__(instance, owner)
return BoundWrapper(bound_function)

def decorator(wrapped):
return Wrapper(wrapped)

class Class:
@decorator
@classmethod
def inner(cls):
# This should already work.
return 'spam'

@classmethod
@decorator
def outer(cls):
# Raised TypeError with a message saying that the 'Wrapper'
# object is not callable.
return 'eggs'

self.assertEqual(Class.inner(), 'spam')
self.assertEqual(Class.outer(), 'eggs')
self.assertEqual(Class().inner(), 'spam')
self.assertEqual(Class().outer(), 'eggs')

def test_bound_function_inside_classmethod(self):
class A:
def foo(self, cls):
Expand All @@ -339,91 +301,6 @@ class B:

self.assertEqual(B.bar(), 'spam')

def test_wrapped_classmethod_inside_classmethod(self):
class MyClassMethod1:
def __init__(self, func):
self.func = func

def __call__(self, cls):
if hasattr(self.func, '__get__'):
return self.func.__get__(cls, cls)()
return self.func(cls)

def __get__(self, instance, owner=None):
if owner is None:
owner = type(instance)
return MethodType(self, owner)

class MyClassMethod2:
def __init__(self, func):
if isinstance(func, classmethod):
func = func.__func__
self.func = func

def __call__(self, cls):
return self.func(cls)

def __get__(self, instance, owner=None):
if owner is None:
owner = type(instance)
return MethodType(self, owner)

for myclassmethod in [MyClassMethod1, MyClassMethod2]:
class A:
@myclassmethod
def f1(cls):
return cls

@classmethod
@myclassmethod
def f2(cls):
return cls

@myclassmethod
@classmethod
def f3(cls):
return cls

@classmethod
@classmethod
def f4(cls):
return cls

@myclassmethod
@MyClassMethod1
def f5(cls):
return cls

@myclassmethod
@MyClassMethod2
def f6(cls):
return cls

self.assertIs(A.f1(), A)
self.assertIs(A.f2(), A)
self.assertIs(A.f3(), A)
self.assertIs(A.f4(), A)
self.assertIs(A.f5(), A)
self.assertIs(A.f6(), A)
a = A()
self.assertIs(a.f1(), A)
self.assertIs(a.f2(), A)
self.assertIs(a.f3(), A)
self.assertIs(a.f4(), A)
self.assertIs(a.f5(), A)
self.assertIs(a.f6(), A)

def f(cls):
return cls

self.assertIs(myclassmethod(f).__get__(a)(), A)
self.assertIs(myclassmethod(f).__get__(a, A)(), A)
self.assertIs(myclassmethod(f).__get__(A, A)(), A)
self.assertIs(myclassmethod(f).__get__(A)(), type(A))
self.assertIs(classmethod(f).__get__(a)(), A)
self.assertIs(classmethod(f).__get__(a, A)(), A)
self.assertIs(classmethod(f).__get__(A, A)(), A)
self.assertIs(classmethod(f).__get__(A)(), type(A))

class TestClassDecorators(unittest.TestCase):

Expand Down
13 changes: 0 additions & 13 deletions Lib/test/test_doctest.py
Expand Up @@ -102,15 +102,6 @@ def a_classmethod(cls, v):

a_class_attribute = 42

@classmethod
@property
def a_classmethod_property(cls):
"""
>>> print(SampleClass.a_classmethod_property)
42
"""
return cls.a_class_attribute

@functools.cached_property
def a_cached_property(self):
"""
Expand Down Expand Up @@ -525,7 +516,6 @@ def basics(): r"""
1 SampleClass.__init__
1 SampleClass.a_cached_property
2 SampleClass.a_classmethod
1 SampleClass.a_classmethod_property
1 SampleClass.a_property
1 SampleClass.a_staticmethod
1 SampleClass.double
Expand Down Expand Up @@ -582,7 +572,6 @@ def basics(): r"""
1 some_module.SampleClass.__init__
1 some_module.SampleClass.a_cached_property
2 some_module.SampleClass.a_classmethod
1 some_module.SampleClass.a_classmethod_property
1 some_module.SampleClass.a_property
1 some_module.SampleClass.a_staticmethod
1 some_module.SampleClass.double
Expand Down Expand Up @@ -625,7 +614,6 @@ def basics(): r"""
1 SampleClass.__init__
1 SampleClass.a_cached_property
2 SampleClass.a_classmethod
1 SampleClass.a_classmethod_property
1 SampleClass.a_property
1 SampleClass.a_staticmethod
1 SampleClass.double
Expand All @@ -647,7 +635,6 @@ def basics(): r"""
1 SampleClass.__init__
1 SampleClass.a_cached_property
2 SampleClass.a_classmethod
1 SampleClass.a_classmethod_property
1 SampleClass.a_property
1 SampleClass.a_staticmethod
1 SampleClass.double
Expand Down
21 changes: 0 additions & 21 deletions Lib/test/test_property.py
Expand Up @@ -183,27 +183,6 @@ def test_refleaks_in___init__(self):
fake_prop.__init__('fget', 'fset', 'fdel', 'doc')
self.assertAlmostEqual(gettotalrefcount() - refs_before, 0, delta=10)

@unittest.skipIf(sys.flags.optimize >= 2,
"Docstrings are omitted with -O2 and above")
def test_class_property(self):
class A:
@classmethod
@property
def __doc__(cls):
return 'A doc for %r' % cls.__name__
self.assertEqual(A.__doc__, "A doc for 'A'")

@unittest.skipIf(sys.flags.optimize >= 2,
"Docstrings are omitted with -O2 and above")
def test_class_property_override(self):
class A:
"""First"""
@classmethod
@property
def __doc__(cls):
return 'Second'
self.assertEqual(A.__doc__, 'Second')

def test_property_set_name_incorrect_args(self):
p = property()

Expand Down
@@ -0,0 +1,6 @@
Removed chained :class:`classmethod` descriptors (introduced in
:issue:`19072`). This can no longer be used to wrap other descriptors such
as :class:`property`. The core design of this feature was flawed and caused
a number of downstream problems. To "pass-through" a :class:`classmethod`,
consider using the :attr:`!__wrapped__` attribute that was added in Python
3.10.
4 changes: 0 additions & 4 deletions Objects/funcobject.c
Expand Up @@ -1110,10 +1110,6 @@ cm_descr_get(PyObject *self, PyObject *obj, PyObject *type)
}
if (type == NULL)
type = (PyObject *)(Py_TYPE(obj));
if (Py_TYPE(cm->cm_callable)->tp_descr_get != NULL) {
return Py_TYPE(cm->cm_callable)->tp_descr_get(cm->cm_callable, type,
type);
}
return PyMethod_New(cm->cm_callable, type);
}

Expand Down

0 comments on commit 7f9a99e

Please sign in to comment.