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

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# ## Local First Party Imports ---- 

48from toolbox_python.collection_types import dict_any, dict_str_any, str_list 

49 

50 

51# ---------------------------------------------------------------------------- # 

52# Exports #### 

53# ---------------------------------------------------------------------------- # 

54 

55__all__: str_list = ["dict_reverse_keys_and_values", "DotDict"] 

56 

57 

58# ---------------------------------------------------------------------------- # 

59# # 

60# Swap Keys & Values #### 

61# # 

62# ---------------------------------------------------------------------------- # 

63 

64 

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. 

72 

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. 

75 

76 Params: 

77 dictionary (dict_any): 

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

79 

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. 

83 

84 Returns: 

85 output_dict (dict_str_int): 

86 The updated `#!py dict`. 

87 

88 ???+ example "Examples" 

89 

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

128 

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> 

143 

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> 

167 

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> 

182 

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 

228 

229 

230# ---------------------------------------------------------------------------- # 

231# # 

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

233# # 

234# ---------------------------------------------------------------------------- # 

235 

236 

237class DotDict(dict): 

238 """ 

239 Dictionary subclass that allows dot notation access to keys. 

240 Nested dictionaries are automatically converted to DotDict instances. 

241 """ 

242 

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) 

248 

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 

260 

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 

267 

268 def __setattr__(self, key, value) -> None: 

269 """Allow setting dictionary keys via attributes.""" 

270 self[key] = value 

271 

272 def __setitem__(self, key, value) -> None: 

273 """Intercept item setting to convert dictionaries.""" 

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

275 

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 

282 

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 

289 

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 

294 

295 def to_dict(self) -> Any: 

296 """Convert back to regular dictionary.""" 

297 

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 

308 

309 return _convert_back(self)