Coverage for src/bobbie/utilities.py: 100%
73 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-10-30 23:03 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-10-30 23:03 +0000
1"""Shared tools for converting and wrapping values.
3Contents:
4 _PAIR_LENGTH: number of parts in a dict entry and of quotes around a
5 quoted string.
7To Do:
10"""
12from __future__ import annotations
14import pathlib
15from typing import TYPE_CHECKING, Any, cast
17if TYPE_CHECKING:
18 from collections.abc import Iterable
20# Number of parts in a dict entry (key and value) and quotes around a string.
21_PAIR_LENGTH = 2
23""" Private Functions """
26def _iterify(item: Any) -> Iterable[Any]:
27 """Returns `item` as an iterable, but does not iterate str types.
29 Args:
30 item: item to turn into an iterable.
32 Returns:
33 Iterable of `item`. A `str` or `bytes` type will be stored as a single
34 item in an Iterable wrapper, and None becomes an empty iterable.
36 """
37 if item is None:
38 return iter(())
39 elif isinstance(item, str | bytes):
40 return iter([item])
41 else:
42 try:
43 return iter(cast('Iterable[Any]', item))
44 except TypeError:
45 return iter((item,))
48def _pathlibify(item: str | pathlib.Path) -> pathlib.Path:
49 """Converts string `item` to a pathlib.Path object.
51 Args:
52 item: either a string of a path or a pathlib.Path object.
54 Returns:
55 pathlib.Path object.
57 Raises:
58 TypeError: if `item` is neither a str or pathlib.Path type.
60 """
61 if isinstance(item, str):
62 return pathlib.Path(item)
63 elif isinstance(item, pathlib.Path):
64 return item
65 else:
66 raise TypeError('item must be str or pathlib.Path type')
69def _typify(
70 item: str) -> list[Any] | dict[Any, Any] | int | float | bool | str:
71 """Converts strings to appropriate, supported datatypes.
73 The method converts strings to list, dict, int, float, or bool datatypes
74 based upon the content of the string. A string wrapped in square brackets
75 (such as '[x, y, z]') becomes a list, a string wrapped in curly braces
76 (such as '{a: 1, b: 2}') becomes a dict, and any other string containing
77 ', ' becomes a list. Items in a list or dict are converted recursively and
78 may be nested. Quotes around an item in a list or dict are removed. If no
79 alternative datatype is found, the item is returned in its original form.
81 Args:
82 item: string to be converted to appropriate datatype.
84 Returns:
85 Converted item.
87 """
88 if not isinstance(item, str):
89 return item
90 try:
91 return int(item)
92 except ValueError:
93 try:
94 return float(item)
95 except ValueError:
96 stripped = item.strip()
97 if item.lower() in {'true', 'yes'}:
98 return True
99 elif item.lower() in {'false', 'no'}:
100 return False
101 elif stripped.startswith('[') and stripped.endswith(']'):
102 return [
103 _typify_element(i)
104 for i in _split_top_level(stripped[1:-1], ',')]
105 elif stripped.startswith('{') and stripped.endswith('}'):
106 entries = [
107 _split_top_level(e, ':', maxsplit = 1)
108 for e in _split_top_level(stripped[1:-1], ',')]
109 if all(len(e) == _PAIR_LENGTH for e in entries):
110 return {
111 _typify_element(k): _typify_element(v)
112 for k, v in entries}
113 return item
114 elif ', ' in item:
115 return [_typify(i) for i in _split_top_level(item, ', ')]
116 else:
117 return item
120def _typify_element(item: str) -> Any:
121 """Converts an item from a list or dict string to its datatype.
123 Matching single or double quotes around `item` are removed and the
124 contents are returned as a str without further conversion. Otherwise,
125 `item` is converted with `_typify`.
127 Args:
128 item: string of one item in a list or dict string.
130 Returns:
131 Converted item.
133 """
134 if (
135 len(item) >= _PAIR_LENGTH
136 and item[0] == item[-1]
137 and item[0] in {'"', "'"}):
138 return item[1:-1]
139 return _typify(item)
142def _split_top_level(
143 item: str,
144 separator: str,
145 maxsplit: int = -1) -> list[str]:
146 """Splits `item` on `separator`, ignoring nested or quoted separators.
148 A `separator` inside square brackets, curly braces, parentheses, or quotes
149 does not split `item`. A quote only counts if it begins a part, so an
150 apostrophe inside a word (such as "don't") does not. Each part has
151 surrounding whitespace removed, and empty parts (such as from a trailing
152 comma) are dropped.
154 Args:
155 item: string to split.
156 separator: substring to split `item` on.
157 maxsplit: maximum number of splits to make. If it is negative, there
158 is no limit. Defaults to -1.
160 Returns:
161 Parts of `item`.
163 """
164 parts: list[str] = []
165 depth = 0
166 quote = None
167 start = 0
168 index = 0
169 while index < len(item):
170 character = item[index]
171 if quote:
172 if character == quote:
173 quote = None
174 elif character in {'"', "'"} and not item[start:index].strip():
175 quote = character
176 elif character in '[{(':
177 depth += 1
178 elif character in ']})':
179 depth = max(depth - 1, 0)
180 elif (
181 depth == 0
182 and item.startswith(separator, index)
183 and maxsplit != len(parts)):
184 parts.append(item[start:index])
185 index += len(separator)
186 start = index
187 continue
188 index += 1
189 parts.append(item[start:])
190 return [p.strip() for p in parts if p.strip()]