Coverage for python/lsst/utils/classes.py: 60%

42 statements  

« prev     ^ index     » next       coverage.py v7.4.3, created at 2024-02-27 11:49 +0000

1# This file is part of utils. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# Use of this source code is governed by a 3-clause BSD-style 

10# license that can be found in the LICENSE file. 

11# 

12 

13"""Utilities to help with class creation. 

14""" 

15 

16from __future__ import annotations 

17 

18__all__ = ["Singleton", "cached_getter", "immutable"] 

19 

20import functools 

21from collections.abc import Callable 

22from threading import RLock 

23from typing import Any, ClassVar, Type, TypeVar 

24 

25 

26class Singleton(type): 

27 """Metaclass to convert a class to a Singleton. 

28 

29 If this metaclass is used the constructor for the singleton class must 

30 take no arguments. This is because a singleton class will only accept 

31 the arguments the first time an instance is instantiated. 

32 Therefore since you do not know if the constructor has been called yet it 

33 is safer to always call it with no arguments and then call a method to 

34 adjust state of the singleton. 

35 """ 

36 

37 _instances: ClassVar[dict[type, Any]] = {} 

38 # This lock isn't ideal because it is shared between all classes using this 

39 # metaclass, but current use cases don't do long-running I/O during 

40 # initialization so the performance impact should be low. It must be an 

41 # RLock instead of a regular Lock because one singleton class might try to 

42 # instantiate another as part of its initialization. 

43 __lock: ClassVar[RLock] = RLock() 

44 

45 # Signature is intentionally not substitutable for type.__call__ (no *args, 

46 # **kwargs) to require classes that use this metaclass to have no 

47 # constructor arguments. 

48 def __call__(cls) -> Any: 

49 with cls.__lock: 

50 if cls not in cls._instances: 

51 cls._instances[cls] = super().__call__() 

52 return cls._instances[cls] 

53 

54 

55_T = TypeVar("_T", bound="Type") 

56 

57 

58def immutable(cls: _T) -> _T: 

59 """Decorate a class to simulate a simple form of immutability. 

60 

61 A class decorated as `immutable` may only set each of its attributes once; 

62 any attempts to set an already-set attribute will raise `AttributeError`. 

63 

64 Notes 

65 ----- 

66 Subclasses of classes marked with ``@immutable`` are also immutable. 

67 

68 Because this behavior interferes with the default implementation for the 

69 ``pickle`` modules, `immutable` provides implementations of 

70 ``__getstate__`` and ``__setstate__`` that override this behavior. 

71 Immutable classes can then implement pickle via ``__reduce__`` or 

72 ``__getnewargs__``. 

73 

74 Following the example of Python's built-in immutable types, such as `str` 

75 and `tuple`, the `immutable` decorator provides a ``__copy__`` 

76 implementation that just returns ``self``, because there is no reason to 

77 actually copy an object if none of its shared owners can modify it. 

78 

79 Similarly, objects that are recursively (i.e. are themselves immutable and 

80 have only recursively immutable attributes) should also reimplement 

81 ``__deepcopy__`` to return ``self``. This is not done by the decorator, as 

82 it has no way of checking for recursive immutability. 

83 """ 

84 

85 def __setattr__(self: _T, name: str, value: Any) -> None: # noqa: N807 

86 if hasattr(self, name): 

87 raise AttributeError(f"{cls.__name__} instances are immutable.") 

88 object.__setattr__(self, name, value) 

89 

90 # mypy says the variable here has signature (str, Any) i.e. no "self"; 

91 # I think it's just confused by descriptor stuff. 

92 cls.__setattr__ = __setattr__ # type: ignore 

93 

94 def __getstate__(self: _T) -> dict: # noqa: N807 

95 # Disable default state-setting when unpickled. 

96 return {} 

97 

98 cls.__getstate__ = __getstate__ # type: ignore[assignment] 

99 

100 def __setstate__(self: _T, state: Any) -> None: # noqa: N807 

101 # Disable default state-setting when copied. 

102 # Sadly what works for pickle doesn't work for copy. 

103 assert not state 

104 

105 cls.__setstate__ = __setstate__ 

106 

107 def __copy__(self: _T) -> _T: # noqa: N807 

108 return self 

109 

110 cls.__copy__ = __copy__ 

111 return cls 

112 

113 

114_S = TypeVar("_S") 

115_R = TypeVar("_R") 

116 

117 

118def cached_getter(func: Callable[[_S], _R]) -> Callable[[_S], _R]: 

119 """Decorate a method to cache the result. 

120 

121 Only works on methods that take only ``self`` 

122 as an argument, and returns the cached result on subsequent calls. 

123 

124 Parameters 

125 ---------- 

126 func : `~collections.abc.Callable` 

127 Method from which the result should be cached. 

128 

129 Returns 

130 ------- 

131 `~collections.abc.Callable` 

132 Decorated method. 

133 

134 Notes 

135 ----- 

136 This is intended primarily as a stopgap for Python 3.8's more sophisticated 

137 ``functools.cached_property``, but it is also explicitly compatible with 

138 the `immutable` decorator, which may not be true of ``cached_property``. 

139 

140 `cached_getter` guarantees that the cached value will be stored in 

141 an attribute named ``_cached_{name-of-decorated-function}``. Classes that 

142 use `cached_getter` are responsible for guaranteeing that this name is not 

143 otherwise used, and is included if ``__slots__`` is defined. 

144 """ 

145 attribute = f"_cached_{func.__name__}" 

146 

147 @functools.wraps(func) 

148 def inner(self: _S) -> _R: 

149 if not hasattr(self, attribute): 

150 object.__setattr__(self, attribute, func(self)) 

151 return getattr(self, attribute) 

152 

153 return inner