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

192 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-08-05 01:26 +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 

33import contextlib 

34from abc import abstractmethod 

35from collections.abc import ( 

36 ItemsView, 

37 Iterable, 

38 Iterator, 

39 KeysView, 

40 Mapping, 

41 MutableMapping, 

42 MutableSet, 

43 Set, 

44 ValuesView, 

45) 

46from types import MappingProxyType 

47from typing import Any, Protocol, TypeVar 

48 

49 

50class Named(Protocol): 

51 """Protocol for objects with string name. 

52 

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

54 maps directly to their equality comparisons. 

55 """ 

56 

57 @property 

58 def name(self) -> str: 

59 pass 

60 

61 

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

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

64V = TypeVar("V") 

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

66 

67 

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

69 """Custom mapping class. 

70 

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

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

73 object are permitted. 

74 

75 Notes 

76 ----- 

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

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

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

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

81 care about types at all. 

82 """ 

83 

84 __slots__ = () 

85 

86 @property 

87 @abstractmethod 

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

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

90 

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

92 """ 

93 raise NotImplementedError() 

94 

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

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

97 ``self`` values. 

98 

99 Returns 

100 ------- 

101 dictionary : `dict` 

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

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

104 not a view. 

105 """ 

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

107 

108 @abstractmethod 

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

110 # TODO: docs 

111 raise NotImplementedError() 

112 

113 @abstractmethod 

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

115 raise NotImplementedError() 

116 

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

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

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

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

121 

122 

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

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

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

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

127""" 

128 

129 

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

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

132 

133 __slots__ = () 

134 

135 @abstractmethod 

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

137 raise NotImplementedError() 

138 

139 @abstractmethod 

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

141 raise NotImplementedError() 

142 

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

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

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

146 

147 

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

149 """Dictionary wrapper for named keys. 

150 

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

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

153 

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

155 when adding new items. 

156 

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

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

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

160 dictionary. 

161 

162 Parameters 

163 ---------- 

164 args 

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

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

167 keys for `NamedKeyDict`. 

168 

169 Raises 

170 ------ 

171 AttributeError 

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

173 attribute to the dictionary. 

174 AssertionError 

175 Raised when multiple keys have the same name. 

176 """ 

177 

178 __slots__ = ( 

179 "_dict", 

180 "_names", 

181 ) 

182 

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

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

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

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

187 

188 @property 

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

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

191 

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

193 """ 

194 return self._names.keys() 

195 

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

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

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

199 

200 def __len__(self) -> int: 

201 return len(self._dict) 

202 

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

204 return iter(self._dict) 

205 

206 def __str__(self) -> str: 

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

208 

209 def __repr__(self) -> str: 

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

211 

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

213 if isinstance(key, str): 

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

215 else: 

216 return self._dict[key] 

217 

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

219 if isinstance(key, str): 

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

221 else: 

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

223 self._dict[key] = value 

224 self._names[key.name] = key 

225 

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

227 if isinstance(key, str): 

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

229 del self._names[key] 

230 else: 

231 del self._dict[key] 

232 del self._names[key.name] 

233 

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

235 return NameMappingSetView(self._names) 

236 

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

238 return self._dict.values() 

239 

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

241 return self._dict.items() 

242 

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

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

245 result = NamedKeyDict.__new__(NamedKeyDict) 

246 result._dict = dict(self._dict) 

247 result._names = dict(self._names) 

248 return result 

249 

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

251 """Disable all mutators. 

252 

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

254 

255 Returns 

256 ------- 

257 self : `NamedKeyMapping` 

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

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

260 to a new variable (and considering any previous references 

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

262 """ 

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

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

265 return self 

266 

267 

268class NamedValueAbstractSet(Set[K_co]): 

269 """Custom sets with named elements. 

270 

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

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

273 views to be supported. 

274 """ 

275 

276 __slots__ = () 

277 

278 @property 

279 @abstractmethod 

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

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

282 

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

284 """ 

285 raise NotImplementedError() 

286 

287 @abstractmethod 

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

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

290 

291 Returns 

292 ------- 

293 dict : `~collections.abc.Mapping` 

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

295 """ 

296 raise NotImplementedError() 

297 

298 @abstractmethod 

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

300 raise NotImplementedError() 

301 

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

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

304 

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

306 """ 

307 try: 

308 return self[key] 

309 except KeyError: 

310 return default 

311 

312 @classmethod 

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

314 """Construct class from an iterable. 

315 

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

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

318 documentation for more information). 

319 

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

321 `NamedValueAbstractSet` instances. 

322 """ 

323 return NamedValueSet(iterable) 

324 

325 

326class NameMappingSetView(NamedValueAbstractSet[K_co]): 

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

328 

329 Backed by a mapping from name to named object. 

330 

331 Parameters 

332 ---------- 

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

334 Mapping this object will provide a view of. 

335 """ 

336 

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

338 self._mapping = mapping 

339 

340 __slots__ = ("_mapping",) 

341 

342 @property 

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

344 # Docstring inherited from NamedValueAbstractSet. 

345 return self._mapping.keys() 

346 

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

348 # Docstring inherited from NamedValueAbstractSet. 

349 return self._mapping 

350 

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

352 if isinstance(key, str): 

353 return self._mapping[key] 

354 else: 

355 return self._mapping[key.name] 

356 

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

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

359 

360 def __len__(self) -> int: 

361 return len(self._mapping) 

362 

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

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

365 

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

367 if isinstance(other, NamedValueAbstractSet): 

368 return self.names == other.names 

369 else: 

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

371 

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

373 if isinstance(other, NamedValueAbstractSet): 

374 return self.names <= other.names 

375 else: 

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

377 

378 def __ge__(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 __str__(self) -> str: 

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

386 

387 def __repr__(self) -> str: 

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

389 

390 

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

392 """Mutable variant of `NamedValueAbstractSet`. 

393 

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

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

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

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

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

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

400 respectively). A `~collections.abc.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: 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: 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: Set[K]) -> bool: 

498 return self <= other 

499 

500 def issuperset(self, other: Set[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: str | K) -> Any: 

521 # Docstring inherited. 

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

523 del self._mapping[k] 

524 

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

526 # Docstring inherited. 

527 with contextlib.suppress(KeyError): 

528 self.remove(element) 

529 

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

531 # Docstring inherited. 

532 if not args: 

533 # Parent is abstract method and we cannot call MutableSet 

534 # implementation directly. Instead follow MutableSet and 

535 # choose first element from iteration. 

536 it = iter(self._mapping) 

537 try: 

538 value = next(it) 

539 except StopIteration: 

540 raise KeyError from None 

541 args = (value,) 

542 

543 return self._mapping.pop(*args) 

544 

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

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

547 

548 Parameters 

549 ---------- 

550 elements : `~collections.abc.Iterable` 

551 Elements to add. 

552 """ 

553 for element in elements: 

554 self.add(element) 

555 

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

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

558 result = NamedValueSet.__new__(NamedValueSet) 

559 result._mapping = dict(self._mapping) 

560 return result 

561 

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

563 """Disable all mutators. 

564 

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

566 

567 Returns 

568 ------- 

569 self : `NamedValueAbstractSet` 

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

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

572 to a new variable (and considering any previous references 

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

574 """ 

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

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

577 return self 

578 

579 _mapping: dict[str, K]