Coverage for src / toolbox_python / dictionaries.py: 100%

68 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-02 22:56 +0000

1# ============================================================================ # 

2# # 

3# Title : Dictionaries # 

4# Purpose : Manipulate and enhance dictionaries. # 

5# # 

6# ============================================================================ # 

7 

8 

9# ---------------------------------------------------------------------------- # 

10# # 

11# Overview #### 

12# # 

13# ---------------------------------------------------------------------------- # 

14 

15 

16# ---------------------------------------------------------------------------- # 

17# Description #### 

18# ---------------------------------------------------------------------------- # 

19 

20 

21""" 

22!!! note "Summary" 

23 The `dictionaries` module is used how to manipulate and enhance Python dictionaries. 

24!!! abstract "Details" 

25 Note that functions in this module will only take-in and manipulate existing `#!py dict` objects, and also output `#!py dict` objects. It will not sub-class the base `#!py dict` object, or create new '`#!py dict`-like' objects. It will always maintain pure python types at it's core. 

26""" 

27 

28 

29# ---------------------------------------------------------------------------- # 

30# # 

31# Setup #### 

32# # 

33# ---------------------------------------------------------------------------- # 

34 

35 

36# ---------------------------------------------------------------------------- # 

37# Imports #### 

38# ---------------------------------------------------------------------------- # 

39 

40 

41# ## Python StdLib Imports ---- 

42from typing import Any 

43 

44# ## Python Third Party Imports ---- 

45from typeguard import typechecked 

46 

47 

48# ---------------------------------------------------------------------------- # 

49# Exports #### 

50# ---------------------------------------------------------------------------- # 

51 

52 

53__all__: list[str] = ["dict_reverse_keys_and_values", "DotDict"] 

54 

55 

56# ---------------------------------------------------------------------------- # 

57# # 

58# Swap Keys & Values #### 

59# # 

60# ---------------------------------------------------------------------------- # 

61 

62 

63@typechecked 

64def dict_reverse_keys_and_values(dictionary: dict[Any, Any]) -> dict[str, Any]: 

