Coverage for python/lsst/daf/butler/core/named.py: 49%

199 statements  

« prev     ^ index     » next       coverage.py v6.4.4, created at 2022-08-31 10:07 +0000

1# This file is part of daf_butler. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (http://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

15# but WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <http://www.gnu.org/licenses/>. 

21from __future__ import annotations 

22 

23__all__ = ( 

24 "NamedKeyDict", 

25 "NamedKeyMapping", 

26 "NamedValueAbstractSet", 

27 "NamedValueMutableSet", 

28 "NamedValueSet", 

29 "NameLookupMapping", 

30 "NameMappingSetView", 

31) 

32 

33from abc import abstractmethod 

34from types import MappingProxyType 

35from typing import ( 

36 AbstractSet, 

37 Any, 

38 Dict, 

39 ItemsView, 

40 Iterable, 

41 Iterator, 

42 KeysView, 

43 Mapping, 

44 MutableMapping, 

45 MutableSet, 

46 Protocol, 

47 TypeVar, 

48 Union, 

49 ValuesView, 

50) 

51 

52 

53class Named(Protocol): 

54 """Protocol for objects with string name. 

55 

56 A non-inheritance interface for objects that have a string name that 

57 maps directly to their equality comparisons. 

58 """ 

59 

60 @property 

61 def name(self) -> str: 

62 pass 

63 

64 

65K = TypeVar("K", bound=Named) 

66K_co = TypeVar("K_co", bound=Named, covariant=True) 

67V = TypeVar("V") 

68V_co = TypeVar("V_co", covariant=True) 

69 

70 

71class NamedKeyMapping(Mapping[K_co, V_co]): 

72 """Custom mapping class. 

73 

74 An abstract base class for custom mappings whose keys are objects with 

75 a `str` ``name`` attribute, for which lookups on the name as well as the 

76 object are permitted. 

77 

78 Notes 

79 ----- 

80 In addition to the new `names` property and `byName` method, this class 

81 simply redefines the type signature for `__getitem__` and `get` that would 

82 otherwise be inherited from `Mapping`. That is only relevant for static 

83 type checking; the actual Python runtime doesn't care about types at all. 

84 """ 

85 

86 __slots__ = () 

87 

88 @property 

89 @abstractmethod 

90 def names(self) -> AbstractSet[str]: 

91 """Return the set of names associated with the keys, in the same order. 

92 

93 (`AbstractSet` [ `str` ]). 

94 """ 

95 raise NotImplementedError() 

96 

97 def byName(self) -> Dict[str, V_co]: 

98 """Return a `Mapping` with names as keys and the ``self`` values. 

99 

100 Returns 

101 ------- 

102 dictionary : `dict` 

103 A dictionary with the same values (and iteration order) as 

104 ``self``, with `str` names as keys. This is always a new object, 

105 not a view. 

106 """ 

107 return dict(zip(self.names, self.values())) 

108 

109 @abstractmethod 

110 def keys(self) -> NamedValueAbstractSet[K_co]: # type: ignore 

111 # TODO: docs 

112 raise NotImplementedError() 

113 

114 @abstractmethod 

115 def __getitem__(self, key: Union[str, K_co]) -> V_co: 

116 raise NotImplementedError() 

117 

118 def get(self, key: Union[str, K_co], default: Any = None) -> Any: 

119 # Delegating to super is not allowed by typing, because it doesn't 

120 # accept str, but we know it just delegates to __getitem__, which does. 

121 return super().get(key, default) # type: ignore 

122 

123 

124NameLookupMapping = Union[NamedKeyMapping[K_co, V_co], Mapping[str, V_co]] 

125"""A type annotation alias for signatures that want to use ``mapping[s]`` 

126(or ``mapping.get(s)``) where ``s`` is a `str`, and don't care whether 

127``mapping.keys()`` returns named objects or direct `str` instances. 

128""" 

129 

130 

131class NamedKeyMutableMapping(NamedKeyMapping[K, V], MutableMapping[K, V]): 

132 """An abstract base class that adds mutation to `NamedKeyMapping`.""" 

133 

134 __slots__ = () 

135 

136 @abstractmethod 

137 def __setitem__(self, key: Union[str, K], value: V) -> None: 

138 raise NotImplementedError() 

139 

140 @abstractmethod 

141 def __delitem__(self, key: Union[str, K]) -> None: 

142 raise NotImplementedError() 

143 

144 def pop(self, key: Union[str, K], default: Any = None) -> Any: 

145 # See comment in `NamedKeyMapping.get`; same logic applies here. 

146 return super().pop(key, default) # type: ignore 

147 

148 

149class NamedKeyDict(NamedKeyMutableMapping[K, V]): 

