Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1#!/usr/bin/env python 

2# cardinal_pythonlib/dicts.py 

3 

4""" 

5=============================================================================== 

6 

7 Original code copyright (C) 2009-2021 Rudolf Cardinal (rudolf@pobox.com). 

8 

9 This file is part of cardinal_pythonlib. 

10 

11 Licensed under the Apache License, Version 2.0 (the "License"); 

12 you may not use this file except in compliance with the License. 

13 You may obtain a copy of the License at 

14 

15 https://www.apache.org/licenses/LICENSE-2.0 

16 

17 Unless required by applicable law or agreed to in writing, software 

18 distributed under the License is distributed on an "AS IS" BASIS, 

19 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 

20 See the License for the specific language governing permissions and 

21 limitations under the License. 

22 

23=============================================================================== 

24 

25**Dictionary manipulations.** 

26 

27""" 

28 

29from typing import Any, Callable, Dict, Hashable, List, Optional 

30 

31 

32# ============================================================================= 

33# Dictionaries 

34# ============================================================================= 

35 

36def get_case_insensitive_dict_key(d: Dict, k: str) -> Optional[str]: 

37 """ 

38 Within the dictionary ``d``, find a key that matches (in case-insensitive 

39 fashion) the key ``k``, and return it (or ``None`` if there isn't one). 

40 """ 

41 for key in d.keys(): 

42 if k.lower() == key.lower(): 

43 return key 

44 return None 

45 

46 

47def merge_dicts(*dict_args: Dict) -> Dict: 

48 """ 

49 Given any number of dicts, shallow-copy them and merge into a new dict. 

50 Precedence goes to key/value pairs in dicts that are later in the list. 

51 

52 See https://stackoverflow.com/questions/38987. 

53 """ 

54 result = {} 

55 for dictionary in dict_args: 

56 result.update(dictionary) 

57 return result 

58 

59 

60def merge_two_dicts(x: Dict, y: Dict) -> Dict: 

61 """ 

62 Given two dicts, merge them into a new dict as a shallow copy, e.g. 

63 

64 .. code-block:: python 

65 

66 z = merge_two_dicts(x, y) 

67 

68 If you can guarantee Python 3.5, then a simpler syntax is: 

69 

70 .. code-block:: python 

71 

72 z = {**x, **y} 

73 

74 See https://stackoverflow.com/questions/38987. 

75 """ 

76 z = x.copy() 

77 z.update(y) 

78 return z 

79 

80 

81def rename_key(d: Dict[str, Any], old: str, new: str) -> None: 

82 """ 

83 Rename a key in dictionary ``d`` from ``old`` to ``new``, in place. 

84 """ 

85 d[new] = d.pop(old) 

86 

87 

88def rename_keys(d: Dict[str, Any], mapping: Dict[str, str]) -> Dict[str, Any]: 

89 """ 

90 Returns a copy of the dictionary ``d`` with its keys renamed according to 

91 ``mapping``. 

92 

93 Args: 

94 d: the starting dictionary 

95 mapping: a dictionary of the format ``{old_key_name: new_key_name}`` 

96 

97 Returns: 

98 a new dictionary 

99 

100 Keys that are not in ``mapping`` are left unchanged. 

101 The input parameters are not modified. 

102 """ 

103 result = {} # type: Dict[str, Any] 

104 for k, v in d.items(): 

105 if k in mapping: 

106 k = mapping[k] 

107 result[k] = v 

108 return result 

109 

110 

111def rename_keys_in_dict(d: Dict[str, Any], renames: Dict[str, str]) -> None: 

112 """ 

113 Renames, IN PLACE, the keys in ``d`` according to the mapping in 

114 ``renames``. 

115  

116 Args: 

117 d: a dictionary to modify  

118 renames: a dictionary of the format ``{old_key_name: new_key_name}`` 

119  

120 See 

121 https://stackoverflow.com/questions/4406501/change-the-name-of-a-key-in-dictionary. 

122 """ # noqa 

123 for old_key, new_key in renames.items(): 

124 if new_key == old_key: 

125 continue 

126 if old_key in d: 

127 if new_key in d: 

