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

41 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-17 08:43 +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 

15from __future__ import annotations 

16 

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

18 

19import functools 

20from collections.abc import Callable 

21from threading import RLock 

22from typing import Any, ClassVar, TypeVar 

23 

24 

25class Singleton(type): 

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

27 

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

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

30 the arguments the first time an instance is instantiated. 

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

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

33 adjust state of the singleton. 

34 """ 

35 

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

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

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

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

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

41 # instantiate another as part of its initialization. 

42 __lock: ClassVar[RLock] = RLock() 

43 

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

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

46 # constructor arguments. 

47 def __call__(cls) -> Any: 

48 with cls.__lock: 

49 if cls not in cls._instances: 

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

51 return cls._instances[cls] 

52 

53 

54_T = TypeVar("_T", bound=Any) 

55 

56 

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

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

59 

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

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

62 

63 Notes 

64 ----- 

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

66 

67 Because this behavior interferes with the default implementation for the 

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

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

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

71 ``__getnewargs__``. 

72 

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

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

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

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

77 

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

79 have only recursively immutable attributes) should also reimplement 

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

81 it has no way of checking for recursive immutability. 

82 """ 

83 

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

85 if hasattr(self, name): 

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

87 object.__setattr__(self, name, value) 

88 

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

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

91 cls.__setattr__ = __setattr__ 

92 

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

94 # Disable default state-setting when unpickled. 

95 return {} 

96 

97 cls.__getstate__ = __getstate__ 

98 

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

100 # Disable default state-setting when copied. 

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

102 assert not state 

103 

104 cls.__setstate__ = __setstate__ 

105 

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

107 return self 

108 

109 cls.__copy__ = __copy__ 

110 return cls 

111 

112 

113_S = TypeVar("_S") 

114_R = TypeVar("_R") 

115 

116 

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

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

119 

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

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

122 

123 Parameters 

124 ---------- 

125 func : `~collections.abc.Callable` 

126 Method from which the result should be cached. 

127 

128 Returns 

129 ------- 

130 `~collections.abc.Callable` 

131 Decorated method. 

132 

133 Notes 

134 ----- 

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

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

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

138 

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

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

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

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

143 """ 

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

145 

146 @functools.wraps(func) 

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

148 if not hasattr(self, attribute): 

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

150 return getattr(self, attribute) 

151 

152 return inner