From 06ba94d1473decd3ae71e9b8ec66b9c09b78856c Mon Sep 17 00:00:00 2001 From: aiolibsbot Date: Sun, 17 May 2026 00:45:28 +0000 Subject: [PATCH 1/7] Use repr() to escape keys in MultiDict __repr__ output Wrapping keys in literal single quotes produced unparseable output when keys contained a single quote (e.g. ``""``). Both the pure-Python and C-extension code paths now format keys via repr(), so single quotes and other special characters round-trip correctly. --- CHANGES/1342.bugfix.rst | 5 +++++ multidict/_multidict_py.py | 8 ++++---- multidict/_multilib/hashtable.h | 11 +++-------- tests/test_multidict.py | 18 ++++++++++++++++++ 4 files changed, 30 insertions(+), 12 deletions(-) create mode 100644 CHANGES/1342.bugfix.rst diff --git a/CHANGES/1342.bugfix.rst b/CHANGES/1342.bugfix.rst new file mode 100644 index 000000000..ff08ed7f2 --- /dev/null +++ b/CHANGES/1342.bugfix.rst @@ -0,0 +1,5 @@ +Fixed ``__repr__`` of :class:`~multidict.MultiDict`, +:class:`~multidict.CIMultiDict`, their proxies, and the keys/items views +producing unparseable output when keys contained quote characters -- +keys are now formatted with :func:`repr` so the result is a valid Python +string literal -- by :user:`aiolibsbot`. diff --git a/multidict/_multidict_py.py b/multidict/_multidict_py.py index e286713ec..e568a7870 100644 --- a/multidict/_multidict_py.py +++ b/multidict/_multidict_py.py @@ -103,7 +103,7 @@ def _iter(self, version: int) -> Iterator[tuple[str, _V]]: def __repr__(self) -> str: lst = [] for e in self._md._keys.iter_entries(): - lst.append(f"'{e.key}': {e.value!r}") + lst.append(f"{e.key!r}: {e.value!r}") body = ", ".join(lst) return f"<{self.__class__.__name__}({body})>" @@ -300,7 +300,7 @@ def _iter(self, version: int) -> Iterator[str]: def __repr__(self) -> str: lst = [] for e in self._md._keys.iter_entries(): - lst.append(f"'{e.key}'") + lst.append(f"{e.key!r}") body = ", ".join(lst) return f"<{self.__class__.__name__}({body})>" @@ -753,7 +753,7 @@ def __contains__(self, key: object) -> bool: @reprlib.recursive_repr() def __repr__(self) -> str: - body = ", ".join(f"'{e.key}': {e.value!r}" for e in self._keys.iter_entries()) + body = ", ".join(f"{e.key!r}: {e.value!r}" for e in self._keys.iter_entries()) return f"<{self.__class__.__name__}({body})>" if sys.implementation.name != "pypy": @@ -1207,7 +1207,7 @@ def __contains__(self, key: object) -> bool: @reprlib.recursive_repr() def __repr__(self) -> str: - body = ", ".join(f"'{k}': {v!r}" for k, v in self.items()) + body = ", ".join(f"{k!r}: {v!r}" for k, v in self.items()) return f"<{self.__class__.__name__}({body})>" def copy(self) -> MultiDict[_V]: diff --git a/multidict/_multilib/hashtable.h b/multidict/_multilib/hashtable.h index f2ba9868a..f01efb04e 100644 --- a/multidict/_multilib/hashtable.h +++ b/multidict/_multilib/hashtable.h @@ -1809,14 +1809,9 @@ md_repr(MultiDictObject *md, PyObject *name, bool show_keys, bool show_values) } } if (show_keys) { - if (PyUnicodeWriter_WriteChar(writer, '\'') < 0) { - goto fail; - } - /* Don't need to convert key to istr, the text is the same*/ - if (PyUnicodeWriter_WriteStr(writer, key) < 0) { - goto fail; - } - if (PyUnicodeWriter_WriteChar(writer, '\'') < 0) { + /* Use repr() so keys containing quotes produce parseable output. + */ + if (PyUnicodeWriter_WriteRepr(writer, key) < 0) { goto fail; } } diff --git a/tests/test_multidict.py b/tests/test_multidict.py index d62db8c5c..136ebb5e0 100644 --- a/tests/test_multidict.py +++ b/tests/test_multidict.py @@ -635,6 +635,24 @@ def test_repr_aiohttp_issue_410(self, cls: type[MutableMultiMapping[str]]) -> No assert sys.exc_info()[1] == e # noqa: PT017 + def test__repr__quotes_keys(self, cls: type[MultiDict[str]]) -> None: + # Keys containing quotes must be repr'd as parseable Python literals, + # not naively wrapped in single quotes. + d = cls([("a'b", "v")]) + _cls = type(d) + + # repr("a'b") == '"a\'b"' (Python uses double quotes when the string + # contains a single quote and no double quote). + assert str(d) == f"<{_cls.__name__}(\"a'b\": 'v')>" + + def test_items__repr__quotes_keys(self, cls: type[MultiDict[str]]) -> None: + d = cls([("a'b", "v")]) + assert repr(d.items()) == "<_ItemsView(\"a'b\": 'v')>" + + def test_keys__repr__quotes_keys(self, cls: type[MultiDict[str]]) -> None: + d = cls([("a'b", "v")]) + assert repr(d.keys()) == '<_KeysView("a\'b")>' + @pytest.mark.parametrize( "op", (operator.or_, operator.and_, operator.sub, operator.xor), From 32c0eb4d063c0627c99c871ff0ce2f7732a04ceb Mon Sep 17 00:00:00 2001 From: aiolibsbot Date: Sun, 17 May 2026 02:22:30 +0000 Subject: [PATCH 2/7] rebase: apply review feedback on #1342 --- multidict/_multilib/hashtable.h | 37 +++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/multidict/_multilib/hashtable.h b/multidict/_multilib/hashtable.h index f01efb04e..bdef29f42 100644 --- a/multidict/_multilib/hashtable.h +++ b/multidict/_multilib/hashtable.h @@ -1809,10 +1809,39 @@ md_repr(MultiDictObject *md, PyObject *name, bool show_keys, bool show_values) } } if (show_keys) { - /* Use repr() so keys containing quotes produce parseable output. - */ - if (PyUnicodeWriter_WriteRepr(writer, key) < 0) { - goto fail; + /* Fast path: ASCII keys without characters that would be escaped + * by repr() can be wrapped in single quotes directly. Falls back + * to PyUnicodeWriter_WriteRepr for keys containing quotes, + * backslashes, or non-printable characters so the output stays + * a valid Python string literal. */ + int fast = 0; + if (PyUnicode_IS_ASCII(key)) { + Py_ssize_t klen = PyUnicode_GET_LENGTH(key); + const unsigned char *kdata = + (const unsigned char *)PyUnicode_DATA(key); + fast = 1; + for (Py_ssize_t ki = 0; ki < klen; ++ki) { + unsigned char c = kdata[ki]; + if (c < 0x20 || c == 0x7f || c == '\'' || c == '\\') { + fast = 0; + break; + } + } + } + if (fast) { + if (PyUnicodeWriter_WriteChar(writer, '\'') < 0) { + goto fail; + } + if (PyUnicodeWriter_WriteStr(writer, key) < 0) { + goto fail; + } + if (PyUnicodeWriter_WriteChar(writer, '\'') < 0) { + goto fail; + } + } else { + if (PyUnicodeWriter_WriteRepr(writer, key) < 0) { + goto fail; + } } } if (show_keys && show_values) { From b7a561dd5c923054c9738a56bd1002bcf790ca68 Mon Sep 17 00:00:00 2001 From: aiolibsbot Date: Sun, 17 May 2026 02:47:20 +0000 Subject: [PATCH 3/7] rebase: apply review feedback on #1342 --- multidict/_multidict_py.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/multidict/_multidict_py.py b/multidict/_multidict_py.py index e568a7870..d34f90d83 100644 --- a/multidict/_multidict_py.py +++ b/multidict/_multidict_py.py @@ -1,5 +1,6 @@ import enum import functools +import re import reprlib import sys from array import array @@ -47,6 +48,17 @@ class istr(str): _version = array("Q", [0]) +# Matches any character that ``repr()`` would escape inside single quotes. +_repr_needs_repr = re.compile(r"[^\x20-\x26\x28-\x5b\x5d-\x7e]").search + + +def _key_repr(key: str) -> str: + # Skip ``repr()`` for the common case where single-quote wrapping suffices. + if _repr_needs_repr(key) is None: + return f"'{key}'" + return repr(key) + + class _Iter(Generic[_T]): __slots__ = ("_size", "_iter") @@ -103,7 +115,7 @@ def _iter(self, version: int) -> Iterator[tuple[str, _V]]: def __repr__(self) -> str: lst = [] for e in self._md._keys.iter_entries(): - lst.append(f"{e.key!r}: {e.value!r}") + lst.append(f"{_key_repr(e.key)}: {e.value!r}") body = ", ".join(lst) return f"<{self.__class__.__name__}({body})>" @@ -300,7 +312,7 @@ def _iter(self, version: int) -> Iterator[str]: def __repr__(self) -> str: lst = [] for e in self._md._keys.iter_entries(): - lst.append(f"{e.key!r}") + lst.append(_key_repr(e.key)) body = ", ".join(lst) return f"<{self.__class__.__name__}({body})>" @@ -753,7 +765,9 @@ def __contains__(self, key: object) -> bool: @reprlib.recursive_repr() def __repr__(self) -> str: - body = ", ".join(f"{e.key!r}: {e.value!r}" for e in self._keys.iter_entries()) + body = ", ".join( + f"{_key_repr(e.key)}: {e.value!r}" for e in self._keys.iter_entries() + ) return f"<{self.__class__.__name__}({body})>" if sys.implementation.name != "pypy": @@ -1207,7 +1221,7 @@ def __contains__(self, key: object) -> bool: @reprlib.recursive_repr() def __repr__(self) -> str: - body = ", ".join(f"{k!r}: {v!r}" for k, v in self.items()) + body = ", ".join(f"{_key_repr(k)}: {v!r}" for k, v in self.items()) return f"<{self.__class__.__name__}({body})>" def copy(self) -> MultiDict[_V]: From 7c3a84699d194ddae107b89d0c95958aff3e678a Mon Sep 17 00:00:00 2001 From: aiolibsbot Date: Sun, 17 May 2026 02:48:45 +0000 Subject: [PATCH 4/7] fix: resolve pre-existing CI failures on #1342 --- CHANGES/1342.bugfix.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES/1342.bugfix.rst b/CHANGES/1342.bugfix.rst index ff08ed7f2..479065284 100644 --- a/CHANGES/1342.bugfix.rst +++ b/CHANGES/1342.bugfix.rst @@ -1,5 +1,5 @@ Fixed ``__repr__`` of :class:`~multidict.MultiDict`, :class:`~multidict.CIMultiDict`, their proxies, and the keys/items views -producing unparseable output when keys contained quote characters -- +producing invalid output when keys contained quote characters -- keys are now formatted with :func:`repr` so the result is a valid Python string literal -- by :user:`aiolibsbot`. From 73742b5aa4d6acdef6abb20409a8abc51f10629a Mon Sep 17 00:00:00 2001 From: aiolibsbot Date: Sun, 17 May 2026 05:39:18 +0000 Subject: [PATCH 5/7] fix: resolve CI failures on #1342 (attempt 1) --- multidict/_multidict_py.py | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/multidict/_multidict_py.py b/multidict/_multidict_py.py index d34f90d83..4f0d735b0 100644 --- a/multidict/_multidict_py.py +++ b/multidict/_multidict_py.py @@ -1,6 +1,5 @@ import enum import functools -import re import reprlib import sys from array import array @@ -48,17 +47,6 @@ class istr(str): _version = array("Q", [0]) -# Matches any character that ``repr()`` would escape inside single quotes. -_repr_needs_repr = re.compile(r"[^\x20-\x26\x28-\x5b\x5d-\x7e]").search - - -def _key_repr(key: str) -> str: - # Skip ``repr()`` for the common case where single-quote wrapping suffices. - if _repr_needs_repr(key) is None: - return f"'{key}'" - return repr(key) - - class _Iter(Generic[_T]): __slots__ = ("_size", "_iter") @@ -115,7 +103,7 @@ def _iter(self, version: int) -> Iterator[tuple[str, _V]]: def __repr__(self) -> str: lst = [] for e in self._md._keys.iter_entries(): - lst.append(f"{_key_repr(e.key)}: {e.value!r}") + lst.append(f"{e.key!r}: {e.value!r}") body = ", ".join(lst) return f"<{self.__class__.__name__}({body})>" @@ -312,7 +300,7 @@ def _iter(self, version: int) -> Iterator[str]: def __repr__(self) -> str: lst = [] for e in self._md._keys.iter_entries(): - lst.append(_key_repr(e.key)) + lst.append(repr(e.key)) body = ", ".join(lst) return f"<{self.__class__.__name__}({body})>" @@ -766,7 +754,7 @@ def __contains__(self, key: object) -> bool: @reprlib.recursive_repr() def __repr__(self) -> str: body = ", ".join( - f"{_key_repr(e.key)}: {e.value!r}" for e in self._keys.iter_entries() + f"{e.key!r}: {e.value!r}" for e in self._keys.iter_entries() ) return f"<{self.__class__.__name__}({body})>" @@ -1221,7 +1209,7 @@ def __contains__(self, key: object) -> bool: @reprlib.recursive_repr() def __repr__(self) -> str: - body = ", ".join(f"{_key_repr(k)}: {v!r}" for k, v in self.items()) + body = ", ".join(f"{k!r}: {v!r}" for k, v in self.items()) return f"<{self.__class__.__name__}({body})>" def copy(self) -> MultiDict[_V]: From a9b2782585c8fdbe36cc9240b106d6b8382350db Mon Sep 17 00:00:00 2001 From: aiolibsbot Date: Sun, 17 May 2026 05:44:12 +0000 Subject: [PATCH 6/7] fix: resolve CI failures on #1342 (attempt 2) --- multidict/_multidict_py.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/multidict/_multidict_py.py b/multidict/_multidict_py.py index 4f0d735b0..5ef921015 100644 --- a/multidict/_multidict_py.py +++ b/multidict/_multidict_py.py @@ -753,9 +753,7 @@ def __contains__(self, key: object) -> bool: @reprlib.recursive_repr() def __repr__(self) -> str: - body = ", ".join( - f"{e.key!r}: {e.value!r}" for e in self._keys.iter_entries() - ) + body = ", ".join(f"{e.key!r}: {e.value!r}" for e in self._keys.iter_entries()) return f"<{self.__class__.__name__}({body})>" if sys.implementation.name != "pypy": From 488cbf75135dc29ad486794e13ae94c7f6114d94 Mon Sep 17 00:00:00 2001 From: aiolibsbot Date: Sun, 17 May 2026 14:57:45 +0000 Subject: [PATCH 7/7] fix: resolve CI failures on #1342 (attempt 1) --- multidict/_multidict_py.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/multidict/_multidict_py.py b/multidict/_multidict_py.py index 5ef921015..d34f90d83 100644 --- a/multidict/_multidict_py.py +++ b/multidict/_multidict_py.py @@ -1,5 +1,6 @@ import enum import functools +import re import reprlib import sys from array import array @@ -47,6 +48,17 @@ class istr(str): _version = array("Q", [0]) +# Matches any character that ``repr()`` would escape inside single quotes. +_repr_needs_repr = re.compile(r"[^\x20-\x26\x28-\x5b\x5d-\x7e]").search + + +def _key_repr(key: str) -> str: + # Skip ``repr()`` for the common case where single-quote wrapping suffices. + if _repr_needs_repr(key) is None: + return f"'{key}'" + return repr(key) + + class _Iter(Generic[_T]): __slots__ = ("_size", "_iter") @@ -103,7 +115,7 @@ def _iter(self, version: int) -> Iterator[tuple[str, _V]]: def __repr__(self) -> str: lst = [] for e in self._md._keys.iter_entries(): - lst.append(f"{e.key!r}: {e.value!r}") + lst.append(f"{_key_repr(e.key)}: {e.value!r}") body = ", ".join(lst) return f"<{self.__class__.__name__}({body})>" @@ -300,7 +312,7 @@ def _iter(self, version: int) -> Iterator[str]: def __repr__(self) -> str: lst = [] for e in self._md._keys.iter_entries(): - lst.append(repr(e.key)) + lst.append(_key_repr(e.key)) body = ", ".join(lst) return f"<{self.__class__.__name__}({body})>" @@ -753,7 +765,9 @@ def __contains__(self, key: object) -> bool: @reprlib.recursive_repr() def __repr__(self) -> str: - body = ", ".join(f"{e.key!r}: {e.value!r}" for e in self._keys.iter_entries()) + body = ", ".join( + f"{_key_repr(e.key)}: {e.value!r}" for e in self._keys.iter_entries() + ) return f"<{self.__class__.__name__}({body})>" if sys.implementation.name != "pypy": @@ -1207,7 +1221,7 @@ def __contains__(self, key: object) -> bool: @reprlib.recursive_repr() def __repr__(self) -> str: - body = ", ".join(f"{k!r}: {v!r}" for k, v in self.items()) + body = ", ".join(f"{_key_repr(k)}: {v!r}" for k, v in self.items()) return f"<{self.__class__.__name__}({body})>" def copy(self) -> MultiDict[_V]: