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

204 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-12-01 11:00 +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 

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 Returns ``default`` if no such element is present. 

326 """ 

327 try: 

328 return self[key] 

329 except KeyError: 

330 return default 

331 

332 @classmethod 

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

334 """Construct class from an iterable. 

335 

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

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

338 documentation for more information). 

339 

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

341 `NamedValueAbstractSet` instances. 

342 """ 

343 return NamedValueSet(iterable) 

344 

345 

346class NameMappingSetView(NamedValueAbstractSet[K_co]): 

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

348 

349 Backed by a mapping from name to named object. 

350 

351 Parameters 

352 ---------- 

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

354 Mapping this object will provide a view of. 

355 """ 

356 

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

358 self._mapping = mapping 

359 

360 __slots__ = ("_mapping",) 

361 

362 @property 

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

364 # Docstring inherited from NamedValueAbstractSet. 

365 return self._mapping.keys() 

366 

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

368 # Docstring inherited from NamedValueAbstractSet. 

369 return self._mapping 

370 

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

372 if isinstance(key, str): 

373 return self._mapping[key] 

374 else: 

375 return self._mapping[key.name] 

376 

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

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

379 

380 def __len__(self) -> int: 

381 return len(self._mapping) 

382 

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

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

385 

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

387 if isinstance(other, NamedValueAbstractSet): 

388 return self.names == other.names 

389 else: 

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

391 

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

393 if isinstance(other, NamedValueAbstractSet): 

394 return self.names <= other.names 

395 else: 

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

397 

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

399 if isinstance(other, NamedValueAbstractSet): 

400 return self.names >= other.names 

401 else: 

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

403 

404 def __str__(self) -> str: 

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

406 

407 def __repr__(self) -> str: 

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

409 

410 

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

412 """Mutable variant of `NamedValueAbstractSet`. 

413 

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

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

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

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

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

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

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

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

422 `NamedValueAbstractSet.__getitem__`). 

423 """ 

424 

425 __slots__ = () 

426 

427 @abstractmethod 

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

429 raise NotImplementedError() 

430 

431 @abstractmethod 

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

433 """Remove an element from the set. 

434 

435 Parameters 

436 ---------- 

437 element : `object` or `str` 

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

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

440 

441 Raises 

442 ------ 

443 KeyError 

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

445 """ 

446 raise NotImplementedError() 

447 

448 @abstractmethod 

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

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

451 

452 Does nothing if no matching element is present. 

453 

454 Parameters 

455 ---------- 

456 element : `object` or `str` 

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

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

459 """ 

460 raise NotImplementedError() 

461 

462 @abstractmethod 

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

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

465 

466 Parameters 

467 ---------- 

468 name : `str`, optional 

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

470 positionally. If not provided, an arbitrary element is 

471 removed and returned. 

472 

473 Raises 

474 ------ 

475 KeyError 

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

477 matching element exists. 

478 """ 

479 raise NotImplementedError() 

480 

481 

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

483 """Custom mutable set class. 

484 

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

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

487 

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

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

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

491 and the `get` method. 

492 

493 Parameters 

494 ---------- 

495 elements : `iterable` 

496 Iterable over elements to include in the set. 

497 

498 Raises 

499 ------ 

500 AttributeError 

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

502 

503 Notes 

504 ----- 

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

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

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

508 their iterator order is not the same. 

509 """ 

510 

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

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

513 

514 def __repr__(self) -> str: 

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

516 

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

518 return self <= other 

519 

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

521 return self >= other 

522 

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

524 del self._mapping[name] 

525 

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

527 """Add an element to the set. 

528 

529 Raises 

530 ------ 

531 AttributeError 

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

533 """ 

534 self._mapping[element.name] = element 

535 

536 def clear(self) -> None: 

537 # Docstring inherited. 

538 self._mapping.clear() 

539 

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

541 # Docstring inherited. 

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

543 del self._mapping[k] 

544 

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

546 # Docstring inherited. 

547 with contextlib.suppress(KeyError): 

548 self.remove(element) 

549 

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

551 # Docstring inherited. 

552 if not args: 

553 # Parent is abstract method and we cannot call MutableSet 

554 # implementation directly. Instead follow MutableSet and 

555 # choose first element from iteration. 

556 it = iter(self._mapping) 

557 try: 

558 value = next(it) 

559 except StopIteration: 

560 raise KeyError from None 

561 args = (value,) 

562 

563 return self._mapping.pop(*args) 

564 

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

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

567 

568 Parameters 

569 ---------- 

570 elements : `~collections.abc.Iterable` 

571 Elements to add. 

572 """ 

573 for element in elements: 

574 self.add(element) 

575 

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

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

578 result = NamedValueSet.__new__(NamedValueSet) 

579 result._mapping = dict(self._mapping) 

580 return result 

581 

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

583 """Disable all mutators. 

584 

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

586 

587 Returns 

588 ------- 

589 self : `NamedValueAbstractSet` 

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

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

592 to a new variable (and considering any previous references 

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

594 """ 

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

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

597 return self 

598 

599 _mapping: dict[str, K]