Coverage for python/lsst/daf/butler/_named.py: 57%
200 statements
« prev ^ index » next coverage.py v7.4.4, created at 2024-04-19 10:53 +0000
« prev ^ index » next coverage.py v7.4.4, created at 2024-04-19 10:53 +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: ... 124 ↛ exitline 124 didn't return from function 'get', because
126 @overload
127 def get(self, key: object, default: V) -> V_co | V: ... 127 ↛ exitline 127 didn't return from function 'get', because
129 def get(self, key: Any, default: Any = None) -> Any:
130 return super().get(key, default)
133NameLookupMapping = NamedKeyMapping[K, V_co] | Mapping[str, V_co]
134"""A type annotation alias for signatures that want to use ``mapping[s]``
135(or ``mapping.get(s)``) where ``s`` is a `str`, and don't care whether
136``mapping.keys()`` returns named objects or direct `str` instances.
137"""
140class NamedKeyMutableMapping(NamedKeyMapping[K, V], MutableMapping[K, V]):
141 """An abstract base class that adds mutation to `NamedKeyMapping`."""
143 __slots__ = ()
145 @abstractmethod
146 def __setitem__(self, key: str | K, value: V) -> None:
147 raise NotImplementedError()
149 @abstractmethod
150 def __delitem__(self, key: str | K) -> None:
151 raise NotImplementedError()
153 def pop(self, key: str | K, default: Any = None) -> Any:
154 # See comment in `NamedKeyMapping.get`; same logic applies here.
155 return super().pop(key, default) # type: ignore
158class NamedKeyDict(NamedKeyMutableMapping[K, V]):
159 """Dictionary wrapper for named keys.
161 Requires keys to have a ``.name`` attribute,
162 and permits lookups using either key objects or their names.
164 Names can be used in place of keys when updating existing items, but not
165 when adding new items.
167 It is assumed (but asserted) that all name equality is equivalent to key
168 equality, either because the key objects define equality this way, or
169 because different objects with the same name are never included in the same
170 dictionary.
172 Parameters
173 ----------
174 *args : `typing.Any`
175 All positional constructor arguments are forwarded directly to `dict`.
176 Keyword arguments are not accepted, because plain strings are not valid
177 keys for `NamedKeyDict`.
179 Raises
180 ------
181 AttributeError
182 Raised when an attempt is made to add an object with no ``.name``
183 attribute to the dictionary.
184 AssertionError
185 Raised when multiple keys have the same name.
186 """
188 __slots__ = (
189 "_dict",
190 "_names",
191 )
193 def __init__(self, *args: Any):
194 self._dict: dict[K, V] = dict(*args)
195 self._names = {key.name: key for key in self._dict}
196 assert len(self._names) == len(self._dict), "Duplicate names in keys."
198 @property
199 def names(self) -> KeysView[str]:
200 """Return set of names associated with the keys, in the same order.
202 (`~collections.abc.KeysView`).
203 """
204 return self._names.keys()
206 def byName(self) -> dict[str, V]:
207 """Return a `dict` with names as keys and the ``self`` values."""
208 return dict(zip(self._names.keys(), self._dict.values(), strict=True))
210 def __len__(self) -> int:
211 return len(self._dict)
213 def __iter__(self) -> Iterator[K]:
214 return iter(self._dict)
216 def __str__(self) -> str:
217 return "{{{}}}".format(", ".join(f"{str(k)}: {str(v)}" for k, v in self.items()))
219 def __repr__(self) -> str:
220 return "NamedKeyDict({{{}}})".format(", ".join(f"{repr(k)}: {repr(v)}" for k, v in self.items()))
222 def __getitem__(self, key: str | K) -> V:
223 if isinstance(key, str):
224 return self._dict[self._names[key]]
225 else:
226 return self._dict[key]
228 def __setitem__(self, key: str | K, value: V) -> None:
229 if isinstance(key, str):
230 self._dict[self._names[key]] = value
231 else:
232 assert self._names.get(key.name, key) == key, "Name is already associated with a different key."
233 self._dict[key] = value
234 self._names[key.name] = key
236 def __delitem__(self, key: str | K) -> None:
237 if isinstance(key, str):
238 del self._dict[self._names[key]]
239 del self._names[key]
240 else:
241 del self._dict[key]
242 del self._names[key.name]
244 def keys(self) -> NamedValueAbstractSet[K]: # type: ignore
245 return NameMappingSetView(self._names)
247 def values(self) -> ValuesView[V]:
248 return self._dict.values()
250 def items(self) -> ItemsView[K, V]:
251 return self._dict.items()
253 def copy(self) -> NamedKeyDict[K, V]:
254 """Return a new `NamedKeyDict` with the same elements."""
255 result = NamedKeyDict.__new__(NamedKeyDict)
256 result._dict = dict(self._dict)
257 result._names = dict(self._names)
258 return result
260 def freeze(self) -> NamedKeyMapping[K, V]:
261 """Disable all mutators.
263 Effectively transforms ``self`` into an immutable mapping.
265 Returns
266 -------
267 self : `NamedKeyMapping`
268 While ``self`` is modified in-place, it is also returned with a
269 type annotation that reflects its new, frozen state; assigning it
270 to a new variable (and considering any previous references
271 invalidated) should allow for more accurate static type checking.
272 """
273 if not isinstance(self._dict, MappingProxyType): # type: ignore[unreachable]
274 self._dict = MappingProxyType(self._dict) # type: ignore
275 return self
278class NamedValueAbstractSet(Set[K_co]):
279 """Custom sets with named elements.
281 An abstract base class for custom sets whose elements are objects with
282 a `str` ``name`` attribute, allowing some dict-like operations and
283 views to be supported.
284 """
286 __slots__ = ()
288 @property
289 @abstractmethod
290 def names(self) -> Set[str]:
291 """Return set of names associated with the keys, in the same order.
293 (`~collections.abc.Set` [ `str` ]).
294 """
295 raise NotImplementedError()
297 @abstractmethod
298 def asMapping(self) -> Mapping[str, K_co]:
299 """Return a mapping view with names as keys.
301 Returns
302 -------
303 dict : `~collections.abc.Mapping`
304 A dictionary-like view with ``values() == self``.
305 """
306 raise NotImplementedError()
308 @abstractmethod
309 def __getitem__(self, key: str | K_co) -> K_co:
310 raise NotImplementedError()
312 @overload
313 def get(self, key: object) -> K_co | None: ... 313 ↛ exitline 313 didn't return from function 'get', because
315 @overload
316 def get(self, key: object, default: V) -> K_co | V: ... 316 ↛ exitline 316 didn't return from function 'get', because
318 def get(self, key: Any, default: Any = None) -> Any:
319 """Return the element with the given name.
321 Parameters
322 ----------
323 key : `typing.Any`
324 The name of the element to be requested.
325 default : `typing.Any`, optional
326 The value returned if no such element is present.
328 Returns
329 -------
330 result : `typing.Any`
331 The value of the element.
332 """
333 try:
334 return self[key]
335 except KeyError:
336 return default
338 @classmethod
339 def _from_iterable(cls, iterable: Iterable[K_co]) -> NamedValueSet[K_co]:
340 """Construct class from an iterable.
342 Hook to ensure that inherited `collections.abc.Set` operators return
343 `NamedValueSet` instances, not something else (see `collections.abc`
344 documentation for more information).
346 Note that this behavior can only be guaranteed when both operands are
347 `NamedValueAbstractSet` instances.
348 """
349 return NamedValueSet(iterable)
352class NameMappingSetView(NamedValueAbstractSet[K_co]):
353 """A lightweight implementation of `NamedValueAbstractSet`.
355 Backed by a mapping from name to named object.
357 Parameters
358 ----------
359 mapping : `~collections.abc.Mapping` [ `str`, `object` ]
360 Mapping this object will provide a view of.
361 """
363 def __init__(self, mapping: Mapping[str, K_co]):
364 self._mapping = mapping
366 __slots__ = ("_mapping",)
368 @property
369 def names(self) -> Set[str]:
370 # Docstring inherited from NamedValueAbstractSet.
371 return self._mapping.keys()
373 def asMapping(self) -> Mapping[str, K_co]:
374 # Docstring inherited from NamedValueAbstractSet.
375 return self._mapping
377 def __getitem__(self, key: str | K_co) -> K_co:
378 if isinstance(key, str):
379 return self._mapping[key]
380 else:
381 return self._mapping[key.name]
383 def __contains__(self, key: Any) -> bool:
384 return getattr(key, "name", key) in self._mapping
386 def __len__(self) -> int:
387 return len(self._mapping)
389 def __iter__(self) -> Iterator[K_co]:
390 return iter(self._mapping.values())
392 def __eq__(self, other: Any) -> bool:
393 if isinstance(other, NamedValueAbstractSet):
394 return self.names == other.names
395 else:
396 return set(self._mapping.values()) == other
398 def __le__(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 __ge__(self, other: Set[K]) -> bool:
405 if isinstance(other, NamedValueAbstractSet):
406 return self.names >= other.names
407 else:
408 return set(self._mapping.values()) >= other
410 def __str__(self) -> str:
411 return "{{{}}}".format(", ".join(str(element) for element in self))
413 def __repr__(self) -> str:
414 return f"NameMappingSetView({self._mapping})"
417class NamedValueMutableSet(NamedValueAbstractSet[K], MutableSet[K]):
418 """Mutable variant of `NamedValueAbstractSet`.
420 Methods that can add new elements to the set are unchanged from their
421 `~collections.abc.MutableSet` definitions, while those that only remove
422 them can generally accept names or element instances. `pop` can be used
423 in either its `~collections.abc.MutableSet` form (no arguments; an
424 arbitrary element is returned) or its `~collections.abc.MutableMapping`
425 form (one or two arguments for the name and optional default value,
426 respectively). A `~collections.abc.MutableMapping`-like `__delitem__`
427 interface is also included, which takes only names (like
428 `NamedValueAbstractSet.__getitem__`).
429 """
431 __slots__ = ()
433 @abstractmethod
434 def __delitem__(self, name: str) -> None:
435 raise NotImplementedError()
437 @abstractmethod
438 def remove(self, element: str | K) -> Any:
439 """Remove an element from the set.
441 Parameters
442 ----------
443 element : `object` or `str`
444 Element to remove or the string name thereof. Assumed to be an
445 element if it has a ``.name`` attribute.
447 Raises
448 ------
449 KeyError
450 Raised if an element with the given name does not exist.
451 """
452 raise NotImplementedError()
454 @abstractmethod
455 def discard(self, element: str | K) -> Any:
456 """Remove an element from the set if it exists.
458 Does nothing if no matching element is present.
460 Parameters
461 ----------
462 element : `object` or `str`
463 Element to remove or the string name thereof. Assumed to be an
464 element if it has a ``.name`` attribute.
465 """
466 raise NotImplementedError()
468 @abstractmethod
469 def pop(self, *args: str) -> K:
470 """Remove and return an element from the set.
472 Parameters
473 ----------
474 *args : `str`, optional
475 Name of the element to remove and return. Must be passed
476 positionally. If not provided, an arbitrary element is
477 removed and returned.
479 Raises
480 ------
481 KeyError
482 Raised if ``name`` is provided but ``default`` is not, and no
483 matching element exists.
484 """
485 raise NotImplementedError()
488class NamedValueSet(NameMappingSetView[K], NamedValueMutableSet[K]):
489 """Custom mutable set class.
491 A custom mutable set class that requires elements to have a ``.name``
492 attribute, which can then be used as keys in `dict`-like lookup.
494 Names and elements can both be used with the ``in`` and ``del``
495 operators, `remove`, and `discard`. Names (but not elements)
496 can be used with ``[]``-based element retrieval (not assignment)
497 and the `get` method.
499 Parameters
500 ----------
501 elements : `collections.abc.Iterable`
502 Iterable over elements to include in the set.
504 Raises
505 ------
506 AttributeError
507 Raised if one or more elements do not have a ``.name`` attribute.
509 Notes
510 -----
511 Iteration order is guaranteed to be the same as insertion order (with
512 the same general behavior as `dict` ordering).
513 Like `dicts`, sets with the same elements will compare as equal even if
514 their iterator order is not the same.
515 """
517 def __init__(self, elements: Iterable[K] = ()):
518 super().__init__({element.name: element for element in elements})
520 def __repr__(self) -> str:
521 return "NamedValueSet({{{}}})".format(", ".join(repr(element) for element in self))
523 def issubset(self, other: Set[K]) -> bool:
524 return self <= other
526 def issuperset(self, other: Set[K]) -> bool:
527 return self >= other
529 def __delitem__(self, name: str) -> None:
530 del self._mapping[name]
532 def add(self, element: K) -> None:
533 """Add an element to the set.
535 Parameters
536 ----------
537 element : `typing.Any`
538 The element to add.
540 Raises
541 ------
542 AttributeError
543 Raised if the element does not have a ``.name`` attribute.
544 """
545 self._mapping[element.name] = element
547 def clear(self) -> None:
548 # Docstring inherited.
549 self._mapping.clear()
551 def remove(self, element: str | K) -> Any:
552 # Docstring inherited.
553 k = element.name if not isinstance(element, str) else element
554 del self._mapping[k]
556 def discard(self, element: str | K) -> Any:
557 # Docstring inherited.
558 with contextlib.suppress(KeyError):
559 self.remove(element)
561 def pop(self, *args: str) -> K:
562 # Docstring inherited.
563 if not args:
564 # Parent is abstract method and we cannot call MutableSet
565 # implementation directly. Instead follow MutableSet and
566 # choose first element from iteration.
567 it = iter(self._mapping)
568 try:
569 value = next(it)
570 except StopIteration:
571 raise KeyError from None
572 args = (value,)
574 return self._mapping.pop(*args)
576 def update(self, elements: Iterable[K]) -> None:
577 """Add multiple new elements to the set.
579 Parameters
580 ----------
581 elements : `~collections.abc.Iterable`
582 Elements to add.
583 """
584 for element in elements:
585 self.add(element)
587 def copy(self) -> NamedValueSet[K]:
588 """Return a new `NamedValueSet` with the same elements."""
589 result = NamedValueSet.__new__(NamedValueSet)
590 result._mapping = dict(self._mapping)
591 return result
593 def freeze(self) -> NamedValueAbstractSet[K]:
594 """Disable all mutators.
596 Effectively transforming ``self`` into an immutable set.
598 Returns
599 -------
600 self : `NamedValueAbstractSet`
601 While ``self`` is modified in-place, it is also returned with a
602 type annotation that reflects its new, frozen state; assigning it
603 to a new variable (and considering any previous references
604 invalidated) should allow for more accurate static type checking.
605 """
606 if not isinstance(self._mapping, MappingProxyType): # type: ignore[unreachable]
607 self._mapping = MappingProxyType(self._mapping) # type: ignore
608 return self
610 _mapping: dict[str, K]