Coverage for src / toolbox_python / classes.py: 100%

33 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-02 22:56 +0000

1# ============================================================================ # 

2# # 

3# Title : Classes # 

4# Purpose : Contain functions which can be run on classes to extract # 

5# general information. # 

6# # 

7# ============================================================================ # 

8 

9 

10# ---------------------------------------------------------------------------- # 

11# # 

12# Overview #### 

13# # 

14# ---------------------------------------------------------------------------- # 

15 

16 

17# ---------------------------------------------------------------------------- # 

18# Description #### 

19# ---------------------------------------------------------------------------- # 

20 

21 

22""" 

23!!! note "Summary" 

24 The `classes` module is designed for functions to be executed _on_ classes; not _within_ classes. 

25 For any methods/functions that should be added _to_ classes, you should consider re-designing the original class, or sub-classing it to make further alterations. 

26""" 

27 

28 

29# ---------------------------------------------------------------------------- # 

30# # 

31# Setup #### 

32# # 

33# ---------------------------------------------------------------------------- # 

34 

35 

36# ---------------------------------------------------------------------------- # 

37# Imports #### 

38# ---------------------------------------------------------------------------- # 

39 

40 

41# ## Python StdLib Imports ---- 

42from functools import wraps 

43from typing import Any 

44 

45# ## Local First Party Imports ---- 

46from toolbox_python.collection_types import str_list 

47 

48 

49# ---------------------------------------------------------------------------- # 

50# Exports #### 

51# ---------------------------------------------------------------------------- # 

52 

53 

54__all__: str_list = ["get_full_class_name", "class_property"] 

55 

56 

57# ---------------------------------------------------------------------------- # 

58# # 

59# Functions #### 

60# # 

61# ---------------------------------------------------------------------------- # 

62 

63 

64# ---------------------------------------------------------------------------- # 

65# get_full_class_name() #### 

66# ---------------------------------------------------------------------------- # 

67 

68 

69def get_full_class_name(obj: Any) -> str: 

70 """ 

71 !!! note "Summary" 

72 This function is designed to extract the full name of a class, including the name of the module from which it was loaded. 

73 

74 ???+ abstract "Details" 

75 Note, this is designed to retrieve the underlying _class name_ of an object, not the _instance name_ of an object. This is useful for debugging purposes, or for logging. 

76 

77 Params: 

78 obj (Any): 

79 The object for which you want to retrieve the full name. 

80 

81 Returns: 

82 (str): 

83 The full name of the class of the object. 

84 

85 ???+ example "Examples" 

86 

87 ```pycon {.py .python linenums="1" title="Set up"} 

88 >>> from toolbox_python.classes import get_full_class_name 

89 ``` 

90 

91 ```pycon {.py .python linenums="1" title="Example 1: Check the name of a standard class"} 

92 >>> print(get_full_class_name(str)) 

93 ``` 

94 <div class="result" markdown> 

95 ```{.sh .shell title="Terminal"} 

96 str 

97 ``` 

98 !!! success "Conclusion: Successful class name extraction." 

99 </div> 

100 

101 ```pycon {.py .python linenums="1" title="Example 2: Check the name of an imported class"} 

102 >>> from random import Random 

103 >>> print(get_full_class_name(Random)) 

104 ``` 

105 <div class="result" markdown> 

106 ```{.sh .shell title="Terminal"} 

107 random.Random 

108 ``` 

109 !!! success "Conclusion: Successful class name extraction." 

110 </div> 

111 

112 ??? success "Credit" 

113 Full credit goes to:<br> 

114 https://stackoverflow.com/questions/18176602/how-to-get-the-name-of-an-exception-that-was-caught-in-python#answer-58045927 

115 """ 

116 module: str = obj.__class__.__module__ 

117 if module is None or module == str.__class__.__module__: 

118 return obj.__class__.__name__ 

119 return module + "." + obj.__class__.__name__ 

120 

121 