150 """Dictionary wrapper for named keys. 

151 

152 Requires keys to have a ``.name`` attribute, 

153 and permits lookups using either key objects or their names. 

154 

155 Names can be used in place of keys when updating existing items, but not 

156 when adding new items. 

157 

158 It is assumed (but asserted) that all name equality is equivalent to key 

159 equality, either because the key objects define equality this way, or 

160 because different objects with the same name are never included in the same 

161 dictionary. 

162 

163 Parameters 

164 ---------- 

165 args 

166 All positional constructor arguments are forwarded directly to `dict`. 

167 Keyword arguments are not accepted, because plain strings are not valid 

168 keys for `NamedKeyDict`. 

169 

170 Raises 

171 ------ 

172 AttributeError 

173 Raised when an attempt is made to add an object with no ``.name`` 

174 attribute to the dictionary. 

175 AssertionError 

176 Raised when multiple keys have the same name. 

177 """ 

178 

179 __slots__ = ( 

180 "_dict", 

181 "_names", 

182 ) 

183 

184 def __init__(self, *args: Any): 

185 self._dict: Dict[K, V] = dict(*args) 

186 self._names = {key.name: key for key in self._dict} 

187 assert len(self._names) == len(self._dict), "Duplicate names in keys." 

188 

189 @property 

190 def names(self) -> KeysView[str]: 

191 """Return set of names associated with the keys, in the same order. 

192 

193 (`~collections.abc.KeysView`). 

194 """ 

195 return self._names.keys() 

196 

197 def byName(self) -> Dict[str, V]: 

198 """Return a `dict` with names as keys and the ``self`` values.""" 

199 return dict(zip(self._names.keys(), self._dict.values())) 

200 

201 def __len__(self) -> int: 

202 return len(self._dict) 

203 

204 def __iter__(self) -> Iterator[K]: 

205 return iter(self._dict) 

206 

207 def __str__(self) -> str: 

208 return "{{{}}}".format(", ".join(f"{str(k)}: {str(v)}" for k, v in self.items())) 

209 

210 def __repr__(self) -> str: 

211 return "NamedKeyDict({{{}}})".format(", ".join(f"{repr(k)}: {repr(v)}" for k, v in self.items())) 

212 

213 def __getitem__(self, key: Union[str, K]) -> V: 

214 if isinstance(key, str): 

215 return self._dict[self._names[key]] 

216 else: 

217 return self._dict[key] 

218 

219 def __setitem__(self, key: Union[str, K], value: V) -> None: 

220 if isinstance(key, str): 

221 self._dict[self._names[key]] = value 

222 else: 

223 assert self._names.get(key.name, key) == key, "Name is already associated with a different key." 

224 self._dict[key] = value 

225 self._names[key.name] = key 

226 

227 def __delitem__(self, key: Union[str, K]) -> None: 

228 if isinstance(key, str): 

229 del self._dict[self._names[key]] 

230 del self._names[key] 

231 else: 

232 del self._dict[key] 

233 del self._names[key.name] 

234 

235 def keys(self) -> NamedValueAbstractSet[K]: # type: ignore 

236 return NameMappingSetView(self._names) 

237 

238 def values(self) -> ValuesView[V]: 

239 return self._dict.values() 

240 

241 def items(self) -> ItemsView[K, V]: 

242 return self._dict.items() 

243 

244 def copy(self) -> NamedKeyDict[K, V]: 

245 """Return a new `NamedKeyDict` with the same elements.""" 

246 result = NamedKeyDict.__new__(NamedKeyDict) 

247 result._dict = dict(self._dict) 

248 result._names = dict(self._names) 

249 return result 

250 

251 def freeze(self) -> NamedKeyMapping[K, V]: 

252 """Disable all mutators. 

253 

254 Effectively transforms ``self`` into an immutable mapping. 

255 

256 Returns 

257 ------- 

258 self : `NamedKeyMapping` 

259 While ``self`` is modified in-place, it is also returned with a 

260 type annotation that reflects its new, frozen state; assigning it 

261 to a new variable (and considering any previous references 

262 invalidated) should allow for more accurate static type checking. 

263 """ 

264 if not isinstance(self._dict, MappingProxyType): 

265 self._dict = MappingProxyType(self._dict) # type: ignore 

266 return self 

267 

268 

269class NamedValueAbstractSet(AbstractSet[K_co]): 

270 """Custom sets with named elements. 

271 

272 An abstract base class for custom sets whose elements are objects with 

273 a `str` ``name`` attribute, allowing some dict-like operations and 

274 views to be supported. 

275 """ 

276 

277 __slots__ = () 

278 

279 @property 

280 @abstractmethod 

281 def names(self) -> AbstractSet[str]: 

282 """Return set of names associated with the keys, in the same order. 

283 

284 (`AbstractSet` [ `str` ]). 

285 """ 

286 raise NotImplementedError() 

287 

288 @abstractmethod 

