diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index 83947d3eb..5e066c18e 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -10,7 +10,11 @@ import sys from contextlib import contextmanager from inspect import Parameter from inspect import signature +from typing import Callable +from typing import Generic +from typing import Optional from typing import overload +from typing import TypeVar import attr import py @@ -20,6 +24,13 @@ from _pytest._io.saferepr import saferepr from _pytest.outcomes import fail from _pytest.outcomes import TEST_OUTCOME +if False: # TYPE_CHECKING + from typing import Type # noqa: F401 (used in type string) + + +_T = TypeVar("_T") +_S = TypeVar("_S") + NOTSET = object() @@ -374,3 +385,33 @@ if getattr(attr, "__version_info__", ()) >= (19, 2): ATTRS_EQ_FIELD = "eq" else: ATTRS_EQ_FIELD = "cmp" + + +if sys.version_info >= (3, 8): + # TODO: Remove type ignore on next mypy update. + # https://github.com/python/typeshed/commit/add0b5e930a1db16560fde45a3b710eefc625709 + from functools import cached_property # type: ignore +else: + + class cached_property(Generic[_S, _T]): + __slots__ = ("func", "__doc__") + + def __init__(self, func: Callable[[_S], _T]) -> None: + self.func = func + self.__doc__ = func.__doc__ + + @overload + def __get__( + self, instance: None, owner: Optional["Type[_S]"] = ... + ) -> "cached_property[_S, _T]": + raise NotImplementedError() + + @overload # noqa: F811 + def __get__(self, instance: _S, owner: Optional["Type[_S]"] = ...) -> _T: + raise NotImplementedError() + + def __get__(self, instance, owner=None): # noqa: F811 + if instance is None: + return self + value = instance.__dict__[self.func.__name__] = self.func(instance) + return value diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index eae783f16..737bc11b7 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -15,6 +15,7 @@ import _pytest._code from _pytest._code.code import ExceptionChainRepr from _pytest._code.code import ExceptionInfo from _pytest._code.code import ReprExceptionInfo +from _pytest.compat import cached_property from _pytest.compat import getfslineno from _pytest.fixtures import FixtureDef from _pytest.fixtures import FixtureLookupError @@ -448,17 +449,9 @@ class Item(Node): def reportinfo(self) -> Tuple[str, Optional[int], str]: return self.fspath, None, "" - @property + @cached_property def location(self) -> Tuple[str, Optional[int], str]: - try: - return self._location - except AttributeError: - location = self.reportinfo() - fspath = self.session._node_location_to_relpath(location[0]) - assert type(location[2]) is str - self._location = ( - fspath, - location[1], - location[2], - ) # type: Tuple[str, Optional[int], str] - return self._location + location = self.reportinfo() + fspath = self.session._node_location_to_relpath(location[0]) + assert type(location[2]) is str + return (fspath, location[1], location[2]) diff --git a/testing/test_compat.py b/testing/test_compat.py index 94dac439d..04d818b4e 100644 --- a/testing/test_compat.py +++ b/testing/test_compat.py @@ -4,6 +4,7 @@ from functools import wraps import pytest from _pytest.compat import _PytestWrapper +from _pytest.compat import cached_property from _pytest.compat import get_real_func from _pytest.compat import is_generator from _pytest.compat import safe_getattr @@ -178,3 +179,23 @@ def test_safe_isclass(): assert False, "Should be ignored" assert safe_isclass(CrappyClass()) is False + + +def test_cached_property() -> None: + ncalls = 0 + + class Class: + @cached_property + def prop(self) -> int: + nonlocal ncalls + ncalls += 1 + return ncalls + + c1 = Class() + assert ncalls == 0 + assert c1.prop == 1 + assert c1.prop == 1 + c2 = Class() + assert ncalls == 1 + assert c2.prop == 2 + assert c1.prop == 1