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

193 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-07-21 09:55 +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 

33from abc import abstractmethod 

34from collections.abc import ( 

35 ItemsView, 

36 Iterable, 

37 Iterator, 

38 KeysView, 

39 Mapping, 

40 MutableMapping, 

41 MutableSet, 

42 Set, 

43 ValuesView, 

44) 

45from types import MappingProxyType 

46from typing import Any, Protocol, TypeVar 

47 

48 

49class Named(Protocol): 

50 """Protocol for objects with string name. 

51 

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

53 maps directly to their equality comparisons. 

54 """ 

55 

56 @property 

57 def name(self) -> str: 

58 pass 

59 

60 

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

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

63V = TypeVar("V") 

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

65 

66 

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

68 """Custom mapping class. 

69 

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

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

72 object are permitted. 

73 

74 Notes 

75 ----- 

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

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

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

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

80 care about types at all. 

81 """ 

82 

83 __slots__ = () 

84 

85 @property 

86 @abstractmethod 

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

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

89 

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

91 """ 

92 raise NotImplementedError() 

93 

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

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

96 ``self`` values. 

97 

98 Returns 

99 ------- 

100 dictionary : `dict` 

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

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

103 not a view. 

104 """ 

105 return dict(zip(self.names, self.values())) 

106 

107 @abstractmethod 

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

109 # TODO: docs 

110 raise NotImplementedError() 

111 

112 @abstractmethod 

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

114 raise NotImplementedError() 

115 

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

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

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

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

120 

121 

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

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

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

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

126""" 

127 

128 

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

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

131 

132 __slots__ = () 

133 

134 @abstractmethod 

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

136 raise NotImplementedError() 

137 

138 @abstractmethod 

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

140 raise NotImplementedError() 

141 

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

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

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

145 

146 

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

148 """Dictionary wrapper for named keys. 

149 

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

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

152 

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

154 when adding new items. 

155 

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

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

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

159 dictionary. 

160 

161 Parameters 

162 ---------- 

163 args 

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

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

166 keys for `NamedKeyDict`. 

167 

168 Raises 

169 ------ 

170 AttributeError 

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

172 attribute to the dictionary. 

173 AssertionError 

174 Raised when multiple keys have the same name. 

175 """ 

176 

177 __slots__ = ( 

178 "_dict", 

179 "_names", 

180 ) 

181 

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

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

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

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

186 

187 @property 

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

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

190 

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

192 """ 

193 return self._names.keys() 

194 

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

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

197 return dict(zip(self._names.keys(), self._dict.values())) 

198 

199 def __len__(self) -> int: 

200 return len(self._dict) 

201 

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

203 return iter(self._dict) 

204 

205 def __str__(self) -> str: 

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

207 

208 def __repr__(self) -> str: 

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

210 

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

212 if isinstance(key, str): 

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

214 else: 

215 return self._dict[key] 

216 

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

218 if isinstance(key, str): 

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

220 else: 

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

222 self._dict[key] = value 

223 self._names[key.name] = key 

224 

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

226 if isinstance(key, str): 

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

228 del self._names[key] 

229 else: 

230 del self._dict[key] 

231 del self._names[key.name] 

232 

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

234 return NameMappingSetView(self._names) 

235 

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

237 return self._dict.values() 

238 

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

240 return self._dict.items() 

241 

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

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

244 result = NamedKeyDict.__new__(NamedKeyDict) 

245 result._dict = dict(self._dict) 

246 result._names = dict(self._names) 

247 return result 

248 

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

250 """Disable all mutators. 

251 

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

253 

254 Returns 

255 ------- 

256 self : `NamedKeyMapping` 

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

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

259 to a new variable (and considering any previous references 

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

261 """ 

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

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

264 return self 

265 

266 

267class NamedValueAbstractSet(Set[K_co]): 

268 """Custom sets with named elements. 

269 

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

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

272 views to be supported. 

273 """ 

274 

275 __slots__ = () 

276 

277 @property 

278 @abstractmethod 

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

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

281 

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

283 """ 

284 raise NotImplementedError() 

285 

286 @abstractmethod 

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

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

289 

290 Returns 

291 ------- 

292 dict : `~collections.abc.Mapping` 

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

294 """ 

295 raise NotImplementedError() 

296 

297 @abstractmethod 

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

299 raise NotImplementedError() 

300 

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

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

303 

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

305 """ 

306 try: 

307 return self[key] 

308 except KeyError: 

309 return default 

310 

311 @classmethod 

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

313 """Construct class from an iterable. 

314 

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

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

317 documentation for more information). 

318 

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

320 `NamedValueAbstractSet` instances. 

