Coverage for src/bunches/utilities.py: 100%
54 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"""Functions for inferring names, iterating, and building subsets.
3The module only holds private helpers for the other `bunches` modules. Each is
4described in its own docstring.
6"""
8from __future__ import annotations
10import copy
11import inspect
12import re
13from collections.abc import Collection, Iterator, Sequence
14from typing import TYPE_CHECKING, Any
16if TYPE_CHECKING:
17 from .kinds import GenericDict, SubsetReturns
20""" Private Functions """
23def _capitalify(item: str) -> str:
24 """Converts a snake case `str` to capital case.
26 Args:
27 item: `str` to convert.
29 Returns:
30 'item' converted to capital case.
32 """
33 return item.replace('_', ' ').title().replace(' ', '')
36def _is_sequence(item: Any) -> bool:
37 """Returns if 'item' is a sequence but not a `str` or `bytes`.
39 Args:
40 item: object or class to examine.
42 Returns:
43 If 'item' is a sequence but not a `str` or `bytes`.
45 """
46 if not inspect.isclass(item):
47 item = item.__class__
48 return issubclass(item, Sequence) and not issubclass(item, str | bytes)
51def _iterify(item: Any) -> Iterator[Any]:
52 """Returns `item` as an iterator, but does not iterate `str` types.
54 Args:
55 item: item to turn into an iterator.
57 Returns:
58 Iterator of `item`. `None` becomes an empty iterator. A `str` or `bytes`
59 type (or any non-iterable) is wrapped so that it is returned as a
60 single item.
62 """
63 if item is None:
64 return iter(())
65 if isinstance(item, str | bytes):
66 return iter([item])
67 try:
68 return iter(item)
69 except TypeError:
70 return iter((item,))
73def _namify(item: Any, /, default: str | None = None) -> str | None:
74 """Returns `str` name representation of 'item'.
76 The name is, in order of preference: 'item' itself if it is a `str`, the
77 `name` attribute of an instance (if it is a `str`), the snake-cased
78 `__name__` of a class or function, or the snake-cased name of the class of
79 'item'.
81 Args:
82 item: item to determine a `str` name.
83 default: name to return if no name can be derived. Defaults to `None`.
85 Returns:
86 A name representation of 'item' or 'default'.
88 """
89 if isinstance(item, str):
90 return item
91 if (hasattr(item, 'name')
92 and not inspect.isclass(item)
93 and isinstance(item.name, str)):
94 return item.name
95 name = getattr(item, '__name__', None)
96 if not isinstance(name, str):
97 name = getattr(item.__class__, '__name__', None)
98 return _snakify(name) if isinstance(name, str) and name else default
101def _return_subset(
102 subset: Collection,
103 existing: Collection,
104 returns: SubsetReturns) -> Collection:
105 """Returns a subset of an item.
107 Args:
108 subset: native Python subset of data from a `Collection`.
109 existing: a subclass instance of `Collection`.
110 returns: whether to return a new instance of the class of 'existing'
111 ('class'), a deep copy of 'existing' with 'subset' as its
112 `contents` ('copy'), or 'subset' itself ('simple').
114 Returns:
115 A `Collection` with a `subset` of data.
117 Raises:
118 ValueError: if 'returns' is not 'class', 'copy', or 'simple'.
120 """
121 if returns == 'class':
122 return existing.__class__(subset) # type: ignore[call-arg]
123 if returns == 'copy':
124 new_collection = copy.deepcopy(existing)
125 new_collection.contents = subset # type: ignore[attr-defined]
126 return new_collection
127 if returns == 'simple':
128 return subset
129 message = 'returns argument must be "class", "copy", or "simple"'
130 raise ValueError(message)
133def _snakify(item: str) -> str:
134 """Converts a capitalized `str` to snake case.
136 Args:
137 item: `str` to convert.
139 Returns:
140 'item' converted to snake case.
142 """
143 item = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', item)
144 return re.sub('([a-z0-9])([A-Z])', r'\1_\2', item).lower()
147def _uniquify(key: str, dictionary: GenericDict, index: int = 1) -> str:
148 """Creates a unique key name to avoid overwriting an item in 'dictionary'.
150 The function is 1-indexed so that the first attempt to avoid a duplicate
151 will be: "old_name2".
153 Args:
154 key: name of key to test.
155 dictionary: `dict` for which a unique key name is sought.
156 index: starting number for the suffix counter. Defaults to 1.
158 Returns:
159 A key name that is not in 'dictionary'.
161 """
162 if key not in dictionary:
163 return key
164 counter = index
165 while True:
166 counter += 1
167 candidate = f'{key}{counter}'
168 if candidate not in dictionary:
169 return candidate