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

142 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2023-01-10 02:25 -0800

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 

12import sys 

13import types 

14 

15import numpy as np 

16 

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

18 

19 

20INTRINSIC_SPECIAL_ATTRIBUTES = frozenset( 

21 ( 

22 "__qualname__", 

23 "__module__", 

24 "__metaclass__", 

25 "__dict__", 

26 "__weakref__", 

27 "__class__", 

28 "__subclasshook__", 

29 "__name__", 

30 "__doc__", 

31 ) 

32) 

33 

34 

35def isAttributeSafeToTransfer(name, value): 

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

37 class. 

38 

39 This rejects special methods that are defined automatically for all 

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

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

42 """ 

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

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

45 ): 

46 return False 

47 return True 

48 

49 

50def continueClass(cls): 

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

52 original. 

53 

54 For example: 

55 

56 .. code-block:: python 

57 

58 class Foo: 

59 pass 

60 

61 @continueClass 

62 class Foo: 

63 def run(self): 

64 return None 

65 

66 is equivalent to: 

67 

68 .. code-block:: python 

69 

70 class Foo: 

71 def run(self): 

72 return None 

73 

74 .. warning:: 

75 

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

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

78 directly using their explicit types instead. 

79 

80 """ 

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

82 for name in dir(cls): 

83 # Common descriptors like classmethod and staticmethod can only be 

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

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

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

87 # class. 

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

89 if isAttributeSafeToTransfer(name, attr): 

90 setattr(orig, name, attr) 

91 return orig 

92 

93 

94def inClass(cls, name=None): 

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

96 

97 For example: 

98 

99 .. code-block:: python 

100 

101 class Foo: 

102 pass 

103 

104 @inClass(Foo) 

105 def run(self): 

106 return None 

107 

108 is equivalent to: 

109 

110 .. code-block:: python 

111 

112 class Foo: 

113 def run(self): 

114 return None 

115 

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

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

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

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

120 """ 

121 

122 def decorate(func): 

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

124 # assignment signals a strictly local variable. 

125 name1 = name 

126 if name1 is None: 

127 if hasattr(func, "__name__"): 

128 name1 = func.__name__ 

129 else: 

130 if hasattr(func, "__func__"): 

131 # classmethod and staticmethod have __func__ but 

132 # no __name__ 

133 name1 = func.__func__.__name__ 

134 elif hasattr(func, "fget"): 

135 # property has fget but no __name__ 

136 name1 = func.fget.__name__ 

137 else: 

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

139 setattr(cls, name1, func) 

140 return func 

141 

142 return decorate 

143 

144 

145class TemplateMeta(type): 

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

147 template types. 

148 

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

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

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

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

153 in a consistent way. 

154 

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

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

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

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

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

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

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

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

163 example:: 

164 

165 .. code-block:: python 

166 

167 import numpy as np 

168 from ._image import ImageF, ImageD 

169 

170 class Image(metaclass=TemplateMeta): 

171 pass 

172 

173 Image.register(np.float32, ImageF) 

174 Image.register(np.float64, ImageD) 

175 Image.alias("F", ImageF) 

176 Image.alias("D", ImageD) 

177 

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

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

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

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

182 

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

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

185 the type keys:: 

186 

187 .. code-block:: python 

188 

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

190 

191 This simply forwards additional positional and keyword arguments to the 

192 wrapped template class's constructor. 

193 

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

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

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

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

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

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

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

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

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

203 generally not be tuples. 

204 

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

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

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

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

209 

210 .. code-block:: python 

211 

212 class Image(metaclass=TemplateMeta): 

213 

214 def sum(self): 

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

216 

217 Image.register(np.float32, ImageF) 

218 Image.register(np.float64, ImageD) 

219 

220 .. note:: 

221 

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

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

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

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

226 subclasses. Instead, attributes added to an instance of 

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

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

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

230 implementations of these methods. 

231 

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

233 like interface for accessing their registered subclasses, providing 

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

235 

236 .. code-block:: python 

237 

238 Image[np.float32] -> ImageF 

239 Image["D"] -> ImageD 

240 

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

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

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

244 method. 

245 

246 .. warning:: 

247 

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

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

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

251 

252 """ 

