Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
cbde09a
Fix memory leak in pop() when key is not found
Jan 22, 2026
daa9f58
feat: Implement to_dict() method
Jan 23, 2026
7a243d8
Add comprehensive psleak test suite
Jan 24, 2026
15000a6
Enhance psleak tests with 100KB tolerance and 1M iterations
Jan 25, 2026
2adb465
Add to_dict leak tests for CIMultiDict and MultiDictProxy
Jan 25, 2026
6f29b13
Remove psleak tests (moved to fix/psleak-expansion branch)
Jan 25, 2026
86be9e1
Add to_dict feature with comprehensive tests and isolated leak check
Jan 25, 2026
d064ebe
Integrate isolated leak test for to_dict into CI suite
Jan 25, 2026
77b606d
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 25, 2026
4455db9
Revert unrelated changes to hashtable.h and pop tests
Jan 25, 2026
8d46e65
Restore multidict_pop.py to leak tests (was accidentally removed)
Jan 25, 2026
ed6d373
Fix CI linting (mypy/clang-format) and restore update leak test
Jan 25, 2026
42e3605
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 25, 2026
02a1a35
Merge branch 'master' into feat/to-dict
rodrigobnogueira Jan 25, 2026
20c411c
Add changelog entry and fix remaining clang-format issues
Jan 25, 2026
8d429f7
Fix PyPy compatibility: use psutil instead of tracemalloc
Jan 25, 2026
f2a9cb7
Merge branch 'master' into feat/to-dict
rodrigobnogueira Jan 26, 2026
31b7e77
Merge branch 'master' into feat/to-dict
rodrigobnogueira Feb 12, 2026
c710e1d
test: add exact type hints to to_dict tests to fix MyPy coverage
Mar 6, 2026
06e097d
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 6, 2026
636e308
Merge branch 'master' into feat/to-dict
rodrigobnogueira Mar 6, 2026
9b704c8
tests: strict type annotations for test_to_dict for 100% coveralls pr…
Mar 6, 2026
9ab8624
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 6, 2026
d58c07f
Resolve formatting conflicts in test_to_dict
Mar 6, 2026
1cb0497
tests: Use Optional instead of | Union syntax for Python 3.9 compat
Mar 6, 2026
a5920ec
tests: add pragma no cover to DictFactory Protocol
Mar 7, 2026
08ae720
Merge branch 'master' into feat/to-dict
rodrigobnogueira May 2, 2026
5dda24d
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] May 2, 2026
68066a3
Address review: use conftest fixtures, collapse duplicate tests, fix …
rodrigobnogueira May 2, 2026
cda3b86
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] May 2, 2026
4c19b42
Fix MyPy: use str values in parametrized test_to_dict
rodrigobnogueira May 2, 2026
4180e63
Merge branch 'master' into feat/to-dict
rodrigobnogueira Jul 5, 2026
a7b051c
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 5, 2026
dd0113a
Document to_dict() in the API reference
rodrigobnogueira Jul 5, 2026
6a1e16f
Merge branch 'master' into feat/to-dict
rodrigobnogueira Jul 13, 2026
0b53603
Merge branch 'master' into feat/to-dict
rodrigobnogueira Aug 9, 2026
08e8338
Fix the ABC break, a NULL-without-exception path and the leak test
rodrigobnogueira Aug 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGES/783.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Added a :meth:`~multidict.MultiDict.to_dict` method returning a plain
:class:`dict` that maps every key to the list of all its values. Unlike
``dict(md)``, which keeps only the first value per key, and unlike a
``{k: md.getall(k) for k in md}`` comprehension, which emits one entry per
spelling of a case-insensitive key, it groups by key identity
-- by :user:`rodrigobnogueira`.
42 changes: 42 additions & 0 deletions docs/multidict.rst
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,41 @@ MultiDict

Return a shallow copy of the dictionary.

.. method:: to_dict()

Return a :class:`dict` mapping each key to a list of all its
values, preserving insertion order, e.g.::

>>> d = MultiDict([('a', 1), ('b', 2), ('a', 3)])
>>> d.to_dict()
{'a': [1, 3], 'b': [2]}

The result is a new dictionary with fresh lists; mutating it
does not affect the multidict.

Every key maps to a list, even a key with a single value, so feeding
the result back does not reproduce the original:
``MultiDict(d.to_dict())`` builds a multidict whose *values* are
lists. This differs from
:class:`dict`\\ ``(d)``, which keeps only the **first** value for each
key::

>>> dict(d)
{'a': 1, 'b': 2}

For :class:`CIMultiDict` the values are grouped by key identity, so
each key appears once under its first-seen spelling. That is what a
``{k: d.getall(k) for k in d}`` comprehension gets wrong: it emits a
separate entry for every spelling, each holding the full list::

