Skip to content

Commit 9cf8afe

Browse files
authored
Merge pull request #121 from CitrineInformatics/bugfix/insensitive-pop
Make CaseInsensitiveDict delete obsolete keys
2 parents d2cedb6 + 2abe60e commit 9cf8afe

File tree

3 files changed

+204
-7
lines changed

3 files changed

+204
-7
lines changed

gemd/entity/case_insensitive_dict.py

Lines changed: 123 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
from typing import Tuple, Sequence, Any, Mapping, Optional
2+
3+
_RaiseKeyError = object() # singleton for no-default behavior
4+
5+
16
class CaseInsensitiveDict(dict):
27
"""
38
A dictionary in which the keys are case-insensitive.
@@ -17,16 +22,16 @@ class CaseInsensitiveDict(dict):
1722
1823
"""
1924

20-
def __init__(self, seq=None, **kwargs):
25+
def __init__(self, seq: Sequence = None, **kwargs) -> None:
2126
super().__init__(seq or {}, **kwargs)
2227
self.lowercase_dict = {}
2328
for key in self:
2429
self._register_key(key)
2530

26-
def __getitem__(self, key: str):
31+
def __getitem__(self, key: str) -> Any:
2732
return super().__getitem__(self.lowercase_dict[key.lower()])
2833

29-
def get(self, key: str, default=None):
34+
def get(self, key: str, default: Any = None) -> Any:
3035
"""
3136
Get the value for a given case-insensitive key.
3237
@@ -50,14 +55,126 @@ def get(self, key: str, default=None):
5055
else:
5156
return default
5257

53-
def __setitem__(self, key: str, value):
58+
def __setitem__(self, key: str, value: Any) -> None:
5459
self._register_key(key)
5560
super().__setitem__(key, value)
5661

57-
def __contains__(self, key: str):
62+
def __contains__(self, key: str) -> bool:
5863
return self.lowercase_dict.__contains__(key.lower())
5964

60-
def _register_key(self, key: str):
65+
def __delitem__(self, key) -> None:
66+
key = self.lowercase_dict.get(key.lower(), key)
67+
super().__delitem__(key)
68+
del self.lowercase_dict[key.lower()]
69+
70+
def clear(self) -> None:
71+
"""Remove all items from the dictionary."""
72+
super().clear()
73+
self.lowercase_dict.clear()
74+
75+
def pop(self, key: str, default=_RaiseKeyError) -> Any:
76+
"""
77+
Remove and return the value for a given key from the dictionary.
78+
79+
If key is in the dictionary, remove it and return its value, else return default.
80+
If default is not given and key is not in the dictionary, a KeyError is raised.
81+
82+
Parameters
83+
----------
84+
key: str
85+
The key to look up (possibly with a different casing).
86+
87+
default: Any
88+
The result to return if the key is not present.
89+
90+
Returns
91+
-------
92+
Any
93+
The value associated with the case-insensitive version of `key`, or None
94+
if `key` is not present.
95+
96+
"""
97+
if default is _RaiseKeyError:
98+
if key not in self:
99+
raise KeyError(key)
100+
val = super().pop(self.lowercase_dict[key.lower()])
101+
else:
102+
val = super().pop(self.lowercase_dict.get(key.lower()), default)
103+
if key in self:
104+
del self.lowercase_dict[key.lower()]
105+
return val
106+
107+
def popitem(self) -> Tuple:
108+
"""
109+
Remove and return a (key, value) pair from the dictionary.
110+
111+
popitem() is useful to destructively iterate over a dictionary, as often used
112+
in set algorithms. If the dictionary is empty, calling popitem() raises a
113+
KeyError.
114+
115+
Changed in version 3.7: LIFO order is now guaranteed. In prior versions,
116+
popitem() would return an arbitrary key/value pair.
117+
118+
Returns
119+
-------
120+
Tuple(str, Any)
121+
The key-value pair
122+
123+
"""
124+
result = super().popitem()
125+
del self.lowercase_dict[result[0].lower()]
126+
return result
127+
128+
def copy(self) -> 'CaseInsensitiveDict':
129+
"""
130+
Return a shallow copy of the dictionary.
131+
132+
Returns
133+
-------
134+
CaseInsensitiveDict
135+
A duplicate of the dictionary
136+
137+
"""
138+
return CaseInsensitiveDict(super().copy())
139+
140+
def update(self, mapping: Optional[Mapping[str, Any]] = None, **kwargs) -> None:
141+
"""
142+
Update the dictionary with the key/value pairs from other, overwriting existing keys.
143+
144+
update() accepts either another dictionary object or an iterable of
145+
key/value pairs (as tuples or other iterables of length two). If keyword
146+
arguments are specified, the dictionary is then updated with those
147+
key/value pairs: d.update(red=1, blue=2).
148+
149+
Parameters
150+
----------
151+
mapping: Mapping
152+
The set of (key, value) pairs to store
153+
154+
kwargs: (str, Any)
155+
Alternatively, the set of keyword arguments
156+
157+
"""
158+
if mapping is None:
159+
mapping = dict()
160+
no_mapping = True
161+
else:
162+
no_mapping = False
163+
for key in list(mapping.keys()) + list(kwargs.keys()):
164+
if key.lower() in self.lowercase_dict:
165+
prev = self.lowercase_dict[key.lower()]
166+
if prev != key:
167+
raise ValueError(
168+
"Key '{}' already exists in dict with different case: "
169+
"'{}'".format(key, prev))
170+
if no_mapping:
171+
super().update(**kwargs)
172+
else:
173+
super().update(mapping, **kwargs)
174+
for key in list(mapping.keys()) + list(kwargs.keys()):
175+
self._register_key(key)
176+
177+
def _register_key(self, key: str) -> None:
61178
"""
62179
Register a key to the dictionary.
63180

