Coverage for python/lsst/utils/wrappers.py: 12%
137 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-07-25 09:27 +0000
« prev ^ index » next coverage.py v7.2.7, created at 2023-07-25 09:27 +0000
1# This file is part of utils.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# Use of this source code is governed by a 3-clause BSD-style
10# license that can be found in the LICENSE file.
12from __future__ import annotations
14import sys
15import types
17import numpy as np
19__all__ = ("continueClass", "inClass", "TemplateMeta")
22INTRINSIC_SPECIAL_ATTRIBUTES = frozenset(
23 (
24 "__qualname__",
25 "__module__",
26 "__metaclass__",
27 "__dict__",
28 "__weakref__",
29 "__class__",
30 "__subclasshook__",
31 "__name__",
32 "__doc__",
33 )
34)
37def isAttributeSafeToTransfer(name, value):
38 """Return True if an attribute is safe to monkeypatch-transfer to another
39 class.
41 This rejects special methods that are defined automatically for all
42 classes, leaving only those explicitly defined in a class decorated by
43 `continueClass` or registered with an instance of `TemplateMeta`.
44 """
45 if name.startswith("__") and (
46 value is getattr(object, name, None) or name in INTRINSIC_SPECIAL_ATTRIBUTES
47 ):
48 return False
49 return True
52def continueClass(cls):
53 """Re-open the decorated class, adding any new definitions into the
54 original.
56 For example:
58 .. code-block:: python
60 class Foo:
61 pass
63 @continueClass
64 class Foo:
65 def run(self):
66 return None
68 is equivalent to:
70 .. code-block:: python
72 class Foo:
73 def run(self):
74 return None
76 .. warning::
78 Python's built-in `super` function does not behave properly in classes
79 decorated with `continueClass`. Base class methods must be invoked
80 directly using their explicit types instead.
82 """
83 orig = getattr(sys.modules[cls.__module__], cls.__name__)
84 for name in dir(cls):
85 # Common descriptors like classmethod and staticmethod can only be
86 # accessed without invoking their magic if we use __dict__; if we use
87 # getattr on those we'll get e.g. a bound method instance on the dummy
88 # class rather than a classmethod instance we can put on the target
89 # class.
90 attr = cls.__dict__.get(name, None) or getattr(cls, name)
91 if isAttributeSafeToTransfer(name, attr):
92 setattr(orig, name, attr)
93 return orig
96def inClass(cls, name=None):
97 """Add the decorated function to the given class as a method.
99 For example:
101 .. code-block:: python
103 class Foo:
104 pass
106 @inClass(Foo)
107 def run(self):
108 return None
110 is equivalent to:
112 .. code-block:: python
114 class Foo:
115 def run(self):
116 return None
118 Standard decorators like ``classmethod``, ``staticmethod``, and
119 ``property`` may be used *after* this decorator. Custom decorators
120 may only be used if they return an object with a ``__name__`` attribute
121 or the ``name`` optional argument is provided.
122 """
124 def decorate(func):
125 # Using 'name' instead of 'name1' breaks the closure because
126 # assignment signals a strictly local variable.
127 name1 = name
128 if name1 is None:
129 if hasattr(func, "__name__"):
130 name1 = func.__name__
131 else:
132 if hasattr(func, "__func__"):
133 # classmethod and staticmethod have __func__ but
134 # no __name__
135 name1 = func.__func__.__name__
136 elif hasattr(func, "fget"):
137 # property has fget but no __name__
138 name1 = func.fget.__name__
139 else:
140 raise ValueError(f"Could not guess attribute name for '{func}'.")
141 setattr(cls, name1, func)
142 return func
144 return decorate
147class TemplateMeta(type):
148 """A metaclass for abstract base classes that tie together wrapped C++
149 template types.
151 C++ template classes are most easily wrapped with a separate Python class
152 for each template type, which results in an unnatural Python interface.
153 TemplateMeta provides a thin layer that connects these Python classes by
154 giving them a common base class and acting as a factory to construct them
155 in a consistent way.
157 To use, simply create a new class with the name of the template class, and
158 use ``TemplateMeta`` as its metaclass, and then call ``register`` on each
159 of its subclasses. This registers the class with a "type key" - usually a
160 Python representation of the C++ template types. The type key must be a
161 hashable object - strings, type objects, and tuples of these (for C++
162 classes with multiple template parameters) are good choices. Alternate
163 type keys for existing classes can be added by calling ``alias``, but only
164 after a subclass already been registered with a "primary" type key. For
165 example::
167 .. code-block:: python
169 import numpy as np
170 from ._image import ImageF, ImageD
172 class Image(metaclass=TemplateMeta):
173 pass
175 Image.register(np.float32, ImageF)
176 Image.register(np.float64, ImageD)
177 Image.alias("F", ImageF)
178 Image.alias("D", ImageD)
180 We have intentionally used ``numpy`` types as the primary keys for these
181 objects in this example, with strings as secondary aliases simply because
182 the primary key is added as a ``dtype`` attribute on the the registered
183 classes (so ``ImageF.dtype == numpy.float32`` in the above example).
185 This allows user code to construct objects directly using ``Image``, as
186 long as an extra ``dtype`` keyword argument is passed that matches one of
187 the type keys::
189 .. code-block:: python
191 img = Image(52, 64, dtype=np.float32)
193 This simply forwards additional positional and keyword arguments to the
194 wrapped template class's constructor.
196 The choice of "dtype" as the name of the template parameter is also
197 configurable, and in fact multiple template parameters are also supported,
198 by setting a ``TEMPLATE_PARAMS`` class attribute on the ABC to a tuple
199 containing the names of the template parameters. A ``TEMPLATE_DEFAULTS``
200 attribute can also be defined to a tuple of the same length containing
201 default values for the template parameters, allowing them to be omitted in
202 constructor calls. When the length of these attributes is more than one,
203 the type keys passed to ``register`` and ``alias`` should be tuple of the
204 same length; when the length of these attributes is one, type keys should
205 generally not be tuples.
207 As an aid for those writing the Python wrappers for C++ classes,
208 ``TemplateMeta`` also provides a way to add pure-Python methods and other
209 attributes to the wrapped template classes. To add a ``sum`` method to
210 all registered types, for example, we can just do::
212 .. code-block:: python
214 class Image(metaclass=TemplateMeta):
216 def sum(self):
217 return np.sum(self.getArray())
219 Image.register(np.float32, ImageF)
220 Image.register(np.float64, ImageD)
222 .. note::
224 ``TemplateMeta`` works by overriding the ``__instancecheck__`` and
225 ``__subclasscheck__`` special methods, and hence does not appear in
226 its registered subclasses' method resolution order or ``__bases__``
227 attributes. That means its attributes are not inherited by registered
228 subclasses. Instead, attributes added to an instance of
229 ``TemplateMeta`` are *copied* into the types registered with it. These
230 attributes will thus *replace* existing attributes in those classes
231 with the same name, and subclasses cannot delegate to base class
232 implementations of these methods.
234 Finally, abstract base classes that use ``TemplateMeta`` define a dict-
235 like interface for accessing their registered subclasses, providing
236 something like the C++ syntax for templates::
238 .. code-block:: python
240 Image[np.float32] -> ImageF
241 Image["D"] -> ImageD
243 Both primary dtypes and aliases can be used as keys in this interface,
244 which means types with aliases will be present multiple times in the dict.
245 To obtain the sequence of unique subclasses, use the ``__subclasses__``
246 method.
248 .. warning::
250 Python's built-in `super` function does not behave properly in classes
251 that have `TemplateMeta` as their metaclass (which should be rare, as
252 TemplateMeta ABCs will have base classes of their own)..
254 """
256 def __new__(cls, name, bases, attrs):
257 # __new__ is invoked when the abstract base class is defined (via a
258 # class statement). We save a dict of class attributes (including
259 # methods) that were defined in the class body so we can copy them
260 # to registered subclasses later.
261 # We also initialize an empty dict to store the registered subclasses.
262 attrs["_inherited"] = {k: v for k, v in attrs.items() if isAttributeSafeToTransfer(k, v)}
263 # The special "TEMPLATE_PARAMS" class attribute, if defined, contains
264 # names of the template parameters, which we use to set those
265 # attributes on registered subclasses and intercept arguments to the
266 # constructor. This line removes it from the dict of things that
267 # should be inherited while setting a default of 'dtype' if it's not
268 # defined.
269 attrs["TEMPLATE_PARAMS"] = attrs["_inherited"].pop("TEMPLATE_PARAMS", ("dtype",))
270 attrs["TEMPLATE_DEFAULTS"] = attrs["_inherited"].pop(
271 "TEMPLATE_DEFAULTS", (None,) * len(attrs["TEMPLATE_PARAMS"])
272 )
273 attrs["_registry"] = {}
274 self = type.__new__(cls, name, bases, attrs)
276 if len(self.TEMPLATE_PARAMS) == 0:
277 raise ValueError("TEMPLATE_PARAMS must be a tuple with at least one element.")
278 if len(self.TEMPLATE_DEFAULTS) != len(self.TEMPLATE_PARAMS):
279 raise ValueError("TEMPLATE_PARAMS and TEMPLATE_DEFAULTS must have same length.")
280 return self
282 def __call__(cls, *args, **kwds):
283 # __call__ is invoked when someone tries to construct an instance of
284 # the abstract base class.
285 # If the ABC defines a "TEMPLATE_PARAMS" attribute, we use those
286 # strings as the kwargs we should intercept to find the right type.
287 # Generate a type mapping key from input keywords. If the type returned
288 # from the keyword lookup is a numpy dtype object, fetch the underlying
289 # type of the dtype
290 key = []
291 for p, d in zip(cls.TEMPLATE_PARAMS, cls.TEMPLATE_DEFAULTS):
292 tempKey = kwds.pop(p, d)
293 if isinstance(tempKey, np.dtype):
294 tempKey = tempKey.type
295 key.append(tempKey)
296 key = tuple(key)
298 # indices are only tuples if there are multiple elements
299 clz = cls._registry.get(key[0] if len(key) == 1 else key, None)
300 if clz is None:
301 d = dict(zip(cls.TEMPLATE_PARAMS, key))
302 raise TypeError(f"No registered subclass for {d}.")
303 return clz(*args, **kwds)
305 def __subclasscheck__(cls, subclass):
306 # Special method hook for the issubclass built-in: we return true for
307 # any registered type or true subclass thereof.
308 if subclass in cls._registry.values():
309 return True
310 return any(issubclass(subclass, v) for v in cls._registry.values())
312 def __instancecheck__(cls, instance):
313 # Special method hook for the isinstance built-in: we return true for
314 # an instance of any registered type or true subclass thereof.
315 if type(instance) in cls._registry.values():
316 return True
317 return any(isinstance(instance, v) for v in cls._registry.values())
319 def __subclasses__(cls):
320 """Return a tuple of all classes that inherit from this class."""
321 # This special method isn't defined as part of the Python data model,
322 # but it exists on builtins (including ABCMeta), and it provides useful
323 # functionality.
324 return tuple(set(cls._registry.values()))
326 def register(cls, key, subclass):
327 """Register a subclass of this ABC with the given key (a string,
328 number, type, or other hashable).
330 Register may only be called once for a given key or a given subclass.
331 """
332 if key is None:
333 raise ValueError("None may not be used as a key.")
334 if subclass in cls._registry.values():
335 raise ValueError("This subclass has already registered with another key; use alias() instead.")
336 if cls._registry.setdefault(key, subclass) != subclass:
337 if len(cls.TEMPLATE_PARAMS) == 1:
338 d = {cls.TEMPLATE_PARAMS[0]: key}
339 else:
340 d = dict(zip(cls.TEMPLATE_PARAMS, key))
341 raise KeyError(f"Another subclass is already registered with {d}")
342 # If the key used to register a class matches the default key,
343 # make the static methods available through the ABC
344 if cls.TEMPLATE_DEFAULTS:
345 defaults = cls.TEMPLATE_DEFAULTS[0] if len(cls.TEMPLATE_DEFAULTS) == 1 else cls.TEMPLATE_DEFAULTS
346 if key == defaults:
347 conflictStr = (
348 "Base class has attribute {}"
349 " which is a {} method of {}."
350 " Cannot link method to base class."
351 )
352 # In the following if statements, the explicit lookup in
353 # __dict__ must be done, as a call to getattr returns the
354 # bound method, which no longer reports as a static or class
355 # method. The static methods must be transfered to the ABC
356 # in this unbound state, so that python will still see them
357 # as static methods and not attempt to pass self. The class
358 # methods must be transfered to the ABC as a bound method
359 # so that the correct cls be called with the class method
360 for name in subclass.__dict__:
361 if name in ("__new__", "__init_subclass__"):
362 continue
363 obj = subclass.__dict__[name]
364 # copy over the static methods
365 isBuiltin = isinstance(obj, types.BuiltinFunctionType)
366 isStatic = isinstance(obj, staticmethod)
367 if isBuiltin or isStatic:
368 if hasattr(cls, name):
369 raise AttributeError(conflictStr.format(name, "static", subclass))
370 setattr(cls, name, obj)
371 # copy over the class methods
372 elif isinstance(obj, classmethod):
373 if hasattr(cls, name):
374 raise AttributeError(conflictStr.format(name, "class", subclass))
375 setattr(cls, name, getattr(subclass, name))
377 def setattrSafe(name, value):
378 try:
379 currentValue = getattr(subclass, name)
380 if currentValue != value:
381 msg = "subclass already has a '{}' attribute with value {} != {}."
382 raise ValueError(msg.format(name, currentValue, value))
383 except AttributeError:
384 setattr(subclass, name, value)
386 if len(cls.TEMPLATE_PARAMS) == 1:
387 setattrSafe(cls.TEMPLATE_PARAMS[0], key)
388 elif len(cls.TEMPLATE_PARAMS) == len(key):
389 for p, k in zip(cls.TEMPLATE_PARAMS, key):
390 setattrSafe(p, k)
391 else:
392 raise ValueError(
393 f"key must have {len(cls.TEMPLATE_PARAMS)} elements (one for each of {cls.TEMPLATE_PARAMS})"
394 )
396 for name, attr in cls._inherited.items():
397 setattr(subclass, name, attr)
399 def alias(cls, key, subclass):
400 """Add an alias that allows an existing subclass to be accessed with a
401 different key.
402 """
403 if key is None:
404 raise ValueError("None may not be used as a key.")
405 if key in cls._registry:
406 raise KeyError(f"Cannot multiply-register key {key}")
407 primaryKey = tuple(getattr(subclass, p, None) for p in cls.TEMPLATE_PARAMS)
408 if len(primaryKey) == 1:
409 # indices are only tuples if there are multiple elements
410 primaryKey = primaryKey[0]
411 if cls._registry.get(primaryKey, None) != subclass:
412 raise ValueError("Subclass is not registered with this base class.")
413 cls._registry[key] = subclass
415 # Immutable mapping interface defined below. We don't use collections
416 # mixins because we don't want their comparison operators.
418 def __getitem__(cls, key):
419 return cls._registry[key]
421 def __iter__(cls):
422 return iter(cls._registry)
424 def __len__(cls):
425 return len(cls._registry)
427 def __contains__(cls, key):
428 return key in cls._registry
430 def keys(cls):
431 """Return an iterable containing all keys (including aliases)."""
432 return cls._registry.keys()
434 def values(cls):
435 """Return an iterable of registered subclasses, with duplicates
436 corresponding to any aliases.
437 """
438 return cls._registry.values()
440 def items(cls):
441 """Return an iterable of (key, subclass) pairs."""
442 return cls._registry.items()
444 def get(cls, key, default=None):
445 """Return the subclass associated with the given key (including
446 aliases), or ``default`` if the key is not recognized.
447 """
448 return cls._registry.get(key, default)