Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

* Speed up querystring callback dispatch [#316](https://github.com/Kludex/python-multipart/pull/316).

## 0.0.32 (2026-06-04)

* Speed up partial-boundary scanning for CR/LF-dense part data [#300](https://github.com/Kludex/python-multipart/pull/300).
Expand Down
56 changes: 45 additions & 11 deletions python_multipart/multipart.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,14 @@ class FormParserConfig(FileConfig):
_missing = object()


def _noop_event() -> None:
pass


def _noop_data(_data: bytes, _start: int, _end: int) -> None:
pass


class QuerystringState(IntEnum):
"""Querystring parser states.

Expand Down Expand Up @@ -836,6 +844,19 @@ def _internal_write(self, data: bytes, length: int) -> int:
state = self.state
strict_parsing = self.strict_parsing
found_sep = self._found_sep
callbacks = cast("QuerystringCallbacks", self.callbacks)
on_field_start = callbacks.get("on_field_start")
on_field_name = callbacks.get("on_field_name")
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
on_field_data = callbacks.get("on_field_data")
on_field_end = callbacks.get("on_field_end")
if on_field_start is None:
on_field_start = _noop_event
if on_field_name is None:
on_field_name = _noop_data
if on_field_data is None:
on_field_data = _noop_data
if on_field_end is None:
on_field_end = _noop_event

i = 0
while i < length:
Expand Down Expand Up @@ -865,7 +886,7 @@ def _internal_write(self, data: bytes, length: int) -> int:
# Emit a field-start event, and go to that state. Also,
# reset the "found_sep" flag, for the next time we get to
# this state.
self.callback("field_start")
on_field_start()
i -= 1
state = QuerystringState.FIELD_NAME
found_sep = False
Expand All @@ -885,7 +906,8 @@ def _internal_write(self, data: bytes, length: int) -> int:

if equals_pos != -1:
# Emit this name.
self.callback("field_name", data, i, equals_pos)
if i != equals_pos:
on_field_name(data, i, equals_pos)

# Jump i to this position. Note that it will then have 1
# added to it below, which means the next iteration of this
Expand All @@ -900,15 +922,17 @@ def _internal_write(self, data: bytes, length: int) -> int:
# end - there's no data callback at all (not even with
# a blank value).
if sep_pos != -1:
self.callback("field_name", data, i, sep_pos)
self.callback("field_end")
if i != sep_pos:
on_field_name(data, i, sep_pos)
on_field_end()

i = sep_pos - 1
state = QuerystringState.BEFORE_FIELD
else:
# Otherwise, no separator in this block, so the
# rest of this chunk must be a name.
self.callback("field_name", data, i, length)
if i != length:
on_field_name(data, i, length)
i = length

else:
Expand All @@ -924,7 +948,8 @@ def _internal_write(self, data: bytes, length: int) -> int:

# No separator in the rest of this chunk, so it's just
# a field name.
self.callback("field_name", data, i, length)
if i != length:
on_field_name(data, i, length)
i = length

elif state == QuerystringState.FIELD_DATA:
Expand All @@ -934,8 +959,9 @@ def _internal_write(self, data: bytes, length: int) -> int:
# If we found it, callback this bit as data and then go back
# to expecting to find a field.
if sep_pos != -1:
self.callback("field_data", data, i, sep_pos)
self.callback("field_end")
if i != sep_pos:
on_field_data(data, i, sep_pos)
on_field_end()

# Note that we go to the separator, which brings us to the
# "before field" state. This allows us to properly emit
Expand All @@ -946,7 +972,8 @@ def _internal_write(self, data: bytes, length: int) -> int:

# Otherwise, emit the rest as data and finish.
else:
self.callback("field_data", data, i, length)
if i != length:
on_field_data(data, i, length)
i = length

else: # pragma: no cover (error case)
Expand All @@ -965,10 +992,17 @@ def finalize(self) -> None:
if we're still in the middle of a field, an on_field_end callback, and
then the on_end callback.
"""
callbacks = cast("QuerystringCallbacks", self.callbacks)
# If we're currently in the middle of a field, we finish it.
if self.state in (QuerystringState.FIELD_DATA, QuerystringState.FIELD_NAME):
self.callback("field_end")
self.callback("end")
on_field_end = callbacks.get("on_field_end")
if on_field_end is None:
on_field_end = _noop_event
on_field_end()
on_end = callbacks.get("on_end")
if on_end is None:
on_end = _noop_event
on_end()

def __repr__(self) -> str:
return "{}(strict_parsing={!r}, max_size={!r})".format(
Expand Down
29 changes: 29 additions & 0 deletions tests/test_multipart.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,10 @@ def on_foo() -> None:
nonlocal called
called += 1

def on_data(data: bytes, start: int, end: int) -> None:
nonlocal called
called += 1

self.b.set_callback("foo", on_foo) # type: ignore[arg-type]
self.b.callback("foo") # type: ignore[arg-type]
self.assertEqual(called, 1)
Expand All @@ -373,6 +377,12 @@ def on_foo() -> None:
self.b.callback("foo") # type: ignore[arg-type]
self.assertEqual(called, 1)

self.b.set_callback("data", on_data)
self.b.callback("data", b"", 0, 0)
self.assertEqual(called, 1)
self.b.callback("data", b"x", 0, 1)
self.assertEqual(called, 2)


class TestQuerystringParser(unittest.TestCase):
def assert_fields(self, *args: tuple[bytes, bytes], **kwargs: Any) -> None:
Expand Down Expand Up @@ -413,6 +423,25 @@ def test_simple_querystring(self) -> None:

self.assert_fields((b"foo", b"bar"))

def test_no_callbacks(self) -> None:
parser = QuerystringParser()

self.assertEqual(parser.write(b"foo=bar"), 7)
parser.finalize()

def test_none_callbacks(self) -> None:
callbacks: Any = {
"on_field_start": None,
"on_field_name": None,
"on_field_data": None,
"on_field_end": None,
"on_end": None,
}
parser = QuerystringParser(callbacks)

self.assertEqual(parser.write(b"foo=bar"), 7)
parser.finalize()

def test_querystring_blank_beginning(self) -> None:
self.p.write(b"&foo=bar")

Expand Down
Loading