Coverage for python/lsst/daf/butler/_named.py: 57%

204 statements  

« prev     ^ index     » next       coverage.py v7.4.0, created at 2024-01-16 10:44 +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 software is dual licensed under the GNU General Public License and also 

10# under a 3-clause BSD license. Recipients may choose which of these licenses 

11# to use; please see the files gpl-3.0.txt and/or bsd_license.txt, 

12# respectively. If you choose the GPL option then the following text applies 

13# (but note that there is still no warranty even if you opt for BSD instead): 

14# 

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

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

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

18# (at your option) any later version. 

19# 

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

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

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

23# GNU General Public License for more details. 

24# 

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

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

27from __future__ import annotations 

28 

29__all__ = ( 

30 "NamedKeyDict", 

31 "NamedKeyMapping", 

32 "NamedValueAbstractSet", 

33 "NamedValueMutableSet", 

34 "NamedValueSet", 

35 "NameLookupMapping", 

36 "NameMappingSetView", 

37) 

38 

39import contextlib 

40from abc import abstractmethod 

41from collections.abc import ( 

42 ItemsView, 

43 Iterable, 

44 Iterator, 

45 KeysView, 

46 Mapping, 

47 MutableMapping, 

48 MutableSet, 

49 Set, 

50 ValuesView, 

51) 

52from types import MappingProxyType 

53from typing import Any, Protocol, TypeVar, overload 

54 

55 

56class Named(Protocol): 

57 """Protocol for objects with string name. 

58 

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

60 maps directly to their equality comparisons. 

61 """ 

62 

63 @property 

64 def name(self) -> str: 

65 pass 

66 

67 

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

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

70V = TypeVar("V") 

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

72 

73 

74class NamedKeyMapping(Mapping[K, V_co]): 

75 """Custom mapping class. 

76 

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

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

79 object are permitted. 

80 

81 Notes 

82 ----- 

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

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

85 otherwise be inherited from `~collections.abc.Mapping`. That is only 

86 relevant for static type checking; the actual Python runtime doesn't 

87 care about types at all. 

88 """ 

89 

90 __slots__ = () 

91 

92 @property 

93 @abstractmethod 

94 def names(self) -> Set[str]: 

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

96 

97 (`~collections.abc.Set` [ `str` ]). 

98 """ 

99 raise NotImplementedError() 

100 

101 def byName(self) -> dict[str, V_co]: 

102 """Return a `~collections.abc.Mapping` with names as keys and the 

103 ``self`` values. 

104 

105 Returns 

106 ------- 

107 dictionary : `dict` 

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

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

110 not a view. 

111 """ 

112 return dict(zip(self.names, self.values(), strict=True)) 

113 

114 @abstractmethod 

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

116 # TODO: docs 

117 raise NotImplementedError() 

118 

119 @abstractmethod 

120 def __getitem__(self, key: str | K) -> V_co: 

121 raise NotImplementedError() 

122 

123 @overload 

124 def get(self, key: object) -> V_co | None: 

125 ... 

126 

127 @overload 

128 def get(self, key: object, default: V) -> V_co | V: 

129 ... 

130 

131 def get(self, key: Any, default: Any = None) -> Any: 

132 return super().get(key, default) 

133 

134 

135NameLookupMapping = NamedKeyMapping[K, V_co] | Mapping[str, V_co] 

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

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

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

139""" 

140 

141 

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

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

144 

145 __slots__ = () 

146 

147 @abstractmethod 

148 def __setitem__(self, key: str | K, value: V) -> None: 

149 raise NotImplementedError() 

150 

151 @abstractmethod 

152 def __delitem__(self, key: str | K) -> None: 

153 raise NotImplementedError() 

154 

155 def pop(self, key: str | K, default: Any = None) -> Any: 

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

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

158 

159 

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

161 """Dictionary wrapper for named keys. 

162 

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

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

165 

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

167 when adding new items. 

168 

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

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

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

172 dictionary. 

173 

174 Parameters 

175 ---------- 

176 *args : `typing.Any` 

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

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

179 keys for `NamedKeyDict`. 

180 

181 Raises 

182 ------ 

183 AttributeError 

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

185 attribute to the dictionary. 

186 AssertionError 

187 Raised when multiple keys have the same name. 

188 """ 

189 

190 __slots__ = ( 

191 "_dict", 

192 "_names", 

193 ) 

194 

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

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

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

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

199 

200 @property 

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

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

203 

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

