multicollections 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {multicollections-0.2.0 → multicollections-0.3.0}/.github/workflows/ci.yml +2 -2
- {multicollections-0.2.0 → multicollections-0.3.0}/PKG-INFO +2 -2
- {multicollections-0.2.0 → multicollections-0.3.0}/README.md +1 -1
- multicollections-0.3.0/multicollections/__init__.py +316 -0
- multicollections-0.3.0/multicollections/_util.py +19 -0
- {multicollections-0.2.0 → multicollections-0.3.0}/multicollections/abc.py +101 -41
- {multicollections-0.2.0 → multicollections-0.3.0}/pyproject.toml +13 -4
- {multicollections-0.2.0 → multicollections-0.3.0}/tests/minimalimpl.py +9 -2
- {multicollections-0.2.0 → multicollections-0.3.0}/tests/test_multicollections.py +304 -105
- multicollections-0.2.0/multicollections/__init__.py +0 -156
- {multicollections-0.2.0 → multicollections-0.3.0}/.github/dependabot.yml +0 -0
- {multicollections-0.2.0 → multicollections-0.3.0}/.github/workflows/pypi-publish.yml +0 -0
- {multicollections-0.2.0 → multicollections-0.3.0}/.gitignore +0 -0
- {multicollections-0.2.0 → multicollections-0.3.0}/.readthedocs.yaml +0 -0
- {multicollections-0.2.0 → multicollections-0.3.0}/LICENSE.txt +0 -0
- {multicollections-0.2.0 → multicollections-0.3.0}/docs/api/abc.md +0 -0
- {multicollections-0.2.0 → multicollections-0.3.0}/docs/api/multicollections.md +0 -0
- {multicollections-0.2.0 → multicollections-0.3.0}/docs/index.md +0 -0
- {multicollections-0.2.0 → multicollections-0.3.0}/mkdocs.yml +0 -0
- {multicollections-0.2.0 → multicollections-0.3.0}/multicollections/py.typed +0 -0
- {multicollections-0.2.0 → multicollections-0.3.0}/tests/__init__.py +0 -0
- {multicollections-0.2.0 → multicollections-0.3.0}/tests/ruff.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: multicollections
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Fully generic MultiDict class
|
|
5
5
|
Project-URL: Homepage, https://github.com/gerlero/multicollections
|
|
6
6
|
Project-URL: Repository, https://github.com/gerlero/multicollections
|
|
@@ -36,8 +36,8 @@ A fully generic [`MultiDict`](https://multicollections.readthedocs.io/en/latest/
|
|
|
36
36
|
[](https://multicollections.readthedocs.io/)
|
|
37
37
|
[](https://github.com/gerlero/multicollections/actions/workflows/ci.yml)
|
|
38
38
|
[](https://codecov.io/gh/gerlero/multicollections)
|
|
39
|
+
[](http://mypy-lang.org/)
|
|
39
40
|
[](https://github.com/astral-sh/ruff)
|
|
40
|
-
[](https://github.com/astral-sh/ty)
|
|
41
41
|
[](https://github.com/astral-sh/uv)
|
|
42
42
|
[](https://github.com/gerlero/multicollections/actions/workflows/pypi-publish.yml)
|
|
43
43
|
[](https://pypi.org/project/multicollections/)
|
|
@@ -5,8 +5,8 @@ A fully generic [`MultiDict`](https://multicollections.readthedocs.io/en/latest/
|
|
|
5
5
|
[](https://multicollections.readthedocs.io/)
|
|
6
6
|
[](https://github.com/gerlero/multicollections/actions/workflows/ci.yml)
|
|
7
7
|
[](https://codecov.io/gh/gerlero/multicollections)
|
|
8
|
+
[](http://mypy-lang.org/)
|
|
8
9
|
[](https://github.com/astral-sh/ruff)
|
|
9
|
-
[](https://github.com/astral-sh/ty)
|
|
10
10
|
[](https://github.com/astral-sh/uv)
|
|
11
11
|
[](https://github.com/gerlero/multicollections/actions/workflows/pypi-publish.yml)
|
|
12
12
|
[](https://pypi.org/project/multicollections/)
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Fully generic `MultiDict` class."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import itertools
|
|
6
|
+
import sys
|
|
7
|
+
from typing import TypeVar
|
|
8
|
+
|
|
9
|
+
if sys.version_info >= (3, 9):
|
|
10
|
+
from collections.abc import (
|
|
11
|
+
Iterable,
|
|
12
|
+
Iterator,
|
|
13
|
+
Mapping,
|
|
14
|
+
Sequence,
|
|
15
|
+
)
|
|
16
|
+
else:
|
|
17
|
+
from typing import (
|
|
18
|
+
Iterable,
|
|
19
|
+
Iterator,
|
|
20
|
+
Mapping,
|
|
21
|
+
Sequence,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from ._util import override
|
|
25
|
+
from .abc import MutableMultiMapping, with_default
|
|
26
|
+
|
|
27
|
+
_K = TypeVar("_K")
|
|
28
|
+
_V = TypeVar("_V")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MultiDict(MutableMultiMapping[_K, _V]):
|
|
32
|
+
"""A fully generic dictionary that allows multiple values with the same key.
|
|
33
|
+
|
|
34
|
+
Preserves insertion order.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self, iterable: Mapping[_K, _V] | Iterable[Sequence[_K | _V]] = (), **kwargs: _V
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Create a MultiDict."""
|
|
41
|
+
self._items: list[tuple[_K, _V]] = []
|
|
42
|
+
self._key_indices: dict[_K, list[int]] = {}
|
|
43
|
+
|
|
44
|
+
# Batch initialization: collect all items first, then build indices once
|
|
45
|
+
if isinstance(iterable, Mapping):
|
|
46
|
+
self._items.extend((key, value) for key, value in iterable.items())
|
|
47
|
+
else:
|
|
48
|
+
self._items.extend((key, value) for key, value in iterable) # type: ignore[misc]
|
|
49
|
+
|
|
50
|
+
# Add kwargs items
|
|
51
|
+
self._items.extend((key, value) for key, value in kwargs.items()) # type: ignore[misc]
|
|
52
|
+
|
|
53
|
+
# Build indices in one pass for better performance
|
|
54
|
+
if self._items:
|
|
55
|
+
self._rebuild_indices()
|
|
56
|
+
|
|
57
|
+
@override
|
|
58
|
+
@with_default
|
|
59
|
+
def getall(self, key: _K) -> list[_V]:
|
|
60
|
+
"""Get all values for a key.
|
|
61
|
+
|
|
62
|
+
Raises a `KeyError` if the key is not found and no default is provided.
|
|
63
|
+
"""
|
|
64
|
+
ret = [self._items[i][1] for i in self._key_indices.get(key, [])]
|
|
65
|
+
if not ret:
|
|
66
|
+
raise KeyError(key)
|
|
67
|
+
return ret
|
|
68
|
+
|
|
69
|
+
@override
|
|
70
|
+
def __setitem__(self, key: _K, value: _V) -> None:
|
|
71
|
+
"""Set the value for a key.
|
|
72
|
+
|
|
73
|
+
Replaces the first value for a key if it exists; otherwise, it adds a new item.
|
|
74
|
+
Any other items with the same key are removed.
|
|
75
|
+
"""
|
|
76
|
+
if key in self._key_indices:
|
|
77
|
+
# Key exists, replace first occurrence and remove others
|
|
78
|
+
indices = self._key_indices[key]
|
|
79
|
+
first_index = indices[0]
|
|
80
|
+
|
|
81
|
+
# Update the first occurrence
|
|
82
|
+
self._items[first_index] = (key, value)
|
|
83
|
+
|
|
84
|
+
if len(indices) > 1:
|
|
85
|
+
# Remove duplicates efficiently by marking items as None and filtering
|
|
86
|
+
for idx in indices[1:]:
|
|
87
|
+
self._items[idx] = None # type: ignore[call-overload]
|
|
88
|
+
|
|
89
|
+
# Filter out None items and rebuild indices
|
|
90
|
+
self._items = [item for item in self._items if item is not None]
|
|
91
|
+
self._rebuild_indices()
|
|
92
|
+
else:
|
|
93
|
+
# Key doesn't exist, add it
|
|
94
|
+
self.add(key, value)
|
|
95
|
+
|
|
96
|
+
def _rebuild_indices(self) -> None:
|
|
97
|
+
"""Rebuild the key indices after items list has been modified."""
|
|
98
|
+
self._key_indices = {}
|
|
99
|
+
for i, (key, _) in enumerate(self._items):
|
|
100
|
+
if key not in self._key_indices:
|
|
101
|
+
self._key_indices[key] = []
|
|
102
|
+
self._key_indices[key].append(i)
|
|
103
|
+
|
|
104
|
+
@override
|
|
105
|
+
def add(self, key: _K, value: _V) -> None:
|
|
106
|
+
"""Add a new value for a key."""
|
|
107
|
+
index = len(self._items)
|
|
108
|
+
self._items.append((key, value))
|
|
109
|
+
if key not in self._key_indices:
|
|
110
|
+
self._key_indices[key] = []
|
|
111
|
+
self._key_indices[key].append(index)
|
|
112
|
+
|
|
113
|
+
@override
|
|
114
|
+
@with_default
|
|
115
|
+
def popone(self, key: _K) -> _V:
|
|
116
|
+
"""Remove and return the first value for a key."""
|
|
117
|
+
if key not in self._key_indices:
|
|
118
|
+
raise KeyError(key)
|
|
119
|
+
|
|
120
|
+
indices = self._key_indices[key]
|
|
121
|
+
first_index = indices[0]
|
|
122
|
+
value = self._items[first_index][1]
|
|
123
|
+
|
|
124
|
+
# Mark the first item for removal
|
|
125
|
+
self._items[first_index] = None # type: ignore[call-overload]
|
|
126
|
+
|
|
127
|
+
# Filter out None items and rebuild indices
|
|
128
|
+
self._items = [item for item in self._items if item is not None]
|
|
129
|
+
self._rebuild_indices()
|
|
130
|
+
|
|
131
|
+
return value
|
|
132
|
+
|
|
133
|
+
@override
|
|
134
|
+
def __delitem__(self, key: _K) -> None:
|
|
135
|
+
"""Remove all values for a key.
|
|
136
|
+
|
|
137
|
+
Raises a `KeyError` if the key is not found.
|
|
138
|
+
"""
|
|
139
|
+
if key not in self._key_indices:
|
|
140
|
+
raise KeyError(key)
|
|
141
|
+
|
|
142
|
+
# Mark items for removal
|
|
143
|
+
indices_to_remove = self._key_indices[key]
|
|
144
|
+
for idx in indices_to_remove:
|
|
145
|
+
self._items[idx] = None # type: ignore[call-overload]
|
|
146
|
+
|
|
147
|
+
# Filter out None items and rebuild indices
|
|
148
|
+
self._items = [item for item in self._items if item is not None]
|
|
149
|
+
self._rebuild_indices()
|
|
150
|
+
|
|
151
|
+
@override
|
|
152
|
+
def __iter__(self) -> Iterator[_K]:
|
|
153
|
+
"""Return an iterator over the keys, in insertion order.
|
|
154
|
+
|
|
155
|
+
Keys with multiple values will be yielded multiple times.
|
|
156
|
+
"""
|
|
157
|
+
return (k for k, _ in self._items)
|
|
158
|
+
|
|
159
|
+
@override
|
|
160
|
+
def __len__(self) -> int:
|
|
161
|
+
"""Return the total number of items."""
|
|
162
|
+
return len(self._items)
|
|
163
|
+
|
|
164
|
+
@override
|
|
165
|
+
def clear(self) -> None:
|
|
166
|
+
"""Remove all items from the multi-mapping."""
|
|
167
|
+
self._items.clear()
|
|
168
|
+
self._key_indices.clear()
|
|
169
|
+
|
|
170
|
+
def _collect_update_items(
|
|
171
|
+
self,
|
|
172
|
+
all_items: list[tuple[_K, _V]],
|
|
173
|
+
existing_keys: set[_K],
|
|
174
|
+
) -> tuple[dict[_K, list[_V]], list[tuple[_K, _V]]]:
|
|
175
|
+
"""Separate items into updates and additions."""
|
|
176
|
+
updates_by_key: dict[_K, list[_V]] = {} # key -> list of values to replace with
|
|
177
|
+
additions = [] # list of (key, value) for new keys
|
|
178
|
+
|
|
179
|
+
for key, value in all_items:
|
|
180
|
+
if key in existing_keys:
|
|
181
|
+
if key not in updates_by_key:
|
|
182
|
+
updates_by_key[key] = []
|
|
183
|
+
updates_by_key[key].append(value)
|
|
184
|
+
else:
|
|
185
|
+
additions.append((key, value))
|
|
186
|
+
|
|
187
|
+
return updates_by_key, additions
|
|
188
|
+
|
|
189
|
+
def _process_updates(self, updates_by_key: dict[_K, list[_V]]) -> None:
|
|
190
|
+
"""Process updates efficiently by batch removing and adding."""
|
|
191
|
+
# Mark items for removal that need to be replaced
|
|
192
|
+
items_to_remove = set()
|
|
193
|
+
for key in updates_by_key:
|
|
194
|
+
items_to_remove.update(self._key_indices[key])
|
|
195
|
+
|
|
196
|
+
# Mark items for removal
|
|
197
|
+
for idx in items_to_remove:
|
|
198
|
+
self._items[idx] = None # type: ignore[call-overload]
|
|
199
|
+
|
|
200
|
+
# Filter out None items
|
|
201
|
+
self._items = [item for item in self._items if item is not None]
|
|
202
|
+
|
|
203
|
+
# Add updated items (all values for each key)
|
|
204
|
+
for key, values in updates_by_key.items():
|
|
205
|
+
for value in values:
|
|
206
|
+
self._items.append((key, value))
|
|
207
|
+
|
|
208
|
+
@override
|
|
209
|
+
def update( # type: ignore[override]
|
|
210
|
+
self,
|
|
211
|
+
other: Mapping[_K, _V] | Iterable[Sequence[_K | _V]] = (),
|
|
212
|
+
**kwargs: _V,
|
|
213
|
+
) -> None:
|
|
214
|
+
"""Update the multi-mapping with items from another object.
|
|
215
|
+
|
|
216
|
+
This replaces existing values for keys found in the other object.
|
|
217
|
+
This is optimized for batch operations.
|
|
218
|
+
"""
|
|
219
|
+
# Collect all items first
|
|
220
|
+
items = other.items() if isinstance(other, Mapping) else other
|
|
221
|
+
items = itertools.chain(items, kwargs.items()) # type: ignore[arg-type]
|
|
222
|
+
all_items = list(items)
|
|
223
|
+
|
|
224
|
+
if not all_items:
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
# Get existing keys once for efficiency
|
|
228
|
+
existing_keys = set(self._key_indices.keys())
|
|
229
|
+
|
|
230
|
+
# Separate items into updates and additions
|
|
231
|
+
updates_by_key, additions = self._collect_update_items(all_items, existing_keys) # type: ignore[arg-type]
|
|
232
|
+
|
|
233
|
+
# Process updates efficiently
|
|
234
|
+
if updates_by_key:
|
|
235
|
+
self._process_updates(updates_by_key)
|
|
236
|
+
|
|
237
|
+
# Add new items
|
|
238
|
+
if additions:
|
|
239
|
+
self._items.extend(additions)
|
|
240
|
+
|
|
241
|
+
# Rebuild indices once at the end
|
|
242
|
+
if updates_by_key or additions:
|
|
243
|
+
self._rebuild_indices()
|
|
244
|
+
|
|
245
|
+
@override
|
|
246
|
+
def merge(
|
|
247
|
+
self,
|
|
248
|
+
other: Mapping[_K, _V] | Iterable[Sequence[_K | _V]] = (),
|
|
249
|
+
**kwargs: _V,
|
|
250
|
+
) -> None:
|
|
251
|
+
"""Merge another object into the multi-mapping.
|
|
252
|
+
|
|
253
|
+
Keys from `other` that already exist in the multi-mapping will not be added.
|
|
254
|
+
This is optimized for batch operations.
|
|
255
|
+
"""
|
|
256
|
+
# Get existing keys once for efficiency
|
|
257
|
+
existing_keys = set(self._key_indices.keys())
|
|
258
|
+
|
|
259
|
+
# Collect all items and filter out existing keys
|
|
260
|
+
items = other.items() if isinstance(other, Mapping) else other
|
|
261
|
+
items = itertools.chain(items, kwargs.items()) # type: ignore[arg-type]
|
|
262
|
+
new_items = [(key, value) for key, value in items if key not in existing_keys]
|
|
263
|
+
|
|
264
|
+
if not new_items:
|
|
265
|
+
return
|
|
266
|
+
|
|
267
|
+
# Add all items to the list at once
|
|
268
|
+
start_index = len(self._items)
|
|
269
|
+
self._items.extend(new_items) # type: ignore[arg-type]
|
|
270
|
+
|
|
271
|
+
# Update indices incrementally for better performance
|
|
272
|
+
for i, (key, _) in enumerate(new_items, start_index):
|
|
273
|
+
if key not in self._key_indices:
|
|
274
|
+
self._key_indices[key] = [] # type: ignore[index]
|
|
275
|
+
self._key_indices[key].append(i) # type: ignore[index]
|
|
276
|
+
|
|
277
|
+
@override
|
|
278
|
+
def extend(
|
|
279
|
+
self,
|
|
280
|
+
other: Mapping[_K, _V] | Iterable[Sequence[_K | _V]] = (),
|
|
281
|
+
**kwargs: _V,
|
|
282
|
+
) -> None:
|
|
283
|
+
"""Extend the multi-mapping with items from another object.
|
|
284
|
+
|
|
285
|
+
This is optimized for batch operations to avoid rebuilding indices
|
|
286
|
+
multiple times.
|
|
287
|
+
"""
|
|
288
|
+
# Collect all new items first
|
|
289
|
+
items = other.items() if isinstance(other, Mapping) else other
|
|
290
|
+
items = itertools.chain(items, kwargs.items()) # type: ignore[arg-type]
|
|
291
|
+
new_items = list(items)
|
|
292
|
+
|
|
293
|
+
if not new_items:
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
# Add all items to the list at once
|
|
297
|
+
start_index = len(self._items)
|
|
298
|
+
self._items.extend(new_items) # type: ignore[arg-type]
|
|
299
|
+
|
|
300
|
+
# Update indices incrementally for better performance
|
|
301
|
+
for i, (key, _) in enumerate(new_items, start_index):
|
|
302
|
+
if key not in self._key_indices:
|
|
303
|
+
self._key_indices[key] = [] # type: ignore[index]
|
|
304
|
+
self._key_indices[key].append(i) # type: ignore[index]
|
|
305
|
+
|
|
306
|
+
def copy(self) -> MultiDict[_K, _V]:
|
|
307
|
+
"""Return a shallow copy of the MultiDict."""
|
|
308
|
+
new_md = MultiDict.__new__(MultiDict)
|
|
309
|
+
new_md._items = self._items.copy() # noqa: SLF001
|
|
310
|
+
new_md._key_indices = {k: v.copy() for k, v in self._key_indices.items()} # noqa: SLF001
|
|
311
|
+
return new_md
|
|
312
|
+
|
|
313
|
+
@override
|
|
314
|
+
def __repr__(self) -> str:
|
|
315
|
+
"""Return a string representation of the MultiDict."""
|
|
316
|
+
return f"{self.__class__.__name__}({list(self._items)!r})"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if sys.version_info >= (3, 12):
|
|
6
|
+
from typing import override
|
|
7
|
+
else:
|
|
8
|
+
try:
|
|
9
|
+
from typing_extensions import override
|
|
10
|
+
except ImportError: # pragma: nocover
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
raise
|
|
13
|
+
|
|
14
|
+
def override(func: Callable) -> Callable:
|
|
15
|
+
"""Fallback override decorator that does nothing."""
|
|
16
|
+
return func
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
__all__ = ["override"]
|