blob: 6ac78758e11eccfce43dd7272a128a8f1fe91704 [file] [log] [blame]
#!/usr/bin/env python3
#
# Copyright 2023 The Fuchsia Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import typing
from threading import RLock
from typing import Callable, Generic, TypeVar
S = TypeVar("S")
T = TypeVar("T")
O = TypeVar("O")
_NOT_FOUND = object()
class cached_property(Generic[T]):
"""A property whose value is computed then cached; deleter can be overridden.
Similar to functools.cached_property(), with the addition of deleter function that
can be overridden to provide custom clean up. The deleter function doesn't throw an
AttributeError if the value doesn't already exist.
Useful for properties that are tied to the lifetime of a device and need to be
recomputed upon reboot of said device.
Example:
```
class LinuxDevice:
@cached_property
def ssh(self) -> SSH:
return SSH(self.ip)
@ssh.deleter
def ssh(self, ssh: SSH) -> None:
ssh.terminate_connections()
```
"""
def __init__(
self, func: Callable[[S], T], deleter: Callable[[S, T], None] | None = None
) -> None:
self.func = func
self._deleter = deleter
self.name: str | None = None
self.__doc__ = func.__doc__
self.lock = RLock()
def __set_name__(self, owner: O, name: str) -> None:
if self.name is None:
self.name = name
elif name != self.name:
raise TypeError(
"Cannot assign the same cached_property to two different names "
f"({self.name!r} and {name!r})."
)
def _cache(self, instance: S) -> dict[str, object]:
if self.name is None:
raise TypeError(
"Cannot use cached_property instance without calling __set_name__ on it."
)
try:
return instance.__dict__
except (
AttributeError
): # not all objects have __dict__ (e.g. class defines slots)
msg = (
f"No '__dict__' attribute on {type(instance).__name__!r} "
f"instance to cache {self.name!r} property."
)
raise TypeError(msg) from None
def __get__(self, instance: S, owner: O | None = None) -> T:
cache = self._cache(instance)
assert self.name is not None
val = cache.get(self.name, _NOT_FOUND)
if val is _NOT_FOUND:
with self.lock:
# check if another thread filled cache while we awaited lock
val = cache.get(self.name, _NOT_FOUND)
if val is _NOT_FOUND:
val = self.func(instance)
try:
cache[self.name] = val
except TypeError:
msg = (
f"The '__dict__' attribute on {type(instance).__name__!r} instance "
f"does not support item assignment for caching {self.name!r} property."
)
raise TypeError(msg) from None
return val
return typing.cast(T, val)
def __delete__(self, instance: S) -> None:
cache = self._cache(instance)
assert self.name is not None
with self.lock:
val = cache.pop(self.name, _NOT_FOUND)
if val is _NOT_FOUND:
return
if self._deleter:
self._deleter(instance, typing.cast(T, val))
def deleter(self, deleter: Callable[[S, T], None]) -> cached_property:
self._deleter = deleter
prop = type(self)(self.func, deleter)
prop.name = self.name
prop.__doc__ = self.__doc__
prop.lock = self.lock
return prop