205 """ 

206 return self._names.keys() 

207 

208 def byName(self) -> dict[str, V]: 

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

210 return dict(zip(self._names.keys(), self._dict.values(), strict=True)) 

211 

212 def __len__(self) -> int: 

213 return len(self._dict) 

214 

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

216 return iter(self._dict) 

217 

218 def __str__(self) -> str: 

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

220 

221 def __repr__(self) -> str: 

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

223 

224 def __getitem__(self, key: str | K) -> V: 

225 if isinstance(key, str): 

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

227 else: 

228 return self._dict[key] 

229 

230 def __setitem__(self, key: str | K, value: V) -> None: 

231 if isinstance(key, str): 

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

233 else: 

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

235 self._dict[key] = value 

236 self._names[key.name] = key 

237 

238 def __delitem__(self, key: str | K) -> None: 

239 if isinstance(key, str): 

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

241 del self._names[key] 

242 else: 

243 del self._dict[key] 

244 del self._names[key.name] 

245 

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

247 return NameMappingSetView(self._names) 

248 

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

250 return self._dict.values() 

251 

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

253 return self._dict.items() 

254 

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

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

257 result = NamedKeyDict.__new__(NamedKeyDict) 

258 result._dict = dict(self._dict) 

259 result._names = dict(self._names) 

260 return result 

261 

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

263 """Disable all mutators. 

264 

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

266 

267 Returns 

268 ------- 

269 self : `NamedKeyMapping` 

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

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

272 to a new variable (and considering any previous references 

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

274 """ 

275 if not isinstance(self._dict, MappingProxyType): # type: ignore[unreachable] 

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

277 return self 

278 

279 

280class NamedValueAbstractSet(Set[K_co]): 

281 """Custom sets with named elements. 

282 

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

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

285 views to be supported. 

286 """ 

287 

288 __slots__ = () 

289 

290 @property 

291 @abstractmethod 

292 def names(self) -> Set[str]: 

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

294 

295 (`~collections.abc.Set` [ `str` ]). 

296 """ 

297 raise NotImplementedError() 

298 

299 @abstractmethod 

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

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

302 

303 Returns 

304 ------- 

305 dict : `~collections.abc.Mapping` 

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

307 """ 

308 raise NotImplementedError() 

309 

310 @abstractmethod 

311 def __getitem__(self, key: str | K_co) -> K_co: 

312 raise NotImplementedError() 

313 

314 @overload 

315 def get(self, key: object) -> K_co | None: 

316 ... 

317 

318 @overload 

319 def get(self, key: object, default: V) -> K_co | V: 

320 ... 

321 

322 def get(self, key: Any, default: Any = None) -> Any: 

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

324 

325 Parameters 

326 ---------- 

327 key : `typing.Any` 

328 The name of the element to be requested. 

329 default : `typing.Any`, optional 

330 The value returned if no such element is present. 

331 

332 Returns 

333 ------- 

334 result : `typing.Any` 

335 The value of the element. 

336 """ 

337 try: 

338 return self[key] 

339 except KeyError: 

340 return default 

341 

342 @classmethod 

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

344 """Construct class from an iterable. 

345 

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

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

348 documentation for more information). 

349 

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

351 `NamedValueAbstractSet` instances. 

352 """ 

353 return NamedValueSet(iterable) 

354 

355 

356class NameMappingSetView(NamedValueAbstractSet[K_co]): 

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

358 

359 Backed by a mapping from name to named object. 

360 

361 Parameters 

362 ---------- 

363 mapping : `~collections.abc.Mapping` [ `str`, `object` ] 

364 Mapping this object will provide a view of. 

365 """ 

366 

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

368 self._mapping = mapping 

369 

370 __slots__ = ("_mapping",) 

371 

372 @property 

373 def names(self) -> Set[str]: 

374 # Docstring inherited from NamedValueAbstractSet. 

375 return self._mapping.keys() 

376 

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

378 # Docstring inherited from NamedValueAbstractSet. 

379 return self._mapping 

380 

381 def __getitem__(self, key: str | K_co) -> K_co: 

382 if isinstance(key, str): 

383 return self._mapping[key] 

384 else: 

385 return self._mapping[key.name] 

386 

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

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

389 

390 def __len__(self) -> int: 

391 return len(self._mapping) 

392 

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

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

395 

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

397 if isinstance(other, NamedValueAbstractSet): 

398 return self.names == other.names 

399 else: 

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

401 

402 def __le__(self, other: Set[K]) -> bool: 

403 if isinstance(other, NamedValueAbstractSet): 

404 return self.names <= other.names 

405 else: 

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

407 

408 def __ge__(self, other: Set[K]) -> bool: 

409 if isinstance(other, NamedValueAbstractSet): 

410 return self.names >= other.names 

411 else: 

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

413 

414 def __str__(self) -> str: 

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

416 

417 def __repr__(self) -> str: 

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

419 

420 

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

422 """Mutable variant of `NamedValueAbstractSet`. 

423 

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

425 `~collections.abc.MutableSet` definitions, while those that only remove 

426 them can generally accept names or element instances. `pop` can be used 

427 in either its `~collections.abc.MutableSet` form (no arguments; an 

428 arbitrary element is returned) or its `~collections.abc.MutableMapping` 

429 form (one or two arguments for the name and optional default value, 

430 respectively). A `~collections.abc.MutableMapping`-like `__delitem__` 

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

432 `NamedValueAbstractSet.__getitem__`). 

