Skip to content

Commit 849ca1d

Browse files
authored
[Core] Case Insensitive Dictionary Implementation (Azure#25074)
* cidict implementation * unit tests * expose CaseInsensitiveDict * changelog * fix typing issues * fix _iter_ mypy issue * pylint fix
1 parent 0fdb908 commit 849ca1d

File tree

4 files changed

+111
-23
lines changed

4 files changed

+111
-23
lines changed

sdk/core/azure-core/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ Azure-core is supported on Python 3.7 or later. For more details, please read ou
66

77
### Features Added
88

9+
- Added `CaseInsensitiveDict` implementation in `azure.core.utils` removing dependency on `requests` and `aiohttp`
10+
911
### Breaking Changes
1012

1113
### Bugs Fixed

sdk/core/azure-core/azure/core/utils/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,6 @@
3232
from ._connection_string_parser import (
3333
parse_connection_string
3434
)
35-
from ._utils import case_insensitive_dict
35+
from ._utils import case_insensitive_dict, CaseInsensitiveDict
3636

37-
__all__ = ["parse_connection_string", "case_insensitive_dict"]
37+
__all__ = ["parse_connection_string", "case_insensitive_dict", "CaseInsensitiveDict"]

sdk/core/azure-core/azure/core/utils/_utils.py

Lines changed: 54 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
# license information.
66
# --------------------------------------------------------------------------
77
import datetime
8-
from typing import Any, MutableMapping
8+
from typing import Any, Dict, Iterator, Mapping, MutableMapping
99

1010

1111
class _FixedOffset(datetime.tzinfo):
@@ -87,26 +87,59 @@ def case_insensitive_dict(*args: Any, **kwargs: Any) -> MutableMapping:
8787
:return: A case-insensitive mutable mapping object.
8888
:rtype: ~collections.abc.MutableMapping
8989
"""
90+
return CaseInsensitiveDict(*args, **kwargs)
9091

91-
# Rational is I don't want to re-implement this, but I don't want
92-
# to assume "requests" or "aiohttp" are installed either.
93-
# So I use the one from "requests" or the one from "aiohttp" ("multidict")
94-
# If one day this library is used in an HTTP context without "requests" nor "aiohttp" installed,
95-
# we can add "multidict" as a dependency or re-implement our own.
96-
try:
97-
from requests.structures import CaseInsensitiveDict
92+
class CaseInsensitiveDict(MutableMapping):
93+
"""
94+
NOTE: This implementation is heavily inspired from the case insensitive dictionary from the requests library.
95+
Thank you !!
96+
Case insensitive dictionary implementation.
97+
The keys are expected to be strings and will be stored in lower case.
98+
case_insensitive_dict = CaseInsensitiveDict()
99+
case_insensitive_dict['Key'] = 'some_value'
100+
case_insensitive_dict['key'] == 'some_value' #True
101+
"""
98102

99-
return CaseInsensitiveDict(*args, **kwargs)
100-
except ImportError:
101-
pass
102-
try:
103-
# multidict is installed by aiohttp
104-
from multidict import CIMultiDict
105-
106-
if len(kwargs) == 0 and len(args) == 1 and (not args[0]):
107-
return CIMultiDict() # in case of case_insensitive_dict(None), we don't want to raise exception
108-
return CIMultiDict(*args, **kwargs)
109-
except ImportError:
110-
raise ValueError(
111-
"Neither 'requests' or 'multidict' are installed and no case-insensitive dict impl have been found"
103+
def __init__(self, data=None, **kwargs: Any) -> None:
104+
self._store: Dict[str, Any] = {}
105+
if data is None:
106+
data = {}
107+
108+
self.update(data, **kwargs)
109+
110+
def copy(self) -> "CaseInsensitiveDict":
111+
return CaseInsensitiveDict(self._store.values())
112+
113+
def __setitem__(self, key: str, value: str) -> None:
114+
"""
115+
Set the `key` to `value`. The original key will be stored with the value
116+
"""
117+
self._store[key.lower()] = (key, value)
118+
119+
def __getitem__(self, key: str) -> Any:
120+
return self._store[key.lower()][1]
121+
122+
def __delitem__(self, key: str) -> None:
123+
del self._store[key.lower()]
124+
125+
def __iter__(self) -> Iterator[Any]:
126+
return (key for key, _ in self._store.values())
127+
128+
def __len__(self) -> int:
129+
return len(self._store)
130+
131+
def lowerkey_items(self):
132+
return (
133+
(lower_case_key, pair[1]) for lower_case_key, pair in self._store.items()
112134
)
135+
136+
def __eq__(self, other: Any) -> bool:
137+
if isinstance(other, Mapping):
138+
other = CaseInsensitiveDict(other)
139+
else:
140+
return False
141+
142+
return dict(self.lowerkey_items()) == dict(other.lowerkey_items())
143+
144+
def __repr__(self) -> str:
145+
return str(dict(self.items()))

sdk/core/azure-core/tests/test_utils.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,56 @@ def test_case_insensitive_dict_initialization():
4040
assert d['platformUpdateDomainCount'] == d['platformupdatedomaincount'] == d['PLATFORMUPDATEDOMAINCOUNT'] == 5
4141
assert d['platformFaultDomainCount'] == d['platformfaultdomaincount'] == d['PLATFORMFAULTDOMAINCOUNT'] == 3
4242
assert d['virtualMachines'] == d['virtualmachines'] == d['VIRTUALMACHINES'] == []
43+
44+
def test_case_insensitive_dict_cant_compare():
45+
my_dict = case_insensitive_dict({"accept": "application/json"})
46+
assert my_dict != "accept"
47+
48+
def test_case_insensitive_dict_lowerkey_items():
49+
my_dict = case_insensitive_dict({"accept": "application/json"})
50+
assert list(my_dict.lowerkey_items()) == [("accept","application/json")]
51+
52+
@pytest.mark.parametrize("other, expected", (
53+
({"PLATFORMUPDATEDOMAINCOUNT": 5}, True),
54+
({}, False),
55+
(None, False),
56+
))
57+
def test_case_insensitive_dict_equality(other, expected):
58+
my_dict = case_insensitive_dict({"platformUpdateDomainCount": 5})
59+
result = my_dict == other
60+
assert result == expected
61+
62+
def test_case_insensitive_dict_keys():
63+
keys = ["One", "TWO", "tHrEe", "four"]
64+
my_dict = case_insensitive_dict({key:idx for idx, key in enumerate(keys,1)})
65+
dict_keys = list(my_dict.keys())
66+
67+
assert dict_keys == keys
68+
69+
def test_case_insensitive_copy():
70+
keys = ["One", "TWO", "tHrEe", "four"]
71+
my_dict = case_insensitive_dict({key:idx for idx, key in enumerate(keys, 1)})
72+
copy_my_dict = my_dict.copy()
73+
assert copy_my_dict is not my_dict
74+
assert copy_my_dict == my_dict
75+
76+
def test_case_insensitive_keys_present(accept_cases):
77+
my_dict = case_insensitive_dict({"accept": "application/json"})
78+
79+
for key in accept_cases:
80+
assert key in my_dict
81+
82+
def test_case_insensitive_keys_delete(accept_cases):
83+
my_dict = case_insensitive_dict({"accept": "application/json"})
84+
85+
for key in accept_cases:
86+
del my_dict[key]
87+
assert key not in my_dict
88+
my_dict[key] = "application/json"
89+
90+
def test_case_iter():
91+
keys = ["One", "TWO", "tHrEe", "four"]
92+
my_dict = case_insensitive_dict({key:idx for idx, key in enumerate(keys, 1)})
93+
94+
for key in my_dict:
95+
assert key in keys

0 commit comments

Comments
 (0)