From 6947f4181c0c8f72651a5147ba909f91a6979e5b Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 9 Aug 2026 10:29:20 +0200 Subject: [PATCH 1/8] Speed up querystring callback dispatch --- CHANGELOG.md | 2 ++ python_multipart/multipart.py | 46 ++++++++++++++++++++++++++--------- tests/test_multipart.py | 27 ++++++++++++++++++++ 3 files changed, 64 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c667c6d..8f407fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +* Speed up querystring callback dispatch. + ## 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..89c20fc 100644 --- a/python_multipart/multipart.py +++ b/python_multipart/multipart.py @@ -836,6 +836,7 @@ 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) i = 0 while i < length: @@ -865,7 +866,9 @@ 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") + callback_event = callbacks.get("on_field_start") + if callback_event is not None: + callback_event() i -= 1 state = QuerystringState.FIELD_NAME found_sep = False @@ -885,7 +888,9 @@ def _internal_write(self, data: bytes, length: int) -> int: if equals_pos != -1: # Emit this name. - self.callback("field_name", data, i, equals_pos) + callback_data = callbacks.get("on_field_name") + if callback_data is not None and i != equals_pos: + callback_data(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 +905,21 @@ 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") + callback_data = callbacks.get("on_field_name") + if callback_data is not None and i != sep_pos: + callback_data(data, i, sep_pos) + callback_event = callbacks.get("on_field_end") + if callback_event is not None: + callback_event() 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) + callback_data = callbacks.get("on_field_name") + if callback_data is not None and i != length: + callback_data(data, i, length) i = length else: @@ -924,7 +935,9 @@ 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) + callback_data = callbacks.get("on_field_name") + if callback_data is not None and i != length: + callback_data(data, i, length) i = length elif state == QuerystringState.FIELD_DATA: @@ -934,8 +947,12 @@ 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") + callback_data = callbacks.get("on_field_data") + if callback_data is not None and i != sep_pos: + callback_data(data, i, sep_pos) + callback_event = callbacks.get("on_field_end") + if callback_event is not None: + callback_event() # Note that we go to the separator, which brings us to the # "before field" state. This allows us to properly emit @@ -946,7 +963,9 @@ 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) + callback_data = callbacks.get("on_field_data") + if callback_data is not None and i != length: + callback_data(data, i, length) i = length else: # pragma: no cover (error case) @@ -965,10 +984,15 @@ 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") + callback = callbacks.get("on_field_end") + if callback is not None: + callback() + callback = callbacks.get("on_end") + if callback is not None: + callback() 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..c581be1 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,23 @@ def test_simple_querystring(self) -> None: self.assert_fields((b"foo", b"bar")) + def test_set_callback_during_write(self) -> None: + names: list[bytes] = [] + + def on_field_start() -> None: + parser.set_callback("field_name", on_replacement_field_name) + + def on_initial_field_name(data: bytes, start: int, end: int) -> None: + raise AssertionError("The replaced callback was called") + + def on_replacement_field_name(data: bytes, start: int, end: int) -> None: + names.append(data[start:end]) + + parser = QuerystringParser(callbacks={"on_field_start": on_field_start, "on_field_name": on_initial_field_name}) + parser.write(b"foo=bar") + + self.assertEqual(names, [b"foo"]) + def test_querystring_blank_beginning(self) -> None: self.p.write(b"&foo=bar") From 710c1f95a779064a3dcfa545e2d56ad84d1ab507 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 9 Aug 2026 10:42:07 +0200 Subject: [PATCH 2/8] Name querystring callbacks explicitly --- python_multipart/multipart.py | 66 +++++++++++++++++------------------ 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/python_multipart/multipart.py b/python_multipart/multipart.py index 89c20fc..572a2e4 100644 --- a/python_multipart/multipart.py +++ b/python_multipart/multipart.py @@ -866,9 +866,9 @@ 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. - callback_event = callbacks.get("on_field_start") - if callback_event is not None: - callback_event() + on_field_start = callbacks.get("on_field_start") + if on_field_start is not None: + on_field_start() i -= 1 state = QuerystringState.FIELD_NAME found_sep = False @@ -888,9 +888,9 @@ def _internal_write(self, data: bytes, length: int) -> int: if equals_pos != -1: # Emit this name. - callback_data = callbacks.get("on_field_name") - if callback_data is not None and i != equals_pos: - callback_data(data, i, equals_pos) + on_field_name = callbacks.get("on_field_name") + if on_field_name is not None and 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 @@ -905,21 +905,21 @@ 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: - callback_data = callbacks.get("on_field_name") - if callback_data is not None and i != sep_pos: - callback_data(data, i, sep_pos) - callback_event = callbacks.get("on_field_end") - if callback_event is not None: - callback_event() + on_field_name = callbacks.get("on_field_name") + if on_field_name is not None and i != sep_pos: + on_field_name(data, i, sep_pos) + on_field_end = callbacks.get("on_field_end") + if on_field_end is not None: + 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. - callback_data = callbacks.get("on_field_name") - if callback_data is not None and i != length: - callback_data(data, i, length) + on_field_name = callbacks.get("on_field_name") + if on_field_name is not None and i != length: + on_field_name(data, i, length) i = length else: @@ -935,9 +935,9 @@ def _internal_write(self, data: bytes, length: int) -> int: # No separator in the rest of this chunk, so it's just # a field name. - callback_data = callbacks.get("on_field_name") - if callback_data is not None and i != length: - callback_data(data, i, length) + on_field_name = callbacks.get("on_field_name") + if on_field_name is not None and i != length: + on_field_name(data, i, length) i = length elif state == QuerystringState.FIELD_DATA: @@ -947,12 +947,12 @@ 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: - callback_data = callbacks.get("on_field_data") - if callback_data is not None and i != sep_pos: - callback_data(data, i, sep_pos) - callback_event = callbacks.get("on_field_end") - if callback_event is not None: - callback_event() + on_field_data = callbacks.get("on_field_data") + if on_field_data is not None and i != sep_pos: + on_field_data(data, i, sep_pos) + on_field_end = callbacks.get("on_field_end") + if on_field_end is not None: + on_field_end() # Note that we go to the separator, which brings us to the # "before field" state. This allows us to properly emit @@ -963,9 +963,9 @@ def _internal_write(self, data: bytes, length: int) -> int: # Otherwise, emit the rest as data and finish. else: - callback_data = callbacks.get("on_field_data") - if callback_data is not None and i != length: - callback_data(data, i, length) + on_field_data = callbacks.get("on_field_data") + if on_field_data is not None and i != length: + on_field_data(data, i, length) i = length else: # pragma: no cover (error case) @@ -987,12 +987,12 @@ def finalize(self) -> None: 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): - callback = callbacks.get("on_field_end") - if callback is not None: - callback() - callback = callbacks.get("on_end") - if callback is not None: - callback() + on_field_end = callbacks.get("on_field_end") + if on_field_end is not None: + on_field_end() + on_end = callbacks.get("on_end") + if on_end is not None: + on_end() def __repr__(self) -> str: return "{}(strict_parsing={!r}, max_size={!r})".format( From 6ce8b8d609f4516b3a03499beec2a5c6c85cd8dc Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 9 Aug 2026 10:42:29 +0200 Subject: [PATCH 3/8] Link querystring optimization changelog entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f407fb..aeb17fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -* Speed up querystring callback dispatch. +* Speed up querystring callback dispatch [#316](https://github.com/Kludex/python-multipart/pull/316). ## 0.0.32 (2026-06-04) From b1c3c9eaa47789dc059fe027c59f0caac9ddede0 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 9 Aug 2026 12:44:05 +0200 Subject: [PATCH 4/8] Use no-op defaults for querystring callbacks --- python_multipart/multipart.py | 57 ++++++++++++++++++----------------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/python_multipart/multipart.py b/python_multipart/multipart.py index 572a2e4..79ece1f 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. @@ -866,9 +874,8 @@ 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. - on_field_start = callbacks.get("on_field_start") - if on_field_start is not None: - on_field_start() + on_field_start = callbacks.get("on_field_start", _noop_event) + on_field_start() i -= 1 state = QuerystringState.FIELD_NAME found_sep = False @@ -888,8 +895,8 @@ def _internal_write(self, data: bytes, length: int) -> int: if equals_pos != -1: # Emit this name. - on_field_name = callbacks.get("on_field_name") - if on_field_name is not None and i != equals_pos: + on_field_name = callbacks.get("on_field_name", _noop_data) + if i != equals_pos: on_field_name(data, i, equals_pos) # Jump i to this position. Note that it will then have 1 @@ -905,20 +912,19 @@ 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: - on_field_name = callbacks.get("on_field_name") - if on_field_name is not None and i != sep_pos: + on_field_name = callbacks.get("on_field_name", _noop_data) + if i != sep_pos: on_field_name(data, i, sep_pos) - on_field_end = callbacks.get("on_field_end") - if on_field_end is not None: - on_field_end() + on_field_end = callbacks.get("on_field_end", _noop_event) + 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. - on_field_name = callbacks.get("on_field_name") - if on_field_name is not None and i != length: + on_field_name = callbacks.get("on_field_name", _noop_data) + if i != length: on_field_name(data, i, length) i = length @@ -935,8 +941,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. - on_field_name = callbacks.get("on_field_name") - if on_field_name is not None and i != length: + on_field_name = callbacks.get("on_field_name", _noop_data) + if i != length: on_field_name(data, i, length) i = length @@ -947,12 +953,11 @@ 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: - on_field_data = callbacks.get("on_field_data") - if on_field_data is not None and i != sep_pos: + on_field_data = callbacks.get("on_field_data", _noop_data) + if i != sep_pos: on_field_data(data, i, sep_pos) - on_field_end = callbacks.get("on_field_end") - if on_field_end is not None: - on_field_end() + on_field_end = callbacks.get("on_field_end", _noop_event) + on_field_end() # Note that we go to the separator, which brings us to the # "before field" state. This allows us to properly emit @@ -963,8 +968,8 @@ def _internal_write(self, data: bytes, length: int) -> int: # Otherwise, emit the rest as data and finish. else: - on_field_data = callbacks.get("on_field_data") - if on_field_data is not None and i != length: + on_field_data = callbacks.get("on_field_data", _noop_data) + if i != length: on_field_data(data, i, length) i = length @@ -987,12 +992,10 @@ def finalize(self) -> None: 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): - on_field_end = callbacks.get("on_field_end") - if on_field_end is not None: - on_field_end() - on_end = callbacks.get("on_end") - if on_end is not None: - on_end() + on_field_end = callbacks.get("on_field_end", _noop_event) + on_field_end() + on_end = callbacks.get("on_end", _noop_event) + on_end() def __repr__(self) -> str: return "{}(strict_parsing={!r}, max_size={!r})".format( From a46af2e58508dd53fab86dc1f94c5476886ab812 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 9 Aug 2026 12:50:45 +0200 Subject: [PATCH 5/8] Test querystring parsing without callbacks --- tests/test_multipart.py | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/tests/test_multipart.py b/tests/test_multipart.py index c581be1..2c7c896 100644 --- a/tests/test_multipart.py +++ b/tests/test_multipart.py @@ -423,22 +423,11 @@ def test_simple_querystring(self) -> None: self.assert_fields((b"foo", b"bar")) - def test_set_callback_during_write(self) -> None: - names: list[bytes] = [] + def test_no_callbacks(self) -> None: + parser = QuerystringParser() - def on_field_start() -> None: - parser.set_callback("field_name", on_replacement_field_name) - - def on_initial_field_name(data: bytes, start: int, end: int) -> None: - raise AssertionError("The replaced callback was called") - - def on_replacement_field_name(data: bytes, start: int, end: int) -> None: - names.append(data[start:end]) - - parser = QuerystringParser(callbacks={"on_field_start": on_field_start, "on_field_name": on_initial_field_name}) - parser.write(b"foo=bar") - - self.assertEqual(names, [b"foo"]) + self.assertEqual(parser.write(b"foo=bar"), 7) + parser.finalize() def test_querystring_blank_beginning(self) -> None: self.p.write(b"&foo=bar") From de37a6517d80d9e7efbf65fbfa3848de69a17ecb Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 9 Aug 2026 13:24:57 +0200 Subject: [PATCH 6/8] Preserve None callback handling --- python_multipart/multipart.py | 29 ++++++++++++++++++----------- tests/test_multipart.py | 13 +++++++++++++ 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/python_multipart/multipart.py b/python_multipart/multipart.py index 79ece1f..49cdd8e 100644 --- a/python_multipart/multipart.py +++ b/python_multipart/multipart.py @@ -845,6 +845,18 @@ def _internal_write(self, data: bytes, length: int) -> int: 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: @@ -874,7 +886,6 @@ 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. - on_field_start = callbacks.get("on_field_start", _noop_event) on_field_start() i -= 1 state = QuerystringState.FIELD_NAME @@ -895,7 +906,6 @@ def _internal_write(self, data: bytes, length: int) -> int: if equals_pos != -1: # Emit this name. - on_field_name = callbacks.get("on_field_name", _noop_data) if i != equals_pos: on_field_name(data, i, equals_pos) @@ -912,10 +922,8 @@ 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: - on_field_name = callbacks.get("on_field_name", _noop_data) if i != sep_pos: on_field_name(data, i, sep_pos) - on_field_end = callbacks.get("on_field_end", _noop_event) on_field_end() i = sep_pos - 1 @@ -923,7 +931,6 @@ def _internal_write(self, data: bytes, length: int) -> int: else: # Otherwise, no separator in this block, so the # rest of this chunk must be a name. - on_field_name = callbacks.get("on_field_name", _noop_data) if i != length: on_field_name(data, i, length) i = length @@ -941,7 +948,6 @@ def _internal_write(self, data: bytes, length: int) -> int: # No separator in the rest of this chunk, so it's just # a field name. - on_field_name = callbacks.get("on_field_name", _noop_data) if i != length: on_field_name(data, i, length) i = length @@ -953,10 +959,8 @@ 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: - on_field_data = callbacks.get("on_field_data", _noop_data) if i != sep_pos: on_field_data(data, i, sep_pos) - on_field_end = callbacks.get("on_field_end", _noop_event) on_field_end() # Note that we go to the separator, which brings us to the @@ -968,7 +972,6 @@ def _internal_write(self, data: bytes, length: int) -> int: # Otherwise, emit the rest as data and finish. else: - on_field_data = callbacks.get("on_field_data", _noop_data) if i != length: on_field_data(data, i, length) i = length @@ -992,9 +995,13 @@ def finalize(self) -> None: 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): - on_field_end = callbacks.get("on_field_end", _noop_event) + 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", _noop_event) + on_end = callbacks.get("on_end") + if on_end is None: + on_end = _noop_event on_end() def __repr__(self) -> str: diff --git a/tests/test_multipart.py b/tests/test_multipart.py index 2c7c896..949d70c 100644 --- a/tests/test_multipart.py +++ b/tests/test_multipart.py @@ -429,6 +429,19 @@ def test_no_callbacks(self) -> None: 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") From 3d370c780226849c4f0428fb8fd8d65645e548f2 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 9 Aug 2026 13:28:39 +0200 Subject: [PATCH 7/8] Document callback update timing --- python_multipart/multipart.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/python_multipart/multipart.py b/python_multipart/multipart.py index 49cdd8e..380eadb 100644 --- a/python_multipart/multipart.py +++ b/python_multipart/multipart.py @@ -678,6 +678,10 @@ def set_callback(self, name: CallbackName, new_func: Callable[..., Any] | None) :param new_func: The new function for the callback. If None, then the callback will be removed (with no error if it does not exist). + + Updates made while a parser operation is running are not guaranteed to + affect later callbacks in that operation. They are guaranteed to apply + to the next call to ``write()`` or ``finalize()``. """ if new_func is None: self.callbacks.pop("on_" + name, None) # type: ignore[misc] From f2195598d0a0017ca4560419c58b18ab661f4ad0 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 9 Aug 2026 13:32:07 +0200 Subject: [PATCH 8/8] Drop callback timing note --- python_multipart/multipart.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/python_multipart/multipart.py b/python_multipart/multipart.py index 380eadb..49cdd8e 100644 --- a/python_multipart/multipart.py +++ b/python_multipart/multipart.py @@ -678,10 +678,6 @@ def set_callback(self, name: CallbackName, new_func: Callable[..., Any] | None) :param new_func: The new function for the callback. If None, then the callback will be removed (with no error if it does not exist). - - Updates made while a parser operation is running are not guaranteed to - affect later callbacks in that operation. They are guaranteed to apply - to the next call to ``write()`` or ``finalize()``. """ if new_func is None: self.callbacks.pop("on_" + name, None) # type: ignore[misc]