122# TODO: This can still be made to work for setters by implementing an accompanying metaclass that supports it. 

123class class_property(property): 

124 """ 

125 !!! note "Summary" 

126 Similar to `property`, but allows class-level properties. That is, a property whose getter is like a `classmethod`. 

127 

128 ???+ abstract "Details" 

129 The wrapped method may explicitly use the `classmethod` decorator (which must become before this decorator), or the `classmethod` may be omitted (it is implicit through use of this decorator). 

130 

131 Params: 

132 fget (callable): 

133 The function that computes the value of this property (in particular, the function when this is used as a decorator) a la `property`. 

134 

135 doc (str, optional): 

136 The docstring for the property--by default inherited from the getter function. 

137 

138 ???+ example "Examples" 

139 

140 Normal usage is as a decorator: 

141 

142 ```pycon {.py .python linenums="1" title="Example 1: Normal usage"} 

143 >>> class Foo: 

144 ... _bar_internal = 1 

145 ... @class_property 

146 ... def bar(cls): 

147 ... return cls._bar_internal + 1 

148 ... 

149 >>> 

150 >>> print(f"Class attribute: `{Foo.bar}`") 

151 >>> 

152 >>> foo_instance = Foo() 

153 >>> print(f"Instantiated class: `{foo_instance.bar}`") 

154 >>> 

155 >>> foo_instance._bar_internal = 2 

156 >>> print( 

157 ... f"Modified instance attribute: `{foo_instance.bar}`" 

158 ... ) # Ignores instance attributes 

159 ``` 

160 <div class="result" markdown> 

161 ```{.sh .shell title="Terminal"} 

162 Class attribute: `2` 

163 Instantiated class: `2` 

164 Modified instance attribute: `2` 

165 ``` 

166 Note that in the third `print()` statement, the instance attribute `_bar_internal` is ignored. This is because `class_property` is designed to be used as a class-level property, not an instance-level property. See the Notes section for more details. 

167 !!! success "Conclusion: Successful usage." 

168 </div> 

169 

170 As previously noted, a `class_property` is limited to implementing read-only attributes: 

171 

172 ```pycon {.py .python linenums="1" title="Example 2: Read-only attributes"} 

173 >>> class Foo: 

174 ... _bar_internal = 1 

175 ... @class_property 

176 ... def bar(cls): 

177 ... return cls._bar_internal 

178 ... @bar.setter 

179 ... def bar(cls, value): 

180 ... cls._bar_internal = value 

181 ... 

182 ``` 

183 <div class="result" markdown> 

184 ```{.sh .shell title="Terminal"} 

185 NotImplementedError: class_property can only be read-only; use a metaclass to implement modifiable class-level properties 

186 ``` 

187 !!! failure "Conclusion: Failed to set a class property." 

188 </div> 

189 

190 ???+ abstract "Notes" 

191 - `@class_property` only works for *read-only* properties. It does not currently allow writeable/deletable properties, due to subtleties of how Python descriptors work. In order to implement such properties on a class, a metaclass for that class must be implemented. 

192 - `@class_property` is not a drop-in replacement for `property`. It is designed to be used as a class-level property, not an instance-level property. If you need to use it as an instance-level property, you will need to use the `@property` decorator instead. 

193 - `@class_property` is defined at class scope, not instance scope. This means that it is not bound to the instance of the class, but rather to the class itself; hence the name `class_property`. This means that it is designed to be used as a class-level property and is accessed through the class itself, not an instance-level property which is accessed through the instance of a class. If it is necessary to access the instance-level property, you will need to use the instance itself (eg. `instantiated_class_name._internal_attribute`) or create an instance-level property using the `@property` decorator. 

194 

195 ???+ success "Credit" 

196 This `@class_property` object is heavily inspired by the [`astropy`](https://www.astropy.org/) library. All credit goes to them. See: [details](https://github.com/astropy/astropy/blob/e4993fffb54e19b04bc4e9af084984650bc0a46f/astropy/utils/decorators.py#L551-L722). 

197 """ 

