Coverage for /home/martinb/.local/share/virtualenvs/camcops/lib/python3.6/site-packages/cardinal_pythonlib/dicts.py : 31%

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
4"""
5===============================================================================
7 Original code copyright (C) 2009-2021 Rudolf Cardinal (rudolf@pobox.com).
9 This file is part of cardinal_pythonlib.
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
15 https://www.apache.org/licenses/LICENSE-2.0
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.
23===============================================================================
25**Dictionary manipulations.**
27"""
29from typing import Any, Callable, Dict, Hashable, List, Optional
32# =============================================================================
33# Dictionaries
34# =============================================================================
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
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.
52 See https://stackoverflow.com/questions/38987.
53 """
54 result = {}
55 for dictionary in dict_args:
56 result.update(dictionary)
57 return result
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.
64 .. code-block:: python
66 z = merge_two_dicts(x, y)
68 If you can guarantee Python 3.5, then a simpler syntax is:
70 .. code-block:: python
72 z = {**x, **y}
74 See https://stackoverflow.com/questions/38987.
75 """
76 z = x.copy()
77 z.update(y)
78 return z
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)
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``.
93 Args:
94 d: the starting dictionary
95 mapping: a dictionary of the format ``{old_key_name: new_key_name}``
97 Returns:
98 a new dictionary
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
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``.
116 Args:
117 d: a dictionary to modify
118 renames: a dictionary of the format ``{old_key_name: new_key_name}``
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)
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
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()}
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
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.
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
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``.
198 Args:
199 d1: a dictionary
200 d2: another dictionary
201 deleted_value: value to use for deleted keys; see below
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}``.
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
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.
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]
238# =============================================================================
239# Lazy dictionaries
240# =============================================================================
242class LazyDict(dict):
243 """
244 A dictionary that only evaluates the argument to :func:`setdefault` or
245 :func:`get` if it needs to.
247 See
248 https://stackoverflow.com/questions/17532929/how-to-implement-a-lazy-setdefault.
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
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)
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.
278 See
279 https://stackoverflow.com/questions/17532929/how-to-implement-a-lazy-setdefault.
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)
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))
300# =============================================================================
301# HashableDict
302# =============================================================================
304class HashableDict(dict):
305 """
306 A dictionary that can be hashed.
308 See https://stackoverflow.com/questions/1151658/python-hashable-dicts.
309 """
310 def __hash__(self) -> int:
311 return hash(tuple(sorted(self.items())))
314# =============================================================================
315# CaseInsensitiveDict
316# =============================================================================
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.
324 See also
326 - https://docs.python.org/3/tutorial/datastructures.html#dictionaries
327 - https://docs.python.org/3/library/stdtypes.html#mapping-types-dict
329 Test code:
331 .. code-block:: python
333 from cardinal_pythonlib.dicts import CaseInsensitiveDict
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}
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}
351 """ # noqa
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
360 def __init__(self, *args, **kwargs) -> None:
361 """
362 Dictionary initialization.
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()
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))
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)
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))
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))
400 # has_key() was removed in Python 3.0
401 # https://docs.python.org/3.1/whatsnew/3.0.html#builtins
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)
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)
419 def setdefault(self, key: Any, default: Any = None) -> Any:
420 """
421 As per the Python docs:
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)
429 def update(self, *args, **kwargs) -> None:
430 """
431 As per the Python docs:
433 Update the dictionary with the key/value pairs from ``other``,
434 overwriting existing keys. Return ``None``.
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)``.
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))
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)