Coverage for python/lsst/daf/butler/_named.py: 55%
192 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-10-27 09:44 +0000
« prev ^ index » next coverage.py v7.3.2, created at 2023-10-27 09:44 +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
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 def get(self, key: str | K, default: Any = None) -> Any:
124 # Delegating to super is not allowed by typing, because it doesn't
125 # accept str, but we know it just delegates to __getitem__, which does.
126 return super().get(key, default) # type: ignore
129NameLookupMapping = NamedKeyMapping[K, V_co] | Mapping[str, V_co]
130"""A type annotation alias for signatures that want to use ``mapping[s]``
131(or ``mapping.get(s)``) where ``s`` is a `str`, and don't care whether
132``mapping.keys()`` returns named objects or direct `str` instances.
133"""
136class NamedKeyMutableMapping(NamedKeyMapping[K, V], MutableMapping[K, V]):
137 """An abstract base class that adds mutation to `NamedKeyMapping`."""
139 __slots__ = ()
141 @abstractmethod
142 def __setitem__(self, key: str | K, value: V) -> None:
143 raise NotImplementedError()
145 @abstractmethod
146 def __delitem__(self, key: str | K) -> None:
147 raise NotImplementedError()
149 def pop(self, key: str | K, default: Any = None) -> Any:
150 # See comment in `NamedKeyMapping.get`; same logic applies here.
151 return super().pop(key, default) # type: ignore
154class NamedKeyDict(NamedKeyMutableMapping[K, V]):
155 """Dictionary wrapper for named keys.
157 Requires keys to have a ``.name`` attribute,
158 and permits lookups using either key objects or their names.
160 Names can be used in place of keys when updating existing items, but not
161 when adding new items.
163 It is assumed (but asserted) that all name equality is equivalent to key
164 equality, either because the key objects define equality this way, or
165 because different objects with the same name are never included in the same
166 dictionary.
168 Parameters
169 ----------
170 args
171 All positional constructor arguments are forwarded directly to `dict`.
172 Keyword arguments are not accepted, because plain strings are not valid
173 keys for `NamedKeyDict`.
175 Raises
176 ------
177 AttributeError
178 Raised when an attempt is made to add an object with no ``.name``
179 attribute to the dictionary.
180 AssertionError
181 Raised when multiple keys have the same name.
182 """
184 __slots__ = (
185 "_dict",
186 "_names",
187 )
189 def __init__(self, *args: Any):
190 self._dict: dict[K, V] = dict(*args)
191 self._names = {key.name: key for key in self._dict}
192 assert len(self._names) == len(self._dict), "Duplicate names in keys."
194 @property
195 def names(self) -> KeysView[str]:
196 """Return set of names associated with the keys, in the same order.
198 (`~collections.abc.KeysView`).
199 """
200 return self._names.keys()
202 def byName(self) -> dict[str, V]:
203 """Return a `dict` with names as keys and the ``self`` values."""
204 return dict(zip(self._names.keys(), self._dict.values(), strict=True))
206 def __len__(self) -> int:
207 return len(self._dict)
209 def __iter__(self) -> Iterator[K]:
210 return iter(self._dict)
212 def __str__(self) -> str:
213 return "{{{}}}".format(", ".join(f"{str(k)}: {str(v)}" for k, v in self.items()))
215 def __repr__(self) -> str:
216 return "NamedKeyDict({{{}}})".format(", ".join(f"{repr(k)}: {repr(v)}" for k, v in self.items()))
218 def __getitem__(self, key: str | K) -> V:
219 if isinstance(key, str):
220 return self._dict[self._names[key]]
221 else:
222 return self._dict[key]
224 def __setitem__(self, key: str | K, value: V) -> None:
225 if isinstance(key, str):
226 self._dict[self._names[key]] = value
227 else:
228 assert self._names.get(key.name, key) == key, "Name is already associated with a different key."
229 self._dict[key] = value
230 self._names[key.name] = key
232 def __delitem__(self, key: str | K) -> None:
233 if isinstance(key, str):
234 del self._dict[self._names[key]]
235 del self._names[key]
236 else:
237 del self._dict[key]
238 del self._names[key.name]
240 def keys(self) -> NamedValueAbstractSet[K]: # type: ignore
241 return NameMappingSetView(self._names)
243 def values(self) -> ValuesView[V]:
244 return self._dict.values()
246 def items(self) -> ItemsView[K, V]:
247 return self._dict.items()
249 def copy(self) -> NamedKeyDict[K, V]:
250 """Return a new `NamedKeyDict` with the same elements."""
251 result = NamedKeyDict.__new__(NamedKeyDict)
252 result._dict = dict(self._dict)
253 result._names = dict(self._names)
254 return result
256 def freeze(self) -> NamedKeyMapping[K, V]:
257 """Disable all mutators.
259 Effectively transforms ``self`` into an immutable mapping.
261 Returns
262 -------
263 self : `NamedKeyMapping`
264 While ``self`` is modified in-place, it is also returned with a
265 type annotation that reflects its new, frozen state; assigning it
266 to a new variable (and considering any previous references
267 invalidated) should allow for more accurate static type checking.
268 """
269 if not isinstance(self._dict, MappingProxyType):
270 self._dict = MappingProxyType(self._dict) # type: ignore
271 return self
274class NamedValueAbstractSet(Set[K_co]):
275 """Custom sets with named elements.
277 An abstract base class for custom sets whose elements are objects with
278 a `str` ``name`` attribute, allowing some dict-like operations and
279 views to be supported.
280 """
282 __slots__ = ()
284 @property
285 @abstractmethod
286 def names(self) -> Set[str]:
287 """Return set of names associated with the keys, in the same order.
289 (`~collections.abc.Set` [ `str` ]).
290 """
291 raise NotImplementedError()
293 @abstractmethod
294 def asMapping(self) -> Mapping[str, K_co]:
295 """Return a mapping view with names as keys.
297 Returns
298 -------
299 dict : `~collections.abc.Mapping`
300 A dictionary-like view with ``values() == self``.
301 """
302 raise NotImplementedError()
304 @abstractmethod
305 def __getitem__(self, key: str | K_co) -> K_co:
306 raise NotImplementedError()
308 def get(self, key: str | K_co, default: Any = None) -> Any:
309 """Return the element with the given name.
311 Returns ``default`` if no such element is present.
312 """
313 try:
314 return self[key]
315 except KeyError:
316 return default
318 @classmethod
319 def _from_iterable(cls, iterable: Iterable[K_co]) -> NamedValueSet[K_co]:
320 """Construct class from an iterable.
322 Hook to ensure that inherited `collections.abc.Set` operators return
323 `NamedValueSet` instances, not something else (see `collections.abc`
324 documentation for more information).
326 Note that this behavior can only be guaranteed when both operands are
327 `NamedValueAbstractSet` instances.
328 """
329 return NamedValueSet(iterable)
332class NameMappingSetView(NamedValueAbstractSet[K_co]):
333 """A lightweight implementation of `NamedValueAbstractSet`.
335 Backed by a mapping from name to named object.
337 Parameters
338 ----------
339 mapping : `~collections.abc.Mapping` [ `str`, `object` ]
340 Mapping this object will provide a view of.
341 """
343 def __init__(self, mapping: Mapping[str, K_co]):
344 self._mapping = mapping
346 __slots__ = ("_mapping",)
348 @property
349 def names(self) -> Set[str]:
350 # Docstring inherited from NamedValueAbstractSet.
351 return self._mapping.keys()
353 def asMapping(self) -> Mapping[str, K_co]:
354 # Docstring inherited from NamedValueAbstractSet.
355 return self._mapping
357 def __getitem__(self, key: str | K_co) -> K_co:
358 if isinstance(key, str):
359 return self._mapping[key]
360 else:
361 return self._mapping[key.name]
363 def __contains__(self, key: Any) -> bool:
364 return getattr(key, "name", key) in self._mapping
366 def __len__(self) -> int:
367 return len(self._mapping)
369 def __iter__(self) -> Iterator[K_co]:
370 return iter(self._mapping.values())
372 def __eq__(self, other: Any) -> bool:
373 if isinstance(other, NamedValueAbstractSet):
374 return self.names == other.names
375 else:
376 return set(self._mapping.values()) == other
378 def __le__(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 __ge__(self, other: Set[K]) -> bool:
385 if isinstance(other, NamedValueAbstractSet):
386 return self.names >= other.names
387 else:
388 return set(self._mapping.values()) >= other
390 def __str__(self) -> str:
391 return "{{{}}}".format(", ".join(str(element) for element in self))
393 def __repr__(self) -> str:
394 return f"NameMappingSetView({self._mapping})"
397class NamedValueMutableSet(NamedValueAbstractSet[K], MutableSet[K]):
398 """Mutable variant of `NamedValueAbstractSet`.
400 Methods that can add new elements to the set are unchanged from their
401 `~collections.abc.MutableSet` definitions, while those that only remove
402 them can generally accept names or element instances. `pop` can be used
403 in either its `~collections.abc.MutableSet` form (no arguments; an
404 arbitrary element is returned) or its `~collections.abc.MutableMapping`
405 form (one or two arguments for the name and optional default value,
406 respectively). A `~collections.abc.MutableMapping`-like `__delitem__`
407 interface is also included, which takes only names (like
408 `NamedValueAbstractSet.__getitem__`).
409 """
411 __slots__ = ()
413 @abstractmethod
414 def __delitem__(self, name: str) -> None:
415 raise NotImplementedError()
417 @abstractmethod
418 def remove(self, element: str | K) -> Any:
419 """Remove an element from the set.
421 Parameters
422 ----------
423 element : `object` or `str`
424 Element to remove or the string name thereof. Assumed to be an
425 element if it has a ``.name`` attribute.
427 Raises
428 ------
429 KeyError
430 Raised if an element with the given name does not exist.
431 """
432 raise NotImplementedError()
434 @abstractmethod
435 def discard(self, element: str | K) -> Any:
436 """Remove an element from the set if it exists.
438 Does nothing if no matching element is present.
440 Parameters
441 ----------
442 element : `object` or `str`
443 Element to remove or the string name thereof. Assumed to be an
444 element if it has a ``.name`` attribute.
445 """
446 raise NotImplementedError()
448 @abstractmethod
449 def pop(self, *args: str) -> K:
450 """Remove and return an element from the set.
452 Parameters
453 ----------
454 name : `str`, optional
455 Name of the element to remove and return. Must be passed
456 positionally. If not provided, an arbitrary element is
457 removed and returned.
459 Raises
460 ------
461 KeyError
462 Raised if ``name`` is provided but ``default`` is not, and no
463 matching element exists.
464 """
465 raise NotImplementedError()
468class NamedValueSet(NameMappingSetView[K], NamedValueMutableSet[K]):
469 """Custom mutable set class.
471 A custom mutable set class that requires elements to have a ``.name``
472 attribute, which can then be used as keys in `dict`-like lookup.
474 Names and elements can both be used with the ``in`` and ``del``
475 operators, `remove`, and `discard`. Names (but not elements)
476 can be used with ``[]``-based element retrieval (not assignment)
477 and the `get` method.
479 Parameters
480 ----------
481 elements : `iterable`
482 Iterable over elements to include in the set.
484 Raises
485 ------
486 AttributeError
487 Raised if one or more elements do not have a ``.name`` attribute.
489 Notes
490 -----
491 Iteration order is guaranteed to be the same as insertion order (with
492 the same general behavior as `dict` ordering).
493 Like `dicts`, sets with the same elements will compare as equal even if
494 their iterator order is not the same.
495 """
497 def __init__(self, elements: Iterable[K] = ()):
498 super().__init__({element.name: element for element in elements})
500 def __repr__(self) -> str:
501 return "NamedValueSet({{{}}})".format(", ".join(repr(element) for element in self))
503 def issubset(self, other: Set[K]) -> bool:
504 return self <= other
506 def issuperset(self, other: Set[K]) -> bool:
507 return self >= other
509 def __delitem__(self, name: str) -> None:
510 del self._mapping[name]
512 def add(self, element: K) -> None:
513 """Add an element to the set.
515 Raises
516 ------
517 AttributeError
518 Raised if the element does not have a ``.name`` attribute.
519 """
520 self._mapping[element.name] = element
522 def clear(self) -> None:
523 # Docstring inherited.
524 self._mapping.clear()
526 def remove(self, element: str | K) -> Any:
527 # Docstring inherited.
528 k = element.name if not isinstance(element, str) else element
529 del self._mapping[k]
531 def discard(self, element: str | K) -> Any:
532 # Docstring inherited.
533 with contextlib.suppress(KeyError):
534 self.remove(element)
536 def pop(self, *args: str) -> K:
537 # Docstring inherited.
538 if not args:
539 # Parent is abstract method and we cannot call MutableSet
540 # implementation directly. Instead follow MutableSet and
541 # choose first element from iteration.
542 it = iter(self._mapping)
543 try:
544 value = next(it)
545 except StopIteration:
546 raise KeyError from None
547 args = (value,)
549 return self._mapping.pop(*args)
551 def update(self, elements: Iterable[K]) -> None:
552 """Add multiple new elements to the set.
554 Parameters
555 ----------
556 elements : `~collections.abc.Iterable`
557 Elements to add.
558 """
559 for element in elements:
560 self.add(element)
562 def copy(self) -> NamedValueSet[K]:
563 """Return a new `NamedValueSet` with the same elements."""
564 result = NamedValueSet.__new__(NamedValueSet)
565 result._mapping = dict(self._mapping)
566 return result
568 def freeze(self) -> NamedValueAbstractSet[K]:
569 """Disable all mutators.
571 Effectively transforming ``self`` into an immutable set.
573 Returns
574 -------
575 self : `NamedValueAbstractSet`
576 While ``self`` is modified in-place, it is also returned with a
577 type annotation that reflects its new, frozen state; assigning it
578 to a new variable (and considering any previous references
579 invalidated) should allow for more accurate static type checking.
580 """
581 if not isinstance(self._mapping, MappingProxyType):
582 self._mapping = MappingProxyType(self._mapping) # type: ignore
583 return self
585 _mapping: dict[str, K]