433 """ 

434 

435 __slots__ = () 

436 

437 @abstractmethod 

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

439 raise NotImplementedError() 

440 

441 @abstractmethod 

442 def remove(self, element: str | K) -> Any: 

443 """Remove an element from the set. 

444 

445 Parameters 

446 ---------- 

447 element : `object` or `str` 

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

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

450 

451 Raises 

452 ------ 

453 KeyError 

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

455 """ 

456 raise NotImplementedError() 

457 

458 @abstractmethod 

459 def discard(self, element: str | K) -> Any: 

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

461 

462 Does nothing if no matching element is present. 

463 

464 Parameters 

465 ---------- 

466 element : `object` or `str` 

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

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

469 """ 

470 raise NotImplementedError() 

471 

472 @abstractmethod 

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

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

475 

476 Parameters 

477 ---------- 

478 *args : `str`, optional 

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

480 positionally. If not provided, an arbitrary element is 

481 removed and returned. 

482 

483 Raises 

484 ------ 

485 KeyError 

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

487 matching element exists. 

488 """ 

489 raise NotImplementedError() 

490 

491 

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

493 """Custom mutable set class. 

494 

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

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

497 

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

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

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

501 and the `get` method. 

502 

503 Parameters 

504 ---------- 

505 elements : `collections.abc.Iterable` 

506 Iterable over elements to include in the set. 

507 

508 Raises 

509 ------ 

510 AttributeError 

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

512 

513 Notes 

514 ----- 

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

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

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

518 their iterator order is not the same. 

519 """ 

520 

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

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

523 

524 def __repr__(self) -> str: 

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

526 

527 def issubset(self, other: Set[K]) -> bool: 

528 return self <= other 

529 

530 def issuperset(self, other: Set[K]) -> bool: 

531 return self >= other 

532 

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

534 del self._mapping[name] 

535 

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

537 """Add an element to the set. 

538 

539 Parameters 

540 ---------- 

541 element : `typing.Any` 

542 The element to add. 

543 

544 Raises 

545 ------ 

546 AttributeError 

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

548 """ 

549 self._mapping[element.name] = element 

550 

551 def clear(self) -> None: 

552 # Docstring inherited. 

553 self._mapping.clear() 

554 

555 def remove(self, element: str | K) -> Any: 

556 # Docstring inherited. 

557 k = element.name if not isinstance(element, str) else element 

558 del self._mapping[k] 

559 

560 def discard(self, element: str | K) -> Any: 

561 # Docstring inherited. 

562 with contextlib.suppress(KeyError): 

563 self.remove(element) 

564 

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

566 # Docstring inherited. 

567 if not args: 

568 # Parent is abstract method and we cannot call MutableSet 

569 # implementation directly. Instead follow MutableSet and 

570 # choose first element from iteration. 

571 it = iter(self._mapping) 

572 try: 

573 value = next(it) 

574 except StopIteration: 

575 raise KeyError from None 

576 args = (value,) 

577 

578 return self._mapping.pop(*args) 

579 

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

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

582 

583 Parameters 

584 ---------- 

585 elements : `~collections.abc.Iterable` 

586 Elements to add. 

587 """ 

588 for element in elements: 

589 self.add(element) 

590 

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

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

593 result = NamedValueSet.__new__(NamedValueSet) 

594 result._mapping = dict(self._mapping) 

595 return result 

596 

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

598 """Disable all mutators. 

599 

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

601 

602 Returns 

603 ------- 

604 self : `NamedValueAbstractSet` 

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

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

607 to a new variable (and considering any previous references 

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

609 """ 

610 if not isinstance(self._mapping, MappingProxyType): # type: ignore[unreachable] 

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

612 return self 

613 

614 _mapping: dict[str, K]