multicollections 0.2.0__tar.gz → 0.2.1__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.2.1}/PKG-INFO +1 -1
  2. multicollections-0.2.1/multicollections/__init__.py +315 -0
  3. multicollections-0.2.1/multicollections/_util.py +16 -0
  4. {multicollections-0.2.0 → multicollections-0.2.1}/multicollections/abc.py +49 -9
  5. {multicollections-0.2.0 → multicollections-0.2.1}/pyproject.toml +6 -2
  6. {multicollections-0.2.0 → multicollections-0.2.1}/tests/minimalimpl.py +7 -0
  7. {multicollections-0.2.0 → multicollections-0.2.1}/tests/test_multicollections.py +211 -0
  8. multicollections-0.2.0/multicollections/__init__.py +0 -156
  9. {multicollections-0.2.0 → multicollections-0.2.1}/.github/dependabot.yml +0 -0
  10. {multicollections-0.2.0 → multicollections-0.2.1}/.github/workflows/ci.yml +0 -0
  11. {multicollections-0.2.0 → multicollections-0.2.1}/.github/workflows/pypi-publish.yml +0 -0
  12. {multicollections-0.2.0 → multicollections-0.2.1}/.gitignore +0 -0
  13. {multicollections-0.2.0 → multicollections-0.2.1}/.readthedocs.yaml +0 -0
  14. {multicollections-0.2.0 → multicollections-0.2.1}/LICENSE.txt +0 -0
  15. {multicollections-0.2.0 → multicollections-0.2.1}/README.md +0 -0
  16. {multicollections-0.2.0 → multicollections-0.2.1}/docs/api/abc.md +0 -0
  17. {multicollections-0.2.0 → multicollections-0.2.1}/docs/api/multicollections.md +0 -0
  18. {multicollections-0.2.0 → multicollections-0.2.1}/docs/index.md +0 -0
  19. {multicollections-0.2.0 → multicollections-0.2.1}/mkdocs.yml +0 -0
  20. {multicollections-0.2.0 → multicollections-0.2.1}/multicollections/py.typed +0 -0
  21. {multicollections-0.2.0 → multicollections-0.2.1}/tests/__init__.py +0 -0
  22. {multicollections-0.2.0 → multicollections-0.2.1}/tests/ruff.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multicollections
3
- Version: 0.2.0
3
+ Version: 0.2.1
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
@@ -0,0 +1,315 @@
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)
49
+
50
+ # Add kwargs items
51
+ self._items.extend((key, value) for key, value in kwargs.items())
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
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
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
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 = {} # 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
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(
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())
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)
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())
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)
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] = []
275
+ self._key_indices[key].append(i)
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())
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)
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] = []
304
+ self._key_indices[key].append(i)
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
+ def __repr__(self) -> str:
314
+ """Return a string representation of the MultiDict."""
315
+ return f"{self.__class__.__name__}({list(self._items)!r})"
@@ -0,0 +1,16 @@
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
@@ -8,7 +8,7 @@ import itertools
8
8
  import sys
9
9
  from abc import abstractmethod
10
10
  from collections import defaultdict
11
- from typing import Generic, TypeVar
11
+ from typing import TYPE_CHECKING, Generic, TypeVar, overload
12
12
 
13
13
  if sys.version_info >= (3, 9):
14
14
  from collections.abc import (
@@ -33,14 +33,21 @@ else:
33
33
  Sequence,
34
34
  )
35
35
 
36
+ if TYPE_CHECKING: # pragma: no cover
37
+ from typing import Protocol
38
+
39
+ from ._util import override
40
+
36
41
  _K = TypeVar("_K")
37
42
  _V = TypeVar("_V")
38
43
  _D = TypeVar("_D")
44
+ _Self = TypeVar("_Self")
39
45
 
40
46
 
41
47
  class MultiMappingView(MappingView, Collection):
42
48
  """Base class for MultiMapping views."""
43
49
 
50
+ @override
44
51
  def __init__(self, mapping: MultiMapping[_K, _V]) -> None:
45
52
  """Initialize the view with the given mapping."""
46
53
  super().__init__(mapping)
