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
« 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# ============================================================================ #
10# ---------------------------------------------------------------------------- #
11# #
12# Overview ####
13# #
14# ---------------------------------------------------------------------------- #
17# ---------------------------------------------------------------------------- #
18# Description ####
19# ---------------------------------------------------------------------------- #
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"""
29# ---------------------------------------------------------------------------- #
30# #
31# Setup ####
32# #
33# ---------------------------------------------------------------------------- #
36# ---------------------------------------------------------------------------- #
37# Imports ####
38# ---------------------------------------------------------------------------- #
41# ## Python StdLib Imports ----
42from functools import wraps
43from typing import Any
45# ## Local First Party Imports ----
46from toolbox_python.collection_types import str_list
49# ---------------------------------------------------------------------------- #
50# Exports ####
51# ---------------------------------------------------------------------------- #
54__all__: str_list = ["get_full_class_name", "class_property"]
57# ---------------------------------------------------------------------------- #
58# #
59# Functions ####
60# #
61# ---------------------------------------------------------------------------- #
64# ---------------------------------------------------------------------------- #
65# get_full_class_name() ####
66# ---------------------------------------------------------------------------- #
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.
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.
77 Params:
78 obj (Any):
79 The object for which you want to retrieve the full name.
81 Returns:
82 (str):
83 The full name of the class of the object.
85 ???+ example "Examples"
87 ```pycon {.py .python linenums="1" title="Set up"}
88 >>> from toolbox_python.classes import get_full_class_name
89 ```
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>
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>
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__
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`.
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).
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`.
135 doc (str, optional):
136 The docstring for the property--by default inherited from the getter function.
138 ???+ example "Examples"
140 Normal usage is as a decorator:
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>
170 As previously noted, a `class_property` is limited to implementing read-only attributes:
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>
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.
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 """
199 def __new__(cls, fget=None, doc=None):
201 if fget is None:
203 # Being used as a decorator-return a wrapper that implements decorator syntax
204 def wrapper(func):
205 return cls(func, doc=doc)
207 return wrapper
209 return super().__new__(cls)
211 def __init__(self, fget, doc=None):
213 fget = self._wrap_fget(fget)
214 super().__init__(fget=fget, doc=doc)
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
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
226 @staticmethod
227 def _wrap_fget(orig_fget):
228 if isinstance(orig_fget, classmethod):
229 orig_fget = orig_fget.__func__
231 # Using standard `functools.wraps` for simplicity.
232 @wraps(orig_fget) # pragma: no cover
233 def fget(obj):
234 return orig_fget(obj.__class__)
236 return fget
238 # def getter(self, fget) -> property:
239 # return super().getter(self._wrap_fget(fget))
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 )
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 )
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.
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.
260# Params:
261# func (Callable):
262# The function to be cached.
264# Returns:
265# (property):
266# The cached property.
268# ???+ example "Examples"
270# ```pycon {.py .python linenums="1" title="Set up"}
271# >>> from toolbox_python.classes import cached_class_property
272# ```
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__
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)
302# return wrapper