Repository: pylint-dev/astroid

Issue:
Replace `cachedproperty` with `functools.cached_property` (>= 3.8)
I thought about this PR recently again. Typing `cachedproperty` might not work, but it can be replaced with `functools.cached_property`. We only need to `sys` guard it for `< 3.8`. This should work
```py
if sys.version_info >= (3, 8):
    from functools import cached_property
else:
    from astroid.decorators import cachedproperty as cached_property
```

Additionally, the deprecation warning can be limited to `>= 3.8`.

_Originally posted by @cdce8p in https://github.com/PyCQA/astroid/issues/1243#issuecomment-1052834322_


Discussion:
@cdce8p Just thinking out loud: can we also use a type guard to define `cached_property`? Would `mypy` pick up on that? 
> @cdce8p Just thinking out loud: can we also use a type guard to define `cached_property`? Would `mypy` pick up on that?

Not completely sure what you want to do with that.

On other thing, I just saw that we don't set the `python-version` for mypy. If we do that, we probably need to do some more workarounds to tell mypy `cachedproperty` is equal to `cached_property`. Adding `TYPE_CHECKING` could work
```py
if sys.version_info >= (3, 8) or TYPE_CHECKING:
    from functools import cached_property
else:
    from astroid.decorators import cachedproperty as cached_property
```

Patch:
diff --git a/astroid/decorators.py b/astroid/decorators.py
--- a/astroid/decorators.py
+++ b/astroid/decorators.py
@@ -52,6 +52,8 @@ def cached(func, instance, args, kwargs):
         return result
 
 
+# TODO: Remove when support for 3.7 is dropped
+# TODO: astroid 3.0 -> move class behind sys.version_info < (3, 8) guard
 class cachedproperty:
     """Provides a cached property equivalent to the stacking of
     @cached and @property, but more efficient.
@@ -70,6 +72,12 @@ class cachedproperty:
     __slots__ = ("wrapped",)
 
     def __init__(self, wrapped):
+        if sys.version_info >= (3, 8):
+            warnings.warn(
+                "cachedproperty has been deprecated and will be removed in astroid 3.0 for Python 3.8+. "
+                "Use functools.cached_property instead.",
+                DeprecationWarning,
+            )
         try:
             wrapped.__name__
         except AttributeError as exc:
diff --git a/astroid/mixins.py b/astroid/mixins.py
--- a/astroid/mixins.py
+++ b/astroid/mixins.py
@@ -18,6 +18,7 @@
 """This module contains some mixins for the different nodes.
 """
 import itertools
+import sys
 from typing import TYPE_CHECKING, Optional
 
 from astroid import decorators
@@ -26,11 +27,16 @@
 if TYPE_CHECKING:
     from astroid import nodes
 
+if sys.version_info >= (3, 8) or TYPE_CHECKING:
+    from functools import cached_property
+else:
+    from astroid.decorators import cachedproperty as cached_property
+
 
 class BlockRangeMixIn:
     """override block range"""
 
-    @decorators.cachedproperty
+    @cached_property
     def blockstart_tolineno(self):
         return self.lineno
 
@@ -135,7 +141,7 @@ class MultiLineBlockMixin:
     Assign nodes, etc.
     """
 
-    @decorators.cachedproperty
+    @cached_property
     def _multi_line_blocks(self):
         return tuple(getattr(self, field) for field in self._multi_line_block_fields)
 
diff --git a/astroid/nodes/node_classes.py b/astroid/nodes/node_classes.py
--- a/astroid/nodes/node_classes.py
+++ b/astroid/nodes/node_classes.py
@@ -80,6 +80,12 @@
     from astroid import nodes
     from astroid.nodes import LocalsDictNodeNG
 
+if sys.version_info >= (3, 8) or TYPE_CHECKING:
+    # pylint: disable-next=ungrouped-imports
+    from functools import cached_property
+else:
+    from astroid.decorators import cachedproperty as cached_property
+
 
 def _is_const(value):
     return isinstance(value, tuple(CONST_CLS))
@@ -824,7 +830,7 @@ def _infer_name(self, frame, name):
             return name
         return None
 
-    @decorators.cachedproperty
+    @cached_property
     def fromlineno(self):
         """The first line that this node appears on in the source code.
 
@@ -833,7 +839,7 @@ def fromlineno(self):
         lineno = super().fromlineno
         return max(lineno, self.parent.fromlineno or 0)
 
-    @decorators.cachedproperty
+    @cached_property
     def arguments(self):
         """Get all the arguments for this node, including positional only and positional and keyword"""
         return list(itertools.chain((self.posonlyargs or ()), self.args or ()))
@@ -2601,7 +2607,7 @@ def postinit(
         if body is not None:
             self.body = body
 
-    @decorators.cachedproperty
+    @cached_property
     def blockstart_tolineno(self):
         """The line on which the beginning of this block ends.
 
