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.
Files changed (22) hide show
  1. {multicollections-0.2.0 → multicollections-0.3.0}/.github/workflows/ci.yml +2 -2
  2. {multicollections-0.2.0 → multicollections-0.3.0}/PKG-INFO +2 -2
  3. {multicollections-0.2.0 → multicollections-0.3.0}/README.md +1 -1
  4. multicollections-0.3.0/multicollections/__init__.py +316 -0
  5. multicollections-0.3.0/multicollections/_util.py +19 -0
  6. {multicollections-0.2.0 → multicollections-0.3.0}/multicollections/abc.py +101 -41
  7. {multicollections-0.2.0 → multicollections-0.3.0}/pyproject.toml +13 -4
  8. {multicollections-0.2.0 → multicollections-0.3.0}/tests/minimalimpl.py +9 -2
  9. {multicollections-0.2.0 → multicollections-0.3.0}/tests/test_multicollections.py +304 -105
  10. multicollections-0.2.0/multicollections/__init__.py +0 -156
  11. {multicollections-0.2.0 → multicollections-0.3.0}/.github/dependabot.yml +0 -0
  12. {multicollections-0.2.0 → multicollections-0.3.0}/.github/workflows/pypi-publish.yml +0 -0
  13. {multicollections-0.2.0 → multicollections-0.3.0}/.gitignore +0 -0
  14. {multicollections-0.2.0 → multicollections-0.3.0}/.readthedocs.yaml +0 -0
  15. {multicollections-0.2.0 → multicollections-0.3.0}/LICENSE.txt +0 -0
  16. {multicollections-0.2.0 → multicollections-0.3.0}/docs/api/abc.md +0 -0
  17. {multicollections-0.2.0 → multicollections-0.3.0}/docs/api/multicollections.md +0 -0
  18. {multicollections-0.2.0 → multicollections-0.3.0}/docs/index.md +0 -0
  19. {multicollections-0.2.0 → multicollections-0.3.0}/mkdocs.yml +0 -0
  20. {multicollections-0.2.0 → multicollections-0.3.0}/multicollections/py.typed +0 -0
  21. {multicollections-0.2.0 → multicollections-0.3.0}/tests/__init__.py +0 -0
  22. {multicollections-0.2.0 → multicollections-0.3.0}/tests/ruff.toml +0 -0
@@ -33,9 +33,9 @@ jobs:
33
33
  uses: actions/setup-python@v5
34
34
  with:
35
35
  python-version-file: pyproject.toml
36
- - name: Check types with ty
36
+ - name: Check types with mypy
37
37
  run: |
38
- uv run ty check
38
+ uv run mypy
39
39
 
40
40
  test:
41
41
  runs-on: ubuntu-22.04
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multicollections
3
- Version: 0.2.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
  [![Documentation](https://img.shields.io/readthedocs/multicollections)](https://multicollections.readthedocs.io/)
37
37
  [![CI](https://github.com/gerlero/multicollections/actions/workflows/ci.yml/badge.svg)](https://github.com/gerlero/multicollections/actions/workflows/ci.yml)
38
38
  [![Codecov](https://codecov.io/gh/gerlero/multicollections/branch/main/graph/badge.svg)](https://codecov.io/gh/gerlero/multicollections)
39
+ [![Checked with mypy](http://www.mypy-lang.org/static/mypy_badge.svg)](http://mypy-lang.org/)
39
40
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
40
- [![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty)
41
41
  [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
42
42
  [![Publish](https://github.com/gerlero/multicollections/actions/workflows/pypi-publish.yml/badge.svg)](https://github.com/gerlero/multicollections/actions/workflows/pypi-publish.yml)
43
43
  [![PyPI](https://img.shields.io/pypi/v/multicollections)](https://pypi.org/project/multicollections/)
@@ -5,8 +5,8 @@ A fully generic [`MultiDict`](https://multicollections.readthedocs.io/en/latest/
5
5
  [![Documentation](https://img.shields.io/readthedocs/multicollections)](https://multicollections.readthedocs.io/)
6
6
  [![CI](https://github.com/gerlero/multicollections/actions/workflows/ci.yml/badge.svg)](https://github.com/gerlero/multicollections/actions/workflows/ci.yml)
7
7
  [![Codecov](https://codecov.io/gh/gerlero/multicollections/branch/main/graph/badge.svg)](https://codecov.io/gh/gerlero/multicollections)
8
+ [![Checked with mypy](http://www.mypy-lang.org/static/mypy_badge.svg)](http://mypy-lang.org/)
8
9
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
9
- [![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty)
10
10
  [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
11
11
  [![Publish](https://github.com/gerlero/multicollections/actions/workflows/pypi-publish.yml/badge.svg)](https://github.com/gerlero/multicollections/actions/workflows/pypi-publish.yml)
12
12
  [![PyPI](https://img.shields.io/pypi/v/multicollections)](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"]