Coverage for python / lsst / utils / wrappers.py: 12%

138 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-26 08:43 +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. 

11 

12from __future__ import annotations 

13 

14import sys 

15import types 

16from typing import Any 

17 

18import numpy as np 

19 

20__all__ = ("TemplateMeta", "continueClass", "inClass") 

21 

22 

23INTRINSIC_SPECIAL_ATTRIBUTES = frozenset( 

24 ( 

25 "__qualname__", 

26 "__module__", 

27 "__metaclass__", 

28 "__dict__", 

29 "__weakref__", 

30 "__class__", 

31 "__subclasshook__", 

32 "__name__", 

33 "__doc__", 

34 ) 

35) 

36 

37 

38def isAttributeSafeToTransfer(name: str, value: Any) -> bool: 

39 """Return True if an attribute is safe to monkeypatch-transfer to another 

40 class. 

41 

42 This rejects special methods that are defined automatically for all 

43 classes, leaving only those explicitly defined in a class decorated by 

44 `continueClass` or registered with an instance of `TemplateMeta`. 

45 

46 Parameters 

47 ---------- 

48 name : `str` 

49 The name of the attribute to check. 

50 value : `~typing.Any` 

51 The value of the attribute. 

52 

53 Returns 

54 ------- 

55 `bool` 

56 Whether the attribute is safe to monkeypatch-transfer. 

57 """ 

58 if name.startswith("__") and ( 

59 value is getattr(object, name, None) or name in INTRINSIC_SPECIAL_ATTRIBUTES 

60 ): 

61 return False 

62 return True 

63 

64 

65def continueClass(cls): 

66 """Re-open the decorated class, adding any new definitions into the 

67 original. 

68 

69 For example: 

70 

71 .. code-block:: python 

72 

73 class Foo: 

74 pass 

75 

76 

77 @continueClass 

78 class Foo: 

79 def run(self): 

80 return None 

81 

82 is equivalent to: 

83 

84 .. code-block:: python 

85 

86 class Foo: 

87 def run(self): 

88 return None 

89 

90 .. warning:: 

91 

92 Python's built-in `super` function does not behave properly in classes 

93 decorated with `continueClass`. Base class methods must be invoked 

94 directly using their explicit types instead. 

95 """ 

96 orig = getattr(sys.modules[cls.__module__], cls.__name__) 

97 for name in dir(cls): 

98 # Common descriptors like classmethod and staticmethod can only be 

99 # accessed without invoking their magic if we use __dict__; if we use 

100 # getattr on those we'll get e.g. a bound method instance on the dummy 

101 # class rather than a classmethod instance we can put on the target 

102 # class. 

103 attr = cls.__dict__.get(name, None) or getattr(cls, name) 

104 if isAttributeSafeToTransfer(name, attr): 

105 setattr(orig, name, attr) 

106 return orig 

107 

108 

109def inClass(cls, name: str | None = None): 

110 """Add the decorated function to the given class as a method. 

111 

112 Parameters 

113 ---------- 

114 name : `str` or `None`, optional 

115 Name to be associated with the decorated function if the default 

116 can not be determined. 

117 

118 Examples 

119 -------- 

120 For example: 

121 

122 .. code-block:: python 

123 

124 class Foo: 

125 pass 

126 

127 

128 @inClass(Foo) 

129 def run(self): 

130 return None 

131 

132 is equivalent to: 

133 

134 .. code-block:: python 

135 

136 class Foo: 

137 def run(self): 

138 return None 

139 

140 Notes 

141 ----- 

142 Standard decorators like ``classmethod``, ``staticmethod``, and 

143 ``property`` may be used *after* this decorator. Custom decorators 

144 may only be used if they return an object with a ``__name__`` attribute 

145 or the ``name`` optional argument is provided. 

146 """ 

147 

148 def decorate(func): 

149 # Using 'name' instead of 'name1' breaks the closure because 

150 # assignment signals a strictly local variable. 

151 name1 = name 

152 if name1 is None: 

153 if hasattr(func, "__name__"): 

154 name1 = func.__name__ 

155 else: 

156 if hasattr(func, "__func__"): 

157 # classmethod and staticmethod have __func__ but 

158 # no __name__ 

159 name1 = func.__func__.__name__ 

160 elif hasattr(func, "fget"): 

161 # property has fget but no __name__ 

162 name1 = func.fget.__name__ 

163 else: 

164 raise ValueError(f"Could not guess attribute name for '{func}'.") 

165 setattr(cls, name1, func) 

166 return func 

167 

168 return decorate 

169 

170 

171class TemplateMeta(type): 