@@ -49,10 +56,12 @@ class MultiMappingView(MappingView, Collection):
49
56
  class KeysView(MultiMappingView):
50
57
  """View for the keys in a MultiMapping."""
51
58
 
52
- def __contains__(self, key: _K) -> bool:
59
+ @override
60
+ def __contains__(self, key: object) -> bool:
53
61
  """Check if the key is in the mapping."""
54
62
  return key in self._mapping
55
63
 
64
+ @override
56
65
  def __iter__(self) -> Iterator[_K]:
57
66
  """Return an iterator over the keys."""
58
67
  return iter(self._mapping)
@@ -61,17 +70,22 @@ class KeysView(MultiMappingView):
61
70
  class ItemsView(MultiMappingView):
62
71
  """View for the items (key-value pairs) in a MultiMapping."""
63
72
 
64
- def __contains__(self, item: tuple[_K, _V]) -> bool:
73
+ @override
74
+ def __contains__(self, item: object) -> bool:
65
75
  """Check if the item is in the mapping."""
66
- key, value = item
76
+ try:
77
+ key, value = item # ty: ignore[not-iterable]
78
+ except TypeError:
79
+ return False
67
80
  try:
68
81
  return value in self._mapping.getall(key)
69
82
  except KeyError:
70
83
  return False
71
84
 
85
+ @override
72
86
  def __iter__(self) -> Iterator[tuple[_K, _V]]:
73
87
  """Return an iterator over the items (key-value pairs)."""
74
- counts = defaultdict(int)
88
+ counts: defaultdict[_K, int] = defaultdict(int)
75
89
  for k in self._mapping:
76
90
  yield (k, self._mapping.getall(k)[counts[k]])
77
91
  counts[k] += 1
@@ -80,10 +94,12 @@ class ItemsView(MultiMappingView):
80
94
  class ValuesView(MultiMappingView):
81
95
  """View for the values in a MultiMapping."""
82
96
 
83
- def __contains__(self, value: _V) -> bool:
97
+ @override
98
+ def __contains__(self, value: object) -> bool:
84
99
  """Check if the value is in the mapping."""
85
100
  return value in iter(self)
86
101
 
102
+ @override
87
103
  def __iter__(self) -> Iterator[_V]:
88
104
  """Return an iterator over the values."""
89
105
  yield from (v for _, v in self._mapping.items())
@@ -95,15 +111,30 @@ class _NoDefault:
95
111
 
96
112
  _NO_DEFAULT = _NoDefault()
97
113
 
114
+ if TYPE_CHECKING: # pragma: no cover
115
+
116
+ class _CallableWithDefault(Protocol[_Self, _K, _V, _D]):
117
+ @overload
118
+ def __call__(self: _Self, key: _K) -> _V: ...
119
+
120
+ @overload
121
+ def __call__(self: _Self, key: _K, default: _D) -> _V | _D: ...
122
+
98
123
 
99
124
  def with_default(
100
- meth: Callable[[MultiMappingView[_K, _V], _K], _V],
101
- ) -> Callable[[MultiMappingView[_K, _V], _K, _D], _V | _D]:
125
+ meth: Callable[[_Self, _K], _V],
126
+ ) -> _CallableWithDefault[_Self, _K, _V, _D]:
102
127
  """Add a default value argument to a method that can raise a `KeyError`."""
103
128
 
129
+ @overload
130
+ def wrapper(self: _Self, key: _K) -> _V: ... # pragma: no cover
131
+
132
+ @overload
133
+ def wrapper(self: _Self, key: _K, default: _D) -> _V | _D: ... # pragma: no cover
134
+
104
135
  @functools.wraps(meth)
105
136
  def wrapper(
106
- self: MultiMappingView[_K, _V], key: _K, default: _D | _NoDefault = _NO_DEFAULT
137
+ self: _Self, key: _K, default: _D | _NoDefault = _NO_DEFAULT
107
138
  ) -> _V | _D:
108
139
  try:
109
140
  return meth(self, key)
