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

192 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-10-27 09: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 

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 def get(self, key: str | K, default: Any = None) -> Any: 

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

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

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

127 

128 

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

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

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

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

133""" 

134 

135 

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

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

138 

139 __slots__ = () 

140 

141 @abstractmethod 

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

143 raise NotImplementedError() 

144 

145 @abstractmethod 

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

147 raise NotImplementedError() 

148 

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

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

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

152 

153 

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

155 """Dictionary wrapper for named keys. 

156 

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

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

159 

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

161 when adding new items. 

162 

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

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

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

166 dictionary. 

167 

168 Parameters 

169 ---------- 

170 args 

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

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

173 keys for `NamedKeyDict`. 

174 

175 Raises 

176 ------ 

177 AttributeError 

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

179 attribute to the dictionary. 

180 AssertionError 

181 Raised when multiple keys have the same name. 

182 """ 

183 

184 __slots__ = ( 

185 "_dict", 

186 "_names", 

187 ) 

188 

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

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

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

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

193 

194 @property 

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

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

197 

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

199 """ 

200 return self._names.keys() 

201 

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

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

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

205 

206 def __len__(self) -> int: 

207 return len(self._dict) 

208 

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

210 return iter(self._dict) 

211 

212 def __str__(self) -> str: 

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

214 

215 def __repr__(self) -> str: 

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

217 

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

219 if isinstance(key, str): 

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

221 else: 

222 return self._dict[key] 

223 

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

225 if isinstance(key, str): 

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

227 else: 

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

229 self._dict[key] = value 

230 self._names[key.name] = key 

231 

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

233 if isinstance(key, str): 

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

235 del self._names[key] 

236 else: 

237 del self._dict[key] 

238 del self._names[key.name] 

239 

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

241 return NameMappingSetView(self._names) 

242 

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

244 return self._dict.values() 

245 

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

247 return self._dict.items() 

248 

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

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

251 result = NamedKeyDict.__new__(NamedKeyDict) 

252 result._dict = dict(self._dict) 

253 result._names = dict(self._names) 

254 return result 

255 

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

257 """Disable all mutators. 

258 

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

260 

261 Returns 

262 ------- 

263 self : `NamedKeyMapping` 

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

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

266 to a new variable (and considering any previous references 

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

268 """ 

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

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

271 return self 

272 

273 

274class NamedValueAbstractSet(Set[K_co]): 

275 """Custom sets with named elements. 

276 

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

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

279 views to be supported. 

280 """ 

281 

282 __slots__ = () 

283 

284 @property 

285 @abstractmethod 

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

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

288 

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

290 """ 

291 raise NotImplementedError() 

292 

293 @abstractmethod 

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

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

296 

297 Returns 

298 ------- 

299 dict : `~collections.abc.Mapping` 

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

301 """ 

302 raise NotImplementedError() 

303 

304 @abstractmethod 

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

306 raise NotImplementedError() 

307 

308 def get(self, key: str | K_co, default: Any = None) -> Any: 

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

310 

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

312 """ 

313 try: 

314 return self[key] 

315 except KeyError: 

316 return default 

317 

318 @classmethod 

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

320 """Construct class from an iterable. 

321 

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

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

324 documentation for more information). 

325 

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

327 `NamedValueAbstractSet` instances. 

328 """ 

329 return NamedValueSet(iterable) 

330 

331 

332class NameMappingSetView(NamedValueAbstractSet[K_co]): 

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

334 

335 Backed by a mapping from name to named object. 

336 

337 Parameters 

338 ---------- 

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

340 Mapping this object will provide a view of. 

341 """ 

342 

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

344 self._mapping = mapping 

345 

346 __slots__ = ("_mapping",) 

347 

348 @property 

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

350 # Docstring inherited from NamedValueAbstractSet. 

351 return self._mapping.keys() 

352 

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

354 # Docstring inherited from NamedValueAbstractSet. 

355 return self._mapping 

356 

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

358 if isinstance(key, str): 

359 return self._mapping[key] 

