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

1"""Shared tools for converting and wrapping values. 

2 

3Contents: 

4 _PAIR_LENGTH: number of parts in a dict entry and of quotes around a 

5 quoted string. 

6 

7To Do: 

8 

9 

10""" 

11 

12from __future__ import annotations 

13 

14import pathlib 

15from typing import TYPE_CHECKING, Any, cast 

16 

17if TYPE_CHECKING: 

18 from collections.abc import Iterable 

19 

20# Number of parts in a dict entry (key and value) and quotes around a string. 

21_PAIR_LENGTH = 2 

22 

23""" Private Functions """ 

24 

25 

26def _iterify(item: Any) -> Iterable[Any]: 

27 """Returns `item` as an iterable, but does not iterate str types. 

28 

29 Args: 

30 item: item to turn into an iterable. 

31 

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. 

35 

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,)) 

46 

47 

48def _pathlibify(item: str | pathlib.Path) -> pathlib.Path: 

49 """Converts string `item` to a pathlib.Path object. 

50 

51 Args: 

52 item: either a string of a path or a pathlib.Path object. 

53 

54 Returns: 

55 pathlib.Path object. 

56 

57 Raises: 

58 TypeError: if `item` is neither a str or pathlib.Path type. 

59 

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') 

67 

68 

69def _typify( 

70 item: str) -> list[Any] | dict[Any, Any] | int | float | bool | str: 

71 """Converts strings to appropriate, supported datatypes. 

72 

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. 

80 

81 Args: 

82 item: string to be converted to appropriate datatype. 

83 

84 Returns: 

85 Converted item. 

86 

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 

118 

119 

120def _typify_element(item: str) -> Any: 

121 """Converts an item from a list or dict string to its datatype. 

122 

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`. 

126 

127 Args: 

128 item: string of one item in a list or dict string. 

129 

130 Returns: 

131 Converted item. 

132 

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) 

140 

141 

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. 

147 

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. 

153 

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. 

159 

160 Returns: 

161 Parts of `item`. 

162 

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()]