Coverage for python/lsst/daf/butler/_named.py: 57%
204 statements
« prev ^ index » next coverage.py v7.4.0, created at 2024-01-25 10:50 +0000
« prev ^ index » next coverage.py v7.4.0, created at 2024-01-25 10:50 +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
29__all__ = (
30 "NamedKeyDict",
31 "NamedKeyMapping",
32 "NamedValueAbstractSet",
33 "NamedValueMutableSet",
34 "NamedValueSet",
35 "NameLookupMapping",
36 "NameMappingSetView",
37)
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
56class Named(Protocol):
57 """Protocol for objects with string name.
59 A non-inheritance interface for objects that have a string name that
60 maps directly to their equality comparisons.
61 """
63 @property
64 def name(self) -> str:
65 pass
68K = TypeVar("K", bound=Named)
69K_co = TypeVar("K_co", bound=Named, covariant=True)
70V = TypeVar("V")
71V_co = TypeVar("V_co", covariant=True)
74class NamedKeyMapping(Mapping[K, V_co]):
75 """Custom mapping class.
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.
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 """
90 __slots__ = ()
92 @property
93 @abstractmethod
94 def names(self) -> Set[str]:
95 """Return the set of names associated with the keys, in the same order.
97 (`~collections.abc.Set` [ `str` ]).
98 """
99 raise NotImplementedError()
101 def byName(self) -> dict[str, V_co]:
102 """Return a `~collections.abc.Mapping` with names as keys and the
103 ``self`` values.
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))
114 @abstractmethod
115 def keys(self) -> NamedValueAbstractSet[K]: # type: ignore
116 # TODO: docs
117 raise NotImplementedError()
119 @abstractmethod
120 def __getitem__(self, key: str | K) -> V_co:
121 raise NotImplementedError()
123 @overload
124 def get(self, key: object) -> V_co | None:
125 ...
127 @overload
128 def get(self, key: object, default: V) -> V_co | V:
129 ...
131 def get(self, key: Any, default: Any = None) -> Any:
132 return super().get(key, default)
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"""
142class NamedKeyMutableMapping(NamedKeyMapping[K, V], MutableMapping[K, V]):
143 """An abstract base class that adds mutation to `NamedKeyMapping`."""
145 __slots__ = ()
147 @abstractmethod
148 def __setitem__(self, key: str | K, value: V) -> None:
149 raise NotImplementedError()
151 @abstractmethod
152 def __delitem__(self, key: str | K) -> None:
153 raise NotImplementedError()
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
160class NamedKeyDict(NamedKeyMutableMapping[K, V]):
161 """Dictionary wrapper for named keys.
163 Requires keys to have a ``.name`` attribute,
164 and permits lookups using either key objects or their names.
166 Names can be used in place of keys when updating existing items, but not
167 when adding new items.
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.
174 Parameters
175 ----------
176 *args : `typing.Any`
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`.
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 """
190 __slots__ = (
191 "_dict",
192 "_names",
193 )
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."
200 @property
201 def names(self) -> KeysView[str]:
202 """Return set of names associated with the keys, in the same order.
204 (`~collections.abc.KeysView`).
205 """
206 return self._names.keys()
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))
212 def __len__(self) -> int:
213 return len(self._dict)
215 def __iter__(self) -> Iterator[K]:
216 return iter(self._dict)
218 def __str__(self) -> str:
219 return "{{{}}}".format(", ".join(f"{str(k)}: {str(v)}" for k, v in self.items()))
221 def __repr__(self) -> str:
222 return "NamedKeyDict({{{}}})".format(", ".join(f"{repr(k)}: {repr(v)}" for k, v in self.items()))
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]
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
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]
246 def keys(self) -> NamedValueAbstractSet[K]: # type: ignore
247 return NameMappingSetView(self._names)
249 def values(self) -> ValuesView[V]:
250 return self._dict.values()
252 def items(self) -> ItemsView[K, V]:
253 return self._dict.items()
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
262 def freeze(self) -> NamedKeyMapping[K, V]:
263 """Disable all mutators.
265 Effectively transforms ``self`` into an immutable mapping.
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
280class NamedValueAbstractSet(Set[K_co]):
281 """Custom sets with named elements.
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 """
288 __slots__ = ()
290 @property
291 @abstractmethod
292 def names(self) -> Set[str]:
293 """Return set of names associated with the keys, in the same order.
295 (`~collections.abc.Set` [ `str` ]).
296 """
297 raise NotImplementedError()
299 @abstractmethod
300 def asMapping(self) -> Mapping[str, K_co]:
301 """Return a mapping view with names as keys.
303 Returns
304 -------
305 dict : `~collections.abc.Mapping`
306 A dictionary-like view with ``values() == self``.
307 """
308 raise NotImplementedError()
310 @abstractmethod
311 def __getitem__(self, key: str | K_co) -> K_co:
312 raise NotImplementedError()
314 @overload
315 def get(self, key: object) -> K_co | None:
316 ...
318 @overload
319 def get(self, key: object, default: V) -> K_co | V:
320 ...
322 def get(self, key: Any, default: Any = None) -> Any:
323 """Return the element with the given name.
325 Parameters
326 ----------
327 key : `typing.Any`
328 The name of the element to be requested.
329 default : `typing.Any`, optional
330 The value returned if no such element is present.
332 Returns
333 -------
334 result : `typing.Any`
335 The value of the element.
336 """
337 try:
338 return self[key]
339 except KeyError:
340 return default
342 @classmethod
343 def _from_iterable(cls, iterable: Iterable[K_co]) -> NamedValueSet[K_co]:
344 """Construct class from an iterable.
346 Hook to ensure that inherited `collections.abc.Set` operators return
347 `NamedValueSet` instances, not something else (see `collections.abc`
348 documentation for more information).
350 Note that this behavior can only be guaranteed when both operands are
351 `NamedValueAbstractSet` instances.
352 """
353 return NamedValueSet(iterable)
356class NameMappingSetView(NamedValueAbstractSet[K_co]):
357 """A lightweight implementation of `NamedValueAbstractSet`.
359 Backed by a mapping from name to named object.
361 Parameters
362 ----------
363 mapping : `~collections.abc.Mapping` [ `str`, `object` ]
364 Mapping this object will provide a view of.
365 """
367 def __init__(self, mapping: Mapping[str, K_co]):
368 self._mapping = mapping
370 __slots__ = ("_mapping",)
372 @property
373 def names(self) -> Set[str]:
374 # Docstring inherited from NamedValueAbstractSet.
375 return self._mapping.keys()
377 def asMapping(self) -> Mapping[str, K_co]:
378 # Docstring inherited from NamedValueAbstractSet.
379 return self._mapping
381 def __getitem__(self, key: str | K_co) -> K_co:
382 if isinstance(key, str):
383 return self._mapping[key]
384 else:
385 return self._mapping[key.name]
387 def __contains__(self, key: Any) -> bool:
388 return getattr(key, "name", key) in self._mapping
390 def __len__(self) -> int:
391 return len(self._mapping)
393 def __iter__(self) -> Iterator[K_co]:
394 return iter(self._mapping.values())
396 def __eq__(self, other: Any) -> bool:
397 if isinstance(other, NamedValueAbstractSet):
398 return self.names == other.names
399 else:
400 return set(self._mapping.values()) == other
402 def __le__(self, other: Set[K]) -> bool:
403 if isinstance(other, NamedValueAbstractSet):
404 return self.names <= other.names
405 else:
406 return set(self._mapping.values()) <= other
408 def __ge__(self, other: Set[K]) -> bool:
409 if isinstance(other, NamedValueAbstractSet):
410 return self.names >= other.names
411 else:
412 return set(self._mapping.values()) >= other
414 def __str__(self) -> str:
415 return "{{{}}}".format(", ".join(str(element) for element in self))
417 def __repr__(self) -> str:
418 return f"NameMappingSetView({self._mapping})"
421class NamedValueMutableSet(NamedValueAbstractSet[K], MutableSet[K]):
422 """Mutable variant of `NamedValueAbstractSet`.
424 Methods that can add new elements to the set are unchanged from their
425 `~collections.abc.MutableSet` definitions, while those that only remove
426 them can generally accept names or element instances. `pop` can be used
427 in either its `~collections.abc.MutableSet` form (no arguments; an
428 arbitrary element is returned) or its `~collections.abc.MutableMapping`
429 form (one or two arguments for the name and optional default value,
430 respectively). A `~collections.abc.MutableMapping`-like `__delitem__`
431 interface is also included, which takes only names (like
432 `NamedValueAbstractSet.__getitem__`).
433 """
435 __slots__ = ()
437 @abstractmethod
438 def __delitem__(self, name: str) -> None:
439 raise NotImplementedError()
441 @abstractmethod
442 def remove(self, element: str | K) -> Any:
443 """Remove an element from the set.
445 Parameters
446 ----------
447 element : `object` or `str`
448 Element to remove or the string name thereof. Assumed to be an
449 element if it has a ``.name`` attribute.
451 Raises
452 ------
453 KeyError
454 Raised if an element with the given name does not exist.
455 """
456 raise NotImplementedError()
458 @abstractmethod
459 def discard(self, element: str | K) -> Any:
460 """Remove an element from the set if it exists.
462 Does nothing if no matching element is present.
464 Parameters
465 ----------
466 element : `object` or `str`
467 Element to remove or the string name thereof. Assumed to be an
468 element if it has a ``.name`` attribute.
469 """
470 raise NotImplementedError()
472 @abstractmethod
473 def pop(self, *args: str) -> K:
474 """Remove and return an element from the set.
476 Parameters
477 ----------
478 *args : `str`, optional
479 Name of the element to remove and return. Must be passed
480 positionally. If not provided, an arbitrary element is
481 removed and returned.
483 Raises
484 ------
485 KeyError
486 Raised if ``name`` is provided but ``default`` is not, and no
487 matching element exists.
488 """
489 raise NotImplementedError()
492class NamedValueSet(NameMappingSetView[K], NamedValueMutableSet[K]):
493 """Custom mutable set class.
495 A custom mutable set class that requires elements to have a ``.name``
496 attribute, which can then be used as keys in `dict`-like lookup.
498 Names and elements can both be used with the ``in`` and ``del``
499 operators, `remove`, and `discard`. Names (but not elements)
500 can be used with ``[]``-based element retrieval (not assignment)
501 and the `get` method.
503 Parameters
504 ----------
505 elements : `collections.abc.Iterable`
506 Iterable over elements to include in the set.
508 Raises
509 ------
510 AttributeError
511 Raised if one or more elements do not have a ``.name`` attribute.
513 Notes
514 -----
515 Iteration order is guaranteed to be the same as insertion order (with
516 the same general behavior as `dict` ordering).
517 Like `dicts`, sets with the same elements will compare as equal even if
518 their iterator order is not the same.
519 """
521 def __init__(self, elements: Iterable[K] = ()):
522 super().__init__({element.name: element for element in elements})
524 def __repr__(self) -> str:
525 return "NamedValueSet({{{}}})".format(", ".join(repr(element) for element in self))
527 def issubset(self, other: Set[K]) -> bool:
528 return self <= other
530 def issuperset(self, other: Set[K]) -> bool:
531 return self >= other
533 def __delitem__(self, name: str) -> None:
534 del self._mapping[name]
536 def add(self, element: K) -> None:
537 """Add an element to the set.
539 Parameters
540 ----------
541 element : `typing.Any`
542 The element to add.
544 Raises
545 ------
546 AttributeError
547 Raised if the element does not have a ``.name`` attribute.
548 """
549 self._mapping[element.name] = element
551 def clear(self) -> None:
552 # Docstring inherited.
553 self._mapping.clear()
555 def remove(self, element: str | K) -> Any:
556 # Docstring inherited.
557 k = element.name if not isinstance(element, str) else element
558 del self._mapping[k]
560 def discard(self, element: str | K) -> Any:
561 # Docstring inherited.
562 with contextlib.suppress(KeyError):
563 self.remove(element)
565 def pop(self, *args: str) -> K:
566 # Docstring inherited.
567 if not args:
568 # Parent is abstract method and we cannot call MutableSet
569 # implementation directly. Instead follow MutableSet and
570 # choose first element from iteration.
571 it = iter(self._mapping)
572 try:
573 value = next(it)
574 except StopIteration:
575 raise KeyError from None
576 args = (value,)
578 return self._mapping.pop(*args)
580 def update(self, elements: Iterable[K]) -> None:
581 """Add multiple new elements to the set.
583 Parameters
584 ----------
585 elements : `~collections.abc.Iterable`
586 Elements to add.
587 """
588 for element in elements:
589 self.add(element)
591 def copy(self) -> NamedValueSet[K]:
592 """Return a new `NamedValueSet` with the same elements."""
593 result = NamedValueSet.__new__(NamedValueSet)
594 result._mapping = dict(self._mapping)
595 return result
597 def freeze(self) -> NamedValueAbstractSet[K]:
598 """Disable all mutators.
600 Effectively transforming ``self`` into an immutable set.
602 Returns
603 -------
604 self : `NamedValueAbstractSet`
605 While ``self`` is modified in-place, it is also returned with a
606 type annotation that reflects its new, frozen state; assigning it
607 to a new variable (and considering any previous references
608 invalidated) should allow for more accurate static type checking.
609 """
610 if not isinstance(self._mapping, MappingProxyType): # type: ignore[unreachable]
611 self._mapping = MappingProxyType(self._mapping) # type: ignore
612 return self
614 _mapping: dict[str, K]