diff --git a/ChangeLog b/ChangeLog index e4eb63d65..59c914fa0 100644 --- a/ChangeLog +++ b/ChangeLog @@ -32,6 +32,11 @@ Release date: TBA Closes #2513 +* Include subclasses of standard property classes as `property` decorators + + Closes #10377 + +* Modify ``astroid.bases`` and ``tests.test_nodes`` to reflect that `enum.property` was added in Python 3.11, not 3.10 What's New in astroid 3.3.11? ============================= diff --git a/astroid/bases.py b/astroid/bases.py index d91a4c9fa..a029da6d4 100644 --- a/astroid/bases.py +++ b/astroid/bases.py @@ -13,7 +13,7 @@ from typing import TYPE_CHECKING, Any, Literal from astroid import decorators, nodes -from astroid.const import PY310_PLUS +from astroid.const import PY311_PLUS from astroid.context import ( CallContext, InferenceContext, @@ -38,8 +38,9 @@ from astroid.constraint import Constraint -PROPERTIES = {"builtins.property", "abc.abstractproperty"} -if PY310_PLUS: +PROPERTIES = {"builtins.property", "abc.abstractproperty", "functools.cached_property"} +# enum.property was added in Python 3.11 +if PY311_PLUS: PROPERTIES.add("enum.property") # List of possible property names. We use this list in order @@ -79,24 +80,30 @@ def _is_property( if any(name in stripped for name in POSSIBLE_PROPERTIES): return True - # Lookup for subclasses of *property* if not meth.decorators: return False + # Lookup for subclasses of *property* for decorator in meth.decorators.nodes or (): inferred = safe_infer(decorator, context=context) if inferred is None or isinstance(inferred, UninferableBase): continue if isinstance(inferred, nodes.ClassDef): + # Check for a class which inherits from a standard property type + if any(inferred.is_subtype_of(pclass) for pclass in PROPERTIES): + return True for base_class in inferred.bases: - if not isinstance(base_class, nodes.Name): + # Check for a class which inherits from functools.cached_property + # and includes a subscripted type annotation + if isinstance(base_class, nodes.Subscript): + value = safe_infer(base_class.value, context=context) + if not isinstance(value, nodes.ClassDef): + continue + if value.name != "cached_property": + continue + module, _ = value.lookup(value.name) + if isinstance(module, nodes.Module) and module.name == "functools": + return True continue - module, _ = base_class.lookup(base_class.name) - if ( - isinstance(module, nodes.Module) - and module.name == "builtins" - and base_class.name == "property" - ): - return True return False diff --git a/tests/test_nodes.py b/tests/test_nodes.py index ffa511581..2a8652364 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -28,7 +28,7 @@ transforms, util, ) -from astroid.const import IS_PYPY, PY310_PLUS, PY312_PLUS, Context +from astroid.const import IS_PYPY, PY310_PLUS, PY311_PLUS, PY312_PLUS, Context from astroid.context import InferenceContext from astroid.exceptions import ( AstroidBuildingError, @@ -924,67 +924,274 @@ def test(self): class BoundMethodNodeTest(unittest.TestCase): - def test_is_property(self) -> None: + def _is_property(self, ast: nodes.Module, prop: str) -> None: + inferred = next(ast[prop].infer()) + self.assertIsInstance(inferred, nodes.Const, prop) + self.assertEqual(inferred.value, 42, prop) + + def test_is_standard_property(self) -> None: + # Test to make sure the Python-provided property decorators + # are properly interpreted as properties ast = builder.parse( """ import abc + import functools - def cached_property(): - # Not a real decorator, but we don't care - pass - def reify(): - # Same as cached_property - pass - def lazy_property(): - pass - def lazyproperty(): - pass - def lazy(): pass class A(object): @property - def builtin_property(self): - return 42 + def builtin_property(self): return 42 + @abc.abstractproperty - def abc_property(self): - return 42 + def abc_property(self): return 42 + + @property + @abc.abstractmethod + def abstractmethod_property(self): return 42 + + @functools.cached_property + def functools_property(self): return 42 + + cls = A() + builtin_p = cls.builtin_property + abc_p = cls.abc_property + abstractmethod_p = cls.abstractmethod_property + functools_p = cls.functools_property + """ + ) + for prop in ( + "builtin_p", + "abc_p", + "abstractmethod_p", + "functools_p", + ): + self._is_property(ast, prop) + + @pytest.mark.skipif(not PY311_PLUS, reason="Uses enum.property introduced in 3.11") + def test_is_standard_property_py311(self) -> None: + # Test to make sure the Python-provided property decorators + # are properly interpreted as properties + ast = builder.parse( + """ + import enum + + class A(object): + @enum.property + def enum_property(self): return 42 + + cls = A() + enum_p = cls.enum_property + """ + ) + self._is_property(ast, "enum_p") + + def test_is_possible_property(self) -> None: + # Test to make sure that decorators with POSSIBLE_PROPERTIES names + # are properly interpreted as properties + ast = builder.parse( + """ + # Not real decorators, but we don't care + def cachedproperty(): pass + def cached_property(): pass + def reify(): pass + def lazy_property(): pass + def lazyproperty(): pass + def lazy(): pass + def lazyattribute(): pass + def lazy_attribute(): pass + def LazyProperty(): pass + def DynamicClassAttribute(): pass + + class A(object): + @cachedproperty + def cachedproperty(self): return 42 + @cached_property def cached_property(self): return 42 + @reify def reified(self): return 42 + @lazy_property def lazy_prop(self): return 42 + @lazyproperty def lazyprop(self): return 42 - def not_prop(self): pass + @lazy def decorated_with_lazy(self): return 42 + @lazyattribute + def lazyattribute(self): return 42 + + @lazy_attribute + def lazy_attribute(self): return 42 + + @LazyProperty + def LazyProperty(self): return 42 + + @DynamicClassAttribute + def DynamicClassAttribute(self): return 42 + cls = A() - builtin_property = cls.builtin_property - abc_property = cls.abc_property + cachedp = cls.cachedproperty cached_p = cls.cached_property reified = cls.reified - not_prop = cls.not_prop lazy_prop = cls.lazy_prop lazyprop = cls.lazyprop decorated_with_lazy = cls.decorated_with_lazy + lazya = cls.lazyattribute + lazy_a = cls.lazy_attribute + LazyP = cls.LazyProperty + DynamicClassA = cls.DynamicClassAttribute """ ) for prop in ( - "builtin_property", - "abc_property", + "cachedp", "cached_p", "reified", "lazy_prop", "lazyprop", "decorated_with_lazy", + "lazya", + "lazy_a", + "LazyP", + "DynamicClassA", ): - inferred = next(ast[prop].infer()) - self.assertIsInstance(inferred, nodes.Const, prop) - self.assertEqual(inferred.value, 42, prop) + self._is_property(ast, prop) + + def test_is_standard_property_subclass(self) -> None: + # Test to make sure that subclasses of the Python-provided property decorators + # are properly interpreted as properties + ast = builder.parse( + """ + import abc + import functools + from typing import Generic, TypeVar + + class user_property(property): pass + class user_abc_property(abc.abstractproperty): pass + class user_functools_property(functools.cached_property): pass + T = TypeVar('T') + class annotated_user_functools_property(functools.cached_property[T], Generic[T]): pass + + class A(object): + @user_property + def user_property(self): return 42 - inferred = next(ast["not_prop"].infer()) - self.assertIsInstance(inferred, bases.BoundMethod) + @user_abc_property + def user_abc_property(self): return 42 + + @user_functools_property + def user_functools_property(self): return 42 + + @annotated_user_functools_property + def annotated_user_functools_property(self): return 42 + + cls = A() + user_p = cls.user_property + user_abc_p = cls.user_abc_property + user_functools_p = cls.user_functools_property + annotated_user_functools_p = cls.annotated_user_functools_property + """ + ) + for prop in ( + "user_p", + "user_abc_p", + "user_functools_p", + "annotated_user_functools_p", + ): + self._is_property(ast, prop) + + @pytest.mark.skipif(not PY311_PLUS, reason="Uses enum.property introduced in 3.11") + def test_is_standard_property_subclass_py311(self) -> None: + # Test to make sure that subclasses of the Python-provided property decorators + # are properly interpreted as properties + ast = builder.parse( + """ + import enum + + class user_enum_property(enum.property): pass + + class A(object): + @user_enum_property + def user_enum_property(self): return 42 + + cls = A() + user_enum_p = cls.user_enum_property + """ + ) + self._is_property(ast, "user_enum_p") + + @pytest.mark.skipif(not PY312_PLUS, reason="Uses 3.12 generic typing syntax") + def test_is_standard_property_subclass_py312(self) -> None: + ast = builder.parse( + """ + from functools import cached_property + + class annotated_user_cached_property[T](cached_property[T]): + pass + + class A(object): + @annotated_user_cached_property + def annotated_user_cached_property(self): return 42 + + cls = A() + annotated_user_cached_p = cls.annotated_user_cached_property + """ + ) + self._is_property(ast, "annotated_user_cached_p") + + def test_is_not_property(self) -> None: + ast = builder.parse( + """ + from collections.abc import Iterator + + class cached_property: pass + # If a decorator is named cached_property, we will accept it as a property, + # even if it isn't functools.cached_property. + # However, do not extend the same leniency to superclasses of decorators. + class wrong_superclass_type1(cached_property): pass + class wrong_superclass_type2(cached_property[float]): pass + cachedproperty = { float: int } + class wrong_superclass_type3(cachedproperty[float]): pass + class wrong_superclass_type4(Iterator[float]): pass + + class A(object): + def no_decorator(self): return 42 + + def property(self): return 42 + + @wrong_superclass_type1 + def wrong_superclass_type1(self): return 42 + + @wrong_superclass_type2 + def wrong_superclass_type2(self): return 42 + + @wrong_superclass_type3 + def wrong_superclass_type3(self): return 42 + + @wrong_superclass_type4 + def wrong_superclass_type4(self): return 42 + + cls = A() + no_decorator = cls.no_decorator + not_prop = cls.property + bad_superclass1 = cls.wrong_superclass_type1 + bad_superclass2 = cls.wrong_superclass_type2 + bad_superclass3 = cls.wrong_superclass_type3 + bad_superclass4 = cls.wrong_superclass_type4 + """ + ) + for prop in ( + "no_decorator", + "not_prop", + "bad_superclass1", + "bad_superclass2", + "bad_superclass3", + "bad_superclass4", + ): + inferred = next(ast[prop].infer()) + self.assertIsInstance(inferred, bases.BoundMethod) class AliasesTest(unittest.TestCase):