289 def asMapping(self) -> Mapping[str, K_co]: 

290 """Return a mapping view with names as keys. 

291 

292 Returns 

293 ------- 

294 dict : `Mapping` 

295 A dictionary-like view with ``values() == self``. 

296 """ 

297 raise NotImplementedError() 

298 

299 @abstractmethod 

300 def __getitem__(self, key: Union[str, K_co]) -> K_co: 

301 raise NotImplementedError() 

302 

303 def get(self, key: Union[str, K_co], default: Any = None) -> Any: 

304 """Return the element with the given name. 

305 

306 Returns ``default`` if no such element is present. 

307 """ 

308 try: 

309 return self[key] 

310 except KeyError: 

311 return default 

312 

313 @classmethod 

314 def _from_iterable(cls, iterable: Iterable[K_co]) -> NamedValueSet[K_co]: 

315 """Construct class from an iterable. 

316 

317 Hook to ensure that inherited `collections.abc.Set` operators return 

318 `NamedValueSet` instances, not something else (see `collections.abc` 

319 documentation for more information). 

320 

321 Note that this behavior can only be guaranteed when both operands are 

322 `NamedValueAbstractSet` instances. 

323 """ 

324 return NamedValueSet(iterable) 

325 

326 

327class NameMappingSetView(NamedValueAbstractSet[K_co]): 

328 """A lightweight implementation of `NamedValueAbstractSet`. 

329 

330 Backed by a mapping from name to named object. 

331 

332 Parameters 

333 ---------- 

334 mapping : `Mapping` [ `str`, `object` ] 

335 Mapping this object will provide a view of. 

336 """ 

337 

338 def __init__(self, mapping: Mapping[str, K_co]): 

339 self._mapping = mapping 

340 

341 __slots__ = ("_mapping",) 

342 

343 @property 

344 def names(self) -> AbstractSet[str]: 

345 # Docstring inherited from NamedValueAbstractSet. 

346 return self._mapping.keys() 

347 

348 def asMapping(self) -> Mapping[str, K_co]: 

349 # Docstring inherited from NamedValueAbstractSet. 

350 return self._mapping 

351 

352 def __getitem__(self, key: Union[str, K_co]) -> K_co: 

353 if isinstance(key, str): 

354 return self._mapping[key] 

355 else: 

356 return self._mapping[key.name] 

357 

358 def __contains__(self, key: Any) -> bool: 

359 return getattr(key, "name", key) in self._mapping 

360 

361 def __len__(self) -> int: 

362 return len(self._mapping) 

363 

364 def __iter__(self) -> Iterator[K_co]: 

365 return iter(self._mapping.values()) 

366 

367 def __eq__(self, other: Any) -> bool: 

368 if isinstance(other, NamedValueAbstractSet): 

369 return self.names == other.names 

370 else: 

371 return set(self._mapping.values()) == other 

372 

373 def __le__(self, other: AbstractSet[K]) -> bool: 

374 if isinstance(other, NamedValueAbstractSet): 

375 return self.names <= other.names 

376 else: 

377 return set(self._mapping.values()) <= other 

378 

379 def __ge__(self, other: AbstractSet[K]) -> bool: 

380 if isinstance(other, NamedValueAbstractSet): 

381 return self.names >= other.names 

382 else: 

383 return set(self._mapping.values()) >= other 

384 

385 def __str__(self) -> str: 

386 return "{{{}}}".format(", ".join(str(element) for element in self)) 

387 

388 def __repr__(self) -> str: 

389 return f"NameMappingSetView({self._mapping})" 

390 

391 

392class NamedValueMutableSet(NamedValueAbstractSet[K], MutableSet[K]): 

393 """Mutable variant of `NamedValueAbstractSet`. 

394 

395 Methods that can add new elements to the set are unchanged from their 

396 `MutableSet` definitions, while those that only remove them can generally 

397 accept names or element instances. `pop` can be used in either its 

398 `MutableSet` form (no arguments; an arbitrary element is returned) or its 

399 `MutableMapping` form (one or two arguments for the name and optional 

400 default value, respectively). A `MutableMapping`-like `__delitem__` 

401 interface is also included, which takes only names (like 

402 `NamedValueAbstractSet.__getitem__`). 

403 """ 

404 

405 __slots__ = () 

406 

407 @abstractmethod 

408 def __delitem__(self, name: str) -> None: 

409 raise NotImplementedError() 

410 

411 @abstractmethod 

412 def remove(self, element: Union[str, K]) -> Any: 

413 """Remove an element from the set. 

414 

415 Parameters 

416 ---------- 

417 element : `object` or `str` 

418 Element to remove or the string name thereof. Assumed to be an 

419 element if it has a ``.name`` attribute. 

420 

421 Raises 

422 ------ 

423 KeyError 

424 Raised if an element with the given name does not exist. 

425 """ 

