Coverage for src/bunches/mappings.py: 100%

199 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-30 23:02 +0000

1"""Extensible, flexible, lightweight dict-like classes 

2 

3Contents: 

4 Dictionary (base.Bunch, MutableMapping): drop-in replacement for a python 

5 dict with some added functionality. 

6 Catalog (Dictionary): wildcard-accepting dict which is primarily intended 

7 for storing different options and strategies. It also returns lists of 

8 matches if a list of keys is provided. 

9 ChainDict (Dictionary): combines the additional functionality of 

10 Dictionary with collections.ChainMap from the Python builtin library. 

11 Repository (Dictionary): stores items using inferred keys when the `add` 

12 method is called. This is useful for mappings where the key naming is 

13 controlled by the mapping instead of based on the passed arguments. 

14 Support for guaranteed unique key creation (using an integer counter) is 

15 provided out of the box based on the `overwrite` argument. 

16 

17""" 

18 

19from __future__ import annotations 

20 

21import contextlib 

22import copy 

23import dataclasses 

24import itertools 

25from collections.abc import ( 

26 Collection, 

27 Hashable, 

28 Iterable, 

29 Iterator, 

30 MutableMapping, 

31 MutableSequence, 

32 Sequence, 

33) 

34from typing import TYPE_CHECKING, Any 

35 

36from . import base, settings, utilities 

37 

38if TYPE_CHECKING: 

39 from .kinds import GenericDict, GenericList, SubsetReturns 

40 

41 

42@dataclasses.dataclass 

43class Dictionary(base.Bunch, MutableMapping): 

44 """Basic bunches dict replacement. 

45 

46 A `Dictionary` differs from an ordinary python `dict` in ways inherited from 

47 `Bunch` by requiring `add` and `subset` methods, storing data in `contents`, 

48 and allowing the "+" operator to join `Dictionary` instances with other 

49 mappings, including `Dictionary` instances. 

50 

51 In addition, it differs in 2 other significant ways: 

52 1) When returning `keys`, `values` and `items`, this class returns them 

53 as tuples instead of `KeysView`, `ValuesView`, and `ItemsView`. 

54 2) It includes similar functionality to `defaultdict` in the python 

55 standard library, including a `setdefault` method. The default is 

56 used by the `get` method (not by `[]` access). 

57 

58 Args: 

59 contents: stored dictionary. Defaults to an empty `dict`. 

60 default_factory: default value to return or default callable to use to 

61 create the default value when `get` is called with a missing key. 

62 Defaults to `None`. 

63 

64 """ 

65 

66 contents: GenericDict = dataclasses.field(default_factory = dict) 

67 default_factory: Any | None = None 

68 

69 """ Class Methods """ 

70 

71 @classmethod 

72 def fromkeys( 

73 cls, 

74 keys: Iterable[Hashable], 

75 value: Any = None, 

76 **kwargs: Any) -> Dictionary: 

77 """Emulates the `fromkeys` class method from a python `dict`. 

78 

79 Args: 

80 keys: items to be keys in a new `Dictionary`. 

81 value: the value to use for all values in a new `Dictionary`. 

82 Defaults to `None`, as in `dict.fromkeys`. 

83 **kwargs: additional arguments to pass to the class constructor 

84 (e.g. `default_factory`). 

85 

86 Returns: 

87 An instance formed from `keys` and `value`. 

88 

89 """ 

90 return cls(contents = dict.fromkeys(keys, value), **kwargs) 

91 

92 """ Public Methods """ 

93 

94 def add(self, item: GenericDict, **kwargs: Any) -> None: 

95 """Adds `item` to the `contents` attribute. 

96 

97 Args: 

98 item: items to add to `contents` attribute. 

99 **kwargs: additional key/value pairs to add, as in `dict.update`. 

100 

101 """ 

102 self.contents.update(item, **kwargs) 

103 

104 def delete(self, item: Hashable) -> None: 

