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

138 statements  

« prev     ^ index     » next       coverage.py v7.4.0, created at 2024-01-19 11:15 +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__ = ("continueClass", "inClass", "TemplateMeta") 

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 @continueClass 

77 class Foo: 

78 def run(self): 

79 return None 

80 

81 is equivalent to: 

82 

83 .. code-block:: python 

84 

85 class Foo: 

86 def run(self): 

87 return None 

88 

89 .. warning:: 

90 

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

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

93 directly using their explicit types instead. 

94 """ 

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

96 for name in dir(cls): 

97 # Common descriptors like classmethod and staticmethod can only be 

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

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

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

101 # class. 

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

103 if isAttributeSafeToTransfer(name, attr): 

104 setattr(orig, name, attr) 

105 return orig 

106 

107 

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

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

110 

111 Parameters 

112 ---------- 

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

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

115 can not be determined. 

116 

117 Examples 

118 -------- 

119 For example: 

120 

121 .. code-block:: python 

122 

123 class Foo: 

124 pass 

125 

126 @inClass(Foo) 

127 def run(self): 

128 return None 

129 

130 is equivalent to: 

131 

132 .. code-block:: python 

133 

134 class Foo: 

135 def run(self): 

136 return None 

137 

138 Notes 

139 ----- 

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

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

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

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

144 """ 

145 

146 def decorate(func): 

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

148 # assignment signals a strictly local variable. 

149 name1 = name 

150 if name1 is None: 

151 if hasattr(func, "__name__"): 

152 name1 = func.__name__ 

153 else: 

154 if hasattr(func, "__func__"): 

155 # classmethod and staticmethod have __func__ but 

156 # no __name__ 

157 name1 = func.__func__.__name__ 

158 elif hasattr(func, "fget"): 

159 # property has fget but no __name__ 

160 name1 = func.fget.__name__ 

161 else: 

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

163 setattr(cls, name1, func) 

164 return func 

165 

166 return decorate 

167 

168 

169class TemplateMeta(type): 

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

171 template types. 

172 

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

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

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

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

177 in a consistent way. 

178 

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

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

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

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

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

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

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

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

187 example:: 

188 

189 .. code-block:: python 

190 

191 import numpy as np 

192 from ._image import ImageF, ImageD 

193 

194 class Image(metaclass=TemplateMeta): 

195 pass 

196 

197 Image.register(np.float32, ImageF) 

198 Image.register(np.float64, ImageD) 

199 Image.alias("F", ImageF) 

200 Image.alias("D", ImageD) 

201 

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

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

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

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

206 

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

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

209 the type keys:: 

210 

211 .. code-block:: python 

212 

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

214 

215 This simply forwards additional positional and keyword arguments to the 

216 wrapped template class's constructor. 

217 

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

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

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

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

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

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

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

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

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

227 generally not be tuples. 

228 

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

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

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

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

233 

234 .. code-block:: python 

235 

236 class Image(metaclass=TemplateMeta): 

237 

238 def sum(self): 

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

240 

241 Image.register(np.float32, ImageF) 

242 Image.register(np.float64, ImageD) 

243 

244 .. note:: 

245 

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

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

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

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

250 subclasses. Instead, attributes added to an instance of 

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

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

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

254 implementations of these methods. 

255 

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

257 like interface for accessing their registered subclasses, providing 

258 something like the C++ syntax for templates:: 

259 

260 .. code-block:: python 

261 

262 Image[np.float32] -> ImageF 

263 Image["D"] -> ImageD 

264 

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

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

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

268 method. 

269 

270 .. warning:: 

271 

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

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

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

275 """ 

276 

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

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

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

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

281 # to registered subclasses later. 

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

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

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

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

286 # attributes on registered subclasses and intercept arguments to the 

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

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

289 # defined. 

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

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

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

293 ) 

294 attrs["_registry"] = {} 

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

296 

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

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

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

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

301 return self 

302 

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

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

305 # the abstract base class. 

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

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

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

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

310 # type of the dtype 

311 key = [] 

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

313 tempKey = kwds.pop(p, d) 

314 if isinstance(tempKey, np.dtype): 

315 tempKey = tempKey.type 

316 key.append(tempKey) 

317 key = tuple(key) 

318 

319 # indices are only tuples if there are multiple elements 

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

321 if clz is None: 

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

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

324 return clz(*args, **kwds) 

325 

326 def __subclasscheck__(cls, subclass): 

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

328 # any registered type or true subclass thereof. 

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

330 return True 

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

332 

333 def __instancecheck__(cls, instance): 

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

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

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

337 return True 

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

339 

340 def __subclasses__(cls): 

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

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

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

344 # functionality. 

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

346 

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

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

349 number, type, or other hashable). 

350 

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

352 

353 Parameters 

354 ---------- 

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

356 Key to use for registration. 

357 subclass : `type` 

358 Subclass to register. 

359 """ 

360 if key is None: 

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

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

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

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

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

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

367 else: 

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

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

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

371 # make the static methods available through the ABC 

372 if cls.TEMPLATE_DEFAULTS: 

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

374 if key == defaults: 

375 conflictStr = ( 

376 "Base class has attribute {}" 

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

378 " Cannot link method to base class." 

379 ) 

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

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

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

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

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

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

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

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

388 for name in subclass.__dict__: 

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

390 continue 

391 obj = subclass.__dict__[name] 

392 # copy over the static methods 

393 isBuiltin = isinstance(obj, types.BuiltinFunctionType) 

394 isStatic = isinstance(obj, staticmethod) 

395 if isBuiltin or isStatic: 

396 if hasattr(cls, name): 

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

398 setattr(cls, name, obj) 

399 # copy over the class methods 

400 elif isinstance(obj, classmethod): 

401 if hasattr(cls, name): 

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

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

404 

405 def setattrSafe(name, value): 

406 try: 

407 currentValue = getattr(subclass, name) 

408 if currentValue != value: 

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

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

411 except AttributeError: 

412 setattr(subclass, name, value) 

413 

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

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

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

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

418 setattrSafe(p, k) 

419 else: 

420 raise ValueError( 

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

422 ) 

423 

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

425 setattr(subclass, name, attr) 

426 

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

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

429 different key. 

430 

431 Parameters 

432 ---------- 

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

434 Key to use for aliasing. 

435 subclass : `type` 

436 Subclass to alias. 

437 """ 

438 if key is None: 

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

440 if key in cls._registry: 

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

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

443 if len(primaryKey) == 1: 

444 # indices are only tuples if there are multiple elements 

445 primaryKey = primaryKey[0] 

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

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

448 cls._registry[key] = subclass 

449 

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

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

452 

453 def __getitem__(cls, key): 

454 return cls._registry[key] 

455 

456 def __iter__(cls): 

457 return iter(cls._registry) 

458 

459 def __len__(cls): 

460 return len(cls._registry) 

461 

462 def __contains__(cls, key): 

463 return key in cls._registry 

464 

465 def keys(cls): 

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

467 return cls._registry.keys() 

468 

469 def values(cls): 

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

471 corresponding to any aliases. 

472 """ 

473 return cls._registry.values() 

474 

475 def items(cls): 

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

477 return cls._registry.items() 

478 

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

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

481 

482 Parameters 

483 ---------- 

484 key : `~collections.abc.Hashable` 

485 Key to query. 

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

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

488 

489 Returns 

490 ------- 

491 `type` 

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

493 if the key is not recognized. 

494 """ 

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