172 """A metaclass for abstract base classes that tie together wrapped C++ 

173 template types. 

174 

175 C++ template classes are most easily wrapped with a separate Python class 

176 for each template type, which results in an unnatural Python interface. 

177 TemplateMeta provides a thin layer that connects these Python classes by 

178 giving them a common base class and acting as a factory to construct them 

179 in a consistent way. 

180 

181 To use, simply create a new class with the name of the template class, and 

182 use ``TemplateMeta`` as its metaclass, and then call ``register`` on each 

183 of its subclasses. This registers the class with a "type key" - usually a 

184 Python representation of the C++ template types. The type key must be a 

185 hashable object - strings, type objects, and tuples of these (for C++ 

186 classes with multiple template parameters) are good choices. Alternate 

187 type keys for existing classes can be added by calling ``alias``, but only 

188 after a subclass already been registered with a "primary" type key. For 

189 example: 

190 

191 .. code-block:: python 

192 

193 import numpy as np 

194 from ._image import ImageF, ImageD 

195 

196 

197 class Image(metaclass=TemplateMeta): 

198 pass 

199 

200 

201 Image.register(np.float32, ImageF) 

202 Image.register(np.float64, ImageD) 

203 Image.alias("F", ImageF) 

204 Image.alias("D", ImageD) 

205 

206 We have intentionally used ``numpy`` types as the primary keys for these 

207 objects in this example, with strings as secondary aliases simply because 

208 the primary key is added as a ``dtype`` attribute on the the registered 

209 classes (so ``ImageF.dtype == numpy.float32`` in the above example). 

210 

211 This allows user code to construct objects directly using ``Image``, as 

212 long as an extra ``dtype`` keyword argument is passed that matches one of 

213 the type keys: 

214 

215 .. code-block:: python 

216 

217 img = Image(52, 64, dtype=np.float32) 

218 

219 This simply forwards additional positional and keyword arguments to the 

220 wrapped template class's constructor. 

221 

222 The choice of "dtype" as the name of the template parameter is also 

223 configurable, and in fact multiple template parameters are also supported, 

224 by setting a ``TEMPLATE_PARAMS`` class attribute on the ABC to a tuple 

225 containing the names of the template parameters. A ``TEMPLATE_DEFAULTS`` 

226 attribute can also be defined to a tuple of the same length containing 

227 default values for the template parameters, allowing them to be omitted in 

228 constructor calls. When the length of these attributes is more than one, 

229 the type keys passed to ``register`` and ``alias`` should be tuple of the 

230 same length; when the length of these attributes is one, type keys should 

231 generally not be tuples. 

232 

233 As an aid for those writing the Python wrappers for C++ classes, 

234 ``TemplateMeta`` also provides a way to add pure-Python methods and other 

235 attributes to the wrapped template classes. To add a ``sum`` method to 

236 all registered types, for example, we can just do: 

237 

238 .. code-block:: python 

239 

240 class Image(metaclass=TemplateMeta): 

241 def sum(self): 

242 return np.sum(self.getArray()) 

243 

244 

245 Image.register(np.float32, ImageF) 

246 Image.register(np.float64, ImageD) 

247 

248 .. note:: 

249 

250 ``TemplateMeta`` works by overriding the ``__instancecheck__`` and 

251 ``__subclasscheck__`` special methods, and hence does not appear in 

252 its registered subclasses' method resolution order or ``__bases__`` 

253 attributes. That means its attributes are not inherited by registered 

254 subclasses. Instead, attributes added to an instance of 

255 ``TemplateMeta`` are *copied* into the types registered with it. These 

256 attributes will thus *replace* existing attributes in those classes 

257 with the same name, and subclasses cannot delegate to base class 

258 implementations of these methods. 

259 

260 Finally, abstract base classes that use ``TemplateMeta`` define a dict- 

261 like interface for accessing their registered subclasses, providing 

262 something like the C++ syntax for templates: 

263 

264 .. code-block:: python 

265 

266 Image[np.float32] -> ImageF 

267 Image["D"] -> ImageD 

268 

269 Both primary dtypes and aliases can be used as keys in this interface, 

270 which means types with aliases will be present multiple times in the dict. 

271 To obtain the sequence of unique subclasses, use the ``__subclasses__`` 

272 method. 

273 

274 .. warning:: 

275 

276 Python's built-in `super` function does not behave properly in classes 

277 that have `TemplateMeta` as their metaclass (which should be rare, as 

278 TemplateMeta ABCs will have base classes of their own).. 

279 """ 

280 

281 def __new__(cls, name, bases, attrs): 

282 # __new__ is invoked when the abstract base class is defined (via a 

283 # class statement). We save a dict of class attributes (including 

284 # methods) that were defined in the class body so we can copy them 

285 # to registered subclasses later. 

286 # We also initialize an empty dict to store the registered subclasses. 