105 """Deletes `item` in `contents`. 

106 

107 Args: 

108 item: key in `contents` to delete the key/value pair. 

109 

110 Raises: 

111 KeyError: if `item` is not a key in `contents`. 

112 

113 """ 

114 del self.contents[item] 

115 

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

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

118 

119 Args: 

120 key: key for value in `contents`. 

121 default: default value to return if `key` is not found in 

122 `contents`. 

123 

124 Returns: 

125 Value matching key in `contents` or a default value. If `default` is 

126 `None`, the `default_factory` attribute is used: it is called 

127 if it is callable and returned as is otherwise. 

128 

129 Raises: 

130 KeyError: if `key` is not in `contents` and `default` and the 

131 `default_factory` attribute are both `None`. 

132 

133 """ 

134 try: 

135 return self[key] 

136 except (KeyError, TypeError) as error: 

137 if default is not None: 

138 return default 

139 if self.default_factory is None: 

140 raise KeyError(f'{key} is not in the Dictionary') from error 

141 if callable(self.default_factory): 

142 return self.default_factory() 

143 return self.default_factory 

144 

145 def items( # type: ignore[override] 

146 self) -> tuple[tuple[Hashable, Any], ...]: 

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

148 

149 Returns: 

150 A `tuple` equivalent to `dict.items()`. 

151 

152 """ 

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

154 

155 def keys(self) -> tuple[Hashable, ...]: # type: ignore[override] 

156 """Returns `contents` keys as a tuple. 

157 

158 Returns: 

159 A `tuple` equivalent to `dict.keys()`. 

160 

161 """ 

162 return tuple(self.contents.keys()) 

163 

164 def setdefault(self, value: Any) -> None: # type: ignore[override] 

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

166 

167 Args: 

168 value: default value to return when `get` is called and the 

169 `default` parameter to `get` is None. 

170 

171 """ 

172 self.default_factory = value 

173 

174 def subset( 

175 self, 

176 include: Collection[Any] | Any | None = None, 

177 exclude: Collection[Any] | Any | None = None, 

178 returns: SubsetReturns | None = None) -> Any: 

179 """Returns a new instance with a subset of `contents`. 

180 

181 This method applies `include` before `exclude` if both are passed. If 

182 `include` is None, all existing items will be added to the new subset 

183 class instance before `exclude` is applied. The values in the subset 

184 are deep copies, so changing them does not change this instance. 

185 

186 Args: 

187 include: key(s) to include in the new `Dictionary`. Defaults to 

188 `None`. 

189 exclude: key(s) to exclude from the new `Dictionary`. Defaults to 

190 `None`. 

191 returns: whether to return a new instance of the `Dictionary` 

192 subclass ("class"), a deep copy of the subclass instance 

193 ("copy") or the simple native Python type ("simple"). Defaults 

194 to `None`, which uses the global setting stored in 

195 `settings._SUBSET_RETURN`. 

196 

197 Returns: 

198 `dict`-like object with only keys from `include` and no keys in 

199 `exclude`, in the form dictated by the `returns` argument. 

200 

201 Raises: 

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

203 KeyError: if a key in `include` is not in `contents`. 

204 

205 """ 

206 if include is None and exclude is None: 

207 raise ValueError('include or exclude must not be None') 

208 returns = self._resolve_returns(returns) 

209 if include is None: 

210 keys = list(self.contents) 

211 else: 

212 keys = list(utilities._iterify(include)) 

213 if exclude is not None: 

214 excluded = list(utilities._iterify(exclude)) 

215 keys = [k for k in keys if k not in excluded] 

216 contents = copy.deepcopy({k: self.contents[k] for k in keys}) 

217 return utilities._return_subset( 

218 subset = contents, existing = self, returns = returns) 

219 

220 def values(self) -> tuple[Any, ...]: # type: ignore[override] 

221 """Returns `contents` values as a `tuple`. 

222 

223 Returns: 

