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

143 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-07-08 09:53 +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 

16 

17import numpy as np 

18 

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

20 

21 

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) 

35 

36 

37def isAttributeSafeToTransfer(name, value): 

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

39 class. 

40 

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 

50 

51 

52def continueClass(cls): 

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

54 original. 

55 

56 For example: 

57 

58 .. code-block:: python 

59 

60 class Foo: 

61 pass 

62 

63 @continueClass 

64 class Foo: 

65 def run(self): 

66 return None 

67 

68 is equivalent to: 

69 

70 .. code-block:: python 

71 

72 class Foo: 

73 def run(self): 

74 return None 

75 

76 .. warning:: 

77 

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. 

81 

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 

94 

95 

96def inClass(cls, name=None): 

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

98 

99 For example: 

100 

101 .. code-block:: python 

102 

103 class Foo: 

104 pass 

105 

106 @inClass(Foo) 

107 def run(self): 

108 return None 

109 

110 is equivalent to: 

111 

112 .. code-block:: python 

113 

114 class Foo: 

115 def run(self): 

116 return None 

117 

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 """ 

123 

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 

143 

144 return decorate 

145 

146 

147class TemplateMeta(type): 

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

149 template types. 

150 

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. 

156 

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:: 

166 

167 .. code-block:: python 

168 

169 import numpy as np 

170 from ._image import ImageF, ImageD 

171 

172 class Image(metaclass=TemplateMeta): 

173 pass 

174 

175 Image.register(np.float32, ImageF) 

176 Image.register(np.float64, ImageD) 

177 Image.alias("F", ImageF) 

178 Image.alias("D", ImageD) 

179 

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). 

184 

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:: 

188 

189 .. code-block:: python 

190 

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

192 

193 This simply forwards additional positional and keyword arguments to the 

194 wrapped template class's constructor. 

195 

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. 

206 

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:: 

211 

212 .. code-block:: python 

213 

214 class Image(metaclass=TemplateMeta): 

215 

216 def sum(self): 

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

218 

219 Image.register(np.float32, ImageF) 

220 Image.register(np.float64, ImageD) 

221 

222 .. note:: 

223 

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. 

233 

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:: 

237 

238 .. code-block:: python 

239 

240 Image[np.float32] -> ImageF 

241 Image["D"] -> ImageD 

242 

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. 

247 

248 .. warning:: 

249 

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).. 

253 

254 """ 

255 

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) 

275 

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 

281 

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) 

297 

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) 

304 

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 for v in cls._registry.values(): 

311 if issubclass(subclass, v): 

312 return True 

313 return False 

314 

315 def __instancecheck__(cls, instance): 

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

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

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

319 return True 

320 for v in cls._registry.values(): 

321 if isinstance(instance, v): 

322 return True 

323 return False 

324 

325 def __subclasses__(cls): 

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

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

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

329 # functionality. 

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

331 

332 def register(cls, key, subclass): 

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

334 number, type, or other hashable). 

335 

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

337 """ 

338 if key is None: 

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

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

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

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

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

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

345 else: 

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

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

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

349 # make the static methods available through the ABC 

350 if cls.TEMPLATE_DEFAULTS: 

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

352 if key == defaults: 

353 conflictStr = ( 

354 "Base class has attribute {}" 

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

356 " Cannot link method to base class." 

357 ) 

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

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

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

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

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

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

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

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

366 for name in subclass.__dict__: 

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

368 continue 

369 obj = subclass.__dict__[name] 

370 # copy over the static methods 

371 isBuiltin = isinstance(obj, types.BuiltinFunctionType) 

372 isStatic = isinstance(obj, staticmethod) 

373 if isBuiltin or isStatic: 

374 if hasattr(cls, name): 

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

376 setattr(cls, name, obj) 

377 # copy over the class methods 

378 elif isinstance(obj, classmethod): 

379 if hasattr(cls, name): 

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

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

382 

383 def setattrSafe(name, value): 

384 try: 

385 currentValue = getattr(subclass, name) 

386 if currentValue != value: 

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

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

389 except AttributeError: 

390 setattr(subclass, name, value) 

391 

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

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

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

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

396 setattrSafe(p, k) 

397 else: 

398 raise ValueError( 

399 "key must have {} elements (one for each of {})".format( 

400 len(cls.TEMPLATE_PARAMS), cls.TEMPLATE_PARAMS 

401 ) 

402 ) 

403 

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

405 setattr(subclass, name, attr) 

406 

407 def alias(cls, key, subclass): 

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

409 different key. 

410 """ 

411 if key is None: 

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

413 if key in cls._registry: 

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

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

416 if len(primaryKey) == 1: 

417 # indices are only tuples if there are multiple elements 

418 primaryKey = primaryKey[0] 

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

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

421 cls._registry[key] = subclass 

422 

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

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

425 

426 def __getitem__(cls, key): 

427 return cls._registry[key] 

428 

429 def __iter__(cls): 

430 return iter(cls._registry) 

431 

432 def __len__(cls): 

433 return len(cls._registry) 

434 

435 def __contains__(cls, key): 

436 return key in cls._registry 

437 

438 def keys(cls): 

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

440 return cls._registry.keys() 

441 

442 def values(cls): 

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

444 corresponding to any aliases. 

445 """ 

446 return cls._registry.values() 

447 

448 def items(cls): 

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

450 return cls._registry.items() 

451 

452 def get(cls, key, default=None): 

453 """Return the subclass associated with the given key (including 

454 aliases), or ``default`` if the key is not recognized. 

455 """ 

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