65 """ 

66 !!! note "Summary" 

67 Take the `key` and `values` of a dictionary, and reverse them. 

68 

69 ???+ abstract "Details" 

70 This process is simple enough if the `values` are atomic types, like `#!py str`, `#!py int`, or `#!py float` types. But it is a little more tricky when the `values` are more complex types, like `#!py list` or `#!py dict`; here we need to use some recursion. 

71 

72 Params: 

73 dictionary (Dict[Any, Any]): 

74 The input `#!py dict` that you'd like to have the `keys` and `values` switched. 

75 

76 Raises: 

77 (TypeCheckError): 

78 If any of the inputs parsed to the parameters of this function are not the correct type. Uses the [`@typeguard.typechecked`](https://typeguard.readthedocs.io/en/stable/api.html#typeguard.typechecked) decorator. 

79 (KeyError): 

80 When there are duplicate `values` being coerced to `keys` in the new dictionary. Raised because a Python `#!py dict` cannot have duplicate keys of the same value. 

81 

82 Returns: 

83 output_dict (Dict[str,Any]): 

84 The updated `#!py dict`. 

85 

86 ???+ example "Examples" 

87 

88 ```pycon {.py .python linenums="1" title="Set up"} 

89 >>> # Imports 

90 >>> from toolbox_python.dictionaries import dict_reverse_keys_and_values 

91 >>> 

92 >>> # Basic dictionary 

93 >>> dict_basic = { 

94 ... "a": 1, 

95 ... "b": 2, 

96 ... "c": 3, 

97 ... } 

98 >>> 

99 >>> # Dictionary with iterables 

100 >>> dict_iterables = { 

101 ... "a": ["1", "2", "3"], 

102 ... "b": [4, 5, 6], 

103 ... "c": ("7", "8", "9"), 

104 ... "d": (10, 11, 12), 

105 ... } 

106 >>> 

107 >>> # Dictionary with iterables and duplicates 

108 >>> dict_iterables_with_duplicates = { 

109 ... "a": [1, 2, 3], 

110 ... "b": [4, 2, 5], 

111 ... } 

112 >>> 

113 >>> # Dictionary with sub-dictionaries 

114 >>> dict_with_dicts = { 

115 ... "a": { 

116 ... "aa": 11, 

117 ... "bb": 22, 

118 ... "cc": 33, 

119 ... }, 

120 ... "b": { 

121 ... "dd": [1, 2, 3], 

122 ... "ee": ("4", "5", "6"), 

123 ... }, 

124 ... } 

125 ``` 

126 

127 ```pycon {.py .python linenums="1" title="Example 1: Reverse one-for-one"} 

128 >>> print(dict_reverse_keys_and_values(dict_basic)) 

129 ``` 

130 <div class="result" markdown> 

131 ```{.sh .shell title="Terminal"} 

132 { 

133 "1": "a", 

134 "2": "b", 

135 "3": "c", 

136 } 

137 ``` 

138 !!! success "Conclusion: Successful conversion." 

139 !!! observation "Notice here that the original values were type `#!py int`, but here they have been converted to `#!py str`. This is because `#!py dict` keys should ideally only be `#!py str` type." 

140 </div> 

141 

142 ```pycon {.py .python linenums="1" title="Example 2: Reverse dictionary containing iterables in `values`"} 

143 >>> print(dict_reverse_keys_and_values(dict_iterables)) 

144 ``` 

145 <div class="result" markdown> 

146 ```{.sh .shell title="Terminal"} 

147 { 

148 "1": "a", 

149 "2": "a", 

150 "3": "a", 

151 "4": "b", 

152 "5": "b", 

153 "6": "b", 

154 "7": "c", 

155 "8": "c", 

156 "9": "c", 

157 "10": "d", 

158 "11": "d", 

159 "12": "d", 

160 } 

161 ``` 

162 !!! success "Conclusion: Successful conversion." 

163 !!! observation "Notice here how it has 'flattened' the iterables in the `values` in to individual keys, and assigned the original `key` to multiple keys. They keys have again been coerced to `#!py str` type." 

164 </div> 

165 

166 ```pycon {.py .python linenums="1" title="Example 3: Dictionary with iterables, raise error when `key` already exists"} 

167 >>> print(dict_reverse_keys_and_values(dict_iterables_with_duplicates)) 

168 ``` 

169 <div class="result" markdown> 

170 ```{.sh .shell title="Terminal"} 

171 KeyError: Key already existing. 

172 Cannot update `output_dict` with new elements: {2: 'b'} 

173 Because the key is already existing for: {'2': 'a'} 

174 Full `output_dict` so far: 

175 {'1': 'a', '2': 'a', '3': 'a', '4': 'b'} 

176 ``` 

177 !!! failure "Conclusion: Failed conversion." 

178 !!! observation "Here, in the second element of the dictionary (`#!py "b"`), there is a duplicate value `#!py 2` which is already existing in the first element of the dictionary (`#!py "a"`). So, we would expect to see an error.<br>Remember, a Python `#!py dict` object _cannot_ contain duplicate keys. They must always be unique." 

179 </div> 

180 

181 ```pycon {.py .python linenums="1" title="Example 4: Dictionary with embedded dictionaries"} 

182 >>> print(dict_reverse_keys_and_values(dict_with_dicts)) 

183 ``` 

184 <div class="result" markdown> 

185 ```{.sh .shell title="Terminal"} 

186 { 

187 "1": "a", 

188 "2": "a", 

189 "3": "a", 

190 "4": "b", 

191 "5": "b", 

192 "6": "b", 

193 "7": "c", 

194 "8": "c", 

195 "9": "c", 

196 "10": "d", 

197 "11": "d", 

198 "12": "d", 

199 } 

200 ``` 

201 !!! success "Conclusion: Successful conversion." 

202 !!! observation "Here, the process would be to run a recursive process when it recognises that any `value` is a `#!py dict` object. So long as there are no duplicate values in any of the contained `#!py dict`'s, the resulting output will be a big, flat dictionary." 

203 </div> 

204 """ 

205 output_dict: dict[str, Any] = dict() 

206 for key, value in dictionary.items(): 

207 if isinstance(value, (str, int, float)): 

208 output_dict[str(value)] = key 

209 elif isinstance(value, (tuple, list)): 

210 for elem in value: 

211 if str(elem) in output_dict.keys(): 

212 raise KeyError( 

213 f"Key already existing.\n" 

214 f"Cannot update `output_dict` with new elements: { {elem: key} }\n" 

215 f"Because the key is already existing for: { {new_key: new_value for (new_key, new_value) in output_dict.items() if new_key==str(elem)} }\n" 

216 f"Full `output_dict` so far:\n{output_dict}" 

217 ) 

218 output_dict[str(elem)] = key 

219 elif isinstance(value, dict): 

220 interim_dict: dict[str, Any] = dict_reverse_keys_and_values(value) 

221 output_dict = { 

222 **output_dict, 

223 **interim_dict, 

224 } 

225 return output_dict 