224 A `tuple` equivalent to `dict.values()`. 

225 

226 """ 

227 return tuple(self.contents.values()) 

228 

229 """ Dunder Methods """ 

230 

231 def __contains__(self, key: object) -> bool: 

232 """Returns whether `key` is a key in `contents`. 

233 

234 Unlike the default `Mapping` method, this does not call `__getitem__`, 

235 so special keys (such as the wildcards of a `Catalog`) are only in an 

236 instance if they are stored in `contents`. 

237 

238 Args: 

239 key: possible key in `contents`. 

240 

241 Returns: 

242 Whether `key` is a key in `contents`. An unhashable `key` (such as 

243 a `list` of keys) is never a key. 

244 

245 """ 

246 try: 

247 return key in self.contents 

248 except TypeError: 

249 return False 

250 

251 def __getitem__(self, key: Hashable) -> Any: 

252 """Returns value for `key` in `contents`. 

253 

254 Args: 

255 key: key in `contents` for which a value is sought. 

256 

257 Returns: 

258 Value stored in `contents`. 

259 

260 """ 

261 return self.contents[key] 

262 

263 def __setitem__(self, key: Hashable, value: Any) -> None: 

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

265 

266 Args: 

267 key: key to set in `contents`. 

268 value: value to be paired with `key` in `contents`. 

269 

270 """ 

271 self.contents[key] = value 

272 

273 

274@dataclasses.dataclass 

275class Catalog(Dictionary): 

276 """Wildcard and list-accepting dictionary. 

277 

278 A Catalog inherits the differences between a Dictionary and an ordinary 

279 python dict. 

280 

281 A Catalog differs from a Dictionary in 5 significant ways: 

282 1) It recognizes an `all` key which will return a list of all values 

283 stored in a Catalog instance. 

284 2) It recognizes a `default` key which will return all values matching 

285 keys listed in the `default` attribute. `default` can also be set 

286 using the `catalog.default = new_default` assignment. If `default` 

287 is not passed when the instance is initialized, the initial value 

288 of `default` is `all`. 

289 3) It recognizes a `none` key which will return `None` (or an empty 

290 list if `always_return_list` is `True`). If `default_factory` is 

291 set, the default value it supplies is returned instead. 

292 4) It supports a list of keys being accessed with the matching values 

293 returned. For example, `catalog[['first_key', 'second_key']]` will 

294 return the values for those keys in a list ['first_value', 

295 'second_value']. Keys that are not in the Catalog are skipped. 

296 5) If a single key is sought, a Catalog can either return the stored 

297 value or a stored value in a list (if `always_return_list` is 

298 True). The latter option is available to make iteration easier 

299 when the iterator assumes a single type will be returned. 

300 

301 Args: 

302 contents: stored dictionary. Defaults to an empty `dict`. 

303 default_factory: default value to return or default callable to use to 

304 create the default value. 

305 default: a key or list of keys in `contents` which will be used to 

306 return items when `default` is sought. Defaults to 'all', which 

307 returns all values. 

308 always_return_list: whether to return a list even when the key 

309 passed is not a list or special access key (`True`) or to return a 

310 list only when a list or special access key is used (`False`). 

311 Defaults to `False`. 

312 

313 """ 

314 

315 contents: GenericDict = dataclasses.field(default_factory = dict) 

316 default_factory: Any | None = None 

317 default: Any | None = 'all' 

318 always_return_list: bool = False 

319 

320 """ Public Methods """ 

321 

322 def delete(self, item: Hashable | Sequence[Hashable]) -> None: 

323 """Deletes `item` in `contents`. 

324 

325 Args: 

326 item: name(s) of key(s) in `contents` to delete the key/value pair. 

327 A `tuple` that is itself a key in `contents` is deleted as a 

328 single key. 

329 

330 Raises: 

331 KeyError: if any key in `item` is not in `contents`. When this 

332 error is raised, nothing is deleted. 

333 

334 """ 