321 """ 

322 return NamedValueSet(iterable) 

323 

324 

325class NameMappingSetView(NamedValueAbstractSet[K_co]): 

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

327 

328 Backed by a mapping from name to named object. 

329 

330 Parameters 

331 ---------- 

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

333 Mapping this object will provide a view of. 

334 """ 

335 

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

337 self._mapping = mapping 

338 

339 __slots__ = ("_mapping",) 

340 

341 @property 

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

343 # Docstring inherited from NamedValueAbstractSet. 

344 return self._mapping.keys() 

345 

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

347 # Docstring inherited from NamedValueAbstractSet. 

348 return self._mapping 

349 

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

351 if isinstance(key, str): 

352 return self._mapping[key] 

353 else: 

354 return self._mapping[key.name] 

355 

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

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

358 

359 def __len__(self) -> int: 

360 return len(self._mapping) 

361 

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

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

364 

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

366 if isinstance(other, NamedValueAbstractSet): 

367 return self.names == other.names 

368 else: 

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

370 

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

372 if isinstance(other, NamedValueAbstractSet): 

373 return self.names <= other.names 

374 else: 

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

376 

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

378 if isinstance(other, NamedValueAbstractSet): 

379 return self.names >= other.names 

380 else: 

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

382 

383 def __str__(self) -> str: 

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

385 

386 def __repr__(self) -> str: 

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

388 

389 

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

391 """Mutable variant of `NamedValueAbstractSet`. 

392 

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

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

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

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

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

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

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

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

401 `NamedValueAbstractSet.__getitem__`). 

402 """ 

403 

404 __slots__ = () 

405 

406 @abstractmethod 

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

408 raise NotImplementedError() 

409 

410 @abstractmethod 

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

412 """Remove an element from the set. 

413 

414 Parameters 

415 ---------- 

416 element : `object` or `str` 

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

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

419 

420 Raises 

421 ------ 

422 KeyError 

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

424 """ 

425 raise NotImplementedError() 

426 

427 @abstractmethod 

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

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

430 

431 Does nothing if no matching element is present. 

432 

433 Parameters 

434 ---------- 

435 element : `object` or `str` 

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

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

438 """ 

439 raise NotImplementedError() 

440 

441 @abstractmethod 

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

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

444 

445 Parameters 

446 ---------- 

447 name : `str`, optional 

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

449 positionally. If not provided, an arbitrary element is 

450 removed and returned. 

451 

452 Raises 

453 ------ 

454 KeyError 

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

456 matching element exists. 

457 """ 

458 raise NotImplementedError() 

459 

460 

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

462 """Custom mutable set class. 

463 

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

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

466 

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

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

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

470 and the `get` method. 

471 

472 Parameters 

473 ---------- 

474 elements : `iterable` 

475 Iterable over elements to include in the set. 

476 

477 Raises 

478 ------ 

479 AttributeError 

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

481 

482 Notes 

483 ----- 

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

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

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

487 their iterator order is not the same. 

488 """ 

489 

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

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

492 

493 def __repr__(self) -> str: 

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

495 

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

497 return self <= other 

498 

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

500 return self >= other 

501 

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

503 del self._mapping[name] 

504 

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

506 """Add an element to the set. 

507 

508 Raises 

509 ------ 

510 AttributeError 

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

512 """ 

513 self._mapping[element.name] = element 

514 

515 def clear(self) -> None: 

516 # Docstring inherited. 

517 self._mapping.clear() 

518 

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

520 # Docstring inherited. 

521 k = getattr(element, "name") if not isinstance(element, str) else element 

522 del self._mapping[k] 

523 

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

525 # Docstring inherited. 

526 try: 

527 self.remove(element) 

528 except KeyError: 

529 pass 

530 

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

532 # Docstring inherited. 

533 if not args: 

534 # Parent is abstract method and we cannot call MutableSet 

535 # implementation directly. Instead follow MutableSet and 

536 # choose first element from iteration. 

537 it = iter(self._mapping) 

538 try: 

539 value = next(it) 

540 except StopIteration: 

541 raise KeyError from None 

542 args = (value,) 

543 

544 return self._mapping.pop(*args) 

545 

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

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

548 

549 Parameters 

550 ---------- 

551 elements : `~collections.abc.Iterable` 

552 Elements to add. 

553 """ 

554 for element in elements: 

555 self.add(element) 

556 

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

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

559 result = NamedValueSet.__new__(NamedValueSet) 

560 result._mapping = dict(self._mapping) 

561 return result 

562 

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

564 """Disable all mutators. 

565 

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

567 

568 Returns 

569 ------- 

570 self : `NamedValueAbstractSet` 

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

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

573 to a new variable (and considering any previous references 

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

575 """ 

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

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

578 return self 

579 

580 _mapping: dict[str, K]