226 

227 

228# ---------------------------------------------------------------------------- # 

229# # 

230# Use dot-methods to access values #### 

231# # 

232# ---------------------------------------------------------------------------- # 

233 

234 

235class DotDict(dict): 

236 """ 

237 !!! note "Summary" 

238 Dictionary subclass that allows dot notation access to keys. 

239 

240 !!! abstract "Details" 

241 Nested dictionaries are automatically converted to DotDict instances. 

242 

243 ???+ example "Examples" 

244 ```pycon {.py .python linenums="1" title="Set up"} 

245 >>> # Imports 

246 >>> from toolbox_python.dictionaries import DotDict 

247 >>> 

248 >>> # Create a DotDict 

249 >>> dot_dict = DotDict({"a": 1, "b": {"c": 2}}) 

250 ``` 

251 

252 ```pycon {.py .python linenums="1" title="Example 1: Accessing values with dot notation"} 

253 >>> print(dot_dict.a) 

254 ``` 

255 <div class="result" markdown> 

256 ```{.sh .shell title="Output"} 

257 1 

258 ``` 

259 !!! success "Conclusion: Successfully accessed value using dot notation." 

260 </div> 

261 

262 ```pycon {.py .python linenums="1" title="Example 2: Accessing nested values with dot notation"} 

263 >>> print(dot_dict.b.c) 

264 ``` 

265 <div class="result" markdown> 

266 ```{.sh .shell title="Output"} 

267 2 

268 ``` 

269 !!! success "Conclusion: Successfully accessed nested value using dot notation." 

270 </div> 

271 

272 ```pycon {.py .python linenums="1" title="Example 3: Setting values with dot notation"} 

273 >>> dot_dict.d = 3 

274 >>> print(dot_dict.d) 

275 ``` 

276 <div class="result" markdown> 

277 ```{.sh .shell title="Output"} 

278 3 

279 ``` 

280 !!! success "Conclusion: Successfully set value using dot notation." 

281 </div> 

282 

283 ```pycon {.py .python linenums="1" title="Example 4: Updating nested values with dot notation"} 

284 >>> dot_dict.b.e = 4 

285 >>> print(dot_dict.b.e) 

286 ``` 

287 <div class="result" markdown> 

288 ```{.sh .shell title="Output"} 

289 4 

290 ``` 

291 !!! success "Conclusion: Successfully updated nested value using dot notation." 

292 </div> 

293 

294 ```pycon {.py .python linenums="1" title="Example 5: Converting back to regular dict"} 

295 >>> regular_dict = dot_dict.to_dict() 

296 >>> print(regular_dict) 

297 ``` 

298 <div class="result" markdown> 

299 ```{.sh .shell title="Output"} 

300 {'a': 1, 'b': {'c': 2, 'e': 4}, 'd': 3} 

301 ``` 

302 !!! success "Conclusion: Successfully converted DotDict back to regular dict." 

303 </div> 

304 """ 

305 

306 def __init__(self, *args: Any, **kwargs: Any) -> None: 

307 dict.__init__(self) 

308 d = dict(*args, **kwargs) 

309 for key, value in d.items(): 

310 self[key] = self._convert_value(value) 

311 

312 def _convert_value(self, value: Any): 

313 """ 

314 !!! note "Summary" 

315 Convert dictionary values recursively. 

316 

317 Params: 

318 value (Any): 

319 The value to convert. 

320 

321 Returns: 

322 (Any): 

323 The converted value. 

324 """ 

325 if isinstance(value, dict): 

326 return DotDict(value) 

327 elif isinstance(value, list): 

328 return list(self._convert_value(item) for item in value) 

329 elif isinstance(value, tuple): 

330 return tuple(self._convert_value(item) for item in value) 

331 elif isinstance(value, set): 

332 return {self._convert_value(item) for item in value} 

333 return value 

334 

335 def __getattr__(self, key: str) -> Any: 

336 """ 

337 !!! note "Summary" 

338 Allow dictionary keys to be accessed as attributes. 

339 

340 Params: 

341 key (str): 

342 The key to access. 

343 

344 Raises: 

345 (AttributeError): 

346 If the key does not exist in the dictionary. 

347 

348 Returns: 

349 (Any): 

350 The value associated with the key. 

351 """ 

352 try: 

353 return self[key] 

354 except KeyError as e: 

355 raise AttributeError(f"Key not found: '{key}'") from e 

356 

357 def __setattr__(self, key: str, value: Any) -> None: 