gemd/entity/tests/test_case_insensitive_dict.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,83 @@ def test_contains():
5656
assert k in data_dict
5757

5858
assert 'not_a_key' not in data_dict
59+
60+
61+
def test_all_dict_methods():
62+
"""Tests checking consistency of all standard dictionary methods."""
63+
# __init__
64+
data = {'K' + x: 'V' + x for x in ('1', '2', '3', '4', '5')}
65+
ci_dict = CaseInsensitiveDict(**data)
66+
67+
assert sorted(list(ci_dict)) == sorted(list(data))
68+
assert len(ci_dict) == len(data)
69+
70+
# __getitem__
71+
assert ci_dict['K1'] == data['K1']
72+
73+
# __setitem__
74+
ci_dict['K6'] = 'V6'
75+
assert ci_dict['K6'] == 'V6'
76+
77+
# __delitem__
78+
del ci_dict['K6']
79+
assert 'K6' not in ci_dict
80+
81+
# iter(d)
82+
ci_iter = iter(ci_dict)
83+
for k in ci_iter:
84+
assert k in data
85+
86+
# clear
87+
ci_dict.clear()
88+
assert len(ci_dict) == 0
89+
assert len(ci_dict.lowercase_dict) == 0
90+
for k, v in data.items():
91+
ci_dict[k.lower()] = v.lower()
92+
93+
# copy
94+
dup = ci_dict.copy()
95+
assert type(dup) == type(ci_dict)
96+
97+
# fromkeys
98+
key_copy = CaseInsensitiveDict.fromkeys(dup)
99+
assert set(dup) == set(key_copy)
100+
assert type(dup) == type(key_copy)
101+
102+
# get
103+
assert ci_dict.get('K1') == 'v1'
104+
assert ci_dict.get('K6', None) is None
105+
106+
# items
107+
for k, v in ci_dict.items():
108+
assert data[k.upper()] == v.upper()
109+
110+
# keys
111+
for k in ci_dict.keys():
112+
assert k not in data # because the cases are all wrong
113+
114+
# pop
115+
assert ci_dict.pop('k1') == 'v1'
116+
assert 'K1' not in ci_dict
117+
with pytest.raises(KeyError):
118+
ci_dict.pop('k1')
119+
assert ci_dict.pop('k1', None) is None
120+
121+
# popitem
122+
pop_k, pop_v = ci_dict.popitem()
123+
assert pop_k not in ci_dict
124+
125+
# setdefault
126+
assert ci_dict.setdefault(pop_k.upper(), pop_v.upper()) == pop_v.upper()
127+
assert ci_dict.setdefault(pop_k.upper(), pop_v.lower()) == pop_v.upper()
128+
129+
# update
130+
ci_dict.update({pop_k.upper(): pop_v.lower(), 'K6': 'v6'})
131+
ci_dict.update(K6='V6')
132+
with pytest.raises(ValueError):
133+
ci_dict.update(k6='v6')
134+
with pytest.raises(ValueError):
135+
ci_dict.update({k.lower(): v for k, v in ci_dict.items()})
136+
137+
# values
138+
assert 'V6' in ci_dict.values()

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def run(self):
3636

3737

3838
setup(name='gemd',
39-
version='0.14.0',
39+
version='0.14.1',
4040
url='http://github.com/CitrineInformatics/gemd-python',
4141
description="Python binding for Citrine's GEMD data model",
4242
author='Max Hutchinson',

0 commit comments

Comments
 (0)