128 raise ValueError( 

129 f"rename_keys_in_dict: renaming {old_key!r} -> " 

130 f"{new_key!r} but new key already exists") 

131 d[new_key] = d.pop(old_key) 

132 

133 

134def prefix_dict_keys(d: Dict[str, Any], prefix: str) -> Dict[str, Any]: 

135 """ 

136 Returns a dictionary that's a copy of as ``d`` but with ``prefix`` 

137 prepended to its keys. 

138 """ 

139 result = {} # type: Dict[str, Any] 

140 for k, v in d.items(): 

141 result[prefix + k] = v 

142 return result 

143 

144 

145def reversedict(d: Dict[Any, Any]) -> Dict[Any, Any]: 

146 """ 

147 Takes a ``k -> v`` mapping and returns a ``v -> k`` mapping. 

148 """ 

149 return {v: k for k, v in d.items()} 

150 

151 

152def set_null_values_in_dict(d: Dict[str, Any], 

153 null_literals: List[Any]) -> None: 

154 """ 

155 Within ``d`` (in place), replace any values found in ``null_literals`` with 

156 ``None``. 

157 """ 

158 if not null_literals: 

159 return 

160 # DO NOT add/delete values to/from a dictionary during iteration, but it 

161 # is OK to modify existing keys: 

162 # https://stackoverflow.com/questions/6777485 

163 # https://stackoverflow.com/questions/2315520 

164 # https://docs.python.org/3/library/stdtypes.html#dict-views 

165 for k, v in d.items(): 

166 if v in null_literals: 

167 d[k] = None 

168 

169 

170# noinspection PyPep8 

171def map_keys_to_values(keys: List[Any], d: Dict[Any, Any], default: Any = None, 

172 raise_if_missing: bool = False, 

173 omit_if_missing: bool = False) -> List[Any]: 

174 """ 

175 The ``d`` dictionary contains a ``key -> value`` mapping. 

176 

177 We start with a list of potential keys in ``keys``, and return a list of 

178 corresponding values -- substituting ``default`` if any are missing, 

179 or raising :exc:`KeyError` if ``raise_if_missing`` is true, or omitting the 

180 entry if ``omit_if_missing`` is true. 

181 """ 

182 result = [] 

183 for k in keys: 

184 if raise_if_missing and k not in d: 

185 raise ValueError("Missing key: " + repr(k)) 

186 if omit_if_missing and k not in d: 

187 continue 

188 result.append(d.get(k, default)) 

189 return result 

190 

191 

192def dict_diff(d1: Dict[Any, Any], d2: Dict[Any, Any], 

193 deleted_value: Any = None) -> Dict[Any, Any]: 

194 """ 

195 Returns a representation of the changes that need to be made to ``d1`` to 

196 create ``d2``. 

197 

198 Args: 

199 d1: a dictionary 

200 d2: another dictionary 

201 deleted_value: value to use for deleted keys; see below 

202 

203 Returns: 

204 dict: a dictionary of the format ``{k: v}`` where the ``k``/``v`` pairs 

205 are key/value pairs that are absent from ``d1`` and present in ``d2``, 

206 or present in both but with different values (in which case the ``d2`` 

207 value is shown). If a key ``k`` is present in ``d1`` but absent in 

208 ``d2``, the result dictionary has the entry ``{k: deleted_value}``. 

209 

210 """ 

211 changes = {k: v for k, v in d2.items() 

212 if k not in d1 or d2[k] != d1[k]} 

213 for k in d1.keys(): 

214 if k not in d2: 

215 changes[k] = deleted_value 

216 return changes 

217 

218 

219def delete_keys(d: Dict[Any, Any], 

220 keys_to_delete: List[Any], 

221 keys_to_keep: List[Any]) -> None: 

222 """ 

223 Deletes keys from a dictionary, in place. 

224 

225 Args: 

226 d: 

227 dictonary to modify 

228 keys_to_delete: 

229 if any keys are present in this list, they are deleted... 

230 keys_to_keep: 

231 ... unless they are present in this list. 

232 """ 

233 for k in keys_to_delete: 

234 if k in d and k not in keys_to_keep: 

235 del d[k] 

236 

237 

238# ============================================================================= 

239# Lazy dictionaries 

