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

200 statements  

« prev     ^ index     » next       coverage.py v7.5.1, created at 2024-05-11 03:16 -0700

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: ... 124 ↛ exitline 124 didn't return from function 'get', because

125 

126 @overload 

127 def get(self, key: object, default: V) -> V_co | V: ... 127 ↛ exitline 127 didn't return from function 'get', because

128 

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

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

131 

132 

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

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

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

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

137""" 

138 

139 

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

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

142 

143 __slots__ = () 

144 

145 @abstractmethod 

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

147 raise NotImplementedError() 

148 

149 @abstractmethod 

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

151 raise NotImplementedError() 

152 

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

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

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

156 

157 

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

159 """Dictionary wrapper for named keys. 

160 

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

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

163 

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

165 when adding new items. 

166 

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

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

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

170 dictionary. 

171 

172 Parameters 

173 ---------- 

174 *args : `typing.Any` 

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

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

177 keys for `NamedKeyDict`. 

178 

179 Raises 

180 ------ 

181 AttributeError 

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

183 attribute to the dictionary. 

184 AssertionError 

185 Raised when multiple keys have the same name. 

186 """ 

187 

188 __slots__ = ( 

189 "_dict", 

190 "_names", 

191 ) 

192 

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

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

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

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

197 

198 @property 

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

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

201 

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

203 """ 

204 return self._names.keys() 

205 

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

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

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

209 

210 def __len__(self) -> int: 

211 return len(self._dict) 

212 

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

214 return iter(self._dict) 

215 

216 def __str__(self) -> str: 

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

218 

219 def __repr__(self) -> str: 

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

221 

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

223 if isinstance(key, str): 

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

225 else: 

226 return self._dict[key] 

227 

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

229 if isinstance(key, str): 

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

231 else: 

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

233 self._dict[key] = value 

234 self._names[key.name] = key 

235 

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

237 if isinstance(key, str): 

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

239 del self._names[key] 

240 else: 

241 del self._dict[key] 

242 del self._names[key.name] 

243 

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

245 return NameMappingSetView(self._names) 

246 

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

248 return self._dict.values() 

249 

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

251 return self._dict.items() 

252 

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

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

255 result = NamedKeyDict.__new__(NamedKeyDict) 

256 result._dict = dict(self._dict) 

257 result._names = dict(self._names) 

258 return result 

259 

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

261 """Disable all mutators. 

262 

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

264 

265 Returns 

266 ------- 

267 self : `NamedKeyMapping` 

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

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

270 to a new variable (and considering any previous references 

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

272 """ 

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

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

275 return self 

276 

277 

278class NamedValueAbstractSet(Set[K_co]): 

279 """Custom sets with named elements. 

280 

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

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

283 views to be supported. 

284 """ 

285 

286 __slots__ = () 

287 

288 @property 

289 @abstractmethod 

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

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

292 

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

294 """ 

295 raise NotImplementedError() 

296 

297 @abstractmethod 

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

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

300 

301 Returns 

302 ------- 

303 dict : `~collections.abc.Mapping` 

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

305 """ 

306 raise NotImplementedError() 

307 

308 @abstractmethod 

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

310 raise NotImplementedError() 

311 

312 @overload 

313 def get(self, key: object) -> K_co | None: ... 313 ↛ exitline 313 didn't return from function 'get', because

314 

315 @overload 

316 def get(self, key: object, default: V) -> K_co | V: ... 316 ↛ exitline 316 didn't return from function 'get', because

317 

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

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

320 

321 Parameters 

322 ---------- 

323 key : `typing.Any` 

324 The name of the element to be requested. 

325 default : `typing.Any`, optional 

326 The value returned if no such element is present. 

327 

328 Returns 

329 ------- 

330 result : `typing.Any` 

331 The value of the element. 

332 """ 

333 try: 

334 return self[key] 

335 except KeyError: 

336 return default 

337 

338 @classmethod 

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

340 """Construct class from an iterable. 

341 

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

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

344 documentation for more information). 

345 

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

347 `NamedValueAbstractSet` instances. 

348 """ 

349 return NamedValueSet(iterable) 

350 

351 

352class NameMappingSetView(NamedValueAbstractSet[K_co]): 

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

354 

355 Backed by a mapping from name to named object. 

356 

357 Parameters 

358 ---------- 

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

360 Mapping this object will provide a view of. 

361 """ 

362 

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

364 self._mapping = mapping 

365 