198 

199 def __new__(cls, fget=None, doc=None): 

200 

201 if fget is None: 

202 

203 # Being used as a decorator-return a wrapper that implements decorator syntax 

204 def wrapper(func): 

205 return cls(func, doc=doc) 

206 

207 return wrapper 

208 

209 return super().__new__(cls) 

210 

211 def __init__(self, fget, doc=None): 

212 

213 fget = self._wrap_fget(fget) 

214 super().__init__(fget=fget, doc=doc) 

215 

216 # There is a bug in Python where `self.__doc__` doesn't get set properly on instances of property subclasses if the `doc` argument was used rather than taking the docstring from `fget`. 

217 # Related Python issue: https://bugs.python.org/issue24766 

218 if doc is not None: 

219 self.__doc__ = doc 

220 

221 def __get__(self, obj, objtype) -> Any: # type: ignore[override] 

222 # The base `property.__get__` will just return self here; instead we pass `objtype` through to the original wrapped function (which takes the `class` as its sole argument). This is how we obtain the `class` attribute and not the `instance` attribute, and the key difference between `@property` and `@class_property`. This is also the same as `classmethod.__get__()`, but we need to pass `objtype` rather than `obj` to the wrapped function. 

223 val: Any = self.fget.__wrapped__(objtype) # type: ignore[union-attr] 

224 return val 

225 

226 @staticmethod 

227 def _wrap_fget(orig_fget): 

228 if isinstance(orig_fget, classmethod): 

229 orig_fget = orig_fget.__func__ 

230 

231 # Using standard `functools.wraps` for simplicity. 

232 @wraps(orig_fget) # pragma: no cover 

233 def fget(obj): 

234 return orig_fget(obj.__class__) 

235 

236 return fget 

237 

238 # def getter(self, fget) -> property: 

239 # return super().getter(self._wrap_fget(fget)) 

240 

241 def setter(self, fset) -> Any: 

242 raise NotImplementedError( 

243 "class_property can only be read-only; use a metaclass to implement modifiable class-level properties" 

244 ) 

245 

246 def deleter(self, fdel) -> Any: 

247 raise NotImplementedError( 

248 "class_property can only be read-only; use a metaclass to implement modifiable class-level properties" 

249 ) 

250 

251 

252# def cached_class_property(func) -> property: 

253# """ 

254# !!! note "Summary" 

255# This function is designed to cache the result of a class property, so that it is only computed once, and then stored for future reference. 

256 

257# ???+ abstract "Details" 

258# This is useful for properties that are computationally expensive, but are not expected to change over the lifetime of the object. 

259 

260# Params: 

261# func (Callable): 

262# The function to be cached. 

263 

264# Returns: 

265# (property): 

266# The cached property. 

267 

268# ???+ example "Examples" 

269 

270# ```pycon {.py .python linenums="1" title="Set up"} 

271# >>> from toolbox_python.classes import cached_class_property 

272# ``` 

273 

274# ```pycon {.py .python linenums="1" title="Example 1: Create a cached class property"} 

275# >>> class TestClass: 

276# ... @cached_class_property 

277# ... def expensive_property(cls): 

278# ... print("Computing expensive property...") 

279# ... return 42 

280# ... 

281# >>> print(TestClass.expensive_property) 

282# >>> print(TestClass.expensive_property) 

283# ``` 

284# <div class="result" markdown> 

285# ```{.sh .shell title="Terminal"} 

286# Computing expensive property... 

287# 42 

288# 42 

289# ``` 

290# !!! success "Conclusion: Successful property caching." 

291# </div> 

292# """ 

293# attr_name = "_cached_" + func.__name__ 

294 

295# @property 

296# @wraps(func) 

297# def wrapper(self): 

298# if not hasattr(self, attr_name): 

299# setattr(self, attr_name, func(self)) 

300# return getattr(self, attr_name) 

301 

302# return wrapper