typedmultidict 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- typedmultidict/__init__.py +14 -0
- typedmultidict/_cimultidict.py +31 -0
- typedmultidict/_multidict.py +170 -0
- typedmultidict/_pairs.py +37 -0
- typedmultidict/_state.py +48 -0
- typedmultidict/_storage.py +167 -0
- typedmultidict/_views.py +60 -0
- typedmultidict/py.typed +0 -0
- typedmultidict-0.2.0.dist-info/METADATA +213 -0
- typedmultidict-0.2.0.dist-info/RECORD +12 -0
- typedmultidict-0.2.0.dist-info/WHEEL +4 -0
- typedmultidict-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Pure-Python typed ordered multi-value dictionary with a case-insensitive variant."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._cimultidict import CIMultiDict
|
|
6
|
+
from ._multidict import MultiDict
|
|
7
|
+
|
|
8
|
+
__version__ = "0.2.0"
|
|
9
|
+
|
|
10
|
+
# Pickles name a class by its module: point them at the public one, which never moves.
|
|
11
|
+
CIMultiDict.__module__ = __name__
|
|
12
|
+
MultiDict.__module__ = __name__
|
|
13
|
+
|
|
14
|
+
__all__ = ["CIMultiDict", "MultiDict"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Case-insensitive multi-value mapping for HTTP-style string keys."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Hashable
|
|
6
|
+
from typing import ClassVar, TypeVar
|
|
7
|
+
|
|
8
|
+
from ._multidict import MultiDict
|
|
9
|
+
|
|
10
|
+
V = TypeVar("V")
|
|
11
|
+
|
|
12
|
+
# Never equal to a stored token, so a key of the wrong type is simply absent on lookup.
|
|
13
|
+
_NO_MATCH = object()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CIMultiDict(MultiDict[str, V]):
|
|
17
|
+
"""Keys compare case-insensitively (``str.lower``); each pair keeps its own spelling.
|
|
18
|
+
|
|
19
|
+
Keys must be ``str`` (or a subclass): adding any other key raises ``TypeError``, while
|
|
20
|
+
looking one up simply finds nothing.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
_folds_case: ClassVar[bool] = True
|
|
24
|
+
|
|
25
|
+
def _token(self, key: str) -> Hashable:
|
|
26
|
+
if not isinstance(key, str):
|
|
27
|
+
raise TypeError(f"{type(self).__name__} keys must be str, not {type(key).__name__}")
|
|
28
|
+
return str.lower(key)
|
|
29
|
+
|
|
30
|
+
def _probe(self, key: object) -> Hashable:
|
|
31
|
+
return str.lower(key) if isinstance(key, str) else _NO_MATCH
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Ordered mapping where a single key can hold several values."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy as _copy
|
|
6
|
+
from collections.abc import Hashable
|
|
7
|
+
from typing import TYPE_CHECKING, Any, TypeVar, overload
|
|
8
|
+
|
|
9
|
+
from ._pairs import MultiDictInput, read_pairs
|
|
10
|
+
from ._storage import _Entry, _Storage
|
|
11
|
+
from ._views import AllItemsView, AllValuesView
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Iterable
|
|
15
|
+
|
|
16
|
+
from _typeshed import SupportsKeysAndGetItem
|
|
17
|
+
|
|
18
|
+
K = TypeVar("K", bound=Hashable)
|
|
19
|
+
V = TypeVar("V")
|
|
20
|
+
T = TypeVar("T")
|
|
21
|
+
Self = TypeVar("Self", bound="MultiDict[Any, Any]")
|
|
22
|
+
|
|
23
|
+
_MISSING: Any = object()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MultiDict(_Storage[K, V]):
|
|
27
|
+
"""A typed, insertion-ordered multi-value mapping.
|
|
28
|
+
|
|
29
|
+
Mapping operations act on the first value of each key, so the type behaves like a
|
|
30
|
+
normal mapping (``len()`` counts keys). The multi-value surface (``add``, ``getall``,
|
|
31
|
+
``allitems`` and friends) sees every stored pair in insertion order.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, initial: MultiDictInput[K, V] | None = None) -> None:
|
|
35
|
+
self._setup()
|
|
36
|
+
if initial is not None:
|
|
37
|
+
self.extend(initial)
|
|
38
|
+
|
|
39
|
+
def _collect(self, source: object, extra: dict[str, Any]) -> list[tuple[Hashable, K, V]]:
|
|
40
|
+
pairs = read_pairs(source, _Storage)
|
|
41
|
+
pairs.extend(extra.items())
|
|
42
|
+
return [(self._token(key), key, value) for key, value in pairs]
|
|
43
|
+
|
|
44
|
+
def add(self, key: K, value: V) -> None:
|
|
45
|
+
"""Append another value for ``key`` without replacing existing ones."""
|
|
46
|
+
self._append([(self._token(key), key, value)])
|
|
47
|
+
|
|
48
|
+
def extend(self, other: MultiDictInput[K, V]) -> None:
|
|
49
|
+
"""Append every pair of a mapping, multi-dict or iterable of pairs."""
|
|
50
|
+
self._append(self._collect(other, {}))
|
|
51
|
+
|
|
52
|
+
def merge(self, other: MultiDictInput[K, V]) -> None:
|
|
53
|
+
"""Append the pairs whose key is not present yet; keys already here are skipped."""
|
|
54
|
+
present = set(self._index)
|
|
55
|
+
self._append([item for item in self._collect(other, {}) if item[0] not in present])
|
|
56
|
+
|
|
57
|
+
@overload
|
|
58
|
+
def update(self, other: SupportsKeysAndGetItem[K, V], /) -> None: ...
|
|
59
|
+
@overload
|
|
60
|
+
def update(
|
|
61
|
+
self: MultiDict[str, V], other: SupportsKeysAndGetItem[str, V], /, **kwargs: V
|
|
62
|
+
) -> None: ...
|
|
63
|
+
@overload
|
|
64
|
+
def update(self, other: Iterable[tuple[K, V]], /) -> None: ...
|
|
65
|
+
@overload
|
|
66
|
+
def update(self: MultiDict[str, V], other: Iterable[tuple[str, V]], /, **kwargs: V) -> None: ...
|
|
67
|
+
@overload
|
|
68
|
+
def update(self: MultiDict[str, V], /, **kwargs: V) -> None: ...
|
|
69
|
+
|
|
70
|
+
def update(self, other: Any = (), /, **kwargs: Any) -> None:
|
|
71
|
+
"""Replace the values of every incoming key, reusing the old positions in order.
|
|
72
|
+
|
|
73
|
+
Extra new values go to the end, extra old values are removed, and keys that do
|
|
74
|
+
not appear in ``other`` are left alone.
|
|
75
|
+
"""
|
|
76
|
+
# Plan first: every hash and key comparison happens before anything changes.
|
|
77
|
+
used: dict[Hashable, int] = {}
|
|
78
|
+
reused: list[tuple[_Entry, K, V]] = []
|
|
79
|
+
fresh: list[tuple[Hashable, K, V]] = []
|
|
80
|
+
for token, key, value in self._collect(other, kwargs):
|
|
81
|
+
slot = used.get(token, 0)
|
|
82
|
+
used[token] = slot + 1
|
|
83
|
+
entries = self._index.get(token, [])
|
|
84
|
+
if slot < len(entries):
|
|
85
|
+
reused.append((entries[slot], key, value))
|
|
86
|
+
else:
|
|
87
|
+
fresh.append((token, key, value))
|
|
88
|
+
stale = [entry for token, n in used.items() for entry in self._index.get(token, [])[n:]]
|
|
89
|
+
self._append(fresh) # undoes itself if it fails; nothing after it can fail
|
|
90
|
+
for entry, key, value in reused:
|
|
91
|
+
entry.key = key
|
|
92
|
+
entry.value = value
|
|
93
|
+
self._drop(stale)
|
|
94
|
+
|
|
95
|
+
@overload
|
|
96
|
+
def getone(self, key: K) -> V: ...
|
|
97
|
+
@overload
|
|
98
|
+
def getone(self, key: K, default: T) -> V | T: ...
|
|
99
|
+
|
|
100
|
+
def getone(self, key: K, default: Any = _MISSING) -> Any:
|
|
101
|
+
"""Return the first value for ``key``, else ``default`` or raise ``KeyError``."""
|
|
102
|
+
entries = self._index.get(self._probe(key))
|
|
103
|
+
if entries is not None:
|
|
104
|
+
return entries[0].value
|
|
105
|
+
if default is _MISSING:
|
|
106
|
+
raise KeyError(key)
|
|
107
|
+
return default
|
|
108
|
+
|
|
109
|
+
def getall(self, key: K) -> list[V]:
|
|
110
|
+
"""Return a new list of every value for ``key`` in order, empty if absent."""
|
|
111
|
+
return [entry.value for entry in self._index.get(self._probe(key), ())]
|
|
112
|
+
|
|
113
|
+
@overload
|
|
114
|
+
def popone(self, key: K) -> V: ...
|
|
115
|
+
@overload
|
|
116
|
+
def popone(self, key: K, default: T) -> V | T: ...
|
|
117
|
+
|
|
118
|
+
def popone(self, key: K, default: Any = _MISSING) -> Any:
|
|
119
|
+
"""Remove and return the first value for ``key``; later values stay."""
|
|
120
|
+
entries = self._index.get(self._probe(key))
|
|
121
|
+
if entries is None:
|
|
122
|
+
if default is _MISSING:
|
|
123
|
+
raise KeyError(key)
|
|
124
|
+
return default
|
|
125
|
+
first = entries[0]
|
|
126
|
+
self._drop([first])
|
|
127
|
+
return first.value
|
|
128
|
+
|
|
129
|
+
@overload
|
|
130
|
+
def popall(self, key: K) -> list[V]: ...
|
|
131
|
+
@overload
|
|
132
|
+
def popall(self, key: K, default: T) -> list[V] | T: ...
|
|
133
|
+
|
|
134
|
+
def popall(self, key: K, default: Any = _MISSING) -> Any:
|
|
135
|
+
"""Remove and return every value for ``key``; raise ``KeyError`` if absent."""
|
|
136
|
+
entries = self._index.get(self._probe(key))
|
|
137
|
+
if entries is None:
|
|
138
|
+
if default is _MISSING:
|
|
139
|
+
raise KeyError(key)
|
|
140
|
+
return default
|
|
141
|
+
self._drop(list(entries))
|
|
142
|
+
return [entry.value for entry in entries]
|
|
143
|
+
|
|
144
|
+
def popitem(self) -> tuple[K, V]:
|
|
145
|
+
"""Remove and return the last pair added; other values of its key stay."""
|
|
146
|
+
if not self._entries:
|
|
147
|
+
raise KeyError("popitem(): multi-dict is empty")
|
|
148
|
+
last = self._entries.pop()
|
|
149
|
+
entries = self._index[last.token]
|
|
150
|
+
entries.pop()
|
|
151
|
+
if not entries:
|
|
152
|
+
del self._index[last.token]
|
|
153
|
+
self._version += 1
|
|
154
|
+
return last.key, last.value
|
|
155
|
+
|
|
156
|
+
def allitems(self) -> AllItemsView[K, V]:
|
|
157
|
+
"""A live view of every ``(key, value)`` pair in insertion order."""
|
|
158
|
+
return AllItemsView(self)
|
|
159
|
+
|
|
160
|
+
def allvalues(self) -> AllValuesView[V]:
|
|
161
|
+
"""A live view of every value in insertion order."""
|
|
162
|
+
return AllValuesView(self)
|
|
163
|
+
|
|
164
|
+
def to_dict(self) -> dict[K, list[V]]:
|
|
165
|
+
"""A new ``dict`` from each key (first spelling) to a new list of its values."""
|
|
166
|
+
return {key: self.getall(key) for key in self}
|
|
167
|
+
|
|
168
|
+
def copy(self: Self) -> Self:
|
|
169
|
+
"""A shallow copy of the same type; values are shared, not copied."""
|
|
170
|
+
return _copy.copy(self)
|
typedmultidict/_pairs.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Reading bulk input (mappings, multi-dicts, iterables of pairs) before anything changes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from typing import TYPE_CHECKING, Any, TypeVar, Union
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from _typeshed import SupportsKeysAndGetItem
|
|
10
|
+
|
|
11
|
+
from ._storage import _Storage
|
|
12
|
+
|
|
13
|
+
K = TypeVar("K")
|
|
14
|
+
V = TypeVar("V")
|
|
15
|
+
|
|
16
|
+
MultiDictInput = Union["SupportsKeysAndGetItem[K, V]", Iterable[tuple[K, V]]]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def read_pairs(source: Any, storage_type: type[_Storage[Any, Any]]) -> list[tuple[Any, Any]]:
|
|
20
|
+
"""Return every pair of ``source`` as a new list, validating each element first."""
|
|
21
|
+
if isinstance(source, storage_type):
|
|
22
|
+
return [(entry.key, entry.value) for entry in source._entries]
|
|
23
|
+
if hasattr(source, "keys"):
|
|
24
|
+
# The same test dict.update uses: anything with keys() is read as a mapping.
|
|
25
|
+
return [(key, source[key]) for key in source.keys()] # noqa: SIM118
|
|
26
|
+
pairs: list[tuple[Any, Any]] = []
|
|
27
|
+
for position, item in enumerate(source):
|
|
28
|
+
try:
|
|
29
|
+
pair = tuple(item)
|
|
30
|
+
except TypeError:
|
|
31
|
+
raise TypeError(
|
|
32
|
+
f"element #{position} is not a (key, value) pair: {type(item).__name__}"
|
|
33
|
+
) from None
|
|
34
|
+
if len(pair) != 2:
|
|
35
|
+
raise ValueError(f"element #{position} has length {len(pair)}; 2 is required")
|
|
36
|
+
pairs.append(pair)
|
|
37
|
+
return pairs
|
typedmultidict/_state.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Pickling and copying state: plain pairs plus whatever a subclass adds."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from ._storage import _Storage
|
|
9
|
+
|
|
10
|
+
# Rebuilt from the pairs, never copied as they are.
|
|
11
|
+
_OWN = frozenset({"_entries", "_index", "_version"})
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def state_of(obj: _Storage[Any, Any]) -> tuple[Any, ...]:
|
|
15
|
+
"""The pairs, the instance attributes and any subclass slots, in that order."""
|
|
16
|
+
pairs = [(entry.key, entry.value) for entry in obj._entries]
|
|
17
|
+
attrs = {name: value for name, value in vars(obj).items() if name not in _OWN}
|
|
18
|
+
slots = {name: getattr(obj, name) for name in _slot_names(type(obj)) if hasattr(obj, name)}
|
|
19
|
+
return pairs, attrs or None, slots or None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def restore(obj: _Storage[Any, Any], state: Any) -> None:
|
|
23
|
+
"""Fill an object made with ``__new__`` from ``state_of`` output or a 0.1.0 pickle."""
|
|
24
|
+
if isinstance(state, dict):
|
|
25
|
+
# 0.1.0 pickled the instance dictionary, with every pair under "_items".
|
|
26
|
+
attrs = {name: value for name, value in state.items() if name != "_items"}
|
|
27
|
+
pairs, slots = state.get("_items", []), None
|
|
28
|
+
else:
|
|
29
|
+
pairs, attrs, slots = state
|
|
30
|
+
vars(obj).update(attrs or {})
|
|
31
|
+
for name, value in (slots or {}).items():
|
|
32
|
+
object.__setattr__(obj, name, value)
|
|
33
|
+
# Attributes first: a subclass may need them to normalise its keys.
|
|
34
|
+
obj._setup()
|
|
35
|
+
obj._append([(obj._token(key), key, value) for key, value in pairs])
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _slot_names(cls: type) -> list[str]:
|
|
39
|
+
names: list[str] = []
|
|
40
|
+
for klass in cls.__mro__:
|
|
41
|
+
declared = klass.__dict__.get("__slots__", ())
|
|
42
|
+
for name in [declared] if isinstance(declared, str) else declared:
|
|
43
|
+
if name in ("__dict__", "__weakref__"):
|
|
44
|
+
continue
|
|
45
|
+
if name.startswith("__") and not name.endswith("__"):
|
|
46
|
+
name = f"_{klass.__name__.lstrip('_')}{name}"
|
|
47
|
+
names.append(name)
|
|
48
|
+
return names
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Indexed, insertion-ordered storage of key/value pairs behind the mapping protocol."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copyreg
|
|
6
|
+
import reprlib
|
|
7
|
+
from collections.abc import Hashable, Iterator, Mapping, MutableMapping
|
|
8
|
+
from typing import Any, ClassVar, TypeVar
|
|
9
|
+
|
|
10
|
+
from ._state import restore, state_of
|
|
11
|
+
|
|
12
|
+
K = TypeVar("K", bound=Hashable)
|
|
13
|
+
V = TypeVar("V")
|
|
14
|
+
|
|
15
|
+
_CHANGED = "multi-dict changed during iteration"
|
|
16
|
+
# Rebuilds with cls.__new__(cls); pickle encodes it as the compact NEWOBJ opcode.
|
|
17
|
+
_NEWOBJ: Any = getattr(copyreg, "__newobj__") # noqa: B009
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _Entry:
|
|
21
|
+
__slots__ = ("key", "token", "value")
|
|
22
|
+
|
|
23
|
+
def __init__(self, token: Hashable, key: Any, value: Any) -> None:
|
|
24
|
+
self.token = token
|
|
25
|
+
self.key = key
|
|
26
|
+
self.value = value
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _Storage(MutableMapping[K, V]):
|
|
30
|
+
# Pairs live in one ordered list; the index maps each key token to its entries in order,
|
|
31
|
+
# so lookups never scan the list. Only removals pay a linear pass. No __slots__: 0.1.0
|
|
32
|
+
# instances took extra attributes and weak references, and still do.
|
|
33
|
+
|
|
34
|
+
_folds_case: ClassVar[bool] = False
|
|
35
|
+
|
|
36
|
+
def _setup(self) -> None:
|
|
37
|
+
self._entries: list[_Entry] = []
|
|
38
|
+
self._index: dict[Hashable, list[_Entry]] = {}
|
|
39
|
+
self._version = 0
|
|
40
|
+
|
|
41
|
+
def _token(self, key: K) -> Hashable:
|
|
42
|
+
"""The identity a key is stored under; raises for keys this mapping refuses."""
|
|
43
|
+
return key
|
|
44
|
+
|
|
45
|
+
def _probe(self, key: object) -> Hashable:
|
|
46
|
+
"""The identity to look a key up by; a refused key matches nothing."""
|
|
47
|
+
return key
|
|
48
|
+
|
|
49
|
+
def _append(self, pairs: list[tuple[Hashable, K, V]]) -> None:
|
|
50
|
+
added: list[_Entry] = []
|
|
51
|
+
try:
|
|
52
|
+
for token, key, value in pairs:
|
|
53
|
+
entry = _Entry(token, key, value)
|
|
54
|
+
# A key whose __hash__ or __eq__ raises fails here; undo what came before.
|
|
55
|
+
self._index.setdefault(token, []).append(entry)
|
|
56
|
+
added.append(entry)
|
|
57
|
+
except BaseException:
|
|
58
|
+
for entry in reversed(added):
|
|
59
|
+
self._index[entry.token].pop()
|
|
60
|
+
if not self._index[entry.token]:
|
|
61
|
+
del self._index[entry.token]
|
|
62
|
+
raise
|
|
63
|
+
self._entries.extend(added)
|
|
64
|
+
self._version += 1
|
|
65
|
+
|
|
66
|
+
def _drop(self, doomed: list[_Entry]) -> None:
|
|
67
|
+
if not doomed:
|
|
68
|
+
return
|
|
69
|
+
gone = {id(entry) for entry in doomed}
|
|
70
|
+
self._entries = [entry for entry in self._entries if id(entry) not in gone]
|
|
71
|
+
for token in {entry.token for entry in doomed}:
|
|
72
|
+
kept = [entry for entry in self._index[token] if id(entry) not in gone]
|
|
73
|
+
if kept:
|
|
74
|
+
self._index[token] = kept
|
|
75
|
+
else:
|
|
76
|
+
del self._index[token]
|
|
77
|
+
self._version += 1
|
|
78
|
+
|
|
79
|
+
def _iterate(self, version: int) -> Iterator[_Entry]:
|
|
80
|
+
# Checked before each step, so a change made between iter() and next() is caught.
|
|
81
|
+
for entry in self._entries:
|
|
82
|
+
if self._version != version:
|
|
83
|
+
raise RuntimeError(_CHANGED)
|
|
84
|
+
yield entry
|
|
85
|
+
if self._version != version:
|
|
86
|
+
raise RuntimeError(_CHANGED)
|
|
87
|
+
|
|
88
|
+
def __getitem__(self, key: K) -> V:
|
|
89
|
+
entries = self._index.get(self._probe(key))
|
|
90
|
+
if entries is None:
|
|
91
|
+
raise KeyError(key)
|
|
92
|
+
value: V = entries[0].value
|
|
93
|
+
return value
|
|
94
|
+
|
|
95
|
+
def __setitem__(self, key: K, value: V) -> None:
|
|
96
|
+
token = self._token(key)
|
|
97
|
+
entries = self._index.get(token)
|
|
98
|
+
if entries is None:
|
|
99
|
+
self._append([(token, key, value)])
|
|
100
|
+
return
|
|
101
|
+
first = entries[0]
|
|
102
|
+
first.key = key
|
|
103
|
+
first.value = value
|
|
104
|
+
self._drop(entries[1:])
|
|
105
|
+
self._version += 1
|
|
106
|
+
|
|
107
|
+
def __delitem__(self, key: K) -> None:
|
|
108
|
+
entries = self._index.get(self._probe(key))
|
|
109
|
+
if entries is None:
|
|
110
|
+
raise KeyError(key)
|
|
111
|
+
self._drop(list(entries))
|
|
112
|
+
|
|
113
|
+
def __contains__(self, key: object) -> bool:
|
|
114
|
+
return self._probe(key) in self._index
|
|
115
|
+
|
|
116
|
+
def __iter__(self) -> Iterator[K]:
|
|
117
|
+
# Not a generator itself, so the version is taken when iter() is called.
|
|
118
|
+
return self._first_keys(self._iterate(self._version))
|
|
119
|
+
|
|
120
|
+
def _first_keys(self, entries: Iterator[_Entry]) -> Iterator[K]:
|
|
121
|
+
seen: set[Hashable] = set()
|
|
122
|
+
for entry in entries:
|
|
123
|
+
if entry.token not in seen:
|
|
124
|
+
seen.add(entry.token)
|
|
125
|
+
yield entry.key
|
|
126
|
+
|
|
127
|
+
def __len__(self) -> int:
|
|
128
|
+
return len(self._index)
|
|
129
|
+
|
|
130
|
+
def clear(self) -> None:
|
|
131
|
+
self._entries = []
|
|
132
|
+
self._index = {}
|
|
133
|
+
self._version += 1
|
|
134
|
+
|
|
135
|
+
def __eq__(self, other: object) -> bool:
|
|
136
|
+
if isinstance(other, _Storage):
|
|
137
|
+
fold = self._folds_case or other._folds_case
|
|
138
|
+
return _compared(self._entries, fold) == _compared(other._entries, fold)
|
|
139
|
+
if isinstance(other, Mapping):
|
|
140
|
+
if not len(self._entries) == len(self._index) == len(other):
|
|
141
|
+
return False
|
|
142
|
+
return {e.key: e.value for e in self._entries} == dict(other.items())
|
|
143
|
+
return NotImplemented
|
|
144
|
+
|
|
145
|
+
__hash__ = None # type: ignore[assignment]
|
|
146
|
+
|
|
147
|
+
@reprlib.recursive_repr()
|
|
148
|
+
def __repr__(self) -> str:
|
|
149
|
+
pairs = [(entry.key, entry.value) for entry in self._entries]
|
|
150
|
+
return f"{type(self).__name__}({pairs!r})"
|
|
151
|
+
|
|
152
|
+
def __reduce__(self) -> tuple[Any, ...]:
|
|
153
|
+
# The pairs travel as state, not as arguments, so an object that contains itself is
|
|
154
|
+
# memoised before its pairs are copied. Built with __new__: no __init__ is called.
|
|
155
|
+
return (_NEWOBJ, (type(self),), state_of(self))
|
|
156
|
+
|
|
157
|
+
def __setstate__(self, state: Any) -> None:
|
|
158
|
+
restore(self, state)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _compared(entries: list[_Entry], fold: bool) -> list[tuple[Any, Any]]:
|
|
162
|
+
if not fold:
|
|
163
|
+
return [(entry.token, entry.value) for entry in entries]
|
|
164
|
+
return [
|
|
165
|
+
(str.lower(entry.key) if isinstance(entry.key, str) else entry.key, entry.value)
|
|
166
|
+
for entry in entries
|
|
167
|
+
]
|
typedmultidict/_views.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Live views over every stored pair or value, not just the first of each key."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Collection, Hashable, Iterator
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from ._storage import _Storage
|
|
10
|
+
|
|
11
|
+
K = TypeVar("K", bound=Hashable)
|
|
12
|
+
V = TypeVar("V")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AllItemsView(Collection[tuple[K, V]], Generic[K, V]):
|
|
16
|
+
"""Every ``(key, value)`` pair in insertion order; ``len()`` counts pairs."""
|
|
17
|
+
|
|
18
|
+
__slots__ = ("_mapping",)
|
|
19
|
+
|
|
20
|
+
def __init__(self, mapping: _Storage[K, V]) -> None:
|
|
21
|
+
self._mapping = mapping
|
|
22
|
+
|
|
23
|
+
def __len__(self) -> int:
|
|
24
|
+
return len(self._mapping._entries)
|
|
25
|
+
|
|
26
|
+
def __iter__(self) -> Iterator[tuple[K, V]]:
|
|
27
|
+
mapping = self._mapping
|
|
28
|
+
return ((entry.key, entry.value) for entry in mapping._iterate(mapping._version))
|
|
29
|
+
|
|
30
|
+
def __contains__(self, item: object) -> bool:
|
|
31
|
+
if not isinstance(item, tuple) or len(item) != 2:
|
|
32
|
+
return False
|
|
33
|
+
key, value = item
|
|
34
|
+
entries = self._mapping._index.get(self._mapping._probe(key), ())
|
|
35
|
+
return any(entry.value is value or entry.value == value for entry in entries)
|
|
36
|
+
|
|
37
|
+
def __repr__(self) -> str:
|
|
38
|
+
return f"{type(self).__name__}({list(self)!r})"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AllValuesView(Collection[V], Generic[V]):
|
|
42
|
+
"""Every value in insertion order; ``len()`` counts pairs."""
|
|
43
|
+
|
|
44
|
+
__slots__ = ("_mapping",)
|
|
45
|
+
|
|
46
|
+
def __init__(self, mapping: _Storage[Any, V]) -> None:
|
|
47
|
+
self._mapping = mapping
|
|
48
|
+
|
|
49
|
+
def __len__(self) -> int:
|
|
50
|
+
return len(self._mapping._entries)
|
|
51
|
+
|
|
52
|
+
def __iter__(self) -> Iterator[V]:
|
|
53
|
+
mapping = self._mapping
|
|
54
|
+
return (entry.value for entry in mapping._iterate(mapping._version))
|
|
55
|
+
|
|
56
|
+
def __contains__(self, value: object) -> bool:
|
|
57
|
+
return any(entry.value is value or entry.value == value for entry in self._mapping._entries)
|
|
58
|
+
|
|
59
|
+
def __repr__(self) -> str:
|
|
60
|
+
return f"{type(self).__name__}({list(self)!r})"
|
typedmultidict/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: typedmultidict
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Pure-Python typed ordered multi-value dictionary with a case-insensitive variant
|
|
5
|
+
Project-URL: Homepage, https://lianes8studio.com
|
|
6
|
+
Project-URL: Repository, https://github.com/JaviLianes8/typedmultidict
|
|
7
|
+
Project-URL: Issues, https://github.com/JaviLianes8/typedmultidict/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/JaviLianes8/typedmultidict/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: LinkedIn, https://www.linkedin.com/in/jlianes/
|
|
10
|
+
Project-URL: Funding, https://buymeacoffee.com/jlianesglrs
|
|
11
|
+
Author-email: Javier Lianes <javier@lianes8studio.com>
|
|
12
|
+
License-Expression: MIT
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Keywords: dictionary,headers,mapping,multi-value,multidict,typed
|
|
15
|
+
Classifier: Development Status :: 4 - Beta
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: build; extra == 'dev'
|
|
29
|
+
Requires-Dist: hypothesis; extra == 'dev'
|
|
30
|
+
Requires-Dist: multidict; extra == 'dev'
|
|
31
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
33
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
34
|
+
Requires-Dist: twine; extra == 'dev'
|
|
35
|
+
Requires-Dist: werkzeug; extra == 'dev'
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# typedmultidict
|
|
39
|
+
|
|
40
|
+
Pure-Python typed ordered multi-value dictionary with a case-insensitive variant.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
pip install typedmultidict
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
A `MultiDict[K, V]` is a normal mapping over the first value of each key, plus methods
|
|
51
|
+
that see every value. Pairs keep their insertion order across keys.
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from typedmultidict import MultiDict
|
|
55
|
+
|
|
56
|
+
params: MultiDict[str, str] = MultiDict([("tag", "python"), ("page", "2")])
|
|
57
|
+
params.add("tag", "typing")
|
|
58
|
+
|
|
59
|
+
print(params["tag"]) # python
|
|
60
|
+
print(params.getall("tag")) # ['python', 'typing']
|
|
61
|
+
print(len(params), len(params.allitems())) # 2 3
|
|
62
|
+
print(list(params.allitems())) # [('tag', 'python'), ('page', '2'), ('tag', 'typing')]
|
|
63
|
+
print(dict(params)) # {'tag': 'python', 'page': '2'}
|
|
64
|
+
print(params.to_dict()) # {'tag': ['python', 'typing'], 'page': ['2']}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Assigning replaces every value of a key in the position of its first one; removing can
|
|
68
|
+
take the first value, every value, or the last pair added:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from typedmultidict import MultiDict
|
|
72
|
+
|
|
73
|
+
md = MultiDict([("a", 1), ("b", 2), ("a", 3), ("a", 4)])
|
|
74
|
+
md["a"] = 9
|
|
75
|
+
print(md) # MultiDict([('a', 9), ('b', 2)])
|
|
76
|
+
|
|
77
|
+
md.extend([("a", 5), ("c", 6)])
|
|
78
|
+
print(md.popone("a"), md.getall("a")) # 9 [5]
|
|
79
|
+
print(md.popall("a"), "a" in md) # [5] False
|
|
80
|
+
print(md.popitem(), md) # ('c', 6) MultiDict([('b', 2)])
|
|
81
|
+
print(md.getone("missing", "default")) # default
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`extend` appends, `merge` only adds keys that are not present yet, and `update` replaces
|
|
85
|
+
the values of each incoming key in place (extra new values go to the end, extra old
|
|
86
|
+
values are dropped):
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from typedmultidict import MultiDict
|
|
90
|
+
|
|
91
|
+
md = MultiDict([("a", 1), ("b", 2), ("a", 3)])
|
|
92
|
+
md.merge([("a", 0), ("c", 4)])
|
|
93
|
+
print(md) # MultiDict([('a', 1), ('b', 2), ('a', 3), ('c', 4)])
|
|
94
|
+
md.update([("a", 7), ("a", 8), ("a", 9)], b=0)
|
|
95
|
+
print(md) # MultiDict([('a', 7), ('b', 0), ('a', 8), ('c', 4), ('a', 9)])
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`CIMultiDict[V]` has `str` keys compared case-insensitively. Every pair keeps the spelling
|
|
99
|
+
it was added with, and keys are listed in the spelling of their first pair:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from typedmultidict import CIMultiDict
|
|
103
|
+
|
|
104
|
+
headers: CIMultiDict[str] = CIMultiDict()
|
|
105
|
+
headers.add("Set-Cookie", "a=1")
|
|
106
|
+
headers.add("set-cookie", "b=2")
|
|
107
|
+
headers.add("Content-Type", "text/html")
|
|
108
|
+
|
|
109
|
+
print(headers["SET-COOKIE"]) # a=1
|
|
110
|
+
print(headers.getall("set-cookie")) # ['a=1', 'b=2']
|
|
111
|
+
print(list(headers)) # ['Set-Cookie', 'Content-Type']
|
|
112
|
+
print(5 in headers) # False
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Views from `allitems()` and `allvalues()` follow the mapping. Changing the mapping while
|
|
116
|
+
iterating over it, or over any of its views, raises `RuntimeError`. As in `multidict`,
|
|
117
|
+
every write counts, even replacing a value in place (which `dict` allows):
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from typedmultidict import MultiDict
|
|
121
|
+
|
|
122
|
+
md = MultiDict([("a", 1)])
|
|
123
|
+
pairs = md.allitems()
|
|
124
|
+
md.add("a", 2)
|
|
125
|
+
print(len(pairs), ("a", 2) in pairs) # 2 True
|
|
126
|
+
try:
|
|
127
|
+
for key in md:
|
|
128
|
+
md.add("b", 3)
|
|
129
|
+
except RuntimeError as error:
|
|
130
|
+
print(error) # multi-dict changed during iteration
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Equality, copies and pickles:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
import pickle
|
|
137
|
+
|
|
138
|
+
from typedmultidict import CIMultiDict, MultiDict
|
|
139
|
+
|
|
140
|
+
print(MultiDict([("a", 1), ("b", 2)]) == MultiDict([("b", 2), ("a", 1)])) # False
|
|
141
|
+
print(MultiDict([("a", 1), ("b", 2)]) == {"b": 2, "a": 1}) # True
|
|
142
|
+
print(MultiDict([("a", 1), ("a", 2)]) == {"a": 1}) # False
|
|
143
|
+
print(CIMultiDict([("A", 1)]) == MultiDict([("a", 1)])) # True
|
|
144
|
+
|
|
145
|
+
headers = CIMultiDict([("X-Id", "1")])
|
|
146
|
+
print(pickle.loads(pickle.dumps(headers))) # CIMultiDict([('X-Id', '1')])
|
|
147
|
+
print(headers.copy() == headers, headers.copy() is headers) # True False
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## API
|
|
151
|
+
|
|
152
|
+
| Name | What it does |
|
|
153
|
+
| --- | --- |
|
|
154
|
+
| `MultiDict[K, V](initial=None)` | Ordered multi-value mapping. `initial` is a mapping, a multi-dict (every pair is copied) or an iterable of pairs. `K` must be hashable. |
|
|
155
|
+
| `CIMultiDict[V](initial=None)` | `MultiDict[str, V]` whose keys compare with `str.lower`. A non-`str` key raises `TypeError` when added and is absent when looked up. |
|
|
156
|
+
| `md[key]`, `get`, `in`, `keys`, `items`, `values`, `len()`, iteration | The `Mapping` view: first value of each key, keys once each. |
|
|
157
|
+
| `md[key] = value` | Replaces every value of `key` with one, in the place of the first. |
|
|
158
|
+
| `del md[key]`, `pop(key[, default])` | Remove every value of `key` (`pop` returns the first). |
|
|
159
|
+
| `setdefault(key[, default])` | First value of `key`, or adds `default`. |
|
|
160
|
+
| `add(key, value)` | Appends one more pair. |
|
|
161
|
+
| `extend(other)` | Appends every pair of `other`. |
|
|
162
|
+
| `merge(other)` | Appends the pairs whose key is not present yet. |
|
|
163
|
+
| `update(other=(), /, **kwargs)` | Replaces the values of each incoming key in place. |
|
|
164
|
+
| `getone(key[, default])` | First value, else `default`, else `KeyError`. |
|
|
165
|
+
| `getall(key)` | New list of every value of `key`, empty if absent. |
|
|
166
|
+
| `popone(key[, default])` | Removes and returns the first value only. |
|
|
167
|
+
| `popall(key[, default])` | Removes and returns every value as a list. |
|
|
168
|
+
| `popitem()` | Removes and returns the last pair added (`KeyError` when empty). |
|
|
169
|
+
| `allitems()`, `allvalues()` | Live views of every pair or value; `len()` counts pairs. |
|
|
170
|
+
| `to_dict()` | New `dict` from each key to a new list of its values. |
|
|
171
|
+
| `copy()`, `clear()` | Shallow copy of the same class; remove everything. |
|
|
172
|
+
|
|
173
|
+
Bulk input (`initial`, `extend`, `merge`, `update`) is read and checked in full first: an
|
|
174
|
+
element that is not a pair raises `TypeError`, one of the wrong length raises
|
|
175
|
+
`ValueError`, both with its position, and the mapping is left unchanged. Lookups cost the
|
|
176
|
+
same whatever the size; removals are linear in the number of pairs.
|
|
177
|
+
|
|
178
|
+
### Compared with `multidict` and Werkzeug
|
|
179
|
+
|
|
180
|
+
The method names and their behaviour follow the `multidict` package, with two
|
|
181
|
+
differences that keep the type a consistent `Mapping`: `len()` counts keys (use
|
|
182
|
+
`len(md.allitems())` for pairs), and `keys`, `items` and `values` show the first value of
|
|
183
|
+
each key, as Werkzeug's `MultiDict` does. `getall` returns an empty list for a missing
|
|
184
|
+
key instead of raising, and `pop` removes every value of the key.
|
|
185
|
+
|
|
186
|
+
Like `dict`, a multi-dict is not safe to change from several threads at once without a
|
|
187
|
+
lock. Equality between a plain and a case-insensitive multi-dict folds case, so it is not
|
|
188
|
+
transitive across the two kinds. Any other mapping, a `multidict.MultiDict` included, is
|
|
189
|
+
compared as a plain mapping.
|
|
190
|
+
|
|
191
|
+
## Why
|
|
192
|
+
|
|
193
|
+
HTTP headers and query strings routinely carry repeated keys, so a mapping that keeps every
|
|
194
|
+
value is a common need. The existing options each fall short for a plain, typed project:
|
|
195
|
+
|
|
196
|
+
- `multidict` (the aiohttp one) is a C extension; its pure-Python fallback runs many times
|
|
197
|
+
slower and still needs a build step for the fast path.
|
|
198
|
+
- `orderedmultidict` depends on `six` and ships no type hints.
|
|
199
|
+
- The `MultiDict` types in `werkzeug` and `starlette` come bundled with a whole framework.
|
|
200
|
+
|
|
201
|
+
`typedmultidict` is a single, standalone, fully typed implementation with `py.typed`, zero
|
|
202
|
+
runtime dependencies and no compilation. Reach for it when you want multi-value semantics
|
|
203
|
+
and `mypy --strict` support without pulling in a framework or a native wheel.
|
|
204
|
+
|
|
205
|
+
## License
|
|
206
|
+
|
|
207
|
+
MIT. See [LICENSE](LICENSE).
|
|
208
|
+
|
|
209
|
+
## Links
|
|
210
|
+
|
|
211
|
+
- Website: https://lianes8studio.com
|
|
212
|
+
- GitHub: https://github.com/JaviLianes8
|
|
213
|
+
- LinkedIn: https://www.linkedin.com/in/jlianes/
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
typedmultidict/__init__.py,sha256=90bOn67mLk-o-D4t_arZgGJ_Q-QyDDZPSXm0ohVZXzg,414
|
|
2
|
+
typedmultidict/_cimultidict.py,sha256=E4-RwWKHJGJNtImANf7bdw5_IkTdz9oRJuOrsMC1YwE,982
|
|
3
|
+
typedmultidict/_multidict.py,sha256=cBeU1g_FOGmvJqVIVZ3VjO-KWrAeK9KN_YrpnI8M1Rk,6535
|
|
4
|
+
typedmultidict/_pairs.py,sha256=oHn0QYCCkIyu5kDefg-Pf0HbKaDaFrSnk8b3X050UOk,1387
|
|
5
|
+
typedmultidict/_state.py,sha256=SpPqUmeUNqnYYJdpM3GkO06qJ5D9bCt8kTZSq54LqIg,1962
|
|
6
|
+
typedmultidict/_storage.py,sha256=UvTToMueIpkUAc9KiYjJSptxLGYOC2u-9DUYLYls5rM,5959
|
|
7
|
+
typedmultidict/_views.py,sha256=UHlDkYRy6NqE-rf0blKvVRpCWz2YYfh1eF6s9whe-iM,1961
|
|
8
|
+
typedmultidict/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
typedmultidict-0.2.0.dist-info/METADATA,sha256=9l0M9y55cJVSUf_bqBtxDgq50z7_Gbqj3vP7yCthfgI,8855
|
|
10
|
+
typedmultidict-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
11
|
+
typedmultidict-0.2.0.dist-info/licenses/LICENSE,sha256=wirD5nCA2aQa5UusK_1YHLBogI3CGcw23xAnulp3L_U,1091
|
|
12
|
+
typedmultidict-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Javier Lianes
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|