Coverage for python/lsst/daf/butler/_named.py: 57%
204 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-12-05 11:07 +0000
« prev ^ index » next coverage.py v7.3.2, created at 2023-12-05 11:07 +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
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 Returns ``default`` if no such element is present.
326 """
327 try:
328 return self[key]
329 except KeyError:
330 return default
332 @classmethod
333 def _from_iterable(cls, iterable: Iterable[K_co]) -> NamedValueSet[K_co]:
334 """Construct class from an iterable.
336 Hook to ensure that inherited `collections.abc.Set` operators return
337 `NamedValueSet` instances, not something else (see `collections.abc`
338 documentation for more information).
340 Note that this behavior can only be guaranteed when both operands are
341 `NamedValueAbstractSet` instances.
342 """
343 return NamedValueSet(iterable)
346class NameMappingSetView(NamedValueAbstractSet[K_co]):
347 """A lightweight implementation of `NamedValueAbstractSet`.
349 Backed by a mapping from name to named object.
351 Parameters
352 ----------
353 mapping : `~collections.abc.Mapping` [ `str`, `object` ]
354 Mapping this object will provide a view of.
355 """
357 def __init__(self, mapping: Mapping[str, K_co]):
358 self._mapping = mapping
360 __slots__ = ("_mapping",)
362 @property
363 def names(self) -> Set[str]:
364 # Docstring inherited from NamedValueAbstractSet.
365 return self._mapping.keys()
367 def asMapping(self) -> Mapping[str, K_co]:
368 # Docstring inherited from NamedValueAbstractSet.
369 return self._mapping
371 def __getitem__(self, key: str | K_co) -> K_co:
372 if isinstance(key, str):
373 return self._mapping[key]
374 else:
375 return self._mapping[key.name]
377 def __contains__(self, key: Any) -> bool:
378 return getattr(key, "name", key) in self._mapping
380 def __len__(self) -> int:
381 return len(self._mapping)
383 def __iter__(self) -> Iterator[K_co]:
384 return iter(self._mapping.values())
386 def __eq__(self, other: Any) -> bool:
387 if isinstance(other, NamedValueAbstractSet):
388 return self.names == other.names
389 else:
390 return set(self._mapping.values()) == other
392 def __le__(self, other: Set[K]) -> bool:
393 if isinstance(other, NamedValueAbstractSet):
394 return self.names <= other.names
395 else:
396 return set(self._mapping.values()) <= other
398 def __ge__(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
404 def __str__(self) -> str:
405 return "{{{}}}".format(", ".join(str(element) for element in self))
407 def __repr__(self) -> str:
408 return f"NameMappingSetView({self._mapping})"
411class NamedValueMutableSet(NamedValueAbstractSet[K], MutableSet[K]):
412 """Mutable variant of `NamedValueAbstractSet`.
414 Methods that can add new elements to the set are unchanged from their
415 `~collections.abc.MutableSet` definitions, while those that only remove
416 them can generally accept names or element instances. `pop` can be used
417 in either its `~collections.abc.MutableSet` form (no arguments; an
418 arbitrary element is returned) or its `~collections.abc.MutableMapping`
419 form (one or two arguments for the name and optional default value,
420 respectively). A `~collections.abc.MutableMapping`-like `__delitem__`
421 interface is also included, which takes only names (like
422 `NamedValueAbstractSet.__getitem__`).
423 """
425 __slots__ = ()
427 @abstractmethod
428 def __delitem__(self, name: str) -> None:
429 raise NotImplementedError()
431 @abstractmethod
432 def remove(self, element: str | K) -> Any:
433 """Remove an element from the set.
435 Parameters
436 ----------
437 element : `object` or `str`
438 Element to remove or the string name thereof. Assumed to be an
439 element if it has a ``.name`` attribute.
441 Raises
442 ------
443 KeyError
444 Raised if an element with the given name does not exist.
445 """
446 raise NotImplementedError()
448 @abstractmethod
449 def discard(self, element: str | K) -> Any:
450 """Remove an element from the set if it exists.
452 Does nothing if no matching element is present.
454 Parameters
455 ----------
456 element : `object` or `str`
457 Element to remove or the string name thereof. Assumed to be an
458 element if it has a ``.name`` attribute.
459 """
460 raise NotImplementedError()
462 @abstractmethod
463 def pop(self, *args: str) -> K:
464 """Remove and return an element from the set.
466 Parameters
467 ----------
468 name : `str`, optional
469 Name of the element to remove and return. Must be passed
470 positionally. If not provided, an arbitrary element is
471 removed and returned.
473 Raises
474 ------
475 KeyError
476 Raised if ``name`` is provided but ``default`` is not, and no
477 matching element exists.
478 """
479 raise NotImplementedError()
482class NamedValueSet(NameMappingSetView[K], NamedValueMutableSet[K]):
483 """Custom mutable set class.
485 A custom mutable set class that requires elements to have a ``.name``
486 attribute, which can then be used as keys in `dict`-like lookup.
488 Names and elements can both be used with the ``in`` and ``del``
489 operators, `remove`, and `discard`. Names (but not elements)
490 can be used with ``[]``-based element retrieval (not assignment)
491 and the `get` method.
493 Parameters
494 ----------
495 elements : `iterable`
496 Iterable over elements to include in the set.
498 Raises
499 ------
500 AttributeError
501 Raised if one or more elements do not have a ``.name`` attribute.
503 Notes
504 -----
505 Iteration order is guaranteed to be the same as insertion order (with
506 the same general behavior as `dict` ordering).
507 Like `dicts`, sets with the same elements will compare as equal even if
508 their iterator order is not the same.
509 """
511 def __init__(self, elements: Iterable[K] = ()):
512 super().__init__({element.name: element for element in elements})
514 def __repr__(self) -> str:
515 return "NamedValueSet({{{}}})".format(", ".join(repr(element) for element in self))
517 def issubset(self, other: Set[K]) -> bool:
518 return self <= other
520 def issuperset(self, other: Set[K]) -> bool:
521 return self >= other
523 def __delitem__(self, name: str) -> None:
524 del self._mapping[name]
526 def add(self, element: K) -> None:
527 """Add an element to the set.
529 Raises
530 ------
531 AttributeError
532 Raised if the element does not have a ``.name`` attribute.
533 """
534 self._mapping[element.name] = element
536 def clear(self) -> None:
537 # Docstring inherited.
538 self._mapping.clear()
540 def remove(self, element: str | K) -> Any:
541 # Docstring inherited.
542 k = element.name if not isinstance(element, str) else element
543 del self._mapping[k]
545 def discard(self, element: str | K) -> Any:
546 # Docstring inherited.
547 with contextlib.suppress(KeyError):
548 self.remove(element)
550 def pop(self, *args: str) -> K:
551 # Docstring inherited.
552 if not args:
553 # Parent is abstract method and we cannot call MutableSet
554 # implementation directly. Instead follow MutableSet and
555 # choose first element from iteration.
556 it = iter(self._mapping)
557 try:
558 value = next(it)
559 except StopIteration:
560 raise KeyError from None
561 args = (value,)
563 return self._mapping.pop(*args)
565 def update(self, elements: Iterable[K]) -> None:
566 """Add multiple new elements to the set.
568 Parameters
569 ----------
570 elements : `~collections.abc.Iterable`
571 Elements to add.
572 """
573 for element in elements:
574 self.add(element)
576 def copy(self) -> NamedValueSet[K]:
577 """Return a new `NamedValueSet` with the same elements."""
578 result = NamedValueSet.__new__(NamedValueSet)
579 result._mapping = dict(self._mapping)
580 return result
582 def freeze(self) -> NamedValueAbstractSet[K]:
583 """Disable all mutators.
585 Effectively transforming ``self`` into an immutable set.
587 Returns
588 -------
589 self : `NamedValueAbstractSet`
590 While ``self`` is modified in-place, it is also returned with a
591 type annotation that reflects its new, frozen state; assigning it
592 to a new variable (and considering any previous references
593 invalidated) should allow for more accurate static type checking.
594 """
595 if not isinstance(self._mapping, MappingProxyType): # type: ignore[unreachable]
596 self._mapping = MappingProxyType(self._mapping) # type: ignore
597 return self
599 _mapping: dict[str, K]