240# ============================================================================= 

241 

242class LazyDict(dict): 

243 """ 

244 A dictionary that only evaluates the argument to :func:`setdefault` or 

245 :func:`get` if it needs to. 

246  

247 See 

248 https://stackoverflow.com/questions/17532929/how-to-implement-a-lazy-setdefault. 

249  

250 The ``*args``/``**kwargs`` parts are useful, but we don't want to have to 

251 name 'thunk' explicitly. 

252 """ # noqa 

253 def get(self, key: Hashable, thunk: Any = None, 

254 *args: Any, **kwargs: Any) -> Any: 

255 if key in self: 

256 return self[key] 

257 elif callable(thunk): 

258 return thunk(*args, **kwargs) 

259 else: 

260 return thunk 

261 

262 def setdefault(self, key: Hashable, thunk: Any = None, 

263 *args: Any, **kwargs: Any) -> Any: 

264 if key in self: 

265 return self[key] 

266 elif callable(thunk): 

267 return dict.setdefault(self, key, thunk(*args, **kwargs)) 

268 else: 

269 return dict.setdefault(self, key, thunk) 

270 

271 

272class LazyButHonestDict(dict): 

273 """ 

274 A dictionary that provides alternatives to :func:`get` and 

275 :func:`setdefault`, namely :func:`lazyget` and :func:`lazysetdefault`, 

276 that only evaluate their arguments if they have to. 

277 

278 See 

279 https://stackoverflow.com/questions/17532929/how-to-implement-a-lazy-setdefault. 

280 

281 Compared to the StackOverflow version: no obvious need to have a default 

282 returning ``None``, when we're implementing this as a special function. 

283 In contrast, helpful to have ``*args``/``**kwargs`` options. 

284 """ # noqa 

285 def lazyget(self, key: Hashable, thunk: Callable, 

286 *args: Any, **kwargs: Any) -> Any: 

287 if key in self: 

288 return self[key] 

289 else: 

290 return thunk(*args, **kwargs) 

291 

292 def lazysetdefault(self, key: Hashable, thunk: Callable, 

293 *args: Any, **kwargs: Any) -> Any: 

294 if key in self: 

295 return self[key] 

296 else: 

297 return self.setdefault(key, thunk(*args, **kwargs)) 

298 

299 

300# ============================================================================= 

301# HashableDict 

302# ============================================================================= 

303 

304class HashableDict(dict): 

305 """ 

306 A dictionary that can be hashed. 

307 

308 See https://stackoverflow.com/questions/1151658/python-hashable-dicts. 

309 """ 

310 def __hash__(self) -> int: 

311 return hash(tuple(sorted(self.items()))) 

312 

313 

314# ============================================================================= 

315# CaseInsensitiveDict 

316# ============================================================================= 

317 

318class CaseInsensitiveDict(dict): 

319 """ 

320 A case-insensitive dictionary, as per 

321 https://stackoverflow.com/questions/2082152/case-insensitive-dictionary/32888599#32888599, 

322 with updates for Python 3 and type hinting. 

323  

324 See also 

325  

326 - https://docs.python.org/3/tutorial/datastructures.html#dictionaries 

327 - https://docs.python.org/3/library/stdtypes.html#mapping-types-dict 

328  

329 Test code: 

330  

331 .. code-block:: python 

332  

333 from cardinal_pythonlib.dicts import CaseInsensitiveDict 

334  

335 d1 = CaseInsensitiveDict() # d1 is now: {} 

336 d2 = CaseInsensitiveDict({'A': 1, 'b': 2}) # d2 is now: {'a': 1, 'b': 2} 

337 d3 = CaseInsensitiveDict(C=3, d=4) # d3 is now: {'c': 3, 'd': 4} 

338  

339 d1.update({'E': 5, 'f': 6}) # d1 is now: {'e': 5, 'f': 6} 

340 d1.update(G=7, h=8) # d1 is now: {'e': 5, 'f': 6, 'g': 7, 'h': 8} 

341 'H' in d1 # True 

342 d1['I'] = 9 # None, and key 'i' added 

343 del d1['I'] # None, and key 'i' deleted 

344 d1.pop('H') # 8 

345 d1.get('E') # 5 

346 d1.get('Z') # None 

347 d1.setdefault('J', 10) # 10, and key 'j' added 

348 d1.update([('K', 11), ('L', 12)]) 

349 d1 # {'e': 5, 'f': 6, 'g': 7, 'j': 10, 'k': 11, 'l': 12} 

350 

351 """ # noqa 