358 """ 

359 !!! note "Summary" 

360 Allow setting dictionary keys via attributes. 

361 

362 Params: 

363 key (str): 

364 The key to set. 

365 value (Any): 

366 The value to set. 

367 

368 Returns: 

369 (None): 

370 This function does not return a value. It sets the key-value pair in the dictionary. 

371 """ 

372 self[key] = value 

373 

374 def __setitem__(self, key: str, value: Any) -> None: 

375 """ 

376 !!! note "Summary" 

377 Intercept item setting to convert dictionaries. 

378 

379 Params: 

380 key (str): 

381 The key to set. 

382 value (Any): 

383 The value to set. 

384 

385 Returns: 

386 (None): 

387 This function does not return a value. It sets the key-value pair in the dictionary. 

388 """ 

389 dict.__setitem__(self, key, self._convert_value(value)) 

390 

391 def __delitem__(self, key: str) -> None: 

392 """ 

393 !!! note "Summary" 

394 Intercept item deletion to remove keys. 

395 

396 Params: 

397 key (str): 

398 The key to delete. 

399 

400 Raises: 

401 (KeyError): 

402 If the key does not exist in the dictionary. 

403 

404 Returns: 

405 (None): 

406 This function does not return a value. It deletes the key-value pair from the dictionary. 

407 """ 

408 try: 

409 dict.__delitem__(self, key) 

410 except KeyError as e: 

411 raise KeyError(f"Key not found: '{key}'.") from e 

412 

413 def __delattr__(self, key: str) -> None: 

414 """ 

415 !!! note "Summary" 

416 Allow deleting dictionary keys via attributes. 

417 

418 Params: 

419 key (str): 

420 The key to delete. 

421 

422 Raises: 

423 (AttributeError): 

424 If the key does not exist in the dictionary. 

425 

426 Returns: 

427 (None): 

428 This function does not return a value. It deletes the key-value pair from the dictionary. 

429 """ 

430 try: 

431 del self[key] 

432 except KeyError as e: 

433 raise AttributeError(f"Key not found: '{key}'") from e 

434 

435 def update(self, *args: Any, **kwargs: Any) -> None: 

436 """ 

437 !!! note "Summary" 

438 Override update to convert new values. 

439 

440 Params: 

441 args (Any): 

442 Variable length argument list. 

443 kwargs (Any): 

444 Arbitrary keyword arguments. 

445 

446 Returns: 

447 (None): 

448 This function does not return a value. It updates the dictionary with new key-value pairs. 

449 

450 ???+ example "Examples" 

451 ```pycon {.py .python linenums="1" title="Update DotDict"} 

452 >>> dot_dict = DotDict({"a": 1, "b": 2}) 

453 >>> dot_dict.update({"c": 3, "d": {"e": 4}}) 

454 >>> print(dot_dict) 

455 ``` 

456 <div class="result" markdown> 

457 ```{.sh .shell title="Output"} 

458 {'a': 1, 'b': 2, 'c': 3, 'd': {'e': 4}} 

459 ``` 

460 !!! success "Conclusion: Successfully updated DotDict with new values." 

461 </div> 

462 """ 

463 for k, v in dict(*args, **kwargs).items(): 

464 self[k] = v 

465 

466 def to_dict(self) -> Any: 

467 """ 

468 !!! note "Summary" 

469 Convert back to regular dictionary. 

470 

471 Returns: 

472 (Any): 

473 The original dictionary structure, with all nested `#!py DotDict` instances converted back to regular dictionaries. 

474 

475 ???+ example "Examples" 

476 ```pycon {.py .python linenums="1" title="Convert DotDict to regular dict"} 

477 >>> dot_dict = DotDict({"a": 1, "b": {"c": 2}}) 

478 >>> regular_dict = dot_dict.to_dict() 

479 >>> print(regular_dict) 

480 ``` 

481 <div class="result" markdown> 

482 ```{.sh .shell title="Output"} 

483 {'a': 1, 'b': {'c': 2}} 

484 ``` 

485 !!! success "Conclusion: Successfully converted DotDict back to regular dict." 

486 </div> 

487 """ 

488 

489 def _convert_back(obj) -> Any: 

490 if isinstance(obj, DotDict): 

491 return {k: _convert_back(v) for k, v in obj.items()} 

492 elif isinstance(obj, list): 

493 return list(_convert_back(item) for item in obj) 

494 elif isinstance(obj, tuple): 

495 return tuple(_convert_back(item) for item in obj) 

496 elif isinstance(obj, set): 

497 return {_convert_back(item) for item in obj} 

498 return obj 

499 

500 return _convert_back(self)