From d907690dc3535eeeca80e7fbac65a63352d9d3ef Mon Sep 17 00:00:00 2001 From: Himanshu Agrawal Date: Fri, 5 Jun 2026 00:51:22 +0530 Subject: [PATCH] Escape surrogate values in tracing output --- changelog/681.bugfix.rst | 1 + src/pluggy/_manager.py | 2 +- src/pluggy/_tracing.py | 2 +- testing/test_pluginmanager.py | 33 +++++++++++++++++++++++++++++++++ testing/test_tracer.py | 6 ++++++ 5 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 changelog/681.bugfix.rst diff --git a/changelog/681.bugfix.rst b/changelog/681.bugfix.rst new file mode 100644 index 00000000..02fa529a --- /dev/null +++ b/changelog/681.bugfix.rst @@ -0,0 +1 @@ +Fix tracing output to safely escape surrogate characters in hook arguments and return values. diff --git a/src/pluggy/_manager.py b/src/pluggy/_manager.py index c20105c8..31d3d0d7 100644 --- a/src/pluggy/_manager.py +++ b/src/pluggy/_manager.py @@ -503,7 +503,7 @@ def after( kwargs: Mapping[str, object], ) -> None: if outcome.exception is None: - hooktrace("finish", hook_name, "-->", outcome.get_result()) + hooktrace("finish", hook_name, "-->", repr(outcome.get_result())) hooktrace.root.indent -= 1 return self.add_hookcall_monitoring(before, after) diff --git a/src/pluggy/_tracing.py b/src/pluggy/_tracing.py index e90418f5..507dc435 100644 --- a/src/pluggy/_tracing.py +++ b/src/pluggy/_tracing.py @@ -35,7 +35,7 @@ def _format_message(self, tags: Sequence[str], args: Sequence[object]) -> str: lines = [f"{indent}{content} [{':'.join(tags)}]\n"] for name, value in extra.items(): - lines.append(f"{indent} {name}: {value}\n") + lines.append(f"{indent} {name}: {value!r}\n") return "".join(lines) diff --git a/testing/test_pluginmanager.py b/testing/test_pluginmanager.py index 219b17d5..7055d58e 100644 --- a/testing/test_pluginmanager.py +++ b/testing/test_pluginmanager.py @@ -695,6 +695,39 @@ def he_method1(self): undo() +def test_hook_tracing_escapes_surrogate_values(pm: PluginManager) -> None: + class Hooks: + @hookspec(firstresult=True) + def he_method1(self, arg: object) -> object: + raise NotImplementedError() + + class Plugin: + @hookimpl + def he_method1(self, arg: object) -> object: + return arg + + out: list[str] = [] + + def write(message: str) -> None: + message.encode() + out.append(message) + + pm.add_hookspecs(Hooks) + pm.register(Plugin()) + pm.trace.root.setwriter(write) + undo = pm.enable_tracing() + try: + result = pm.hook.he_method1(arg="\ud800") + finally: + undo() + + assert result == "\ud800" + assert out == [ + " he_method1 [hook]\n arg: '\\ud800'\n", + " finish he_method1 --> '\\ud800' [hook]\n", + ] + + @pytest.mark.parametrize("historic", [False, True]) def test_register_while_calling( pm: PluginManager, diff --git a/testing/test_tracer.py b/testing/test_tracer.py index c90c78f1..050639a3 100644 --- a/testing/test_tracer.py +++ b/testing/test_tracer.py @@ -57,6 +57,12 @@ def test_readable_output_dictargs(rootlogger: TagTracer) -> None: assert out2 == "test [test]\n a: 1\n" +def test_dictargs_escape_surrogate_values(rootlogger: TagTracer) -> None: + out = rootlogger._format_message(["test"], ["test", {"arg": "\ud800"}]) + assert out == "test [test]\n arg: '\\ud800'\n" + out.encode() + + def test_setprocessor(rootlogger: TagTracer) -> None: log = rootlogger.get("1") log2 = log.get("2")