Coverage for python/lsst/daf/butler/core/named.py: 47%
193 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-23 09:30 +0000
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-23 09:30 +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)
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
49class Named(Protocol):
50 """Protocol for objects with string name.
52 A non-inheritance interface for objects that have a string name that
53 maps directly to their equality comparisons.
54 """
56 @property
57 def name(self) -> str:
58 pass
61K = TypeVar("K", bound=Named)
62K_co = TypeVar("K_co", bound=Named, covariant=True)
63V = TypeVar("V")
64V_co = TypeVar("V_co", covariant=True)
67class NamedKeyMapping(Mapping[K, V_co]):
68 """Custom mapping class.
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.
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 """
83 __slots__ = ()
85 @property
86 @abstractmethod
87 def names(self) -> Set[str]:
88 """Return the set of names associated with the keys, in the same order.
90 (`~collections.abc.Set` [ `str` ]).
91 """
92 raise NotImplementedError()
94 def byName(self) -> dict[str, V_co]:
95 """Return a `~collections.abc.Mapping` with names as keys and the
96 ``self`` values.
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()))
107 @abstractmethod
108 def keys(self) -> NamedValueAbstractSet[K]: # type: ignore
109 # TODO: docs
110 raise NotImplementedError()
112 @abstractmethod
113 def __getitem__(self, key: str | K) -> V_co:
114 raise NotImplementedError()
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
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"""
129class NamedKeyMutableMapping(NamedKeyMapping[K, V], MutableMapping[K, V]):
130 """An abstract base class that adds mutation to `NamedKeyMapping`."""
132 __slots__ = ()
134 @abstractmethod
135 def __setitem__(self, key: str | K, value: V) -> None:
136 raise NotImplementedError()
138 @abstractmethod
139 def __delitem__(self, key: str | K) -> None:
140 raise NotImplementedError()
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
147class NamedKeyDict(NamedKeyMutableMapping[K, V]):
148 """Dictionary wrapper for named keys.
150 Requires keys to have a ``.name`` attribute,
151 and permits lookups using either key objects or their names.
153 Names can be used in place of keys when updating existing items, but not
154 when adding new items.
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.
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`.
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 """
177 __slots__ = (
178 "_dict",
179 "_names",
180 )
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."
187 @property
188 def names(self) -> KeysView[str]:
189 """Return set of names associated with the keys, in the same order.
191 (`~collections.abc.KeysView`).
192 """
193 return self._names.keys()
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()))
199 def __len__(self) -> int:
200 return len(self._dict)
202 def __iter__(self) -> Iterator[K]:
203 return iter(self._dict)
205 def __str__(self) -> str:
206 return "{{{}}}".format(", ".join(f"{str(k)}: {str(v)}" for k, v in self.items()))
208 def __repr__(self) -> str:
209 return "NamedKeyDict({{{}}})".format(", ".join(f"{repr(k)}: {repr(v)}" for k, v in self.items()))
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]
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
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]
233 def keys(self) -> NamedValueAbstractSet[K]: # type: ignore
234 return NameMappingSetView(self._names)
236 def values(self) -> ValuesView[V]:
237 return self._dict.values()
239 def items(self) -> ItemsView[K, V]:
240 return self._dict.items()
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
249 def freeze(self) -> NamedKeyMapping[K, V]:
250 """Disable all mutators.
252 Effectively transforms ``self`` into an immutable mapping.
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
267class NamedValueAbstractSet(Set[K_co]):
268 """Custom sets with named elements.
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 """
275 __slots__ = ()
277 @property
278 @abstractmethod
279 def names(self) -> Set[str]:
280 """Return set of names associated with the keys, in the same order.
282 (`~collections.abc.Set` [ `str` ]).
283 """
284 raise NotImplementedError()
286 @abstractmethod
287 def asMapping(self) -> Mapping[str, K_co]:
288 """Return a mapping view with names as keys.
290 Returns
291 -------
292 dict : `~collections.abc.Mapping`
293 A dictionary-like view with ``values() == self``.
294 """
295 raise NotImplementedError()
297 @abstractmethod
298 def __getitem__(self, key: str | K_co) -> K_co:
299 raise NotImplementedError()
301 def get(self, key: str | K_co, default: Any = None) -> Any:
302 """Return the element with the given name.
304 Returns ``default`` if no such element is present.
305 """
306 try:
307 return self[key]
308 except KeyError:
309 return default
311 @classmethod
312 def _from_iterable(cls, iterable: Iterable[K_co]) -> NamedValueSet[K_co]:
313 """Construct class from an iterable.
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).
319 Note that this behavior can only be guaranteed when both operands are
320 `NamedValueAbstractSet` instances.
321 """
322 return NamedValueSet(iterable)
325class NameMappingSetView(NamedValueAbstractSet[K_co]):
326 """A lightweight implementation of `NamedValueAbstractSet`.
328 Backed by a mapping from name to named object.
330 Parameters
331 ----------
332 mapping : `~collections.abc.Mapping` [ `str`, `object` ]
333 Mapping this object will provide a view of.
334 """
336 def __init__(self, mapping: Mapping[str, K_co]):
337 self._mapping = mapping
339 __slots__ = ("_mapping",)
341 @property
342 def names(self) -> Set[str]:
343 # Docstring inherited from NamedValueAbstractSet.
344 return self._mapping.keys()
346 def asMapping(self) -> Mapping[str, K_co]:
347 # Docstring inherited from NamedValueAbstractSet.
348 return self._mapping
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]
356 def __contains__(self, key: Any) -> bool:
357 return getattr(key, "name", key) in self._mapping
359 def __len__(self) -> int:
360 return len(self._mapping)
362 def __iter__(self) -> Iterator[K_co]:
363 return iter(self._mapping.values())
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
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
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
383 def __str__(self) -> str:
384 return "{{{}}}".format(", ".join(str(element) for element in self))
386 def __repr__(self) -> str:
387 return f"NameMappingSetView({self._mapping})"
390class NamedValueMutableSet(NamedValueAbstractSet[K], MutableSet[K]):
391 """Mutable variant of `NamedValueAbstractSet`.
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 """
404 __slots__ = ()
406 @abstractmethod
407 def __delitem__(self, name: str) -> None:
408 raise NotImplementedError()
410 @abstractmethod
411 def remove(self, element: str | K) -> Any:
412 """Remove an element from the set.
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.
420 Raises
421 ------
422 KeyError
423 Raised if an element with the given name does not exist.
424 """
425 raise NotImplementedError()
427 @abstractmethod
428 def discard(self, element: str | K) -> Any:
429 """Remove an element from the set if it exists.
431 Does nothing if no matching element is present.
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()
441 @abstractmethod
442 def pop(self, *args: str) -> K:
443 """Remove and return an element from the set.
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.
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()
461class NamedValueSet(NameMappingSetView[K], NamedValueMutableSet[K]):
462 """Custom mutable set class.
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.
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.
472 Parameters
473 ----------
474 elements : `iterable`
475 Iterable over elements to include in the set.
477 Raises
478 ------
479 AttributeError
480 Raised if one or more elements do not have a ``.name`` attribute.
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 """
490 def __init__(self, elements: Iterable[K] = ()):
491 super().__init__({element.name: element for element in elements})
493 def __repr__(self) -> str:
494 return "NamedValueSet({{{}}})".format(", ".join(repr(element) for element in self))
496 def issubset(self, other: Set[K]) -> bool:
497 return self <= other
499 def issuperset(self, other: Set[K]) -> bool:
500 return self >= other
502 def __delitem__(self, name: str) -> None:
503 del self._mapping[name]
505 def add(self, element: K) -> None:
506 """Add an element to the set.
508 Raises
509 ------
510 AttributeError
511 Raised if the element does not have a ``.name`` attribute.
512 """
513 self._mapping[element.name] = element
515 def clear(self) -> None:
516 # Docstring inherited.
517 self._mapping.clear()
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]
524 def discard(self, element: str | K) -> Any:
525 # Docstring inherited.
526 try:
527 self.remove(element)
528 except KeyError:
529 pass
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,)
544 return self._mapping.pop(*args)
546 def update(self, elements: Iterable[K]) -> None:
547 """Add multiple new elements to the set.
549 Parameters
550 ----------
551 elements : `~collections.abc.Iterable`
552 Elements to add.
553 """
554 for element in elements:
555 self.add(element)
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
563 def freeze(self) -> NamedValueAbstractSet[K]:
564 """Disable all mutators.
566 Effectively transforming ``self`` into an immutable set.
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
580 _mapping: dict[str, K]