426 raise NotImplementedError() 

427 

428 @abstractmethod 

429 def discard(self, element: Union[str, K]) -> Any: 

430 """Remove an element from the set if it exists. 

431 

432 Does nothing if no matching element is present. 

433 

434 Parameters 

435 ---------- 

436 element : `object` or `str` 

437 Element to remove or the string name thereof. Assumed to be an 

438 element if it has a ``.name`` attribute. 

439 """ 

440 raise NotImplementedError() 

441 

442 @abstractmethod 

443 def pop(self, *args: str) -> K: 

444 """Remove and return an element from the set. 

445 

446 Parameters 

447 ---------- 

448 name : `str`, optional 

449 Name of the element to remove and return. Must be passed 

450 positionally. If not provided, an arbitrary element is 

451 removed and returned. 

452 

453 Raises 

454 ------ 

455 KeyError 

456 Raised if ``name`` is provided but ``default`` is not, and no 

457 matching element exists. 

458 """ 

459 raise NotImplementedError() 

460 

461 

462class NamedValueSet(NameMappingSetView[K], NamedValueMutableSet[K]): 

463 """Custom mutable set class. 

464 

465 A custom mutable set class that requires elements to have a ``.name`` 

466 attribute, which can then be used as keys in `dict`-like lookup. 

467 

468 Names and elements can both be used with the ``in`` and ``del`` 

469 operators, `remove`, and `discard`. Names (but not elements) 

470 can be used with ``[]``-based element retrieval (not assignment) 

471 and the `get` method. 

472 

473 Parameters 

474 ---------- 

475 elements : `iterable` 

476 Iterable over elements to include in the set. 

477 

478 Raises 

479 ------ 

480 AttributeError 

481 Raised if one or more elements do not have a ``.name`` attribute. 

482 

483 Notes 

484 ----- 

485 Iteration order is guaranteed to be the same as insertion order (with 

486 the same general behavior as `dict` ordering). 

487 Like `dicts`, sets with the same elements will compare as equal even if 

488 their iterator order is not the same. 

489 """ 

490 

491 def __init__(self, elements: Iterable[K] = ()): 

492 super().__init__({element.name: element for element in elements}) 

493 

494 def __repr__(self) -> str: 

495 return "NamedValueSet({{{}}})".format(", ".join(repr(element) for element in self)) 

496 

497 def issubset(self, other: AbstractSet[K]) -> bool: 

498 return self <= other 

499 

500 def issuperset(self, other: AbstractSet[K]) -> bool: 

501 return self >= other 

502 

503 def __delitem__(self, name: str) -> None: 

504 del self._mapping[name] 

505 

506 def add(self, element: K) -> None: 

507 """Add an element to the set. 

508 

509 Raises 

510 ------ 

511 AttributeError 

512 Raised if the element does not have a ``.name`` attribute. 

513 """ 

514 self._mapping[element.name] = element 

515 

516 def clear(self) -> None: 

517 # Docstring inherited. 

518 self._mapping.clear() 

519 

520 def remove(self, element: Union[str, K]) -> Any: 

521 # Docstring inherited. 

522 k = getattr(element, "name") if not isinstance(element, str) else element 

523 del self._mapping[k] 

524 

525 def discard(self, element: Union[str, K]) -> Any: 

526 # Docstring inherited. 

527 try: 

528 self.remove(element) 

529 except KeyError: 

530 pass 

531 

532 def pop(self, *args: str) -> K: 

533 # Docstring inherited. 

534 if not args: 

535 return super().pop() 

536 else: 

537 return self._mapping.pop(*args) 

538 

539 def update(self, elements: Iterable[K]) -> None: 

540 """Add multiple new elements to the set. 

541 

542 Parameters 

543 ---------- 

544 elements : `Iterable` 

545 Elements to add. 

546 """ 

547 for element in elements: 

548 self.add(element) 

549 

550 def copy(self) -> NamedValueSet[K]: 

551 """Return a new `NamedValueSet` with the same elements.""" 

552 result = NamedValueSet.__new__(NamedValueSet) 

553 result._mapping = dict(self._mapping) 

554 return result 

555 

556 def freeze(self) -> NamedValueAbstractSet[K]: 

557 """Disable all mutators. 

558 

559 Effectively transforming ``self`` into an immutable set. 

560 

561 Returns 

562 ------- 

563 self : `NamedValueAbstractSet` 

564 While ``self`` is modified in-place, it is also returned with a 

565 type annotation that reflects its new, frozen state; assigning it 

566 to a new variable (and considering any previous references 

567 invalidated) should allow for more accurate static type checking. 

568 """ 

569 if not isinstance(self._mapping, MappingProxyType): 

570 self._mapping = MappingProxyType(self._mapping) # type: ignore 

571 return self 

572 

573 _mapping: Dict[str, K]