diff --git a/CHANGELOG.md b/CHANGELOG.md index c667c6d..aeb17fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/python_multipart/multipart.py b/python_multipart/multipart.py index d50e5b3..49cdd8e 100644 --- a/python_multipart/multipart.py +++ b/python_multipart/multipart.py @@ -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. @@ -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") + 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: @@ -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 @@ -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 @@ -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: @@ -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: @@ -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 @@ -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) @@ -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( diff --git a/tests/test_multipart.py b/tests/test_multipart.py index 8dda87c..949d70c 100644 --- a/tests/test_multipart.py +++ b/tests/test_multipart.py @@ -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) @@ -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: @@ -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")