Coverage for src/toolbox_python/dictionaries.py: 100%
69 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-06 07:32 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-06 07:32 +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
47# ## Local First Party Imports ----
48from toolbox_python.collection_types import dict_any, dict_str_any, str_list
51# ---------------------------------------------------------------------------- #
52# Exports ####
53# ---------------------------------------------------------------------------- #
55__all__: str_list = ["dict_reverse_keys_and_values", "DotDict"]
58# ---------------------------------------------------------------------------- #
59# #
60# Swap Keys & Values ####
61# #
62# ---------------------------------------------------------------------------- #
65@typechecked
66def dict_reverse_keys_and_values(
67 dictionary: dict_any,
68) -> dict_str_any:
69 """
70 !!! note "Summary"
71 Take the `key` and `values` of a dictionary, and reverse them.
73 ???+ info "Details"
74 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.
76 Params:
77 dictionary (dict_any):
78 The input `#!py dict` that you'd like to have the `keys` and `values` switched.
80 Raises:
81 TypeError: 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.
82 KeyError: 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.
84 Returns:
85 output_dict (dict_str_int):
86 The updated `#!py dict`.
88 ???+ example "Examples"
90 ```{.py .python linenums="1" title="Set up"}
91 >>> # Imports
92 >>> from toolbox_python.dictionaries import dict_reverse_keys_and_values
93 >>>
94 >>> # Basic dictionary
95 >>> dict_basic = {
96 ... "a": 1,
97 ... "b": 2,
98 ... "c": 3,
99 ... }
100 >>>
101 >>> # Dictionary with iterables
102 >>> dict_iterables = {
103 ... "a": ["1", "2", "3"],
104 ... "b": [4, 5, 6],
105 ... "c": ("7", "8", "9"),
106 ... "d": (10, 11, 12),
107 ... }
108 >>>
109 >>> # Dictionary with iterables and duplicates
110 >>> dict_iterables_with_duplicates = {
111 ... "a": [1, 2, 3],
112 ... "b": [4, 2, 5],
113 ... }
114 >>>
115 >>> # Dictionary with sub-dictionaries
116 >>> dict_with_dicts = {
117 ... "a": {
118 ... "aa": 11,
119 ... "bb": 22,
120 ... "cc": 33,
121 ... },
122 ... "b": {
123 ... "dd": [1, 2, 3],
124 ... "ee": ("4", "5", "6"),
125 ... },
126 ... }
127 ```
129 ```{.py .python linenums="1" title="Example 1: Reverse one-for-one"}
130 >>> print(dict_reverse_keys_and_values(dict_basic))
131 ```
132 <div class="result" markdown>
133 ```{.sh .shell title="Terminal"}
134 {
135 "1": "a",
136 "2": "b",
137 "3": "c",
138 }
139 ```
140 !!! success "Conclusion: Successful conversion."
141 !!! 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."
142 </div>
144 ```{.py .python linenums="1" title="Example 2: Reverse dictionary containing iterables in `values`"}
145 >>> print(dict_reverse_keys_and_values(dict_iterables))
146 ```
147 <div class="result" markdown>
148 ```{.sh .shell title="Terminal"}
149 {
150 "1": "a",
151 "2": "a",
152 "3": "a",
153 "4": "b",
154 "5": "b",
155 "6": "b",
156 "7": "c",
157 "8": "c",
158 "9": "c",
159 "10": "d",
160 "11": "d",
161 "12": "d",
162 }
163 ```
164 !!! success "Conclusion: Successful conversion."
165 !!! 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."
166 </div>
168 ```{.py .python linenums="1" title="Example 3: Dictionary with iterables, raise error when `key` already exists"}
169 >>> print(dict_reverse_keys_and_values(dict_iterables_with_duplicates))
170 ```
171 <div class="result" markdown>
172 ```{.sh .shell title="Terminal"}
173 KeyError: Key already existing.
174 Cannot update `output_dict` with new elements: {2: 'b'}
175 Because the key is already existing for: {'2': 'a'}
176 Full `output_dict` so far:
177 {'1': 'a', '2': 'a', '3': 'a', '4': 'b'}
178 ```
179 !!! failure "Conclusion: Failed conversion."
180 !!! 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."
181 </div>
183 ```{.py .python linenums="1" title="Example 4: Dictionary with embedded dictionaries"}
184 >>> print(dict_reverse_keys_and_values(dict_with_dicts))
185 ```
186 <div class="result" markdown>
187 ```{.sh .shell title="Terminal"}
188 {
189 "1": "a",
190 "2": "a",
191 "3": "a",
192 "4": "b",
193 "5": "b",
194 "6": "b",
195 "7": "c",
196 "8": "c",
197 "9": "c",
198 "10": "d",
199 "11": "d",
200 "12": "d",
201 }
202 ```
203 !!! success "Conclusion: Successful conversion."
204 !!! 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."
205 </div>
206 """
207 output_dict: dict_str_any = dict()
208 for key, value in dictionary.items():
209 if isinstance(value, (str, int, float)):
210 output_dict[str(value)] = key
211 elif isinstance(value, (tuple, list)):
212 for elem in value:
213 if str(elem) in output_dict.keys():
214 raise KeyError(
215 f"Key already existing.\n"
216 f"Cannot update `output_dict` with new elements: { {elem: key} }\n"
217 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"
218 f"Full `output_dict` so far:\n{output_dict}"
219 )
220 output_dict[str(elem)] = key
221 elif isinstance(value, dict):
222 interim_dict: dict_str_any = dict_reverse_keys_and_values(value)
223 output_dict = {
224 **output_dict,
225 **interim_dict,
226 }
227 return output_dict
230# ---------------------------------------------------------------------------- #
231# #
232# Use dot-methods to access values ####
233# #
234# ---------------------------------------------------------------------------- #
237class DotDict(dict):
238 """
239 Dictionary subclass that allows dot notation access to keys.
240 Nested dictionaries are automatically converted to DotDict instances.
241 """
243 def __init__(self, *args, **kwargs) -> None:
244 dict.__init__(self)
245 d = dict(*args, **kwargs)
246 for key, value in d.items():
247 self[key] = self._convert_value(value)
249 def _convert_value(self, value):
250 """Convert dictionary values recursively."""
251 if isinstance(value, dict):
252 return DotDict(value)
253 elif isinstance(value, list):
254 return list(self._convert_value(item) for item in value)
255 elif isinstance(value, tuple):
256 return tuple(self._convert_value(item) for item in value)
257 elif isinstance(value, set):
258 return set(self._convert_value(item) for item in value)
259 return value
261 def __getattr__(self, key) -> Any:
262 """Allow dictionary keys to be accessed as attributes."""
263 try:
264 return self[key]
265 except KeyError as e:
266 raise AttributeError(f"Key not found: '{key}'") from e
268 def __setattr__(self, key, value) -> None:
269 """Allow setting dictionary keys via attributes."""
270 self[key] = value
272 def __setitem__(self, key, value) -> None:
273 """Intercept item setting to convert dictionaries."""
274 dict.__setitem__(self, key, self._convert_value(value))
276 def __delitem__(self, key) -> None:
277 """Intercept item deletion to remove keys."""
278 try:
279 dict.__delitem__(self, key)
280 except KeyError as e:
281 raise KeyError(f"Key not found: '{key}'.") from e
283 def __delattr__(self, key) -> None:
284 """Allow deleting dictionary keys via attributes."""
285 try:
286 del self[key]
287 except KeyError as e:
288 raise AttributeError(f"Key not found: '{key}'") from e
290 def update(self, *args, **kwargs) -> None:
291 """Override update to convert new values."""
292 for k, v in dict(*args, **kwargs).items():
293 self[k] = v
295 def to_dict(self) -> Any:
296 """Convert back to regular dictionary."""
298 def _convert_back(obj) -> Any:
299 if isinstance(obj, DotDict):
300 return {k: _convert_back(v) for k, v in obj.items()}
301 elif isinstance(obj, list):
302 return list(_convert_back(item) for item in obj)
303 elif isinstance(obj, tuple):
304 return tuple(_convert_back(item) for item in obj)
305 elif isinstance(obj, set):
306 return set(_convert_back(item) for item in obj)
307 return obj
309 return _convert_back(self)