335 keys = [item] if item in self else list(utilities._iterify(item)) 

336 if not all(k in self.contents for k in keys): 

337 raise KeyError(f'{item} not found in the Catalog') 

338 for key in keys: 

339 del self.contents[key] 

340 

341 """ Dunder Methods """ 

342 

343 def __getitem__( 

344 self, key: Hashable | Sequence[Hashable]) -> Any | GenericList: 

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

346 

347 The method searches for 'all', 'default', and 'none' matching wildcard 

348 options before searching for direct matches in `contents`. A sequence 

349 of keys returns a `list` of the matching values, unless the sequence 

350 (such as a `tuple`) is itself a key in `contents`. 

351 

352 Args: 

353 key: key(s) in `contents`. 

354 

355 Returns: 

356 Value(s) stored in `contents`. 

357 

358 Raises: 

359 KeyError: if a single `key` is not in `contents`. 

360 

361 """ 

362 if key in settings._ALL_KEYS: 

363 return list(self.contents.values()) 

364 if key in settings._DEFAULT_KEYS: 

365 return self[self.default] 

366 if key in settings._NONE_KEYS: 

367 if self.default_factory is None: 

368 return [] if self.always_return_list else None 

369 if callable(self.default_factory): 

370 return self.default_factory() 

371 return self.default_factory 

372 if (isinstance(key, Sequence) 

373 and not isinstance(key, str | bytes) 

374 and key not in self): 

375 return [self.contents[k] for k in key if k in self.contents] 

376 try: 

377 if self.always_return_list: 

378 return [self.contents[key]] 

379 return self.contents[key] 

380 except KeyError as error: 

381 message = f'{key} is not in {self.__class__.__name__}' 

382 raise KeyError(message) from error 

383 

384 def __setitem__( 

385 self, 

386 key: Hashable | Sequence[Hashable], 

387 value: Any | GenericList) -> None: 

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

389 

390 If `key` is unhashable (e.g., a `list` of keys), `key` and `value` are 

391 zipped together and each pair is stored. 

392 

393 Args: 

394 key: key(s) to set in `contents`. 

395 value: value(s) to be paired with `key` in `contents`. 

396 

397 Raises: 

398 ValueError: if `key` is a `list` of keys and `value` has a 

399 different length. 

400 

401 """ 

402 if isinstance(key, Hashable): 

403 self.contents[key] = value 

404 else: 

405 self.contents.update(dict(zip(key, value, strict = True))) 

406 

407 

408@dataclasses.dataclass 

409class ChainDict(Dictionary): 

410 """Combines functionality of `collections.ChainMap` with `Dictionary`. 

411 

412 Iterating a `ChainDict` (and `len`) uses the keys of all stored mappings, 

413 as with any other mapping. A key that appears in more than one stored 

414 mapping appears once for each mapping that has it. `keys`, `values` and 

415 `items` are consistent with each other. 

416 

417 Args: 

418 contents: list of stored `Dictionary` instances. This is equivalent to 

419 the `maps` attribute of a `collections.ChainMap` instance but uses a 

420 different name for compatibility with `base.Bunch`. A separate 

421 `maps` property is included which points to `contents` to ensure 

422 compatibility in the opposite direction. 

423 default_factory: default value to return or default callable to use to 

424 create the default value. 

425 return_first: whether to only return the first match found (`True`) or 

426 to search all of the stored `Dictionary` instances (`False`). 

427 Defaults to `True`. 

428 

429 """ 

430 

431 contents: MutableSequence[Dictionary] = ( 

432 dataclasses.field(default_factory = list)) # type: ignore[assignment] 

433 default_factory: Any | None = None 

434 return_first: bool = True 

435 

436 """ Properties """ 

437 

438 @property 

439 def maps(self) -> MutableSequence[Dictionary]: 

440 """Returns `contents` attribute. 

441 

442 Returns: 

443 Stored `Dictionary` instances. 

444 

445 """ 

