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
« 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# ============================================================================ #
9# ---------------------------------------------------------------------------- #
10# #
11# Overview ####
12# #
13# ---------------------------------------------------------------------------- #
16# ---------------------------------------------------------------------------- #
17# Description ####
18# ---------------------------------------------------------------------------- #
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"""
29# ---------------------------------------------------------------------------- #
30# #
31# Setup ####
32# #
33# ---------------------------------------------------------------------------- #
36# ---------------------------------------------------------------------------- #
37# Imports ####
38# ---------------------------------------------------------------------------- #
41# ## Python StdLib Imports ----
42from typing import Any
44# ## Python Third Party Imports ----
45from typeguard import typechecked
48# ---------------------------------------------------------------------------- #
49# Exports ####
50# ---------------------------------------------------------------------------- #
53__all__: list[str] = ["dict_reverse_keys_and_values", "DotDict"]
56# ---------------------------------------------------------------------------- #
57# #
58# Swap Keys & Values ####
59# #
60# ---------------------------------------------------------------------------- #
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.
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.
72 Params:
73 dictionary (Dict[Any, Any]):
74 The input `#!py dict` that you'd like to have the `keys` and `values` switched.
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.
82 Returns:
83 output_dict (Dict[str,Any]):
84 The updated `#!py dict`.
86 ???+ example "Examples"
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 ```
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>
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>
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>
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
228# ---------------------------------------------------------------------------- #
229# #
230# Use dot-methods to access values ####
231# #
232# ---------------------------------------------------------------------------- #
235class DotDict(dict):
236 """
237 !!! note "Summary"
238 Dictionary subclass that allows dot notation access to keys.
240 !!! abstract "Details"
241 Nested dictionaries are automatically converted to DotDict instances.
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 ```
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>
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>
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>
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>
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 """
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)
312 def _convert_value(self, value: Any):
313 """
314 !!! note "Summary"
315 Convert dictionary values recursively.
317 Params:
318 value (Any):
319 The value to convert.
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
335 def __getattr__(self, key: str) -> Any:
336 """
337 !!! note "Summary"
338 Allow dictionary keys to be accessed as attributes.
340 Params:
341 key (str):
342 The key to access.
344 Raises:
345 (AttributeError):
346 If the key does not exist in the dictionary.
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
357 def __setattr__(self, key: str, value: Any) -> None:
358 """
359 !!! note "Summary"
360 Allow setting dictionary keys via attributes.
362 Params:
363 key (str):
364 The key to set.
365 value (Any):
366 The value to set.
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
374 def __setitem__(self, key: str, value: Any) -> None:
375 """
376 !!! note "Summary"
377 Intercept item setting to convert dictionaries.
379 Params:
380 key (str):
381 The key to set.
382 value (Any):
383 The value to set.
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))
391 def __delitem__(self, key: str) -> None:
392 """
393 !!! note "Summary"
394 Intercept item deletion to remove keys.
396 Params:
397 key (str):
398 The key to delete.
400 Raises:
401 (KeyError):
402 If the key does not exist in the dictionary.
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
413 def __delattr__(self, key: str) -> None:
414 """
415 !!! note "Summary"
416 Allow deleting dictionary keys via attributes.
418 Params:
419 key (str):
420 The key to delete.
422 Raises:
423 (AttributeError):
424 If the key does not exist in the dictionary.
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
435 def update(self, *args: Any, **kwargs: Any) -> None:
436 """
437 !!! note "Summary"
438 Override update to convert new values.
440 Params:
441 args (Any):
442 Variable length argument list.
443 kwargs (Any):
444 Arbitrary keyword arguments.
446 Returns:
447 (None):
448 This function does not return a value. It updates the dictionary with new key-value pairs.
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
466 def to_dict(self) -> Any:
467 """
468 !!! note "Summary"
469 Convert back to regular dictionary.
471 Returns:
472 (Any):
473 The original dictionary structure, with all nested `#!py DotDict` instances converted back to regular dictionaries.
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 """
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
500 return _convert_back(self)