366 __slots__ = ("_mapping",) 

367 

368 @property 

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

370 # Docstring inherited from NamedValueAbstractSet. 

371 return self._mapping.keys() 

372 

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

374 # Docstring inherited from NamedValueAbstractSet. 

375 return self._mapping 

376 

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

378 if isinstance(key, str): 

379 return self._mapping[key] 

380 else: 

381 return self._mapping[key.name] 

382 

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

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

385 

386 def __len__(self) -> int: 

387 return len(self._mapping) 

388 

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

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

391 

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

393 if isinstance(other, NamedValueAbstractSet): 

394 return self.names == other.names 

395 else: 

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

397 

398 def __le__(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 __ge__(self, other: Set[K]) -> bool: 

405 if isinstance(other, NamedValueAbstractSet): 

406 return self.names >= other.names 

407 else: 

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

409 

410 def __str__(self) -> str: 

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

412 

413 def __repr__(self) -> str: 

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

415 

416 

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

418 """Mutable variant of `NamedValueAbstractSet`. 

419 

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

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

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

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

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

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

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

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

428 `NamedValueAbstractSet.__getitem__`). 

429 """ 

430 

431 __slots__ = () 

432 

433 @abstractmethod 

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

435 raise NotImplementedError() 

436 

437 @abstractmethod 

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

439 """Remove an element from the set. 

440 

441 Parameters 

442 ---------- 

443 element : `object` or `str` 

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

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

446 

447 Raises 

448 ------ 

449 KeyError 

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

451 """ 

452 raise NotImplementedError() 

453 

454 @abstractmethod 

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

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

457 

458 Does nothing if no matching element is present. 

459 

460 Parameters 

461 ---------- 

462 element : `object` or `str` 

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

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

465 """ 

466 raise NotImplementedError() 

467 

468 @abstractmethod 

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

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

471 

472 Parameters 

473 ---------- 

474 *args : `str`, optional 

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

476 positionally. If not provided, an arbitrary element is 

477 removed and returned. 

478 

479 Raises 

480 ------ 

481 KeyError 

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

483 matching element exists. 

484 """ 

485 raise NotImplementedError() 

486 

487 

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

489 """Custom mutable set class. 

490 

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

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

493 

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

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

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

497 and the `get` method. 

498 

499 Parameters 

500 ---------- 

501 elements : `collections.abc.Iterable` 

502 Iterable over elements to include in the set. 

503 

504 Raises 

505 ------ 

506 AttributeError 

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

508 

509 Notes 

510 ----- 

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

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

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

514 their iterator order is not the same. 

515 """ 

516 

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

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

519 

520 def __repr__(self) -> str: 

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

522 

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

524 return self <= other 

525 

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

527 return self >= other 

528 

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

530 del self._mapping[name] 

531 

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

533 """Add an element to the set. 

534 

535 Parameters 

536 ---------- 

537 element : `typing.Any` 

538 The element to add. 

539 

540 Raises 

541 ------ 

542 AttributeError 

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

544 """ 

545 self._mapping[element.name] = element 

546 

547 def clear(self) -> None: 

548 # Docstring inherited. 

549 self._mapping.clear() 

550 

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

552 # Docstring inherited. 

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

554 del self._mapping[k] 

555 

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

557 # Docstring inherited. 

558 with contextlib.suppress(KeyError): 

559 self.remove(element) 

560 

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

562 # Docstring inherited. 

563 if not args: 

564 # Parent is abstract method and we cannot call MutableSet 

565 # implementation directly. Instead follow MutableSet and 

566 # choose first element from iteration. 

567 it = iter(self._mapping) 

568 try: 

569 value = next(it) 

570 except StopIteration: 

571 raise KeyError from None 

572 args = (value,) 

573 

574 return self._mapping.pop(*args) 

575 

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

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

578 

579 Parameters 

580 ---------- 

581 elements : `~collections.abc.Iterable` 

582 Elements to add. 

583 """ 

584 for element in elements: 

585 self.add(element) 

586 

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

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

589 result = NamedValueSet.__new__(NamedValueSet) 

590 result._mapping = dict(self._mapping) 

591 return result 

592 

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

594 """Disable all mutators. 

595 

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

597 

598 Returns 

599 ------- 

600 self : `NamedValueAbstractSet` 

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

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

603 to a new variable (and considering any previous references 

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

605 """ 

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

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

608 return self 

609 

610 _mapping: dict[str, K]