@@ -2734,7 +2740,7 @@ def postinit(
     See astroid/protocols.py for actual implementation.
     """
 
-    @decorators.cachedproperty
+    @cached_property
     def blockstart_tolineno(self):
         """The line on which the beginning of this block ends.
 
@@ -3093,7 +3099,7 @@ def postinit(
         if isinstance(self.parent, If) and self in self.parent.orelse:
             self.is_orelse = True
 
-    @decorators.cachedproperty
+    @cached_property
     def blockstart_tolineno(self):
         """The line on which the beginning of this block ends.
 
@@ -3762,7 +3768,7 @@ def _wrap_attribute(self, attr):
             return const
         return attr
 
-    @decorators.cachedproperty
+    @cached_property
     def _proxied(self):
         builtins = AstroidManager().builtins_module
         return builtins.getattr("slice")[0]
@@ -4384,7 +4390,7 @@ def postinit(
         if orelse is not None:
             self.orelse = orelse
 
-    @decorators.cachedproperty
+    @cached_property
     def blockstart_tolineno(self):
         """The line on which the beginning of this block ends.
 
@@ -4500,7 +4506,7 @@ def postinit(
     See astroid/protocols.py for actual implementation.
     """
 
-    @decorators.cachedproperty
+    @cached_property
     def blockstart_tolineno(self):
         """The line on which the beginning of this block ends.
 
diff --git a/astroid/nodes/node_ng.py b/astroid/nodes/node_ng.py
--- a/astroid/nodes/node_ng.py
+++ b/astroid/nodes/node_ng.py
@@ -38,6 +38,12 @@
 else:
     from typing_extensions import Literal
 
+if sys.version_info >= (3, 8) or TYPE_CHECKING:
+    # pylint: disable-next=ungrouped-imports
+    from functools import cached_property
+else:
+    # pylint: disable-next=ungrouped-imports
+    from astroid.decorators import cachedproperty as cached_property
 
 # Types for 'NodeNG.nodes_of_class()'
 T_Nodes = TypeVar("T_Nodes", bound="NodeNG")
@@ -435,14 +441,14 @@ def previous_sibling(self):
     # these are lazy because they're relatively expensive to compute for every
     # single node, and they rarely get looked at
 
-    @decorators.cachedproperty
+    @cached_property
     def fromlineno(self) -> Optional[int]:
         """The first line that this node appears on in the source code."""
         if self.lineno is None:
             return self._fixed_source_line()
         return self.lineno
 
-    @decorators.cachedproperty
+    @cached_property
     def tolineno(self) -> Optional[int]:
         """The last line that this node appears on in the source code."""
         if self.end_lineno is not None:
diff --git a/astroid/nodes/scoped_nodes/scoped_nodes.py b/astroid/nodes/scoped_nodes/scoped_nodes.py
--- a/astroid/nodes/scoped_nodes/scoped_nodes.py
+++ b/astroid/nodes/scoped_nodes/scoped_nodes.py
@@ -52,7 +52,7 @@
 import sys
 import typing
 import warnings
-from typing import Dict, List, Optional, Set, TypeVar, Union, overload
+from typing import TYPE_CHECKING, Dict, List, Optional, Set, TypeVar, Union, overload
 
 from astroid import bases
 from astroid import decorators as decorators_mod
@@ -93,6 +93,12 @@
 else:
     from typing_extensions import Literal
 
+if sys.version_info >= (3, 8) or TYPE_CHECKING:
+    from functools import cached_property
+else:
+    # pylint: disable-next=ungrouped-imports
+    from astroid.decorators import cachedproperty as cached_property
+
 
 ITER_METHODS = ("__iter__", "__getitem__")
 EXCEPTION_BASE_CLASSES = frozenset({"Exception", "BaseException"})
@@ -1611,7 +1617,7 @@ def postinit(
         self.position = position
         self.doc_node = doc_node
 
-    @decorators_mod.cachedproperty
+    @cached_property
     def extra_decorators(self) -> List[node_classes.Call]:
         """The extra decorators that this function can have.
 
@@ -1652,7 +1658,7 @@ def extra_decorators(self) -> List[node_classes.Call]:
                             decorators.append(assign.value)
         return decorators
 
-    @decorators_mod.cachedproperty
+    @cached_property
     def type(
         self,
     ):  # pylint: disable=invalid-overridden-method,too-many-return-statements
@@ -1726,7 +1732,7 @@ def type(
                 pass
         return type_name
 
-    @decorators_mod.cachedproperty
+    @cached_property
     def fromlineno(self) -> Optional[int]:
         """The first line that this node appears on in the source code."""
         # lineno is the line number of the first decorator, we want the def
@@ -1739,7 +1745,7 @@ def fromlineno(self) -> Optional[int]:
 
         return lineno
 
-    @decorators_mod.cachedproperty
+    @cached_property
     def blockstart_tolineno(self):
         """The line on which the beginning of this block ends.
 
@@ -2337,7 +2343,7 @@ def _newstyle_impl(self, context=None):
         doc=("Whether this is a new style class or not\n\n" ":type: bool or None"),
     )
 
-    @decorators_mod.cachedproperty
+    @cached_property
     def fromlineno(self) -> Optional[int]:
         """The first line that this node appears on in the source code."""
         if not PY38_PLUS:
@@ -2352,7 +2358,7 @@ def fromlineno(self) -> Optional[int]:
             return lineno
         return super().fromlineno
 
-    @decorators_mod.cachedproperty
+    @cached_property
     def blockstart_tolineno(self):
         """The line on which the beginning of this block ends.
 
diff --git a/astroid/objects.py b/astroid/objects.py
--- a/astroid/objects.py
+++ b/astroid/objects.py
@@ -22,8 +22,10 @@
     Call(func=Name('frozenset'), args=Tuple(...))
 """
 
+import sys
+from typing import TYPE_CHECKING
 
-from astroid import bases, decorators, util
+from astroid import bases, util
 from astroid.exceptions import (
     AttributeInferenceError,
     InferenceError,
@@ -35,6 +37,11 @@
 
 objectmodel = util.lazy_import("interpreter.objectmodel")
 
+if sys.version_info >= (3, 8) or TYPE_CHECKING:
+    from functools import cached_property
+else:
+    from astroid.decorators import cachedproperty as cached_property
+
 
 class FrozenSet(node_classes.BaseContainer):
     """class representing a FrozenSet composite node"""
@@ -45,7 +52,7 @@ def pytype(self):
     def _infer(self, context=None):
         yield self
 
-    @decorators.cachedproperty
+    @cached_property
     def _proxied(self):  # pylint: disable=method-hidden
         ast_builtins = AstroidManager().builtins_module
         return ast_builtins.getattr("frozenset")[0]
@@ -114,7 +121,7 @@ def super_mro(self):
         index = mro.index(self.mro_pointer)
         return mro[index + 1 :]
 
-    @decorators.cachedproperty
+    @cached_property
     def _proxied(self):
         ast_builtins = AstroidManager().builtins_module
         return ast_builtins.getattr("super")[0]
@@ -218,7 +225,7 @@ class ExceptionInstance(bases.Instance):
     the case of .args.
     """
 
-    @decorators.cachedproperty
+    @cached_property
     def special_attributes(self):
         qname = self.qname()
         instance = objectmodel.BUILTIN_EXCEPTIONS.get(


Test patch:
diff --git a/tests/unittest_decorators.py b/tests/unittest_decorators.py
--- a/tests/unittest_decorators.py
+++ b/tests/unittest_decorators.py
@@ -1,7 +1,8 @@
 import pytest
 from _pytest.recwarn import WarningsRecorder
 
-from astroid.decorators import deprecate_default_argument_values
+from astroid.const import PY38_PLUS
+from astroid.decorators import cachedproperty, deprecate_default_argument_values
 
 
 class SomeClass:
@@ -97,3 +98,18 @@ def test_deprecated_default_argument_values_ok(recwarn: WarningsRecorder) -> Non
         instance = SomeClass(name="some_name")
         instance.func(name="", var=42)
         assert len(recwarn) == 0
+
+
+@pytest.mark.skipif(not PY38_PLUS, reason="Requires Python 3.8 or higher")
+def test_deprecation_warning_on_cachedproperty() -> None:
+    """Check the DeprecationWarning on cachedproperty."""
+
+    with pytest.warns(DeprecationWarning) as records:
+
+        class MyClass:  # pylint: disable=unused-variable
+            @cachedproperty
+            def my_property(self):
+                return 1
+
+        assert len(records) == 1
+        assert "functools.cached_property" in records[0].message.args[0]