352 

353 @classmethod 

354 def _k(cls, key: Any) -> Any: 

355 """ 

356 Convert key to lower case, if it's a string. 

357 """ 

358 return key.lower() if isinstance(key, str) else key 

359 

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

361 """ 

362 Dictionary initialization. 

363 

364 - Optional positional argument is ``mapping`` or ``iterable``. If an 

365 iterable, its elements are iterables of length 2. 

366 (Note that passing ``None`` is different from not passing anything, 

367 hence the signature. The type of the first argument, if present, is 

368 ``Union[Mapping, Iterable[Tuple[Any, Any]]]``.) 

369 - Keyword arguments are key/value pairs. 

370 """ 

371 super().__init__(*args, **kwargs) 

372 self._convert_keys() 

373 

374 def __getitem__(self, key: Any) -> Any: 

375 """ 

376 Given a key, return the associated value. Implements ``d[key]`` as an 

377 rvalue. 

378 """ 

379 return super().__getitem__(self.__class__._k(key)) 

380 

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

382 """ 

383 Sets the value for a key. Implements ``d[key] = value``. 

384 """ 

385 super().__setitem__(self.__class__._k(key), value) 

386 

387 def __delitem__(self, key: Any) -> None: 

388 """ 

389 Deletes the item with the specified key. Implements ``del d[key]``. 

390 Raises :exc:`KeyError` if absent. 

391 """ 

392 super().__delitem__(self.__class__._k(key)) 

393 

394 def __contains__(self, key: Any) -> bool: 

395 """ 

396 Is the key in the dictionary? Implements ``key in d``. 

397 """ 

398 return super().__contains__(self.__class__._k(key)) 

399 

400 # has_key() was removed in Python 3.0 

401 # https://docs.python.org/3.1/whatsnew/3.0.html#builtins 

402 

403 def pop(self, key: Any, *args, **kwargs) -> Any: 

404 """ 

405 Retrieves/returns the item and removes it. Takes a single optional 

406 argument, being the default to return if the key is not present 

407 (otherwise raises :exc:`KeyError`). Note that supplying a default of 

408 ``None`` is different to supplying no default. 

409 """ 

410 return super().pop(self.__class__._k(key), *args, **kwargs) 

411 

412 def get(self, key: Any, default: Any = None) -> Any: 

413 """ 

414 If the key is in the dictionary, return the corresponding value; 

415 otherwise, return ``default``, which defaults to ``None``. 

416 """ 

417 return super().get(self.__class__._k(key), default) 

418 

419 def setdefault(self, key: Any, default: Any = None) -> Any: 

420 """ 

421 As per the Python docs: 

422 

423 If ``key`` is in the dictionary, return its value. If not, insert 

424 ``key`` with a value of ``default`` and return ``default``. ``default`` 

425 defaults to ``None``. 

426 """ 

427 return super().setdefault(self.__class__._k(key), default) 

428 

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

430 """ 

431 As per the Python docs: 

432 

433 Update the dictionary with the key/value pairs from ``other``, 

434 overwriting existing keys. Return ``None``. 

435 

436 :func:`update``accepts either another dictionary object or an iterable 

437 of key/value pairs (as tuples or other iterables of length two). If 

438 keyword arguments are specified, the dictionary is then updated with 

439 those key/value pairs: ``d.update(red=1, blue=2)``. 

440 

441 ... so the type of the first argument, if present, is ``Union[Mapping, 

442 .Iterable[Tuple[Any, Any]]]``. 

443 """ 

444 # noinspection PyTypeChecker 

445 super().update(self.__class__(*args, **kwargs)) 

446 

447 def _convert_keys(self) -> None: 

448 """ 

449 Ensure all our keys are in lower case. 

450 """ 

451 for k in list(self.keys()): 

452 v = super().pop(k) 

453 self.__setitem__(k, v)