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

33 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-04-25 01:04 +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 ```{.py .python linenums="1" title="Set up"} 

88 >>> from toolbox_python.classes import get_full_class_name 

89 ``` 

90 

91 ```{.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 ```{.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 ```{.py .python linenums="1" title="Example 1: Normal usage"} 

143 >>> class Foo: 

144 ... _bar_internal = 1 

145 ... 

146 ... @class_property 

147 ... def bar(cls): 

148 ... return cls._bar_internal + 1 

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(f"Modified instance attribute: `{foo_instance.bar}`") # Ignores instance attributes 

157 ``` 

158 <div class="result" markdown> 

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

160 Class attribute: `2` 

161 Instantiated class: `2` 

162 Modified instance attribute: `2` 

163 ``` 

164 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. 

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

166 </div> 

167 

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

169 

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

171 >>> class Foo: 

172 ... _bar_internal = 1 

173 ... 

174 ... @class_property 

175 ... def bar(cls): 

176 ... return cls._bar_internal 

177 ... 

178 ... @bar.setter 

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

180 ... cls._bar_internal = value 

181 ``` 

182 <div class="result" markdown> 

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

184 Traceback (most recent call last): 

185 ... 

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

187 ``` 

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

189 </div> 

190 

191 ???+ info "Notes" 

192 - `@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. 

193 - `@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. 

194 - `@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. 

195 

196 ???+ success "Credit" 

197 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). 

198 """ 

199 

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

201 

202 if fget is None: 

203 

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

205 def wrapper(func): 

206 return cls(func, doc=doc) 

207 

208 return wrapper 

209 

210 return super().__new__(cls) 

211 

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

213 

214 fget = self._wrap_fget(fget) 

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

216 

217 # 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`. 

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

219 if doc is not None: 

220 self.__doc__ = doc 

221 

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

223 # 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. 

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

225 return val 

226 

227 @staticmethod 

228 def _wrap_fget(orig_fget): 

229 if isinstance(orig_fget, classmethod): 

230 orig_fget = orig_fget.__func__ 

231 

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

233 @wraps(orig_fget) # pragma: no cover 

234 def fget(obj): 

235 return orig_fget(obj.__class__) 

236 

237 return fget 

238 

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

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

241 

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

243 raise NotImplementedError( 

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

245 ) 

246 

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

248 raise NotImplementedError( 

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

250 ) 

251 

252 

253# def cached_class_property(func) -> property: 

254# """ 

255# !!! note "Summary" 

256# 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. 

257 

258# ???+ abstract "Details" 

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

260 

261# Params: 

262# func (Callable): 

263# The function to be cached. 

264 

265# Returns: 

266# (property): 

267# The cached property. 

268 

269# ???+ example "Examples" 

270 

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

272# >>> from toolbox_python.classes import cached_class_property 

273# ``` 

274 

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

276# >>> class TestClass: 

277# ... @cached_class_property 

278# ... def expensive_property(cls): 

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

280# ... return 42 

281# ... 

282# >>> print(TestClass.expensive_property) 

283# >>> print(TestClass.expensive_property) 

284# ``` 

285# <div class="result" markdown> 

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

287# Computing expensive property... 

288# 42 

289# 42 

290# ``` 

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

292# </div> 

293# """ 

294# attr_name = "_cached_" + func.__name__ 

295 

296# @property 

297# @wraps(func) 

298# def wrapper(self): 

299# if not hasattr(self, attr_name): 

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

301# return getattr(self, attr_name) 

302 

303# return wrapper