>>> ci = CIMultiDict([('A', '1'), ('a', '2')])
>>> ci.to_dict()
{'A': ['1', '2']}
>>> {k: ci.getall(k) for k in ci}
{'A': ['1', '2'], 'a': ['1', '2']}

.. versionadded:: 6.8

.. method:: getone(key[, default])

Return the **first** value for *key* if *key* is in the
Expand Down Expand Up @@ -301,6 +336,13 @@ MultiDictProxy

Return a shallow copy of the underlying multidict.

.. method:: to_dict()

Return a :class:`dict` mapping each key to a list of all its
values, preserving insertion order.

.. versionadded:: 6.8

.. method:: getone(key[, default])

Return the **first** value for *key* if *key* is in the
Expand Down
18 changes: 18 additions & 0 deletions multidict/_abc.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,24 @@ def getone(self, key: str, default: _T) -> _V_co | _T: ...
def getone(self, key: str, default: _T = ...) -> _V_co | _T:
"""Return first value for key."""

def to_dict(self) -> dict[str, list[_V_co]]:
"""Return a dict with lists of all values for each key.

Deliberately concrete, not abstract: ``MultiMapping`` is public, so
requiring a new method would stop every existing subclass outside
this project from being instantiated. Subclasses whose keys compare
equal under a normalisation the iteration does not apply, such as
case-insensitive mappings, must override this; the default would
emit one entry per spelling.
"""
result: dict[str, list[_V_co]] = {}
for key in self:
# Iteration yields a duplicated key once per occurrence, so the
# membership test is what keeps getall() from being applied twice.
if key not in result:
result[key] = list(self.getall(key))
return result


class MutableMultiMapping(MultiMapping[_V], MutableMapping[str, _V]):
@abc.abstractmethod
Expand Down
89 changes: 89 additions & 0 deletions multidict/_multidict.c
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,81 @@ _multidict_proxy_copy(MultiDictProxyObject *self, PyTypeObject *type)
return multidict_copy(self->md);
}

PyDoc_STRVAR(multidict_to_dict_doc,
"Return a dict with lists of all values for each key.");

static PyObject *
multidict_to_dict(MultiDictObject *self)
{
PyObject *result = PyDict_New();
if (result == NULL) {
return NULL;
}

PyObject *seen = PyDict_New();
if (seen == NULL) {
Py_DECREF(result);
return NULL;
}

md_pos_t pos;
md_init_pos(self, &pos);
PyObject *identity = NULL;
PyObject *key = NULL;
PyObject *value = NULL;

int tmp;
while ((tmp = md_next(self, &pos, &identity, &key, &value)) > 0) {
/* `seen` maps identity to the very list held in `result`, not to the
first key. Looking the list up again in `result` would have to
hash a key that may be a str subclass with a user-defined
__hash__, and PyDict_GetItem() swallows the exception from that,
so a miss would return NULL with nothing set. */
PyObject *lst = PyDict_GetItem(seen, identity);
if (lst == NULL) {
lst = PyList_New(1);
if (lst == NULL) {
goto fail;
}
PyList_SET_ITEM(lst, 0, value);
value = NULL;
if (PyDict_SetItem(seen, identity, lst) < 0) {
Py_DECREF(lst);
goto fail;
}
if (PyDict_SetItem(result, key, lst) < 0) {
Py_DECREF(lst);
goto fail;
}
Py_DECREF(lst);
} else {
if (PyList_Append(lst, value) < 0) {
goto fail;
}
Py_DECREF(value);
value = NULL;
}
Py_DECREF(identity);
Py_DECREF(key);
identity = NULL;
key = NULL;
}
if (tmp < 0) {
goto fail;
}

Py_DECREF(seen);
return result;

fail:
Py_XDECREF(identity);
Py_XDECREF(key);
Py_XDECREF(value);
Py_DECREF(seen);
Py_DECREF(result);
return NULL;
}

/******************** Base Methods ********************/

static inline PyObject *
Expand Down Expand Up @@ -887,6 +962,10 @@ static PyMethodDef multidict_methods[] = {
METH_FASTCALL | METH_KEYWORDS,
multidict_add_doc},
{"copy", (PyCFunction)multidict_copy, METH_NOARGS, multidict_copy_doc},
{"to_dict",
(PyCFunction)multidict_to_dict,
METH_NOARGS,
multidict_to_dict_doc},
{"extend",
(PyCFunction)multidict_extend,
METH_VARARGS | METH_KEYWORDS,
Expand Down Expand Up @@ -1144,6 +1223,12 @@ multidict_proxy_reduce(MultiDictProxyObject *self)
return NULL;
}

static PyObject *
multidict_proxy_to_dict(MultiDictProxyObject *self)
{
return multidict_to_dict(self->md);
}

