Coverage for src/bunches/sequences.py: 100%
96 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"""List-like classes
3Contents:
4 Listing (base.Bunch, MutableSequence): drop-in replacement for a python
5 `list` with additional functionality.
6 DictList (Listing): iterable with both `dict` and `list` interfaces. Stored
7 items must be hashable or have a `name` attribute.
9"""
11from __future__ import annotations
13import copy
14import dataclasses
15from collections.abc import Hashable, Mapping, MutableSequence
16from typing import TYPE_CHECKING, Any
18from . import base, settings, utilities
20if TYPE_CHECKING:
21 from .kinds import GenericList, SubsetReturns
24@dataclasses.dataclass
25class Listing(base.Bunch, MutableSequence):
26 """Basic `list` replacement.
28 A `Listing` differs from an ordinary python `list` in ways required by
29 inheriting from `Bunch`: `add`, `delete`, and `subset` methods, and allowing
30 the "+" operator to join `Listing`s with other `list`-like objects) and in 1
31 other way:
32 1) It includes a `prepend` method for adding one or more items to the
33 beginning of the stored list.
35 The `add` method attempts to extend `contents` with the item to be added.
36 If it is not a sequence, it appends the item to `contents`.
38 Args:
39 contents: items to store in a `list`. Defaults to an empty `list`.
41 """
43 contents: GenericList = dataclasses.field(default_factory = list)
45 """ Public Methods """
47 def add(self, item: Any | GenericList) -> None:
48 """Extends `contents` with `item` if it is a sequence or appends it.
50 The method will extend all passed sequences, except `str` and `bytes`
51 types, which it will append.
53 Args:
54 item: item(s) to add to `contents`.
56 """
57 if utilities._is_sequence(item = item):
58 self.contents.extend(item)
59 else:
60 self.contents.append(item)
62 def delete(self, item: int | slice) -> None:
63 """Deletes item at the index in `contents`.
65 Args:
66 item: index (or slice) in `contents` to delete.
68 Raises:
69 IndexError: if `item` is out of range.
71 """
72 del self.contents[item]
74 def insert(self, index: int, item: Any) -> None:
75 """Inserts `item` at `index` in `contents`.
77 Args:
78 index: index to insert `item` at.
79 item: object to be inserted.
81 """
82 self.contents.insert(index, item)
84 def prepend(self, item: Any | GenericList) -> None:
85 """Prepends `item` to `contents`.
87 If `item` is a non-str sequence, `prepend` adds its contents to the
88 beginning of the stored list in the order they appear in `item`.
90 Args:
91 item: item(s) to prepend to `contents`.
93 """
94 if utilities._is_sequence(item = item):
95 self.contents[:0] = list(item)
96 else:
97 self.insert(0, item)
99 def subset(
100 self,
101 include: Any | GenericList | None = None,
102 exclude: Any | GenericList | None = None,
103 returns: SubsetReturns | None = None) -> Any:
104 """Returns a new instance with a subset of `contents`.
106 This method applies `include` before `exclude` if both are passed. If
107 `include` is None, all existing items will be added to the new subset
108 class instance before `exclude` is applied. Items are compared with
109 `include` and `exclude` by the `_is_match` method. The items in the
110 subset are deep copies, so changing them does not change this instance.
112 Args:
113 include: item(s) to include in the new instance. Defaults to `None`.
114 exclude: item(s) to exclude in the new instance. Defaults to `None`.
115 returns: whether to return a new instance of the `Listing` subclass
116 ("class"), a deep copy of the subclass instance ("copy") or the
117 simple native Python type ("simple"). Defaults to `None`, which
118 uses the global setting stored in `settings._SUBSET_RETURN`.
120 Returns:
121 Instance with only items from `include` and no items in `exclude`,
122 in the form dictated by the `returns` argument.
124 Raises:
125 ValueError: if `include` and `exclude` are both None.
127 """
128 if include is None and exclude is None:
129 raise ValueError('include or exclude must not be None')
130 returns = self._resolve_returns(returns)
131 contents = list(self.contents)
132 if include is not None:
133 included = list(utilities._iterify(include))
134 contents = [i for i in contents if self._is_match(i, included)]
135 if exclude is not None:
136 excluded = list(utilities._iterify(exclude))
137 contents = [i for i in contents if not self._is_match(i, excluded)]
138 contents = copy.deepcopy(contents)
139 return utilities._return_subset(
140 subset = contents, existing = self, returns = returns)
142 """ Private Methods """
144 def _is_match(self, item: Any, matches: GenericList) -> bool:
145 """Returns whether `item` is in `matches`.
147 Args:
148 item: stored item to test.
149 matches: items passed to `include` or `exclude` in `subset`.
151 Returns:
152 Whether `item` is in `matches`.
154 """
155 return item in matches
157 """ Dunder Methods """
159 def __getitem__(self, index: Any) -> Any:
160 """Returns value(s) for `index` in `contents`.
162 Args:
163 index: index (or slice) to search for in `contents`.
165 Returns:
166 Item(s) stored in `contents` at `index`.
168 """
169 return self.contents[index]
171 def __setitem__(self, index: Any, value: Any) -> None:
172 """Sets `index` in `contents` to `value`.
174 Args:
175 index: index (or slice) to set `value` to in `contents`.
176 value: value to be set at `index` in `contents`.
178 """
179 self.contents[index] = value
182@dataclasses.dataclass
183class DictList(Listing):
184 """Iterable that has both a dict and list interfaces.
186 DictList combines the functionality and interfaces of python dicts and
187 lists. It allows duplicate keys and list-like iteration while supporting the
188 easier access methods of dictionaries. In order to support this hybrid
189 approach to iterables, DictList can only store items that are hashable or
190 have a `name` attribute or property that contains or returns a hashable
191 value.
193 A DictList inherits the differences between a Listing and an ordinary python
194 list.
196 A DictList differs from a Listing in 4 significant ways:
197 1) It only stores hashable items or objects for which a str name can be
198 derived (using the global key namer in `settings._KEY_NAMER`).
199 2) DictList has an interface of both a dict and a list, but stores a
200 list. DictList does this by taking advantage of the `name`
201 attribute or hashability of stored items. A `name` or hash acts as
202 a key to create the facade of a dict with the items in the stored
203 list serving as values. This allows for duplicate keys for storing
204 items, simpler iteration than a dict, and support for returning
205 multiple matching items. This design comes at the expense of lookup
206 speed. As a result, DictList should only be used if a high volume
207 of access calls is not anticipated. Ordinarily, the loss of lookup
208 speed should have negligible effect on overall performance.
209 3) DictLists should not store int types. This ensures that when, for
210 example, a `dictlist_instance[3]` is called, the item at that index
211 is returned. If int types are stored, that call would create
212 uncertainty as to whether an index or item should be returned. By
213 design, int types are assumed to be calls to return the item at that
214 index.
215 4) When using dict access methods, a `DictList` of matches may be
216 returned because a DictList allows duplicate pseudo-keys to be used.
217 `subset` also selects items by name.
219 Args:
220 contents: items to store that are hashable or have a `name` attribute.
221 Defaults to an empty list.
222 default_factory: default value to return or default function to call
223 when the `get` method is used. Defaults to `None`.
225 """
227 contents: MutableSequence[Hashable] = dataclasses.field(
228 default_factory = list)
229 default_factory: Any | None = None
231 """ Public Methods """
233 def delete(self, item: Any | int | slice) -> None:
234 """Deletes item in `contents`.
236 If `item` is not an int type, this method looks for a matching `name`
237 attribute in the stored instances and deletes all such items. If `item`
238 is an int type, only the item at that index is deleted.
240 Args:
241 item: name or index in `contents` to delete.
243 Raises:
244 KeyError: if `item` is not an int and no stored item matches it.
246 """
247 if isinstance(item, int | slice):
248 del self.contents[item]
249 else:
250 namer = settings._KEY_NAMER
251 remaining = [c for c in self.contents if namer(c) != item]
252 if len(remaining) == len(self.contents):
253 raise KeyError(f'{item} is not in {self.__class__.__name__}')
254 self.contents[:] = remaining
256 def get(self, key: Hashable, default: Any | None = None) -> Any:
257 """Returns value in `contents` or default options.
259 Args:
260 key: name or index for value in `contents`.
261 default: default value to return if `key` is not found in
262 `contents`.
264 Returns:
265 Value matching key in `contents`, `default`, or the
266 `default_factory` value (called if it is callable).
268 Raises:
269 KeyError: if `key` is not in the DictList and `default` and the
270 `default_factory` attribute are both None.
272 """
273 try:
274 return self[key]
275 except (KeyError, IndexError, TypeError) as error:
276 if default is not None:
277 return default
278 if self.default_factory is None:
279 raise KeyError(f'{key} is not in the DictList') from error
280 if callable(self.default_factory):
281 return self.default_factory()
282 return self.default_factory
284 def items(self) -> tuple[tuple[Hashable, Any], ...]:
285 """Emulates python dict `items` method.
287 Returns:
288 A tuple of (key, value) pairs equivalent to `dict.items()`. A
289 DictList cannot actually create an ItemsView because that would
290 eliminate any duplicate keys, which are permitted by DictList.
292 """
293 return tuple(zip(self.keys(), self.values(), strict = True))
295 def keys(self) -> tuple[Hashable, ...]:
296 """Emulates python dict `keys` method.
298 Returns:
299 A tuple equivalent to dict.keys(). A DictList cannot actually
300 create a KeysView because that would eliminate any duplicate
301 keys, which are permitted by DictList.
303 """
304 namer = settings._KEY_NAMER
305 return tuple(namer(c) for c in self.contents)
307 def setdefault(self, value: Any) -> None:
308 """Sets default value to return when `get` method is used.
310 Args:
311 value: default value to return.
313 """
314 self.default_factory = value
316 def update(self, items: Mapping[Any, Any]) -> None:
317 """Mimics the dict `update` method by extending `contents` with `items`.
319 Args:
320 items: items to add to the `contents` attribute. The values of
321 `items` are added to `contents` and the keys become the `name`
322 attributes of those values. As a result, the keys of `items`
323 are discarded. To mimic `dict.update`, the passed `items`
324 values are added to `contents` by the `extend` method which
325 adds the values to the end of `contents`.
327 """
328 self.extend(list(items.values()))
330 def values(self) -> tuple[Any, ...]:
331 """Emulates python dict `values` method.
333 Returns:
334 A tuple equivalent to dict.values(). A DictList cannot actually
335 create a ValuesView because that would eliminate any duplicate
336 keys, which are permitted by DictList.
338 """
339 return tuple(self.contents)
341 """ Private Methods """
343 def _is_match(self, item: Any, matches: GenericList) -> bool:
344 """Returns whether the name of `item` is in `matches`.
346 This makes `subset` select items by name, as `__getitem__`, `get` and
347 `delete` do. The name is found with the global key namer stored in
348 `settings._KEY_NAMER`.
350 Args:
351 item: stored item to test.
352 matches: names passed to `include` or `exclude` in `subset`.
354 Returns:
355 Whether the name of `item` is in `matches`.
357 """
358 return settings._KEY_NAMER(item) in matches
360 """ Dunder Methods """
362 def __getitem__(self, key: Hashable | int | slice) -> Any:
363 """Returns value(s) for `key` in `contents`.
365 If `key` is not an int type, this method looks for a matching `name`
366 attribute in the stored instances.
368 If `key` is an int (or a slice), this method returns the stored item(s)
369 at the corresponding index.
371 If only one match is found, a single item is returned. If more are
372 found, a `DictList` or `DictList` subclass with the matching stored
373 items is returned.
375 Args:
376 key: name of an item or index to search for in `contents`.
378 Returns:
379 Value(s) stored in `contents` that correspond to `key`. If there is
380 more than one match, the return is a DictList or DictList
381 subclass with the matching stored items.
383 Raises:
384 KeyError: if `key` is not an int or slice and no item matches it.
385 IndexError: if `key` is an int outside of the range of `contents`.
387 """
388 if isinstance(key, int | slice):
389 return self.contents[key]
390 namer = settings._KEY_NAMER
391 matches = [c for c in self.contents if namer(c) == key]
392 if not matches:
393 raise KeyError(f'{key} is not in {self.__class__.__name__}')
394 if len(matches) == 1:
395 return matches[0]
396 return self.__class__(
397 contents = matches, default_factory = self.default_factory)
399 def __setitem__(self, key: Any | int | slice, value: Any) -> None:
400 """Sets `key` in `contents` to `value`.
402 Args:
403 key: if key isn't an int or slice, it is ignored (since the `name`
404 attribute of the value will be acting as the key). In such a
405 case, the `value` is added to the end of `contents`. If key is
406 an int, `value` is assigned at the that index number in
407 `contents`.
408 value: value to be paired with `key` in `contents`.
410 """
411 if isinstance(key, int | slice):
412 self.contents[key] = value
413 else:
414 self.add(value)