Coverage for python/lsst/daf/butler/core/named.py: 55%
192 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-10-25 15:14 +0000
« prev ^ index » next coverage.py v7.3.2, created at 2023-10-25 15:14 +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
23__all__ = (
24 "NamedKeyDict",
25 "NamedKeyMapping",
26 "NamedValueAbstractSet",
27 "NamedValueMutableSet",
28 "NamedValueSet",
29 "NameLookupMapping",
30 "NameMappingSetView",
31)
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
50class Named(Protocol):
51 """Protocol for objects with string name.
53 A non-inheritance interface for objects that have a string name that
54 maps directly to their equality comparisons.
55 """
57 @property
58 def name(self) -> str:
59 pass
62K = TypeVar("K", bound=Named)
63K_co = TypeVar("K_co", bound=Named, covariant=True)
64V = TypeVar("V")
65V_co = TypeVar("V_co", covariant=True)
68class NamedKeyMapping(Mapping[K, V_co]):
69 """Custom mapping class.
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.
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 """
84 __slots__ = ()
86 @property
87 @abstractmethod
88 def names(self) -> Set[str]:
89 """Return the set of names associated with the keys, in the same order.
91 (`~collections.abc.Set` [ `str` ]).
92 """
93 raise NotImplementedError()
95 def byName(self) -> dict[str, V_co]:
96 """Return a `~collections.abc.Mapping` with names as keys and the
97 ``self`` values.
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))
108 @abstractmethod
109 def keys(self) -> NamedValueAbstractSet[K]: # type: ignore
110 # TODO: docs
111 raise NotImplementedError()
113 @abstractmethod
114 def __getitem__(self, key: str | K) -> V_co:
115 raise NotImplementedError()
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
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"""
130class NamedKeyMutableMapping(NamedKeyMapping[K, V], MutableMapping[K, V]):
131 """An abstract base class that adds mutation to `NamedKeyMapping`."""
133 __slots__ = ()
135 @abstractmethod
136 def __setitem__(self, key: str | K, value: V) -> None:
137 raise NotImplementedError()
139 @abstractmethod
140 def __delitem__(self, key: str | K) -> None:
141 raise NotImplementedError()
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
148class NamedKeyDict(NamedKeyMutableMapping[K, V]):
149 """Dictionary wrapper for named keys.
151 Requires keys to have a ``.name`` attribute,
152 and permits lookups using either key objects or their names.
154 Names can be used in place of keys when updating existing items, but not
155 when adding new items.
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.
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`.
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 """
178 __slots__ = (
179 "_dict",
180 "_names",
181 )
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."
188 @property
189 def names(self) -> KeysView[str]:
190 """Return set of names associated with the keys, in the same order.
192 (`~collections.abc.KeysView`).
193 """
194 return self._names.keys()
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))
200 def __len__(self) -> int:
201 return len(self._dict)
203 def __iter__(self) -> Iterator[K]:
204 return iter(self._dict)
206 def __str__(self) -> str:
207 return "{{{}}}".format(", ".join(f"{str(k)}: {str(v)}" for k, v in self.items()))
209 def __repr__(self) -> str:
210 return "NamedKeyDict({{{}}})".format(", ".join(f"{repr(k)}: {repr(v)}" for k, v in self.items()))
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]
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
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]
234 def keys(self) -> NamedValueAbstractSet[K]: # type: ignore
235 return NameMappingSetView(self._names)
237 def values(self) -> ValuesView[V]:
238 return self._dict.values()
240 def items(self) -> ItemsView[K, V]:
241 return self._dict.items()
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
250 def freeze(self) -> NamedKeyMapping[K, V]:
251 """Disable all mutators.
253 Effectively transforms ``self`` into an immutable mapping.
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
268class NamedValueAbstractSet(Set[K_co]):
269 """Custom sets with named elements.
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 """
276 __slots__ = ()
278 @property
279 @abstractmethod
280 def names(self) -> Set[str]:
281 """Return set of names associated with the keys, in the same order.
283 (`~collections.abc.Set` [ `str` ]).
284 """
285 raise NotImplementedError()
287 @abstractmethod
288 def asMapping(self) -> Mapping[str, K_co]:
289 """Return a mapping view with names as keys.
291 Returns
292 -------
293 dict : `~collections.abc.Mapping`
294 A dictionary-like view with ``values() == self``.
295 """
296 raise NotImplementedError()
298 @abstractmethod
299 def __getitem__(self, key: str | K_co) -> K_co:
300 raise NotImplementedError()
302 def get(self, key: str | K_co, default: Any = None) -> Any:
303 """Return the element with the given name.
305 Returns ``default`` if no such element is present.
306 """
307 try:
308 return self[key]
309 except KeyError:
310 return default
312 @classmethod
313 def _from_iterable(cls, iterable: Iterable[K_co]) -> NamedValueSet[K_co]:
314 """Construct class from an iterable.
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).
320 Note that this behavior can only be guaranteed when both operands are
321 `NamedValueAbstractSet` instances.
322 """
323 return NamedValueSet(iterable)
326class NameMappingSetView(NamedValueAbstractSet[K_co]):
327 """A lightweight implementation of `NamedValueAbstractSet`.
329 Backed by a mapping from name to named object.
331 Parameters
332 ----------
333 mapping : `~collections.abc.Mapping` [ `str`, `object` ]
334 Mapping this object will provide a view of.
335 """
337 def __init__(self, mapping: Mapping[str, K_co]):
338 self._mapping = mapping
340 __slots__ = ("_mapping",)
342 @property
343 def names(self) -> Set[str]:
344 # Docstring inherited from NamedValueAbstractSet.
345 return self._mapping.keys()
347 def asMapping(self) -> Mapping[str, K_co]:
348 # Docstring inherited from NamedValueAbstractSet.
349 return self._mapping
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]
357 def __contains__(self, key: Any) -> bool:
358 return getattr(key, "name", key) in self._mapping
360 def __len__(self) -> int:
361 return len(self._mapping)
363 def __iter__(self) -> Iterator[K_co]:
364 return iter(self._mapping.values())
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
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
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
384 def __str__(self) -> str:
385 return "{{{}}}".format(", ".join(str(element) for element in self))
387 def __repr__(self) -> str:
388 return f"NameMappingSetView({self._mapping})"
391class NamedValueMutableSet(NamedValueAbstractSet[K], MutableSet[K]):
392 """Mutable variant of `NamedValueAbstractSet`.
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 """
405 __slots__ = ()
407 @abstractmethod
408 def __delitem__(self, name: str) -> None:
409 raise NotImplementedError()
411 @abstractmethod
412 def remove(self, element: str | K) -> Any:
413 """Remove an element from the set.
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.
421 Raises
422 ------
423 KeyError
424 Raised if an element with the given name does not exist.
425 """
426 raise NotImplementedError()
428 @abstractmethod
429 def discard(self, element: str | K) -> Any:
430 """Remove an element from the set if it exists.
432 Does nothing if no matching element is present.
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()
442 @abstractmethod
443 def pop(self, *args: str) -> K:
444 """Remove and return an element from the set.
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.
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()
462class NamedValueSet(NameMappingSetView[K], NamedValueMutableSet[K]):
463 """Custom mutable set class.
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.
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.
473 Parameters
474 ----------
475 elements : `iterable`
476 Iterable over elements to include in the set.
478 Raises
479 ------
480 AttributeError
481 Raised if one or more elements do not have a ``.name`` attribute.
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 """
491 def __init__(self, elements: Iterable[K] = ()):
492 super().__init__({element.name: element for element in elements})
494 def __repr__(self) -> str:
495 return "NamedValueSet({{{}}})".format(", ".join(repr(element) for element in self))
497 def issubset(self, other: Set[K]) -> bool:
498 return self <= other
500 def issuperset(self, other: Set[K]) -> bool:
501 return self >= other
503 def __delitem__(self, name: str) -> None:
504 del self._mapping[name]
506 def add(self, element: K) -> None:
507 """Add an element to the set.
509 Raises
510 ------
511 AttributeError
512 Raised if the element does not have a ``.name`` attribute.
513 """
514 self._mapping[element.name] = element
516 def clear(self) -> None:
517 # Docstring inherited.
518 self._mapping.clear()
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]
525 def discard(self, element: str | K) -> Any:
526 # Docstring inherited.
527 with contextlib.suppress(KeyError):
528 self.remove(element)
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,)
543 return self._mapping.pop(*args)
545 def update(self, elements: Iterable[K]) -> None:
546 """Add multiple new elements to the set.
548 Parameters
549 ----------
550 elements : `~collections.abc.Iterable`
551 Elements to add.
552 """
553 for element in elements:
554 self.add(element)
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
562 def freeze(self) -> NamedValueAbstractSet[K]:
563 """Disable all mutators.
565 Effectively transforming ``self`` into an immutable set.
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
579 _mapping: dict[str, K]