Coverage for src/bunches/mappings.py: 100%
199 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-30 23:02 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-30 23:02 +0000
1"""Extensible, flexible, lightweight dict-like classes
3Contents:
4 Dictionary (base.Bunch, MutableMapping): drop-in replacement for a python
5 dict with some added functionality.
6 Catalog (Dictionary): wildcard-accepting dict which is primarily intended
7 for storing different options and strategies. It also returns lists of
8 matches if a list of keys is provided.
9 ChainDict (Dictionary): combines the additional functionality of
10 Dictionary with collections.ChainMap from the Python builtin library.
11 Repository (Dictionary): stores items using inferred keys when the `add`
12 method is called. This is useful for mappings where the key naming is
13 controlled by the mapping instead of based on the passed arguments.
14 Support for guaranteed unique key creation (using an integer counter) is
15 provided out of the box based on the `overwrite` argument.
17"""
19from __future__ import annotations
21import contextlib
22import copy
23import dataclasses
24import itertools
25from collections.abc import (
26 Collection,
27 Hashable,
28 Iterable,
29 Iterator,
30 MutableMapping,
31 MutableSequence,
32 Sequence,
33)
34from typing import TYPE_CHECKING, Any
36from . import base, settings, utilities
38if TYPE_CHECKING:
39 from .kinds import GenericDict, GenericList, SubsetReturns
42@dataclasses.dataclass
43class Dictionary(base.Bunch, MutableMapping):
44 """Basic bunches dict replacement.
46 A `Dictionary` differs from an ordinary python `dict` in ways inherited from
47 `Bunch` by requiring `add` and `subset` methods, storing data in `contents`,
48 and allowing the "+" operator to join `Dictionary` instances with other
49 mappings, including `Dictionary` instances.
51 In addition, it differs in 2 other significant ways:
52 1) When returning `keys`, `values` and `items`, this class returns them
53 as tuples instead of `KeysView`, `ValuesView`, and `ItemsView`.
54 2) It includes similar functionality to `defaultdict` in the python
55 standard library, including a `setdefault` method. The default is
56 used by the `get` method (not by `[]` access).
58 Args:
59 contents: stored dictionary. Defaults to an empty `dict`.
60 default_factory: default value to return or default callable to use to
61 create the default value when `get` is called with a missing key.
62 Defaults to `None`.
64 """
66 contents: GenericDict = dataclasses.field(default_factory = dict)
67 default_factory: Any | None = None
69 """ Class Methods """
71 @classmethod
72 def fromkeys(
73 cls,
74 keys: Iterable[Hashable],
75 value: Any = None,
76 **kwargs: Any) -> Dictionary:
77 """Emulates the `fromkeys` class method from a python `dict`.
79 Args:
80 keys: items to be keys in a new `Dictionary`.
81 value: the value to use for all values in a new `Dictionary`.
82 Defaults to `None`, as in `dict.fromkeys`.
83 **kwargs: additional arguments to pass to the class constructor
84 (e.g. `default_factory`).
86 Returns:
87 An instance formed from `keys` and `value`.
89 """
90 return cls(contents = dict.fromkeys(keys, value), **kwargs)
92 """ Public Methods """
94 def add(self, item: GenericDict, **kwargs: Any) -> None:
95 """Adds `item` to the `contents` attribute.
97 Args:
98 item: items to add to `contents` attribute.
99 **kwargs: additional key/value pairs to add, as in `dict.update`.
101 """
102 self.contents.update(item, **kwargs)
104 def delete(self, item: Hashable) -> None:
105 """Deletes `item` in `contents`.
107 Args:
108 item: key in `contents` to delete the key/value pair.
110 Raises:
111 KeyError: if `item` is not a key in `contents`.
113 """
114 del self.contents[item]
116 def get(self, key: Hashable, default: Any | None = None) -> Any:
117 """Returns value in `contents` or default options.
119 Args:
120 key: key for value in `contents`.
121 default: default value to return if `key` is not found in
122 `contents`.
124 Returns:
125 Value matching key in `contents` or a default value. If `default` is
126 `None`, the `default_factory` attribute is used: it is called
127 if it is callable and returned as is otherwise.
129 Raises:
130 KeyError: if `key` is not in `contents` and `default` and the
131 `default_factory` attribute are both `None`.
133 """
134 try:
135 return self[key]
136 except (KeyError, TypeError) as error:
137 if default is not None:
138 return default
139 if self.default_factory is None:
140 raise KeyError(f'{key} is not in the Dictionary') from error
141 if callable(self.default_factory):
142 return self.default_factory()
143 return self.default_factory
145 def items( # type: ignore[override]
146 self) -> tuple[tuple[Hashable, Any], ...]:
147 """Emulates python dict `items` method.
149 Returns:
150 A `tuple` equivalent to `dict.items()`.
152 """
153 return tuple(zip(self.keys(), self.values(), strict = True))
155 def keys(self) -> tuple[Hashable, ...]: # type: ignore[override]
156 """Returns `contents` keys as a tuple.
158 Returns:
159 A `tuple` equivalent to `dict.keys()`.
161 """
162 return tuple(self.contents.keys())
164 def setdefault(self, value: Any) -> None: # type: ignore[override]
165 """Sets default value to return when `get` method is used.
167 Args:
168 value: default value to return when `get` is called and the
169 `default` parameter to `get` is None.
171 """
172 self.default_factory = value
174 def subset(
175 self,
176 include: Collection[Any] | Any | None = None,
177 exclude: Collection[Any] | Any | None = None,
178 returns: SubsetReturns | None = None) -> Any:
179 """Returns a new instance with a subset of `contents`.
181 This method applies `include` before `exclude` if both are passed. If
182 `include` is None, all existing items will be added to the new subset
183 class instance before `exclude` is applied. The values in the subset
184 are deep copies, so changing them does not change this instance.
186 Args:
187 include: key(s) to include in the new `Dictionary`. Defaults to
188 `None`.
189 exclude: key(s) to exclude from the new `Dictionary`. Defaults to
190 `None`.
191 returns: whether to return a new instance of the `Dictionary`
192 subclass ("class"), a deep copy of the subclass instance
193 ("copy") or the simple native Python type ("simple"). Defaults
194 to `None`, which uses the global setting stored in
195 `settings._SUBSET_RETURN`.
197 Returns:
198 `dict`-like object with only keys from `include` and no keys in
199 `exclude`, in the form dictated by the `returns` argument.
201 Raises:
202 ValueError: if `include` and `exclude` are both None.
203 KeyError: if a key in `include` is not in `contents`.
205 """
206 if include is None and exclude is None:
207 raise ValueError('include or exclude must not be None')
208 returns = self._resolve_returns(returns)
209 if include is None:
210 keys = list(self.contents)
211 else:
212 keys = list(utilities._iterify(include))
213 if exclude is not None:
214 excluded = list(utilities._iterify(exclude))
215 keys = [k for k in keys if k not in excluded]
216 contents = copy.deepcopy({k: self.contents[k] for k in keys})
217 return utilities._return_subset(
218 subset = contents, existing = self, returns = returns)
220 def values(self) -> tuple[Any, ...]: # type: ignore[override]
221 """Returns `contents` values as a `tuple`.
223 Returns:
224 A `tuple` equivalent to `dict.values()`.
226 """
227 return tuple(self.contents.values())
229 """ Dunder Methods """
231 def __contains__(self, key: object) -> bool:
232 """Returns whether `key` is a key in `contents`.
234 Unlike the default `Mapping` method, this does not call `__getitem__`,
235 so special keys (such as the wildcards of a `Catalog`) are only in an
236 instance if they are stored in `contents`.
238 Args:
239 key: possible key in `contents`.
241 Returns:
242 Whether `key` is a key in `contents`. An unhashable `key` (such as
243 a `list` of keys) is never a key.
245 """
246 try:
247 return key in self.contents
248 except TypeError:
249 return False
251 def __getitem__(self, key: Hashable) -> Any:
252 """Returns value for `key` in `contents`.
254 Args:
255 key: key in `contents` for which a value is sought.
257 Returns:
258 Value stored in `contents`.
260 """
261 return self.contents[key]
263 def __setitem__(self, key: Hashable, value: Any) -> None:
264 """Sets `key` in `contents` to `value`.
266 Args:
267 key: key to set in `contents`.
268 value: value to be paired with `key` in `contents`.
270 """
271 self.contents[key] = value
274@dataclasses.dataclass
275class Catalog(Dictionary):
276 """Wildcard and list-accepting dictionary.
278 A Catalog inherits the differences between a Dictionary and an ordinary
279 python dict.
281 A Catalog differs from a Dictionary in 5 significant ways:
282 1) It recognizes an `all` key which will return a list of all values
283 stored in a Catalog instance.
284 2) It recognizes a `default` key which will return all values matching
285 keys listed in the `default` attribute. `default` can also be set
286 using the `catalog.default = new_default` assignment. If `default`
287 is not passed when the instance is initialized, the initial value
288 of `default` is `all`.
289 3) It recognizes a `none` key which will return `None` (or an empty
290 list if `always_return_list` is `True`). If `default_factory` is
291 set, the default value it supplies is returned instead.
292 4) It supports a list of keys being accessed with the matching values
293 returned. For example, `catalog[['first_key', 'second_key']]` will
294 return the values for those keys in a list ['first_value',
295 'second_value']. Keys that are not in the Catalog are skipped.
296 5) If a single key is sought, a Catalog can either return the stored
297 value or a stored value in a list (if `always_return_list` is
298 True). The latter option is available to make iteration easier
299 when the iterator assumes a single type will be returned.
301 Args:
302 contents: stored dictionary. Defaults to an empty `dict`.
303 default_factory: default value to return or default callable to use to
304 create the default value.
305 default: a key or list of keys in `contents` which will be used to
306 return items when `default` is sought. Defaults to 'all', which
307 returns all values.
308 always_return_list: whether to return a list even when the key
309 passed is not a list or special access key (`True`) or to return a
310 list only when a list or special access key is used (`False`).
311 Defaults to `False`.
313 """
315 contents: GenericDict = dataclasses.field(default_factory = dict)
316 default_factory: Any | None = None
317 default: Any | None = 'all'
318 always_return_list: bool = False
320 """ Public Methods """
322 def delete(self, item: Hashable | Sequence[Hashable]) -> None:
323 """Deletes `item` in `contents`.
325 Args:
326 item: name(s) of key(s) in `contents` to delete the key/value pair.
327 A `tuple` that is itself a key in `contents` is deleted as a
328 single key.
330 Raises:
331 KeyError: if any key in `item` is not in `contents`. When this
332 error is raised, nothing is deleted.
334 """
335 keys = [item] if item in self else list(utilities._iterify(item))
336 if not all(k in self.contents for k in keys):
337 raise KeyError(f'{item} not found in the Catalog')
338 for key in keys:
339 del self.contents[key]
341 """ Dunder Methods """
343 def __getitem__(
344 self, key: Hashable | Sequence[Hashable]) -> Any | GenericList:
345 """Returns value(s) for `key` in `contents`.
347 The method searches for 'all', 'default', and 'none' matching wildcard
348 options before searching for direct matches in `contents`. A sequence
349 of keys returns a `list` of the matching values, unless the sequence
350 (such as a `tuple`) is itself a key in `contents`.
352 Args:
353 key: key(s) in `contents`.
355 Returns:
356 Value(s) stored in `contents`.
358 Raises:
359 KeyError: if a single `key` is not in `contents`.
361 """
362 if key in settings._ALL_KEYS:
363 return list(self.contents.values())
364 if key in settings._DEFAULT_KEYS:
365 return self[self.default]
366 if key in settings._NONE_KEYS:
367 if self.default_factory is None:
368 return [] if self.always_return_list else None
369 if callable(self.default_factory):
370 return self.default_factory()
371 return self.default_factory
372 if (isinstance(key, Sequence)
373 and not isinstance(key, str | bytes)
374 and key not in self):
375 return [self.contents[k] for k in key if k in self.contents]
376 try:
377 if self.always_return_list:
378 return [self.contents[key]]
379 return self.contents[key]
380 except KeyError as error:
381 message = f'{key} is not in {self.__class__.__name__}'
382 raise KeyError(message) from error
384 def __setitem__(
385 self,
386 key: Hashable | Sequence[Hashable],
387 value: Any | GenericList) -> None:
388 """Sets `key` in `contents` to `value`.
390 If `key` is unhashable (e.g., a `list` of keys), `key` and `value` are
391 zipped together and each pair is stored.
393 Args:
394 key: key(s) to set in `contents`.
395 value: value(s) to be paired with `key` in `contents`.
397 Raises:
398 ValueError: if `key` is a `list` of keys and `value` has a
399 different length.
401 """
402 if isinstance(key, Hashable):
403 self.contents[key] = value
404 else:
405 self.contents.update(dict(zip(key, value, strict = True)))
408@dataclasses.dataclass
409class ChainDict(Dictionary):
410 """Combines functionality of `collections.ChainMap` with `Dictionary`.
412 Iterating a `ChainDict` (and `len`) uses the keys of all stored mappings,
413 as with any other mapping. A key that appears in more than one stored
414 mapping appears once for each mapping that has it. `keys`, `values` and
415 `items` are consistent with each other.
417 Args:
418 contents: list of stored `Dictionary` instances. This is equivalent to
419 the `maps` attribute of a `collections.ChainMap` instance but uses a
420 different name for compatibility with `base.Bunch`. A separate
421 `maps` property is included which points to `contents` to ensure
422 compatibility in the opposite direction.
423 default_factory: default value to return or default callable to use to
424 create the default value.
425 return_first: whether to only return the first match found (`True`) or
426 to search all of the stored `Dictionary` instances (`False`).
427 Defaults to `True`.
429 """
431 contents: MutableSequence[Dictionary] = (
432 dataclasses.field(default_factory = list)) # type: ignore[assignment]
433 default_factory: Any | None = None
434 return_first: bool = True
436 """ Properties """
438 @property
439 def maps(self) -> MutableSequence[Dictionary]:
440 """Returns `contents` attribute.
442 Returns:
443 Stored `Dictionary` instances.
445 """
446 return self.contents
448 @maps.setter
449 def maps(self, value: MutableSequence[Dictionary]) -> None:
450 """Sets `contents` to `value`.
452 Args:
453 value: new `list`-like instance to assign `contents` to.
455 """
456 self.contents = value
458 @maps.deleter
459 def maps(self) -> None:
460 """Sets `contents` to an empty list."""
461 self.contents = []
463 @property
464 def parents(self) -> ChainDict:
465 """Returns an instance with `contents` after the first.
467 This property mirrors `collections.ChainMap.parents`.
469 Returns:
470 An instance with all stored `Dictionary` instances after the first.
472 """
473 return self.__class__(
474 self.contents[1:],
475 default_factory = self.default_factory,
476 return_first = self.return_first)
478 """ Class Methods """
480 @classmethod
481 def fromkeys(
482 cls,
483 keys: Iterable[Hashable],
484 value: Any = None,
485 **kwargs: Any) -> ChainDict:
486 """Emulates the `fromkeys` class method from a python `dict`.
488 Since this method is an awkward fit with a chained map, it just assigns
489 the `keys` and `value` to a single `Dictionary` stored in the `contents`
490 list.
492 Args:
493 keys: items to be keys in a new `Dictionary`.
494 value: the value to use for all values in a new `Dictionary`.
495 Defaults to `None`, as in `dict.fromkeys`.
496 **kwargs: additional arguments to pass to the `Dictionary`
497 constructor (e.g. `default_factory`).
499 Returns:
500 `ChainDict` containing a single `Dictionary` formed from `keys` and
501 `value`.
503 """
504 return cls(contents = [Dictionary.fromkeys(keys, value, **kwargs)])
506 """ Public Methods """
508 def add(self, item: GenericDict, **kwargs: Any) -> None:
509 """Adds `item` to the end of the `contents` attribute.
511 Args:
512 item: mapping to add to `contents`. If it is not a `Dictionary`, it
513 is wrapped in one.
514 **kwargs: additional key/value pairs to add to the new mapping.
516 """
517 if not isinstance(item, Dictionary):
518 item = Dictionary(dict(item, **kwargs))
519 elif kwargs:
520 item = Dictionary(dict(item.contents, **kwargs))
521 self.contents.append(item)
523 def delete(self, item: Hashable) -> None:
524 """Deletes `item` in `contents`.
526 Because a chained mapping can have identical keys in different stored
527 mappings, this method searches through all of the stored Dictionary
528 instances and removes the key wherever it appears.
530 Args:
531 item: key in `contents` to delete the key/value pair.
533 Raises:
534 KeyError: if `item` is not a key in any stored mapping.
536 """
537 found = False
538 for dictionary in self.contents:
539 with contextlib.suppress(KeyError):
540 del dictionary[item]
541 found = True
542 if not found:
543 raise KeyError(f'{item} is not found in the ChainDict')
545 def keys(self) -> tuple[Hashable, ...]: # type: ignore[override]
546 """Returns `contents` keys as a `tuple`.
548 Returns:
549 A `tuple` equivalent to `dict.keys()` with the keys of all stored
550 mappings, in order.
552 """
553 return tuple(
554 itertools.chain.from_iterable([d.keys() for d in self.contents]))
556 def new_child(self, m: GenericDict | None = None) -> None:
557 """Inserts `m` as the first Dictionary in `contents`.
559 This method mirrors the functionality and parameters of
560 `collections.ChainMap.new_child`, except that `contents` is modified in
561 place.
563 Args:
564 m: mapping to add to `contents` at index 0. If it is not a
565 `Dictionary`, it is wrapped in one. Defaults to `None`, which
566 adds an empty `Dictionary`.
568 """
569 if m is None:
570 m = Dictionary()
571 elif not isinstance(m, Dictionary):
572 m = Dictionary(dict(m))
573 self.contents.insert(0, m)
575 def subset(
576 self,
577 include: Collection[Any] | Any | None = None,
578 exclude: Collection[Any] | Any | None = None,
579 returns: SubsetReturns | None = None) -> Any:
580 """Returns a new instance with a subset of `contents`.
582 This method applies `include` before `exclude` if both are passed. If
583 `include` is None, all existing items will be added to the new subset
584 class instance before `exclude` is applied.
586 This method relies on all stored mappings being compatible with the
587 `Dictionary` class because it uses the `subset` method of those stored
588 mappings. Keys in `include` that are missing from a stored mapping are
589 ignored for that mapping.
591 Args:
592 include: key(s) to include in the new `ChainDict`. Defaults to
593 `None`.
594 exclude: key(s) to exclude from the new `ChainDict`. Defaults to
595 `None`.
596 returns: whether to return a new instance of the `ChainDict`
597 subclass ("class"), a deep copy of the subclass instance
598 ("copy") or the simple native Python type ("simple", a `list`
599 of `Dictionary` instances). Defaults to `None`, which uses the
600 global setting stored in `settings._SUBSET_RETURN`.
602 Returns:
603 A new instance with only keys from `include` and no keys in
604 `exclude`.
606 Raises:
607 ValueError: if `include` and `exclude` are both None.
609 """
610 if include is None and exclude is None:
611 raise ValueError('include or exclude must not be None')
612 returns = self._resolve_returns(returns)
613 if include is not None:
614 include = list(utilities._iterify(include))
615 new_contents = []
616 for dictionary in self.contents:
617 if include is None:
618 these_keys = None
619 else:
620 these_keys = [k for k in include if k in dictionary]
621 new_contents.append(
622 dictionary.subset(
623 include = these_keys, exclude = exclude, returns = 'class'))
624 return utilities._return_subset(
625 subset = new_contents, existing = self, returns = returns)
627 def values(self) -> tuple[Any, ...]: # type: ignore[override]
628 """Returns `contents` values as a `tuple`.
630 Returns:
631 A `tuple` equivalent to `dict.values()` with the values of all
632 stored mappings, in order.
634 """
635 return tuple(
636 itertools.chain.from_iterable([d.values() for d in self.contents]))
638 """ Dunder Methods """
640 def __contains__(self, key: object) -> bool:
641 """Returns whether `key` is a key in any stored mapping.
643 Args:
644 key: possible key in a stored mapping.
646 Returns:
647 Whether `key` is a key in any stored mapping.
649 """
650 return any(key in dictionary for dictionary in self.contents)
652 def __getitem__(self, key: Hashable) -> Any:
653 """Returns value(s) for `key` in `contents`.
655 If there are multiple matches for `key` and the `return_first` attribute
656 is `False`, this method returns all matches in a `list`. Otherwise, only
657 the first match is returned.
659 Args:
660 key: key in `contents` for which a value is sought.
662 Returns:
663 Value(s) stored in `contents`.
665 Raises:
666 KeyError: if `key` is not in any stored mapping.
668 """
669 matches = []
670 for dictionary in self.contents:
671 with contextlib.suppress(KeyError):
672 matches.append(dictionary[key])
673 if self.return_first:
674 return matches[0]
675 if not matches:
676 raise KeyError(f'{key} is not found in the ChainDict')
677 return matches if len(matches) > 1 else matches[0]
679 def __iter__(self) -> Iterator[Hashable]:
680 """Returns iterator of the keys in all stored mappings.
682 Returns:
683 Iterator of `keys`.
685 """
686 return iter(self.keys())
688 def __len__(self) -> int:
689 """Returns the number of keys in all stored mappings.
691 Returns:
692 Length of `keys`.
694 """
695 return len(self.keys())
697 def __setitem__(self, key: Hashable, value: Any) -> None:
698 """Sets `key` in `contents` to `value`.
700 This method stores the passed `key` and `value` in the first stored
701 `Dictionary`. If none exists, one is created to store `key` and
702 `value`.
704 Args:
705 key: key to set in `contents`.
706 value: value to be paired with `key` in `contents`.
708 """
709 if len(self.contents) == 0:
710 self.contents = [Dictionary({key: value})]
711 else:
712 self.contents[0][key] = value
715@dataclasses.dataclass
716class Repository(Dictionary):
717 """Dictionary with inferred keys based on items added.
719 A `Repository` differs from an ordinary python `dict` in ways inherited from
720 `Dictionary`. In addition, it differs in 2 other significant ways:
721 1) The `add` method relies on the internal `_get_name` method to
722 assign a str key for the passed item.
723 2) It includes an `overwrite` parameter which allows users to determine
724 whether existing items will be overwritten when the inferred key
725 matches an existing one or whether a new key will be inferred by
726 adding an integer counter as a suffix to the key.
728 Args:
729 contents: stored dictionary. Defaults to an empty `dict`.
730 default_factory: default value to return or default callable to use to
731 create the default value.
732 overwrite: whether to overwrite existing items in the stored dictionary
733 with the same inferred keys (`True`) or automatically infer a new
734 key based upon a counter suffix (`False`). Defaults to `False`.
736 """
738 contents: GenericDict = dataclasses.field(default_factory = dict)
739 default_factory: Any | None = None
740 overwrite: bool = False
742 """ Public Methods """
744 def add(self, item: Any, key: str | None = None, **kwargs: Any) -> None:
745 """Adds `item` to the `contents` attribute.
747 Args:
748 item: item to add to `contents` attribute.
749 key: key to use for `item` if the user does not want the key to be
750 inferred. Defaults to `None`, which infers the key.
751 **kwargs: additional key/value pairs to add, as in `dict.update`.
753 Raises:
754 ValueError: if `key` is `None` and no name can be inferred for
755 `item`.
757 """
758 if key is None:
759 key = self._get_name(item = item)
760 if not self.overwrite:
761 key = utilities._uniquify(key = key, dictionary = self)
762 self.contents.update({key: item}, **kwargs)
764 """ Private Methods """
766 def _get_name(self, item: Any) -> str:
767 """Infers key name for `item`.
769 By default, this method uses the global key namer stored in
770 `settings._KEY_NAMER` (initially `utilities._namify`), which can be
771 changed with `settings.set_key_namer`. Override this method to use a
772 different naming function for a particular subclass.
774 Args:
775 item: item to infer the name for.
777 Returns:
778 Inferred name.
780 Raises:
781 ValueError: if the key namer cannot infer a name for `item`.
783 """
784 name = settings._KEY_NAMER(item)
785 if name is None:
786 raise ValueError(f'a name could not be inferred for {item!r}')
787 return name