Coverage for python/lsst/utils/iteration.py: 8%

105 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-10-09 03:16 -0700

1# This file is part of utils. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# Use of this source code is governed by a 3-clause BSD-style 

10# license that can be found in the LICENSE file. 

11# 

12 

13"""Utilities relating to iterators.""" 

14 

15from __future__ import annotations 

16 

17__all__ = ["chunk_iterable", "ensure_iterable", "isplit", "sequence_to_string"] 

18 

19import itertools 

20from collections.abc import Iterable, Iterator, Mapping 

21from typing import Any, TypeGuard, TypeVar 

22 

23 

24def chunk_iterable(data: Iterable[Any], chunk_size: int = 1_000) -> Iterator[tuple[Any, ...]]: 

25 """Return smaller chunks of an iterable. 

26 

27 Parameters 

28 ---------- 

29 data : iterable of anything 

30 The iterable to be chunked. Can be a mapping, in which case 

31 the keys are returned in chunks. 

32 chunk_size : int, optional 

33 The largest chunk to return. Can be smaller and depends on the 

34 number of elements in the iterator. Defaults to 1_000. 

35 

36 Yields 

37 ------ 

38 chunk : `tuple` 

39 The contents of a chunk of the iterator as a `tuple`. A tuple is 

40 preferred over an iterator since it is more convenient to tell it is 

41 empty and the caller knows it can be sized and indexed. 

42 """ 

43 it = iter(data) 

44 while chunk := tuple(itertools.islice(it, chunk_size)): 

45 yield chunk 

46 

47 

48def ensure_iterable(a: Any) -> Iterable[Any]: 

49 """Ensure that the input is iterable. 

50 

51 There are multiple cases, when the input is: 

52 

53 - iterable, but not a `str` or Mapping -> iterate over elements 

54 (e.g. ``[i for i in a]``) 

55 - a `str` -> return single element iterable (e.g. ``[a]``) 

56 - a Mapping -> return single element iterable 

57 - not iterable -> return single element iterable (e.g. ``[a]``). 

58 

59 Parameters 

60 ---------- 

61 a : iterable or `str` or not iterable 

62 Argument to be converted to an iterable. 

63 

64 Returns 

65 ------- 

66 i : `~collections.abc.Iterable` 

67 Iterable version of the input value. 

68 """ 

69 if isinstance(a, str): 

70 yield a 

71 return 

72 if isinstance(a, Mapping): 

73 yield a 

74 return 

75 try: 

76 yield from a 

77 except Exception: 

78 yield a 

79 

80 

81T = TypeVar("T", str, bytes) 

82 

83 

84def isplit(string: T, sep: T) -> Iterator[T]: 

85 """Split a string or bytes by separator returning a generator. 

86 

87 Parameters 

88 ---------- 

89 string : `str` or `bytes` 

90 The string to split into substrings. 

91 sep : `str` or `bytes` 

92 The separator to use to split the string. Must be the same 

93 type as ``string``. Must always be given. 

94 

95 Yields 

96 ------ 

97 subset : `str` or `bytes` 

98 The next subset extracted from the input until the next separator. 

99 """ 

100 if type(string) is not type(sep): 

101 raise TypeError(f"String and separator types must match ({type(string)} != {type(sep)})") 

102 begin = 0 

103 while True: 

104 end = string.find(sep, begin) 

105 if end == -1: 

106 yield string[begin:] 

107 return 

108 yield string[begin:end] 

109 begin = end + 1 

110 

111 

112def _extract_numeric_suffix(s: str) -> tuple[str, int | None]: 

113 """Extract the numeric suffix from a string. 

114 

115 Returns the prefix and the numeric suffix as an integer, if present. 

116 

117 For example: 

118 'node1' -> ('node', 1) 

119 'node' -> ('node', None) 

120 'node123abc' -> ('node123abc', None) 

121 

122 Parameters 

123 ---------- 

124 s : str 

125 The string to extract the numeric suffix from. 

126 

127 Returns 

128 ------- 

129 suffix : str 

130 The numeric suffix of the string, if any. 

131 """ 

132 index = len(s) 

133 while index > 0 and s[index - 1].isdigit(): 

134 index -= 1 

135 prefix = s[:index] 

136 suffix = s[index:] 

137 if suffix: 

138 return prefix, int(suffix) 

139 else: 

140 return s, None 

141 

142 

143def _add_pair_to_name(val_name: list[str], val0: int | str, val1: int | str, stride: int = 1) -> None: 

144 """Format a pair of values (val0 and val1) and appends the result to 

145 val_name. 

146 

147 This helper function takes the starting and ending values of a sequence 

148 and formats them into a compact string representation, considering the 

149 stride and whether the values are integers or strings with common 

150 prefixes. 

151 

152 Parameters 

153 ---------- 

154 val_name : List[str] 

155 The list to append the formatted string to. 

156 val0 : [int, str] 

157 The starting value of the sequence. 

158 val1 : [int, str] 

159 The ending value of the sequence. 

160 stride : int, optional 

161 The stride or difference between consecutive numbers in the 

162 sequence. Defaults to 1. 

163 """ 