@@ -152,6 +183,7 @@ class MultiMapping(Mapping[_K, _V], Generic[_K, _V]):
152
183
  """
153
184
  return self.getall(key)[0]
154
185
 
186
+ @override
155
187
  def __getitem__(self, key: _K) -> _V:
156
188
  """Get the first value for a key.
157
189
 
@@ -159,14 +191,17 @@ class MultiMapping(Mapping[_K, _V], Generic[_K, _V]):
159
191
  """
160
192
  return self.getone(key)
161
193
 
194
+ @override
162
195
  def keys(self) -> KeysView[_K]:
163
196
  """Return a view of the keys in the MultiMapping."""
164
197
  return KeysView(self)
165
198
 
199
+ @override
166
200
  def items(self) -> ItemsView[_K, _V]:
167
201
  """Return a view of the items (key-value pairs) in the MultiMapping."""
168
202
  return ItemsView(self)
169
203
 
204
+ @override
170
205
  def values(self) -> ValuesView[_V]:
171
206
  """Return a view of the values in the MultiMapping."""
172
207
  return ValuesView(self)
@@ -226,6 +261,7 @@ class MutableMultiMapping(MultiMapping[_K, _V], MutableMapping[_K, _V]):
226
261
  value = self.popone(key)
227
262
  return key, value
228
263
 
264
+ @override
229
265
  def __delitem__(self, key: _K) -> None:
230
266
  """Remove all values for a key.
231
267
 
