|
| 1 | +""" |
| 2 | +Decorators for functions, methods and classes. |
| 3 | +""" |
| 4 | + |
| 5 | +import functools |
| 6 | +import hashlib |
| 7 | + |
| 8 | +from ._utils import array_to_str |
| 9 | +from .utils import get_logger |
| 10 | +from .wires import ModelSlice, MultiSlice |
| 11 | + |
| 12 | + |
| 13 | +def slice_model(func): |
| 14 | + """ |
| 15 | + Slice the input model applying the ``model_slice`` attribute in the instance. |
| 16 | +
|
| 17 | + Use this decorator on methods that take ``model`` as the first parameter. The |
| 18 | + decorator will use the ``model_slice`` attribute of the object to extract the |
| 19 | + relevant slice of the model and pass it to the decorated method. |
| 20 | +
|
| 21 | + .. important:: |
| 22 | +
|
| 23 | + Use this decorator only for methods that take the ``model`` as the first |
| 24 | + argument. |
| 25 | +
|
| 26 | + .. important:: |
| 27 | +
|
| 28 | + The instance needs to have a ``model_slice`` attribute. If it's None, then the |
| 29 | + method will run without any modification. |
| 30 | + """ |
| 31 | + |
| 32 | + @functools.wraps(func) |
| 33 | + def wrapper(self, model, *args, **kwargs): |
| 34 | + if not hasattr(self, "model_slice"): |
| 35 | + msg = ( |
| 36 | + f"Object '{self}' doesn't have a 'model_slice' attribute. " |
| 37 | + "Cannot slice the model without it." |
| 38 | + ) |
| 39 | + raise AttributeError(msg) |
| 40 | + |
| 41 | + # Get model slice |
| 42 | + model_slice: ModelSlice | MultiSlice = self.model_slice |
| 43 | + |
| 44 | + # Don't slice the model if the object has model_slice as None |
| 45 | + if model_slice is None: |
| 46 | + return func(self, model, *args, **kwargs) |
| 47 | + |
| 48 | + # Don't slice the model if it's already reduced |
| 49 | + if model.size != model_slice.full_size: |
| 50 | + if model.size != model_slice.size: |
| 51 | + msg = ( |
| 52 | + f"Invalid model of size '{model.size}'. " |
| 53 | + f"It should be the full model " |
| 54 | + f"(size of {model_slice.full_size}) " |
| 55 | + f"or the reduced model (size of {model_slice.size})." |
| 56 | + ) |
| 57 | + raise ValueError(msg) |
| 58 | + return func(self, model, *args, **kwargs) |
| 59 | + |
| 60 | + # Slice the model and call the method with it |
| 61 | + model_reduced = model_slice.extract(model) |
| 62 | + return func(self, model_reduced, *args, **kwargs) |
| 63 | + |
| 64 | + return wrapper |
| 65 | + |
| 66 | + |
| 67 | +def expand_output(func): |
| 68 | + """ |
| 69 | + Expand output of method using the ``model_slice`` attribute in the instance. |
| 70 | +
|
| 71 | + Use this decorator on any method that needs to expand its output based on the |
| 72 | + ``model_slice`` attribute of the instance. |
| 73 | + If it's a 1D array, it'll expand the array to fill the extra elements with zeros. |
| 74 | + If it's a square matrix or a square linear operator, it'll fill the extra blocks |
| 75 | + with zeros. |
| 76 | +
|
| 77 | + .. important:: |
| 78 | +
|
| 79 | + The instance needs to have a ``model_slice`` attribute. If it's None, then the |
| 80 | + method will run without any modification. |
| 81 | +
|
| 82 | + .. important:: |
| 83 | +
|
| 84 | + Use this decorator only for methods that return a 1D array, or a 2D array, |
| 85 | + sparse matrix or ``LinearOperator``. |
| 86 | +
|
| 87 | + """ |
| 88 | + |
| 89 | + @functools.wraps(func) |
| 90 | + def wrapper(self, model, *args, **kwargs): |
| 91 | + if not hasattr(self, "model_slice"): |
| 92 | + msg = ( |
| 93 | + f"Object '{self}' doesn't have a 'model_slice' attribute. " |
| 94 | + f"Cannot expand the output of '{func}' without it." |
| 95 | + ) |
| 96 | + raise AttributeError(msg) |
| 97 | + |
| 98 | + # Get model slice |
| 99 | + model_slice: ModelSlice | MultiSlice = self.model_slice |
| 100 | + |
| 101 | + # Don't modify the output if the object has no model_slice |
| 102 | + if model_slice is None: |
| 103 | + return func(self, model, *args, **kwargs) |
| 104 | + |
| 105 | + result = func(self, model, *args, **kwargs) |
| 106 | + |
| 107 | + if not hasattr(result, "ndim"): |
| 108 | + msg = ( |
| 109 | + f"Invalid object '{result}' of type '{type(result).__name__}' " |
| 110 | + f"returned by '{func}''. " |
| 111 | + "The output of the method should be an array, sparse matrix or " |
| 112 | + "LinearOperator to be able to expand it." |
| 113 | + ) |
| 114 | + raise TypeError(msg) |
| 115 | + |
| 116 | + if result.ndim == 1: |
| 117 | + result = model_slice.expand_array(result) |
| 118 | + elif result.ndim == 2: |
| 119 | + result = model_slice.expand_matrix(result) |
| 120 | + else: |
| 121 | + msg = ( |
| 122 | + f"Invalid object with {result.ndim} dimensions. " |
| 123 | + "It must be a 1D or 2D array-like object to be able to expand it." |
| 124 | + ) |
| 125 | + raise ValueError(msg) |
| 126 | + return result |
| 127 | + |
| 128 | + return wrapper |
| 129 | + |
| 130 | + |
| 131 | +def cache_on_model(func): |
| 132 | + """ |
| 133 | + Cache the last result of a method within the instance using the model hash. |
| 134 | +
|
| 135 | + .. important:: |
| 136 | +
|
| 137 | + Use this decorator only for methods that take the ``model`` as the first |
| 138 | + argument. |
| 139 | +
|
| 140 | + .. important:: |
| 141 | +
|
| 142 | + The instance needs to have a ``cache`` bool attribute. If True, the result |
| 143 | + of the decorated method will be cached. If False, no caching will be performed. |
| 144 | +
|
| 145 | + Examples |
| 146 | + -------- |
| 147 | + >>> import numpy as np |
| 148 | + >>> |
| 149 | + >>> class MyClass: |
| 150 | + ... |
| 151 | + ... def __init__(self): |
| 152 | + ... self.cache = True |
| 153 | + ... |
| 154 | + ... @cache_on_model |
| 155 | + ... def squared(self, model) -> float: |
| 156 | + ... return (model ** 2).sum() |
| 157 | + >>> |
| 158 | + >>> sq = MyClass() |
| 159 | + >>> model = np.array([1.0, 2.0, 3.0]) |
| 160 | + >>> print(sq.squared(model)) # perform the computation |
| 161 | + 14.0 |
| 162 | + >>> print(sq.squared(model)) # access the cached result |
| 163 | + 14.0 |
| 164 | +
|
| 165 | + >>> model_new = np.array([4.0, 5.0, 6.0]) |
| 166 | + >>> print(sq.squared(model_new)) # perform a new computation |
| 167 | + 77.0 |
| 168 | + """ |
| 169 | + # Define attribute name for the cached result using the hash of the function |
| 170 | + cache_attr = f"_cache_{hash(func)}" |
| 171 | + |
| 172 | + @functools.wraps(func) |
| 173 | + def wrapper(self, model, *args, **kwargs): |
| 174 | + if not hasattr(self, "cache"): |
| 175 | + msg = f"Missing 'cache' attribute in {self}" |
| 176 | + raise AttributeError(msg) |
| 177 | + |
| 178 | + if self.cache: |
| 179 | + model_hash = hashlib.sha256(model) |
| 180 | + |
| 181 | + # Return cached object if the model hash matches with the cached one |
| 182 | + if hasattr(self, cache_attr): |
| 183 | + model_hash_cached, cached_result = getattr(self, cache_attr) |
| 184 | + if model_hash_cached.digest() == model_hash.digest(): |
| 185 | + # -- Debug log -- |
| 186 | + msg = ( |
| 187 | + f"Returning cached object '{array_to_str(cached_result)}' " |
| 188 | + f"after calling '{func}' with model with hash " |
| 189 | + f"'{model_hash_cached.hexdigest()}'." |
| 190 | + ) |
| 191 | + if args: |
| 192 | + msg += f" With args: '{args}'." |
| 193 | + if kwargs: |
| 194 | + msg += f" With kwargs: '{kwargs}'." |
| 195 | + get_logger().debug(msg) |
| 196 | + # --- |
| 197 | + return cached_result |
| 198 | + |
| 199 | + # Compute new result and cache it |
| 200 | + result = func(self, model, *args, **kwargs) |
| 201 | + setattr(self, cache_attr, (model_hash, result)) |
| 202 | + # -- Debug log -- |
| 203 | + msg = ( |
| 204 | + f"Computed new result '{array_to_str(result)}' after " |
| 205 | + f"calling '{func}' with model with hash '{model_hash.hexdigest()}'. " |
| 206 | + "Cached the result into the object." |
| 207 | + ) |
| 208 | + if args: |
| 209 | + msg += f" With args: '{args}'." |
| 210 | + if kwargs: |
| 211 | + msg += f" With kwargs: '{kwargs}'." |
| 212 | + get_logger().debug(msg) |
| 213 | + # --- |
| 214 | + return result |
| 215 | + |
| 216 | + # Return result without caching |
| 217 | + return func(self, model, *args, **kwargs) |
| 218 | + |
| 219 | + return wrapper |
| 220 | + |
| 221 | + |
| 222 | +def debug(func): |
| 223 | + """ |
| 224 | + Add a debug entry into the logger through a decorator. |
| 225 | +
|
| 226 | + Use this decorator on methods and functions. When such method or function gets |
| 227 | + called, it will add an entry into the logger as a DEBUG level. |
| 228 | + """ |
| 229 | + logger = get_logger() |
| 230 | + |
| 231 | + @functools.wraps(func) |
| 232 | + def wrapper(self, *args, **kwargs): |
| 233 | + msg = f"Called '{func}'" |
| 234 | + if args: |
| 235 | + msg += f" with arguments '{args}'" |
| 236 | + if kwargs: |
| 237 | + msg += f" with keyword arguments '{kwargs}'" |
| 238 | + msg += "." |
| 239 | + logger.debug(msg) |
| 240 | + return func(self, *args, **kwargs) |
| 241 | + |
| 242 | + return wrapper |
| 243 | + |
| 244 | + |
| 245 | +class CountCalls: |
| 246 | + """ |
| 247 | + Class decorator to count function calls. |
| 248 | +
|
| 249 | + Examples |
| 250 | + -------- |
| 251 | + >>> @CountCalls |
| 252 | + ... def my_function(x): |
| 253 | + ... return x**2 |
| 254 | + >>> my_function(1) |
| 255 | + 1 |
| 256 | + >>> my_function(2) |
| 257 | + 4 |
| 258 | + >>> my_function.counts |
| 259 | + 2 |
| 260 | + """ |
| 261 | + |
| 262 | + def __init__(self, func): |
| 263 | + functools.update_wrapper(self, func) |
| 264 | + self.func = func |
| 265 | + self.counts = 0 |
| 266 | + |
| 267 | + def __call__(self, *args, **kwargs): |
| 268 | + result = self.func(*args, **kwargs) |
| 269 | + self.counts += 1 |
| 270 | + return result |
0 commit comments