164 if isinstance(val0, str) and isinstance(val1, str): 

165 prefix0, num_suffix0 = _extract_numeric_suffix(val0) 

166 prefix1, num_suffix1 = _extract_numeric_suffix(val1) 

167 if prefix0 == prefix1 and num_suffix0 is not None and num_suffix1 is not None: 

168 if num_suffix0 == num_suffix1: 

169 dvn = val0 

170 else: 

171 dvn = f"{val0}..{val1}" 

172 if stride > 1: 

173 dvn += f":{stride}" 

174 else: 

175 dvn = val0 if val0 == val1 else f"{val0}^{val1}" 

176 else: 

177 sval0 = str(val0) 

178 sval1 = str(val1) 

179 if val0 == val1: 

180 dvn = sval0 

181 elif isinstance(val0, int) and isinstance(val1, int): 

182 if val1 == val0 + stride: 

183 dvn = f"{sval0}^{sval1}" 

184 else: 

185 dvn = f"{sval0}..{sval1}" 

186 if stride > 1: 

187 dvn += f":{stride}" 

188 else: 

189 dvn = f"{sval0}^{sval1}" 

190 val_name.append(dvn) 

191 

192 

193def _is_list_of_ints(values: list[int | str]) -> TypeGuard[list[int]]: 

194 """Check if a list is composed entirely of integers. 

195 

196 Parameters 

197 ---------- 

198 values : List[int, str]: 

199 The list of values to check. 

200 

201 Returns 

202 ------- 

203 is_ints : bool 

204 True if all values are integers, False otherwise. 

205 """ 

206 return all(isinstance(v, int) for v in values) 

207 

208 

209def sequence_to_string(values: list[int | str]) -> str: 

210 """Convert a list of integers or strings into a compact string 

211 representation by merging consecutive values or sequences. 

212 

213 This function takes a list of integers or strings, sorts them, identifies 

214 sequences where consecutive numbers differ by a consistent stride, or 

215 strings with common prefixes, and returns a string that compactly 

216 represents these sequences. Consecutive numbers are merged into ranges, and 

217 strings with common prefixes are handled to produce a concise 

218 representation. 

219 

220 >>> getNameOfSet([1, 2, 3, 5, 7, 8, 9]) 

221 '1..3^5^7..9' 

222 >>> getNameOfSet(["node1", "node2", "node3"]) 

223 'node1..node3' 

224 >>> getNameOfSet([10, 20, 30, 40]) 

225 '10..40:10' 

226 

227 Parameters 

228 ---------- 

229 values : list[int, str]: 

230 A list of items to be compacted. Must all be of the same type. 

231 

232 Returns 

233 ------- 

234 sequence_as_string : str 

235 A compact string representation of the input list. 

236 

237 Notes 

238 ----- 

239 - The function handles both integers and strings. 

240 - For strings with common prefixes, only the differing suffixes are 

241 considered. 

242 - The stride is determined as the minimum difference between 

243 consecutive numbers. 

244 - Strings without common prefixes are listed individually. 

245 """ 

246 if not values: 

247 return "" 

248 

249 values = sorted(set(values)) 

250 

251 pure_ints_or_pure_strings = all(isinstance(item, int) for item in values) or all( 

252 isinstance(item, str) for item in values 

253 ) 

254 if not pure_ints_or_pure_strings: 

255 types = {type(item) for item in values} 

256 raise TypeError(f"All items in the input list must be either integers or strings, got {types}") 

257 

258 # Determine the stride for integers 

259 stride = 1 

260 if len(values) > 1 and _is_list_of_ints(values): 

261 differences = [values[i + 1] - values[i] for i in range(len(values) - 1)] 

262 stride = min(differences) if differences else 1 

263 stride = max(stride, 1) 

264 

265 val_name: list[str] = [] 

266 val0 = values[0] 

267 val1 = val0 

268 for val in values[1:]: 

269 if isinstance(val, int): 

270 assert isinstance(val1, int) 

271 if val == val1 + stride: 

272 val1 = val 

273 else: 

274 _add_pair_to_name(val_name, val0, val1, stride) 

275 val0 = val 

276 val1 = val0 

277 elif isinstance(val, str): 

278 assert isinstance(val1, str) 

279 prefix1, num_suffix1 = _extract_numeric_suffix(val1) 

280 prefix, num_suffix = _extract_numeric_suffix(val) 

281 if prefix1 == prefix and num_suffix1 is not None and num_suffix is not None: 

282 if num_suffix == num_suffix1 + stride: 

283 val1 = val 

284 else: 

285 _add_pair_to_name(val_name, val0, val1) 

286 val0 = val 

287 val1 = val0 

288 else: 

289 _add_pair_to_name(val_name, val0, val1) 

290 val0 = val 

291 val1 = val0 

292 

293 _add_pair_to_name(val_name, val0, val1, stride) 

294 

295 return "^".join(val_name)