287 attrs["_inherited"] = {k: v for k, v in attrs.items() if isAttributeSafeToTransfer(k, v)} 

288 # The special "TEMPLATE_PARAMS" class attribute, if defined, contains 

289 # names of the template parameters, which we use to set those 

290 # attributes on registered subclasses and intercept arguments to the 

291 # constructor. This line removes it from the dict of things that 

292 # should be inherited while setting a default of 'dtype' if it's not 

293 # defined. 

294 attrs["TEMPLATE_PARAMS"] = attrs["_inherited"].pop("TEMPLATE_PARAMS", ("dtype",)) 

295 attrs["TEMPLATE_DEFAULTS"] = attrs["_inherited"].pop( 

296 "TEMPLATE_DEFAULTS", (None,) * len(attrs["TEMPLATE_PARAMS"]) 

297 ) 

298 attrs["_registry"] = {} 

299 self = type.__new__(cls, name, bases, attrs) 

300 

301 if len(self.TEMPLATE_PARAMS) == 0: 

302 raise ValueError("TEMPLATE_PARAMS must be a tuple with at least one element.") 

303 if len(self.TEMPLATE_DEFAULTS) != len(self.TEMPLATE_PARAMS): 

304 raise ValueError("TEMPLATE_PARAMS and TEMPLATE_DEFAULTS must have same length.") 

305 return self 

306 

307 def __call__(cls, *args, **kwds): 

308 # __call__ is invoked when someone tries to construct an instance of 

309 # the abstract base class. 

310 # If the ABC defines a "TEMPLATE_PARAMS" attribute, we use those 

311 # strings as the kwargs we should intercept to find the right type. 

312 # Generate a type mapping key from input keywords. If the type returned 

313 # from the keyword lookup is a numpy dtype object, fetch the underlying 

314 # type of the dtype 

315 key = [] 

316 for p, d in zip(cls.TEMPLATE_PARAMS, cls.TEMPLATE_DEFAULTS): 

317 tempKey = kwds.pop(p, d) 

318 if isinstance(tempKey, np.dtype): 

319 tempKey = tempKey.type 

320 key.append(tempKey) 

321 key = tuple(key) 

322 

323 # indices are only tuples if there are multiple elements 

324 clz = cls._registry.get(key[0] if len(key) == 1 else key, None) 

325 if clz is None: 

326 d = dict(zip(cls.TEMPLATE_PARAMS, key)) 

327 raise TypeError(f"No registered subclass for {d}.") 

328 return clz(*args, **kwds) 

329 

330 def __subclasscheck__(cls, subclass): 

331 # Special method hook for the issubclass built-in: we return true for 

332 # any registered type or true subclass thereof. 

333 if subclass in cls._registry.values(): 

334 return True 

335 return any(issubclass(subclass, v) for v in cls._registry.values()) 

336 

337 def __instancecheck__(cls, instance): 

338 # Special method hook for the isinstance built-in: we return true for 

339 # an instance of any registered type or true subclass thereof. 

340 if type(instance) in cls._registry.values(): 

341 return True 

342 return any(isinstance(instance, v) for v in cls._registry.values()) 

343 

344 def __subclasses__(cls): 

345 """Return a tuple of all classes that inherit from this class.""" 

346 # This special method isn't defined as part of the Python data model, 

347 # but it exists on builtins (including ABCMeta), and it provides useful 

348 # functionality. 

349 return tuple(set(cls._registry.values())) 

350 

351 def register(cls, key, subclass) -> None: 

352 """Register a subclass of this ABC with the given key (a string, 

353 number, type, or other hashable). 

354 

355 Register may only be called once for a given key or a given subclass. 

356 

357 Parameters 

358 ---------- 

359 key : `str` or `numbers.Number` or `None` or `collections.abc.Hashable` 

360 Key to use for registration. 

361 subclass : `type` 

362 Subclass to register. 

363 """ 

364 if key is None: 

365 raise ValueError("None may not be used as a key.") 

366 if subclass in cls._registry.values(): 

367 raise ValueError("This subclass has already registered with another key; use alias() instead.") 

368 if cls._registry.setdefault(key, subclass) != subclass: 

369 if len(cls.TEMPLATE_PARAMS) == 1: 

370 d = {cls.TEMPLATE_PARAMS[0]: key} 

371 else: 

372 d = dict(zip(cls.TEMPLATE_PARAMS, key)) 

373 raise KeyError(f"Another subclass is already registered with {d}") 

374 # If the key used to register a class matches the default key, 

375 # make the static methods available through the ABC 

376 if cls.TEMPLATE_DEFAULTS: 

377 defaults = cls.TEMPLATE_DEFAULTS[0] if len(cls.TEMPLATE_DEFAULTS) == 1 else cls.TEMPLATE_DEFAULTS 