@@ -233,11 +269,13 @@ class MutableMultiMapping(MultiMapping[_K, _V], MutableMapping[_K, _V]):
233
269
  """
234
270
  self.popall(key)
235
271
 
272
+ @override
236
273
  def clear(self) -> None:
237
274
  """Remove all items from the multi-mapping."""
238
275
  for key in set(self.keys()):
239
276
  self.popall(key)
240
277
 
278
+ @override
241
279
  def extend(
242
280
  self,
243
281
  other: Mapping[_K, _V] | Iterable[Sequence[_K | _V]] = (),
@@ -249,6 +287,7 @@ class MutableMultiMapping(MultiMapping[_K, _V], MutableMapping[_K, _V]):
249
287
  for key, value in items:
250
288
  self.add(key, value)
251
289
 
290
+ @override
252
291
  def merge(
253
292
  self,
254
293
  other: Mapping[_K, _V] | Iterable[Sequence[_K | _V]] = (),
@@ -265,6 +304,7 @@ class MutableMultiMapping(MultiMapping[_K, _V], MutableMapping[_K, _V]):
265
304
  if key not in existing_keys:
266
305
  self.add(key, value)
267
306
 
307
+ @override
268
308
  def update(
269
309
  self,
270
310
  other: Mapping[_K, _V] | Iterable[Sequence[_K | _V]] = (),
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "multicollections"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  authors = [
9
9
  { name = "Gabriel S. Gerlero", email = "ggerlero@cimec.unl.edu.ar" },
10
10
  ]
@@ -31,9 +31,13 @@ requires-python = ">=3.7"
31
31
 
32
32
  [dependency-groups]
33
33
  lint = ["ruff"]
34
- typing = ["ty; python_version>='3.8'"]
34
+ typing = [
35
+ "ty; python_version>='3.8'",
36
+ "typing_extensions >= 4.4.0; python_version<'3.12'"
37
+ ]
35
38
  test = [
36
39
  "multidict>=6,<7",
40
+ "typing_extensions >= 4.4.0; python_version<'3.12'",
37
41
  "pytest>=7,<9",
38
42
  "pytest-cov"
39
43
  ]
@@ -18,6 +18,7 @@ else:
18
18
  Sequence,
19
19
  )
20
20
 
21
+ from multicollections._util import override
21
22
  from multicollections.abc import MutableMultiMapping, with_default
22
23
 
23
24
  _K = TypeVar("_K")
@@ -38,6 +39,7 @@ class ListMultiDict(MutableMultiMapping[_K, _V]):
38
39
  for key, value in kwargs.items():
39
40
  self._items.append((key, value))
40
41
 
42
+ @override
41
43
  @with_default
42
44
  def getall(self, key: _K) -> list[_V]:
43
45
  ret = [v for k, v in self._items if k == key]
@@ -45,6 +47,7 @@ class ListMultiDict(MutableMultiMapping[_K, _V]):
45
47
  raise KeyError(key)
46
48
  return ret
47
49
 
50
+ @override
48
51
  def __setitem__(self, key: _K, value: _V) -> None:
49
52
  replaced: int | None = None
50
53
  for i, (k, _) in enumerate(self._items):
@@ -62,9 +65,11 @@ class ListMultiDict(MutableMultiMapping[_K, _V]):
62
65
  else:
63
66
  self._items.append((key, value))
64
67
 
68
+ @override
65
69
  def add(self, key: _K, value: _V) -> None:
66
70
  self._items.append((key, value))
67
71
 
72
+ @override
68
73
  @with_default
69
74
  def popone(self, key: _K) -> _V:
70
75
  for i, (k, v) in enumerate(self._items):
@@ -73,8 +78,10 @@ class ListMultiDict(MutableMultiMapping[_K, _V]):
73
78
  return v
74
79
  raise KeyError(key)
75
80
 
81
+ @override
76
82
  def __iter__(self) -> Iterator[_K]:
77
83
  return (k for k, _ in self._items)
78
84
 
85
+ @override
79
86
  def __len__(self) -> int:
80
87
  return len(self._items)
@@ -213,6 +213,9 @@ def test_items_view_contains(cls: type[MutableMultiMapping]) -> None:
213
213
  assert ("c", 1) not in items # Key doesn't exist
214
214
  assert ("b", 1) not in items # Wrong value for existing key
215
215
 
216
+ # Test different type
217
+ assert None not in items
218
+
216
219
  # Test empty case
217
220
  empty_md = cls()
218
221
  empty_items = empty_md.items()
@@ -267,6 +270,8 @@ def test_contains(cls: type[MutableMultiMapping]) -> None:
267
270
  assert "a" in md
268
271
  assert "b" in md
269
272
  assert "missing" not in md
273
+ if cls is not multidict.MultiDict or sys.version_info >= (3, 9):
274
+ assert None not in md
270
275
 
271
276
  # Test with empty MultiDict
272
277
  empty_md = cls()
@@ -805,3 +810,209 @@ def test_edge_cases_multidict_specific() -> None:
805
810
 
806
811
  assert len(md._items) == 2 # b and c # noqa: SLF001
807
812
  assert md._key_indices["c"] == [1] # Only one index for c # noqa: SLF001
813
+
814
+
815
+ @pytest.mark.parametrize("cls", [MultiDict, multidict.MultiDict])
816
+ def test_copy_method(cls: type[MultiDict | multidict.MultiDict]) -> None:
817
+ """Test copy() method."""
818
+ # Test copying empty MultiDict
819
+ empty_md = cls()
820
+ empty_copy = empty_md.copy()
821
+ assert len(empty_copy) == 0
822
+ assert list(empty_copy.items()) == []
823
+ assert empty_md is not empty_copy # Different objects
824
+
825
+ # Test copying MultiDict with single item
826
+ single_md = cls([("a", 1)]) # ty: ignore [too-many-positional-arguments]
827
+ single_copy = single_md.copy()
828
+ assert len(single_copy) == 1
829
+ assert list(single_copy.items()) == [("a", 1)]
830
+ assert single_md is not single_copy # Different objects
831
+
832
+ # Test copying MultiDict with multiple items including duplicates
833
+ md = cls([("a", 1), ("b", 2), ("a", 3), ("c", 4)]) # ty: ignore [too-many-positional-arguments]
834
+ copied = md.copy()
835
+
836
+ # Test that content is identical
837
+ assert len(copied) == len(md)
838
+ assert list(copied.items()) == list(md.items())
839
+ assert list(copied.keys()) == list(md.keys())
840
+ assert list(copied.values()) == list(md.values())
841
+
842
+ # Test that duplicate keys are preserved
843
+ assert copied.getall("a") == md.getall("a") == [1, 3]
844
+
845
+ # Test that they are different objects
846
+ assert md is not copied
847
+
848
+ # Test that changes to original don't affect copy
849
+ md.add("d", 5)
850
+ assert len(md) == 5
851
+ assert len(copied) == 4
852
+ assert "d" not in copied
853
+
854
+ # Test that changes to copy don't affect original
855
+ copied.add("e", 6)
856
+ assert len(copied) == 5
857
+ assert len(md) == 5
858
+ assert "e" not in md
859
+
860
+ # Test that copy() creates a shallow copy (values are not deep copied)
861
+ value_list = [1, 2, 3]
862
+ md_with_mutable = cls([("key", value_list)]) # ty: ignore [too-many-positional-arguments]
863
+ copied_with_mutable = md_with_mutable.copy()
864
+
865
+ # The list should be the same object (shallow copy)
866
+ assert copied_with_mutable["key"] is value_list
867
+ assert md_with_mutable["key"] is value_list
868
+
869
+
870
+ # Additional tests for uncovered lines
871
+ def test_empty_init_no_rebuild() -> None:
872
+ """Test that empty MultiDict initialization doesn't call _rebuild_indices."""
873
+ # This tests line 53-54: if self._items: and the else branch
874
+ md = MultiDict()
875
+ assert len(md._items) == 0 # noqa: SLF001
876
+ assert len(md._key_indices) == 0 # noqa: SLF001
877
+
878
+
879
+ def test_update_with_empty_input() -> None:
880
+ """Test update with empty input to cover early return."""
881
+ # This tests line 214-215: if not all_items: return
882
+ md = MultiDict([("a", 1)])
883
+ md.update() # Empty input
884
+ assert len(md) == 1
885
+ assert md["a"] == 1
886
+
887
+ md.update([]) # Empty list
888
+ assert len(md) == 1
889
+ assert md["a"] == 1
890
+
891
+
892
+ def test_merge_with_empty_input() -> None:
893
+ """Test merge with empty input to cover early return."""
894
+ # This tests line 253-254: if not new_items: return
895
+ md = MultiDict([("a", 1)])
896
+ md.merge() # Empty input
897
+ assert len(md) == 1
898
+ assert md["a"] == 1
899
+
900
+ md.merge([]) # Empty list
901
+ assert len(md) == 1
902
+ assert md["a"] == 1
903
+
904
+
905
+ def test_extend_with_empty_input() -> None:
906
+ """Test extend with empty input to cover early return."""
907
+ # This tests line 281-282: if not new_items: return
908
+ md = MultiDict([("a", 1)])
909
+ md.extend() # Empty input
910
+ assert len(md) == 1
911
+ assert md["a"] == 1
912
+
913
+ md.extend([]) # Empty list
914
+ assert len(md) == 1
915
+ assert md["a"] == 1
916
+
917
+
918
+ def test_merge_all_existing_keys() -> None:
919
+ """Test merge where all keys already exist."""
920
+ # This tests the case where new_items becomes empty after filtering
921
+ md = MultiDict([("a", 1), ("b", 2)])
922
+ md.merge([("a", 999), ("b", 888)]) # All keys exist, should be filtered out
923
+ assert len(md) == 2
924
+ assert md["a"] == 1 # Should remain unchanged
925
+ assert md["b"] == 2 # Should remain unchanged
926
+
927
+
928
+ def test_merge_existing_key_in_indices() -> None:
929
+ """Test merge with key that already exists in indices."""
930
+ # This tests line 262-263: if key not in self._key_indices
931
+ md = MultiDict([("a", 1)])
932
+ existing_keys = set(md._key_indices.keys()) # noqa: SLF001
933
+ assert "a" in existing_keys
934
+
935
+ # Since merge filters out existing keys, we test the else branch
936
+ # by adding a new key
937
+ md.merge([("b", 2)])
938
+ assert "b" in md._key_indices # noqa: SLF001
939
+ assert md["b"] == 2
940
+
941
+
942
+ def test_extend_existing_key_in_indices() -> None:
943
+ """Test extend with key that already exists in indices."""
944
+ # This tests line 290-291: if key not in self._key_indices
945
+ md = MultiDict([("a", 1)])
946
+
947
+ # Extend with existing key - should add to existing key's index list
948
+ md.extend([("a", 2)])
949
+ assert len(md._key_indices["a"]) == 2 # noqa: SLF001
950
+ assert md._key_indices["a"] == [0, 1] # noqa: SLF001
951
+
952
+
953
+ def test_update_only_updates_no_additions() -> None:
954
+ """Test update with only existing keys (no new keys)."""
955
+ # This tests line 228-229: if additions: branch when additions is empty
956
+ md = MultiDict([("a", 1), ("b", 2)])
957
+ md.update([("a", 999), ("b", 888)]) # Only existing keys
958
+
959
+ assert len(md) == 2
960
+ assert md["a"] == 999
961
+ assert md["b"] == 888
962
+
963
+
964
+ def test_update_only_additions_no_updates() -> None:
965
+ """Test update with only new keys (no existing keys)."""
966
+ # This tests line 224-225: if updates_by_key: branch when updates_by_key is empty
967
+ md = MultiDict([("a", 1)])
968
+ md.update([("b", 2), ("c", 3)]) # Only new keys
969
+
970
+ assert len(md) == 3
971
+ assert md["a"] == 1 # Original unchanged
972
+ assert md["b"] == 2
973
+ assert md["c"] == 3
974
+
975
+
976
+ def test_init_with_no_items_and_no_kwargs() -> None:
977
+ """Test initialization with completely empty input."""
978
+ # This tests the case where _items remains empty after all initialization
979
+ md = MultiDict(()) # Empty tuple
980
+ assert len(md._items) == 0 # noqa: SLF001
981
+ assert len(md._key_indices) == 0 # noqa: SLF001
982
+
983
+
984
+ def test_collect_update_items_edge_cases() -> None:
985
+ """Test _collect_update_items helper method edge cases."""
986
+ md = MultiDict([("a", 1)])
987
+
988
+ # Test with empty all_items
989
+ updates, additions = md._collect_update_items([], set()) # noqa: SLF001
990
+ assert updates == {}
991
+ assert additions == []
992
+
993
+ # Test with all new keys
994
+ updates, additions = md._collect_update_items([("b", 2), ("c", 3)], set()) # noqa: SLF001
995
+ assert updates == {}
996
+ assert additions == [("b", 2), ("c", 3)]
997
+
998
+ # Test with all existing keys
999
+ updates, additions = md._collect_update_items([("a", 999)], {"a"}) # noqa: SLF001
1000
+ assert updates == {"a": [999]}
1001
+ assert additions == []
1002
+
1003
+
1004
+ def test_process_updates_edge_cases() -> None:
1005
+ """Test _process_updates helper method edge cases."""
1006
+ md = MultiDict([("a", 1), ("b", 2), ("a", 3)])
1007
+ original_len = len(md)
1008
+
1009
+ # Test with empty updates
1010
+ md._process_updates({}) # noqa: SLF001
1011
+ assert len(md) == original_len # Should remain unchanged
1012
+
1013
+ # Test with single key update - need to rebuild indices after _process_updates
1014
+ md = MultiDict([("a", 1), ("b", 2), ("a", 3)])
1015
+ md._process_updates({"a": [999]}) # noqa: SLF001
1016
+ md._rebuild_indices() # _process_updates doesn't rebuild indices # noqa: SLF001
1017
+ assert md["a"] == 999
1018
+ assert md["b"] == 2
@@ -1,156 +0,0 @@
1
- """Fully generic `MultiDict` class."""
2
-
3
- from __future__ import annotations
4
-
5
- import sys
6
- from typing import TypeVar
7
-
8
- if sys.version_info >= (3, 9):
9
- from collections.abc import (
10
- Iterable,
11
- Iterator,
12
- Mapping,
13
- Sequence,
14
- )
15
- else:
16
- from typing import (
17
- Iterable,
18
- Iterator,
19
- Mapping,
20
- Sequence,
21
- )
22
-
23
- from .abc import MutableMultiMapping, with_default
24
-
25
- _K = TypeVar("_K")
26
- _V = TypeVar("_V")
27
-
28
-
29
- class MultiDict(MutableMultiMapping[_K, _V]):
30
- """A fully generic dictionary that allows multiple values with the same key.
31
-
32
- Preserves insertion order.
33
- """
34
-
35
- def __init__(
36
- self, iterable: Mapping[_K, _V] | Iterable[Sequence[_K | _V]] = (), **kwargs: _V
37
- ) -> None:
38
- """Create a MultiDict."""
39
- self._items: list[tuple[_K, _V]] = []
40
- self._key_indices: dict[_K, list[int]] = {}
41
- if isinstance(iterable, Mapping):
42
- for key, value in iterable.items():
43
- self._add_item(key, value)
44
- else:
45
- for key, value in iterable:
46
- self._add_item(key, value)
47
- for key, value in kwargs.items():
48
- self._add_item(key, value)
49
-
50
- def _add_item(self, key: _K, value: _V) -> None:
51
- """Add an item and update the key index."""
52
- index = len(self._items)
53
- self._items.append((key, value))
54
- if key not in self._key_indices:
55
- self._key_indices[key] = []
56
- self._key_indices[key].append(index)
57
-
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
- def __setitem__(self, key: _K, value: _V) -> None:
70
- """Set the value for a key.
71
-
72
- Replaces the first value for a key if it exists; otherwise, it adds a new item.
73
- Any other items with the same key are removed.
74
- """
75
- if key in self._key_indices:
76
- # Key exists, replace first occurrence and remove others
77
- indices = self._key_indices[key]
78
- first_index = indices[0]
79
-
80
- # Update the first occurrence
81
- self._items[first_index] = (key, value)
82
-
83
- if len(indices) > 1:
84
- # Remove duplicates efficiently by marking items as None and filtering
85
- for idx in indices[1:]:
86
- self._items[idx] = None
87
-
88
- # Filter out None items and rebuild indices
89
- self._items = [item for item in self._items if item is not None]
90
- self._rebuild_indices()
91
- else:
92
- # Key doesn't exist, add it
93
- self._add_item(key, value)
94
-
95
- def _rebuild_indices(self) -> None:
96
- """Rebuild the key indices after items list has been modified."""
97
- self._key_indices = {}
98
- for i, (key, _) in enumerate(self._items):
99
- if key not in self._key_indices:
100
- self._key_indices[key] = []
101
- self._key_indices[key].append(i)
102
-
103
- def add(self, key: _K, value: _V) -> None:
104
- """Add a new value for a key."""
105
- self._add_item(key, value)
106
-
107
- @with_default
108
- def popone(self, key: _K) -> _V:
109
- """Remove and return the first value for a key."""
110
- if key not in self._key_indices:
111
- raise KeyError(key)
112
-
113
- indices = self._key_indices[key]
114
- first_index = indices[0]
115
- value = self._items[first_index][1]
116
-
117
- # Mark the first item for removal
118
- self._items[first_index] = None
119
-
120
- # Filter out None items and rebuild indices
121
- self._items = [item for item in self._items if item is not None]
122
- self._rebuild_indices()
123
-
124
- return value
125
-
126
- def __delitem__(self, key: _K) -> None:
127
- """Remove all values for a key.
128
-
129
- Raises a `KeyError` if the key is not found.
130
- """
131
- if key not in self._key_indices:
132
- raise KeyError(key)
133
-
134
- # Mark items for removal
135
- indices_to_remove = self._key_indices[key]
136
- for idx in indices_to_remove:
137
- self._items[idx] = None
138
-
139
- # Filter out None items and rebuild indices
140
- self._items = [item for item in self._items if item is not None]
141
- self._rebuild_indices()
142
-
143
- def __iter__(self) -> Iterator[_K]:
144
- """Return an iterator over the keys, in insertion order.
145
-
146
- Keys with multiple values will be yielded multiple times.
147
- """
148
- return (k for k, _ in self._items)
149
-
150
- def __len__(self) -> int:
151
- """Return the total number of items."""
152
- return len(self._items)
153
-
154
- def __repr__(self) -> str:
155
- """Return a string representation of the MultiDict."""
156
- return f"{self.__class__.__name__}({list(self._items)!r})"