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

1"""List-like classes 

2 

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. 

8 

9""" 

10 

11from __future__ import annotations 

12 

13import copy 

14import dataclasses 

15from collections.abc import Hashable, Mapping, MutableSequence 

16from typing import TYPE_CHECKING, Any 

17 

18from . import base, settings, utilities 

19 

20if TYPE_CHECKING: 

21 from .kinds import GenericList, SubsetReturns 

22 

23 

24@dataclasses.dataclass 

25class Listing(base.Bunch, MutableSequence): 

26 """Basic `list` replacement. 

27 

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. 

34 

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

37 

38 Args: 

39 contents: items to store in a `list`. Defaults to an empty `list`. 

40 

41 """ 

42 

43 contents: GenericList = dataclasses.field(default_factory = list) 

44 

45 """ Public Methods """ 

46 

47 def add(self, item: Any | GenericList) -> None: 

48 """Extends `contents` with `item` if it is a sequence or appends it. 

49 

50 The method will extend all passed sequences, except `str` and `bytes` 

51 types, which it will append. 

52 

53 Args: 

54 item: item(s) to add to `contents`. 

55 

56 """ 

57 if utilities._is_sequence(item = item): 

58 self.contents.extend(item) 

59 else: 

60 self.contents.append(item) 

61 

62 def delete(self, item: int | slice) -> None: 

63 """Deletes item at the index in `contents`. 

64 

65 Args: 

66 item: index (or slice) in `contents` to delete. 

67 

68 Raises: 

69 IndexError: if `item` is out of range. 

70 

71 """ 

72 del self.contents[item] 

73 

74 def insert(self, index: int, item: Any) -> None: 

75 """Inserts `item` at `index` in `contents`. 

76 

77 Args: 

78 index: index to insert `item` at. 

79 item: object to be inserted. 

80 

81 """ 

82 self.contents.insert(index, item) 

83 

84 def prepend(self, item: Any | GenericList) -> None: 

85 """Prepends `item` to `contents`. 

86 

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

89 

90 Args: 

91 item: item(s) to prepend to `contents`. 

92 

93 """ 

94 if utilities._is_sequence(item = item): 

95 self.contents[:0] = list(item) 

96 else: 

97 self.insert(0, item) 

98 

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

105 

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. 

111 

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

119 

120 Returns: 

121 Instance with only items from `include` and no items in `exclude`, 

122 in the form dictated by the `returns` argument. 

123 

124 Raises: 

125 ValueError: if `include` and `exclude` are both None. 

126 

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) 

141 

142 """ Private Methods """ 

143 

144 def _is_match(self, item: Any, matches: GenericList) -> bool: 

145 """Returns whether `item` is in `matches`. 

146 

147 Args: 

148 item: stored item to test. 

149 matches: items passed to `include` or `exclude` in `subset`. 

150 

151 Returns: 

152 Whether `item` is in `matches`. 

153 

154 """ 

155 return item in matches 

156 

157 """ Dunder Methods """ 

158 

159 def __getitem__(self, index: Any) -> Any: 

160 """Returns value(s) for `index` in `contents`. 

161 

162 Args: 

163 index: index (or slice) to search for in `contents`. 

164 

165 Returns: 

166 Item(s) stored in `contents` at `index`. 

167 

168 """ 

169 return self.contents[index] 

170 

171 def __setitem__(self, index: Any, value: Any) -> None: 

172 """Sets `index` in `contents` to `value`. 

173 

174 Args: 

175 index: index (or slice) to set `value` to in `contents`. 

176 value: value to be set at `index` in `contents`. 

177 

178 """ 

179 self.contents[index] = value 

180 

181 

182@dataclasses.dataclass 

183class DictList(Listing): 

184 """Iterable that has both a dict and list interfaces. 

185 

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. 

192 

193 A DictList inherits the differences between a Listing and an ordinary python 

194 list. 

195 

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. 

218 

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

224 

225 """ 

226 

227 contents: MutableSequence[Hashable] = dataclasses.field( 

228 default_factory = list) 

229 default_factory: Any | None = None 

230 

231 """ Public Methods """ 

232 

233 def delete(self, item: Any | int | slice) -> None: 

234 """Deletes item in `contents`. 

235 

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. 

239 

240 Args: 

241 item: name or index in `contents` to delete. 

242 

243 Raises: 

244 KeyError: if `item` is not an int and no stored item matches it. 

245 

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 

255 

256 def get(self, key: Hashable, default: Any | None = None) -> Any: 

257 """Returns value in `contents` or default options. 

258 

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

263 

264 Returns: 

265 Value matching key in `contents`, `default`, or the 

266 `default_factory` value (called if it is callable). 

267 

268 Raises: 

269 KeyError: if `key` is not in the DictList and `default` and the 

270 `default_factory` attribute are both None. 

271 

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 

283 

284 def items(self) -> tuple[tuple[Hashable, Any], ...]: 

285 """Emulates python dict `items` method. 

286 

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. 

291 

292 """ 

293 return tuple(zip(self.keys(), self.values(), strict = True)) 

294 

295 def keys(self) -> tuple[Hashable, ...]: 

296 """Emulates python dict `keys` method. 

297 

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. 

302 

303 """ 

304 namer = settings._KEY_NAMER 

305 return tuple(namer(c) for c in self.contents) 

306 

307 def setdefault(self, value: Any) -> None: 

308 """Sets default value to return when `get` method is used. 

309 

310 Args: 

311 value: default value to return. 

312 

313 """ 

314 self.default_factory = value 

315 

316 def update(self, items: Mapping[Any, Any]) -> None: 

317 """Mimics the dict `update` method by extending `contents` with `items`. 

318 

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

326 

327 """ 

328 self.extend(list(items.values())) 

329 

330 def values(self) -> tuple[Any, ...]: 

331 """Emulates python dict `values` method. 

332 

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. 

337 

338 """ 

339 return tuple(self.contents) 

340 

341 """ Private Methods """ 

342 

343 def _is_match(self, item: Any, matches: GenericList) -> bool: 

344 """Returns whether the name of `item` is in `matches`. 

345 

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

349 

350 Args: 

351 item: stored item to test. 

352 matches: names passed to `include` or `exclude` in `subset`. 

353 

354 Returns: 

355 Whether the name of `item` is in `matches`. 

356 

357 """ 

358 return settings._KEY_NAMER(item) in matches 

359 

360 """ Dunder Methods """ 

361 

362 def __getitem__(self, key: Hashable | int | slice) -> Any: 

363 """Returns value(s) for `key` in `contents`. 

364 

365 If `key` is not an int type, this method looks for a matching `name` 

366 attribute in the stored instances. 

367 

368 If `key` is an int (or a slice), this method returns the stored item(s) 

369 at the corresponding index. 

370 

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. 

374 

375 Args: 

376 key: name of an item or index to search for in `contents`. 

377 

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. 

382 

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

386 

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) 

398 

399 def __setitem__(self, key: Any | int | slice, value: Any) -> None: 

400 """Sets `key` in `contents` to `value`. 

401 

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

409 

410 """ 

411 if isinstance(key, int | slice): 

412 self.contents[key] = value 

413 else: 

414 self.add(value)