378 if key == defaults: 

379 conflictStr = ( 

380 "Base class has attribute {}" 

381 " which is a {} method of {}." 

382 " Cannot link method to base class." 

383 ) 

384 # In the following if statements, the explicit lookup in 

385 # __dict__ must be done, as a call to getattr returns the 

386 # bound method, which no longer reports as a static or class 

387 # method. The static methods must be transfered to the ABC 

388 # in this unbound state, so that python will still see them 

389 # as static methods and not attempt to pass self. The class 

390 # methods must be transfered to the ABC as a bound method 

391 # so that the correct cls be called with the class method 

392 for name in subclass.__dict__: 

393 if name in ("__new__", "__init_subclass__"): 

394 continue 

395 obj = subclass.__dict__[name] 

396 # copy over the static methods 

397 isBuiltin = isinstance(obj, types.BuiltinFunctionType) 

398 isStatic = isinstance(obj, staticmethod) 

399 if isBuiltin or isStatic: 

400 if hasattr(cls, name): 

401 raise AttributeError(conflictStr.format(name, "static", subclass)) 

402 setattr(cls, name, obj) 

403 # copy over the class methods 

404 elif isinstance(obj, classmethod): 

405 if hasattr(cls, name): 

406 raise AttributeError(conflictStr.format(name, "class", subclass)) 

407 setattr(cls, name, getattr(subclass, name)) 

408 

409 def setattrSafe(name, value): 

410 try: 

411 currentValue = getattr(subclass, name) 

412 if currentValue != value: 

413 msg = "subclass already has a '{}' attribute with value {} != {}." 

414 raise ValueError(msg.format(name, currentValue, value)) 

415 except AttributeError: 

416 setattr(subclass, name, value) 

417 

418 if len(cls.TEMPLATE_PARAMS) == 1: 

419 setattrSafe(cls.TEMPLATE_PARAMS[0], key) 

420 elif len(cls.TEMPLATE_PARAMS) == len(key): 

421 for p, k in zip(cls.TEMPLATE_PARAMS, key): 

422 setattrSafe(p, k) 

423 else: 

424 raise ValueError( 

425 f"key must have {len(cls.TEMPLATE_PARAMS)} elements (one for each of {cls.TEMPLATE_PARAMS})" 

426 ) 

427 

428 for name, attr in cls._inherited.items(): 

429 setattr(subclass, name, attr) 

430 

431 def alias(cls, key, subclass) -> None: 

432 """Add an alias that allows an existing subclass to be accessed with a 

433 different key. 

434 

435 Parameters 

436 ---------- 

437 key : `str` or `numbers.Number` or `None` or `collections.abc.Hashable` 

438 Key to use for aliasing. 

439 subclass : `type` 

440 Subclass to alias. 

441 """ 

442 if key is None: 

443 raise ValueError("None may not be used as a key.") 

444 if key in cls._registry: 

445 raise KeyError(f"Cannot multiply-register key {key}") 

446 primaryKey = tuple(getattr(subclass, p, None) for p in cls.TEMPLATE_PARAMS) 

447 if len(primaryKey) == 1: 

448 # indices are only tuples if there are multiple elements 

449 primaryKey = primaryKey[0] 

450 if cls._registry.get(primaryKey, None) != subclass: 

451 raise ValueError("Subclass is not registered with this base class.") 

452 cls._registry[key] = subclass 

453 

454 # Immutable mapping interface defined below. We don't use collections 

455 # mixins because we don't want their comparison operators. 

456 

457 def __getitem__(cls, key): 

458 return cls._registry[key] 

459 

460 def __iter__(cls): 

461 return iter(cls._registry) 

462 

463 def __len__(cls): 

464 return len(cls._registry) 

465 

466 def __contains__(cls, key): 

467 return key in cls._registry 

468 

469 def keys(cls): 

470 """Return an iterable containing all keys (including aliases).""" 

471 return cls._registry.keys() 

472 

473 def values(cls): 

474 """Return an iterable of registered subclasses, with duplicates 

475 corresponding to any aliases. 

476 """ 

477 return cls._registry.values() 

478 

479 def items(cls): 

480 """Return an iterable of (key, subclass) pairs.""" 

481 return cls._registry.items() 

482 

483 def get(cls, key, default=None) -> type: 

484 """Return the subclass associated with the given key. 

485 

486 Parameters 

487 ---------- 

488 key : `~collections.abc.Hashable` 

489 Key to query. 

490 default : `~typing.Any` or `None`, optional 

491 Default value to return if ``key`` is not found. 

492 

493 Returns 

494 ------- 

495 `type` 

496 Subclass with the given key. Includes aliases. Returns ``default`` 

497 if the key is not recognized. 

498 """ 

499 return cls._registry.get(key, default)