253 

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

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

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

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

258 # to registered subclasses later. 

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

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

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

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

263 # attributes on registered subclasses and intercept arguments to the 

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

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

266 # defined. 

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

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

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

270 ) 

271 attrs["_registry"] = dict() 

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

273 

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

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

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

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

278 return self 

279 

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

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

282 # the abstract base class. 

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

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

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

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

287 # type of the dtype 

288 key = [] 

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

290 tempKey = kwds.pop(p, d) 

291 if isinstance(tempKey, np.dtype): 

292 tempKey = tempKey.type 

293 key.append(tempKey) 

294 key = tuple(key) 

295 

296 # indices are only tuples if there are multiple elements 

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

298 if clz is None: 

299 d = {k: v for k, v in zip(cls.TEMPLATE_PARAMS, key)} 

300 raise TypeError("No registered subclass for {}.".format(d)) 

301 return clz(*args, **kwds) 

302 

303 def __subclasscheck__(cls, subclass): 

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

305 # any registered type or true subclass thereof. 

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

307 return True 

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

309 if issubclass(subclass, v): 

310 return True 

311 return False 

312 

313 def __instancecheck__(cls, instance): 

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

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

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

317 return True 

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

319 if isinstance(instance, v): 

320 return True 

321 return False 

322 

323 def __subclasses__(cls): 

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

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

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

327 # functionality. 

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

329 

330 def register(cls, key, subclass): 

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

332 number, type, or other hashable). 

333 

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

335 """ 

336 if key is None: 

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

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

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

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

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

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

343 else: 

344 d = {k: v for k, v in zip(cls.TEMPLATE_PARAMS, key)} 

345 raise KeyError("Another subclass is already registered with {}".format(d)) 

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

347 # make the static methods available through the ABC 

348 if cls.TEMPLATE_DEFAULTS: 

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

350 if key == defaults: 

351 conflictStr = ( 

352 "Base class has attribute {}" 

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

354 " Cannot link method to base class." 

355 ) 

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

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

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

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

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

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

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

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

364 for name in subclass.__dict__: 

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

366 continue 

367 obj = subclass.__dict__[name] 

368 # copy over the static methods 

369 isBuiltin = isinstance(obj, types.BuiltinFunctionType) 

370 isStatic = isinstance(obj, staticmethod) 

371 if isBuiltin or isStatic: 

372 if hasattr(cls, name): 

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

374 setattr(cls, name, obj) 

375 # copy over the class methods 

376 elif isinstance(obj, classmethod): 

377 if hasattr(cls, name): 

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

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

380 

381 def setattrSafe(name, value): 

382 try: 

383 currentValue = getattr(subclass, name) 

384 if currentValue != value: 

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

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

387 except AttributeError: 

388 setattr(subclass, name, value) 

389 

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

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

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

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

394 setattrSafe(p, k) 

395 else: 

396 raise ValueError( 

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

398 len(cls.TEMPLATE_PARAMS), cls.TEMPLATE_PARAMS 

399 ) 

400 ) 

401 

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

403 setattr(subclass, name, attr) 

404 

405 def alias(cls, key, subclass): 

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

407 different key. 

408 """ 

409 if key is None: 

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

411 if key in cls._registry: 

412 raise KeyError("Cannot multiply-register key {}".format(key)) 

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

414 if len(primaryKey) == 1: 

415 # indices are only tuples if there are multiple elements 

416 primaryKey = primaryKey[0] 

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

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

419 cls._registry[key] = subclass 

420 

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

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

423 

424 def __getitem__(cls, key): 

425 return cls._registry[key] 

426 

427 def __iter__(cls): 

428 return iter(cls._registry) 

429 

430 def __len__(cls): 

431 return len(cls._registry) 

432 

433 def __contains__(cls, key): 

434 return key in cls._registry 

435 

436 def keys(cls): 

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

438 return cls._registry.keys() 

439 

440 def values(cls): 

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

442 corresponding to any aliases. 

443 """ 

444 return cls._registry.values() 

445 

446 def items(cls): 

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

448 return cls._registry.items() 

449 

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

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

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

453 """ 

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