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
« 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# ============================================================================ #
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 ```{.py .python linenums="1" title="Set up"}
88 >>> from toolbox_python.classes import get_full_class_name
89 ```
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>
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>
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 ```{.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>
168 As previously noted, a `class_property` is limited to implementing read-only attributes:
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>
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.
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 """
200 def __new__(cls, fget=None, doc=None):
202 if fget is None:
204 # Being used as a decorator-return a wrapper that implements decorator syntax
205 def wrapper(func):
206 return cls(func, doc=doc)
208 return wrapper
210 return super().__new__(cls)
212 def __init__(self, fget, doc=None):
214 fget = self._wrap_fget(fget)
215 super().__init__(fget=fget, doc=doc)
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
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
227 @staticmethod
228 def _wrap_fget(orig_fget):
229 if isinstance(orig_fget, classmethod):
230 orig_fget = orig_fget.__func__
232 # Using standard `functools.wraps` for simplicity.
233 @wraps(orig_fget) # pragma: no cover
234 def fget(obj):
235 return orig_fget(obj.__class__)
237 return fget
239 # def getter(self, fget) -> property:
240 # return super().getter(self._wrap_fget(fget))
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 )
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 )
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.
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.
261# Params:
262# func (Callable):
263# The function to be cached.
265# Returns:
266# (property):
267# The cached property.
269# ???+ example "Examples"
271# ```{.py .python linenums="1" title="Set up"}
272# >>> from toolbox_python.classes import cached_class_property
273# ```
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__
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)
303# return wrapper