446 return self.contents 

447 

448 @maps.setter 

449 def maps(self, value: MutableSequence[Dictionary]) -> None: 

450 """Sets `contents` to `value`. 

451 

452 Args: 

453 value: new `list`-like instance to assign `contents` to. 

454 

455 """ 

456 self.contents = value 

457 

458 @maps.deleter 

459 def maps(self) -> None: 

460 """Sets `contents` to an empty list.""" 

461 self.contents = [] 

462 

463 @property 

464 def parents(self) -> ChainDict: 

465 """Returns an instance with `contents` after the first. 

466 

467 This property mirrors `collections.ChainMap.parents`. 

468 

469 Returns: 

470 An instance with all stored `Dictionary` instances after the first. 

471 

472 """ 

473 return self.__class__( 

474 self.contents[1:], 

475 default_factory = self.default_factory, 

476 return_first = self.return_first) 

477 

478 """ Class Methods """ 

479 

480 @classmethod 

481 def fromkeys( 

482 cls, 

483 keys: Iterable[Hashable], 

484 value: Any = None, 

485 **kwargs: Any) -> ChainDict: 

486 """Emulates the `fromkeys` class method from a python `dict`. 

487 

488 Since this method is an awkward fit with a chained map, it just assigns 

489 the `keys` and `value` to a single `Dictionary` stored in the `contents` 

490 list. 

491 

492 Args: 

493 keys: items to be keys in a new `Dictionary`. 

494 value: the value to use for all values in a new `Dictionary`. 

495 Defaults to `None`, as in `dict.fromkeys`. 

496 **kwargs: additional arguments to pass to the `Dictionary` 

497 constructor (e.g. `default_factory`). 

498 

499 Returns: 

500 `ChainDict` containing a single `Dictionary` formed from `keys` and 

501 `value`. 

502 

503 """ 

504 return cls(contents = [Dictionary.fromkeys(keys, value, **kwargs)]) 

505 

506 """ Public Methods """ 

507 

508 def add(self, item: GenericDict, **kwargs: Any) -> None: 

509 """Adds `item` to the end of the `contents` attribute. 

510 

511 Args: 

512 item: mapping to add to `contents`. If it is not a `Dictionary`, it 

513 is wrapped in one. 

514 **kwargs: additional key/value pairs to add to the new mapping. 

515 

516 """ 

517 if not isinstance(item, Dictionary): 

518 item = Dictionary(dict(item, **kwargs)) 

519 elif kwargs: 

520 item = Dictionary(dict(item.contents, **kwargs)) 

521 self.contents.append(item) 

522 

523 def delete(self, item: Hashable) -> None: 

524 """Deletes `item` in `contents`. 

525 

526 Because a chained mapping can have identical keys in different stored 

527 mappings, this method searches through all of the stored Dictionary 

528 instances and removes the key wherever it appears. 

529 

530 Args: 

531 item: key in `contents` to delete the key/value pair. 

532 

533 Raises: 

534 KeyError: if `item` is not a key in any stored mapping. 

535 

536 """ 

537 found = False 

538 for dictionary in self.contents: 

539 with contextlib.suppress(KeyError): 

540 del dictionary[item] 

541 found = True 

542 if not found: 

543 raise KeyError(f'{item} is not found in the ChainDict') 

544 

545 def keys(self) -> tuple[Hashable, ...]: # type: ignore[override] 

546 """Returns `contents` keys as a `tuple`. 

547 

548 Returns: 

549 A `tuple` equivalent to `dict.keys()` with the keys of all stored 

550 mappings, in order. 

551 

552 """ 

553 return tuple( 

554 itertools.chain.from_iterable([d.keys() for d in self.contents])) 

555 

556 def new_child(self, m: GenericDict | None = None) -> None: 