360 else: 

361 return self._mapping[key.name] 

362 

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

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

365 

366 def __len__(self) -> int: 

367 return len(self._mapping) 

368 

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

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

371 

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

373 if isinstance(other, NamedValueAbstractSet): 

374 return self.names == other.names 

375 else: 

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

377 

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

379 if isinstance(other, NamedValueAbstractSet): 

380 return self.names <= other.names 

381 else: 

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

383 

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

385 if isinstance(other, NamedValueAbstractSet): 

386 return self.names >= other.names 

387 else: 

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

389 

390 def __str__(self) -> str: 

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

392 

393 def __repr__(self) -> str: 

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

395 

396 

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

398 """Mutable variant of `NamedValueAbstractSet`. 

399 

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

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

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

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

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

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

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

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

408 `NamedValueAbstractSet.__getitem__`). 

409 """ 

410 

411 __slots__ = () 

412 

413 @abstractmethod 

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

415 raise NotImplementedError() 

416 

417 @abstractmethod 

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

419 """Remove an element from the set. 

420 

421 Parameters 

422 ---------- 

423 element : `object` or `str` 

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

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

426 

427 Raises 

428 ------ 

429 KeyError 

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

431 """ 

432 raise NotImplementedError() 

433 

434 @abstractmethod 

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

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

437 

438 Does nothing if no matching element is present. 

439 

440 Parameters 

441 ---------- 

442 element : `object` or `str` 

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

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

445 """ 

446 raise NotImplementedError() 

447 

448 @abstractmethod 

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

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

451 

452 Parameters 

453 ---------- 

454 name : `str`, optional 

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

456 positionally. If not provided, an arbitrary element is 

457 removed and returned. 

458 

459 Raises 

460 ------ 

461 KeyError 

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

463 matching element exists. 

464 """ 

465 raise NotImplementedError() 

466 

467 

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

469 """Custom mutable set class. 

470 

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

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

473 

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

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

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

477 and the `get` method. 

478 

479 Parameters 

480 ---------- 

481 elements : `iterable` 

482 Iterable over elements to include in the set. 

483 

484 Raises 

485 ------ 

486 AttributeError 

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

488 

489 Notes 

490 ----- 

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

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

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

494 their iterator order is not the same. 

495 """ 

496 

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

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

499 

500 def __repr__(self) -> str: 

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

502 

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

504 return self <= other 

505 

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

507 return self >= other 

508 

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

510 del self._mapping[name] 

511 

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

513 """Add an element to the set. 

514 

515 Raises 

516 ------ 

517 AttributeError 

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

519 """ 

520 self._mapping[element.name] = element 

521 

522 def clear(self) -> None: 

523 # Docstring inherited. 

524 self._mapping.clear() 

525 

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

527 # Docstring inherited. 

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

529 del self._mapping[k] 

530 

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

532 # Docstring inherited. 

533 with contextlib.suppress(KeyError): 

534 self.remove(element) 

535 

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

537 # Docstring inherited. 

538 if not args: 

539 # Parent is abstract method and we cannot call MutableSet 

540 # implementation directly. Instead follow MutableSet and 

541 # choose first element from iteration. 

542 it = iter(self._mapping) 

543 try: 

544 value = next(it) 

545 except StopIteration: 

546 raise KeyError from None 

547 args = (value,) 

548 

549 return self._mapping.pop(*args) 

550 

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

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

553 

554 Parameters 

555 ---------- 

556 elements : `~collections.abc.Iterable` 

557 Elements to add. 

558 """ 

559 for element in elements: 

560 self.add(element) 

561 

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

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

564 result = NamedValueSet.__new__(NamedValueSet) 

565 result._mapping = dict(self._mapping) 

566 return result 

567 

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

569 """Disable all mutators. 

570 

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

572 

573 Returns 

574 ------- 

575 self : `NamedValueAbstractSet` 

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

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

578 to a new variable (and considering any previous references 

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

580 """ 

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

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

583 return self 

584 

585 _mapping: dict[str, K]