static Py_ssize_t
multidict_proxy_mp_len(MultiDictProxyObject *self)
{
Expand Down Expand Up @@ -1245,6 +1330,10 @@ static PyMethodDef multidict_proxy_methods[] = {
(PyCFunction)Py_GenericAlias,
METH_O | METH_CLASS,
NULL},
{"to_dict",
(PyCFunction)multidict_proxy_to_dict,
METH_NOARGS,
multidict_to_dict_doc},
{NULL, NULL} /* sentinel */
};

Expand Down
20 changes: 20 additions & 0 deletions multidict/_multidict_py.py
Original file line number Diff line number Diff line change
Expand Up @@ -764,6 +764,22 @@ def __sizeof__(self) -> int:
def __reduce__(self) -> tuple[type[Self], tuple[list[tuple[str, _V]]]]:
return (self.__class__, (list(self.items()),))

def to_dict(self) -> dict[str, list[_V]]:
"""Return a dict with lists of all values for each key."""
result: dict[str, list[_V]] = {}
# Keyed by identity so a case-insensitive multidict groups every
# spelling of a key together, and holding the list itself rather than
# the first key so the value append needs no second lookup.
seen: dict[str, list[_V]] = {}
for e in self._keys.iter_entries():
values = seen.get(e.identity)
if values is None:
values = seen[e.identity] = [e.value]
result[self._key(e.key)] = values
else:
values.append(e.value)
return result

def add(self, key: str, value: _V) -> None:
identity = self._identity(key)
hash_ = hash(identity)
Expand Down Expand Up @@ -1210,6 +1226,10 @@ def __repr__(self) -> str:
body = ", ".join(f"'{k}': {v!r}" for k, v in self.items())
return f"<{self.__class__.__name__}({body})>"

def to_dict(self) -> dict[str, list[_V]]:
"""Return a dict with lists of all values for each key."""
return self._md.to_dict()

def copy(self) -> MultiDict[_V]:
"""Return a copy of itself."""
return MultiDict(self._md)
Expand Down
64 changes: 64 additions & 0 deletions tests/isolated/multidict_to_dict.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import gc
import sys
import sysconfig

from multidict import CIMultiDict, MultiDict

# sys.getrefcount is not meaningful under the free-threaded build:
# refcounts are biased per-thread and types may be immortalized, so
# the simple baseline/after comparison below does not apply.
FREETHREADED = bool(sysconfig.get_config_var("Py_GIL_DISABLED"))


if __name__ == "__main__":
if FREETHREADED:
raise SystemExit(0)

# Distinct, non-interned keys and non-immortal values: a leaked reference
# to an interned "a" or to a small int would not move any refcount, which
# is what an RSS-growth check on such objects silently misses.
key_a = "leak-key-a".swapcase().swapcase()
key_b = "leak-key-b".swapcase().swapcase()
value_1 = object()
value_2 = object()
value_3 = object()

# Value leak, duplicate key: exercises both the PyList_SET_ITEM branch
# (first value for a key) and the PyList_Append branch (later values).
md = MultiDict([(key_a, value_1), (key_b, value_2), (key_a, value_3)])
gc.collect()
baselines = [sys.getrefcount(v) for v in (value_1, value_2, value_3)]
for _ in range(1000):
_d = md.to_dict()
del _d
gc.collect()
after = [sys.getrefcount(v) for v in (value_1, value_2, value_3)]
assert after == baselines, (
f"value leaked: {[a - b for a, b in zip(after, baselines)]}"
)

# Key leak, same multidict.
gc.collect()
key_baselines = [sys.getrefcount(k) for k in (key_a, key_b)]
for _ in range(1000):
_d = md.to_dict()
del _d
gc.collect()
key_after = [sys.getrefcount(k) for k in (key_a, key_b)]
assert key_after == key_baselines, (
f"key leaked: {[a - b for a, b in zip(key_after, key_baselines)]}"
)

# CIMultiDict takes the only allocating path: _md_ensure_key() builds a
# fresh istr and stores it back into the entry.
ci = CIMultiDict([(key_a, value_1), (key_a.upper(), value_2)])
gc.collect()
ci_baselines = [sys.getrefcount(v) for v in (value_1, value_2)]
for _ in range(1000):
_d = ci.to_dict()
del _d
gc.collect()
ci_after = [sys.getrefcount(v) for v in (value_1, value_2)]
assert ci_after == ci_baselines, (
f"CI value leaked: {[a - b for a, b in zip(ci_after, ci_baselines)]}"
)
1 change: 1 addition & 0 deletions tests/test_leaks.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"multidict_type_leak.py",
"multidict_type_leak_items_values.py",
"multidict_pop.py",
"multidict_to_dict.py",
),
)
@pytest.mark.leaks
Expand Down
Loading
Loading