557 """Inserts `m` as the first Dictionary in `contents`. 

558 

559 This method mirrors the functionality and parameters of 

560 `collections.ChainMap.new_child`, except that `contents` is modified in 

561 place. 

562 

563 Args: 

564 m: mapping to add to `contents` at index 0. If it is not a 

565 `Dictionary`, it is wrapped in one. Defaults to `None`, which 

566 adds an empty `Dictionary`. 

567 

568 """ 

569 if m is None: 

570 m = Dictionary() 

571 elif not isinstance(m, Dictionary): 

572 m = Dictionary(dict(m)) 

573 self.contents.insert(0, m) 

574 

575 def subset( 

576 self, 

577 include: Collection[Any] | Any | None = None, 

578 exclude: Collection[Any] | Any | None = None, 

579 returns: SubsetReturns | None = None) -> Any: 

580 """Returns a new instance with a subset of `contents`. 

581 

582 This method applies `include` before `exclude` if both are passed. If 

583 `include` is None, all existing items will be added to the new subset 

584 class instance before `exclude` is applied. 

585 

586 This method relies on all stored mappings being compatible with the 

587 `Dictionary` class because it uses the `subset` method of those stored 

588 mappings. Keys in `include` that are missing from a stored mapping are 

589 ignored for that mapping. 

590 

591 Args: 

592 include: key(s) to include in the new `ChainDict`. Defaults to 

593 `None`. 

594 exclude: key(s) to exclude from the new `ChainDict`. Defaults to 

595 `None`. 

596 returns: whether to return a new instance of the `ChainDict` 

597 subclass ("class"), a deep copy of the subclass instance 

598 ("copy") or the simple native Python type ("simple", a `list` 

599 of `Dictionary` instances). Defaults to `None`, which uses the 

600 global setting stored in `settings._SUBSET_RETURN`. 

601 

602 Returns: 

603 A new instance with only keys from `include` and no keys in 

604 `exclude`. 

605 

606 Raises: 

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

608 

609 """ 

610 if include is None and exclude is None: 

611 raise ValueError('include or exclude must not be None') 

612 returns = self._resolve_returns(returns) 

613 if include is not None: 

614 include = list(utilities._iterify(include)) 

615 new_contents = [] 

616 for dictionary in self.contents: 

617 if include is None: 

618 these_keys = None 

619 else: 

620 these_keys = [k for k in include if k in dictionary] 

621 new_contents.append( 

622 dictionary.subset( 

623 include = these_keys, exclude = exclude, returns = 'class')) 

624 return utilities._return_subset( 

625 subset = new_contents, existing = self, returns = returns) 

626 

627 def values(self) -> tuple[Any, ...]: # type: ignore[override] 

628 """Returns `contents` values as a `tuple`. 

629 

630 Returns: 

631 A `tuple` equivalent to `dict.values()` with the values of all 

632 stored mappings, in order. 

633 

634 """ 

635 return tuple( 

636 itertools.chain.from_iterable([d.values() for d in self.contents])) 

637 

638 """ Dunder Methods """ 

639 

640 def __contains__(self, key: object) -> bool: 

641 """Returns whether `key` is a key in any stored mapping. 

642 

643 Args: 

644 key: possible key in a stored mapping. 

645 

646 Returns: 

647 Whether `key` is a key in any stored mapping. 

648 

649 """ 

650 return any(key in dictionary for dictionary in self.contents) 

651 

652 def __getitem__(self, key: Hashable) -> Any: 

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

654 

655 If there are multiple matches for `key` and the `return_first` attribute 

656 is `False`, this method returns all matches in a `list`. Otherwise, only 

657 the first match is returned. 

658 

659 Args: 

660 key: key in `contents` for which a value is sought. 

661 

662 Returns: 

663 Value(s) stored in `contents`. 

664 

665 Raises: 

666 KeyError: if `key` is not in any stored mapping. 

667 

