Skip to content

Include subclasses of standard property classes as property decorators #2735

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

Merged
merged 3 commits into from
May 20, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions ChangeLog
Original file line number Diff line number Diff line change
Expand Up @@ -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?
=============================
Expand Down
31 changes: 19 additions & 12 deletions astroid/bases.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
263 changes: 235 additions & 28 deletions tests/test_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
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")
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):
Expand Down