668 """ 

669 matches = [] 

670 for dictionary in self.contents: 

671 with contextlib.suppress(KeyError): 

672 matches.append(dictionary[key]) 

673 if self.return_first: 

674 return matches[0] 

675 if not matches: 

676 raise KeyError(f'{key} is not found in the ChainDict') 

677 return matches if len(matches) > 1 else matches[0] 

678 

679 def __iter__(self) -> Iterator[Hashable]: 

680 """Returns iterator of the keys in all stored mappings. 

681 

682 Returns: 

683 Iterator of `keys`. 

684 

685 """ 

686 return iter(self.keys()) 

687 

688 def __len__(self) -> int: 

689 """Returns the number of keys in all stored mappings. 

690 

691 Returns: 

692 Length of `keys`. 

693 

694 """ 

695 return len(self.keys()) 

696 

697 def __setitem__(self, key: Hashable, value: Any) -> None: 

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

699 

700 This method stores the passed `key` and `value` in the first stored 

701 `Dictionary`. If none exists, one is created to store `key` and 

702 `value`. 

703 

704 Args: 

705 key: key to set in `contents`. 

706 value: value to be paired with `key` in `contents`. 

707 

708 """ 

709 if len(self.contents) == 0: 

710 self.contents = [Dictionary({key: value})] 

711 else: 

712 self.contents[0][key] = value 

713 

714 

715@dataclasses.dataclass 

716class Repository(Dictionary): 

717 """Dictionary with inferred keys based on items added. 

718 

719 A `Repository` differs from an ordinary python `dict` in ways inherited from 

720 `Dictionary`. In addition, it differs in 2 other significant ways: 

721 1) The `add` method relies on the internal `_get_name` method to 

722 assign a str key for the passed item. 

723 2) It includes an `overwrite` parameter which allows users to determine 

724 whether existing items will be overwritten when the inferred key 

725 matches an existing one or whether a new key will be inferred by 

726 adding an integer counter as a suffix to the key. 

727 

728 Args: 

729 contents: stored dictionary. Defaults to an empty `dict`. 

730 default_factory: default value to return or default callable to use to 

731 create the default value. 

732 overwrite: whether to overwrite existing items in the stored dictionary 

733 with the same inferred keys (`True`) or automatically infer a new 

734 key based upon a counter suffix (`False`). Defaults to `False`. 

735 

736 """ 

737 

738 contents: GenericDict = dataclasses.field(default_factory = dict) 

739 default_factory: Any | None = None 

740 overwrite: bool = False 

741 

742 """ Public Methods """ 

743 

744 def add(self, item: Any, key: str | None = None, **kwargs: Any) -> None: 

745 """Adds `item` to the `contents` attribute. 

746 

747 Args: 

748 item: item to add to `contents` attribute. 

749 key: key to use for `item` if the user does not want the key to be 

750 inferred. Defaults to `None`, which infers the key. 

751 **kwargs: additional key/value pairs to add, as in `dict.update`. 

752 

753 Raises: 

754 ValueError: if `key` is `None` and no name can be inferred for 

755 `item`. 

756 

757 """ 

758 if key is None: 

759 key = self._get_name(item = item) 

760 if not self.overwrite: 

761 key = utilities._uniquify(key = key, dictionary = self) 

762 self.contents.update({key: item}, **kwargs) 

763 

764 """ Private Methods """ 

765 

766 def _get_name(self, item: Any) -> str: 

767 """Infers key name for `item`. 

768 

769 By default, this method uses the global key namer stored in 

770 `settings._KEY_NAMER` (initially `utilities._namify`), which can be 

771 changed with `settings.set_key_namer`. Override this method to use a 

772 different naming function for a particular subclass. 

773 

774 Args: 

775 item: item to infer the name for. 

776 

777 Returns: 

778 Inferred name. 

779 

780 Raises: 

781 ValueError: if the key namer cannot infer a name for `item`. 

782 

783 """ 

784 name = settings._KEY_NAMER(item) 

785 if name is None: 

786 raise ValueError(f'a name could not be inferred for {item!r}') 

787 return name