From 3d6b95504820cae4e01acce772bdb7619205552b Mon Sep 17 00:00:00 2001 From: Ghanchu Date: Tue, 18 Nov 2025 21:01:25 -0500 Subject: [PATCH 1/5] Fixing an issue with pluggy and surrogate escape characters during tracing. #13750 in Pytest --- src/pluggy/_tracing.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pluggy/_tracing.py b/src/pluggy/_tracing.py index f0b36db1..6b471600 100644 --- a/src/pluggy/_tracing.py +++ b/src/pluggy/_tracing.py @@ -41,7 +41,8 @@ def _format_message(self, tags: Sequence[str], args: Sequence[object]) -> str: def _processmessage(self, tags: tuple[str, ...], args: tuple[object, ...]) -> None: if self._writer is not None and args: - self._writer(self._format_message(tags, args)) + msg = self._format_message(tags, args) + self._writer(msg.encode("utf-8", "replace").decode("utf-8")) try: processor = self._tags2proc[tags] except KeyError: From b41c07a5038e0f60ca043f810cec9a173a36e71e Mon Sep 17 00:00:00 2001 From: Ghanchu Date: Tue, 18 Nov 2025 21:39:05 -0500 Subject: [PATCH 2/5] added test in test_tracer.py to expose changes and increase code coverage --- changelog/13750(pytest).bugfix.rst | 3 +++ testing/test_tracer.py | 11 +++++++++++ 2 files changed, 14 insertions(+) create mode 100644 changelog/13750(pytest).bugfix.rst diff --git a/changelog/13750(pytest).bugfix.rst b/changelog/13750(pytest).bugfix.rst new file mode 100644 index 00000000..62d2dc7e --- /dev/null +++ b/changelog/13750(pytest).bugfix.rst @@ -0,0 +1,3 @@ +This change addresses an issue in pluggy that occured when running pytest with any pluggy tracing enabled when parametrized values contained surrogate escape characters. +Before, pluggy attempted to write trace messages using UTF-8 enconding, which fails for lone surrogates. Tracing now encodes lone surrogates with errors="replace" in order +to ensure that trace logging will not crash hook execution in the future. diff --git a/testing/test_tracer.py b/testing/test_tracer.py index c90c78f1..a336215b 100644 --- a/testing/test_tracer.py +++ b/testing/test_tracer.py @@ -75,3 +75,14 @@ def test_setprocessor(rootlogger: TagTracer) -> None: log2("seen") tags, args = l2[0] assert args == ("seen",) + + +def test_unicode_surrogate_handling(rootlogger: TagTracer) -> None: + out: list[str] = [] + rootlogger.setwriter(out.append) + log = rootlogger.get("pytest") + s = "hello \ud800 world" + log(s) + assert len(out) == 1 + assert "\ud800" not in out + assert "hello ? world" in out[0] From c07d4387b8757898fb76f660e39f65f768bd03e0 Mon Sep 17 00:00:00 2001 From: Ghanchu Date: Tue, 18 Nov 2025 22:13:34 -0500 Subject: [PATCH 3/5] added pluggy/testing to .coveragerc --- .coveragerc | 1 + testing/test_tracer.py | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/.coveragerc b/.coveragerc index b79041a4..a2340263 100644 --- a/.coveragerc +++ b/.coveragerc @@ -2,6 +2,7 @@ include = pluggy/* src/pluggy/* + pluggy/testing/* testing/* */lib/python*/site-packages/pluggy/* */pypy*/site-packages/pluggy/* diff --git a/testing/test_tracer.py b/testing/test_tracer.py index a336215b..9ed7d539 100644 --- a/testing/test_tracer.py +++ b/testing/test_tracer.py @@ -86,3 +86,27 @@ def test_unicode_surrogate_handling(rootlogger: TagTracer) -> None: assert len(out) == 1 assert "\ud800" not in out assert "hello ? world" in out[0] + + +def test_unicode_surrogate_handling_2(rootlogger: TagTracer) -> None: + out: list[str] = [] + rootlogger.setwriter(out.append) + log = rootlogger.get("pytest") + + bad = b"\xed\xa0\x80".decode("utf-8", "surrogatepass") + + log(bad) + + assert len(out) == 1 + assert "\ud800" not in out[0] + assert "?" in out[0] + + +def test_unicode_surrogate_handling_normal(rootlogger: TagTracer) -> None: + out: list[str] = [] + rootlogger.setwriter(out.append) + log = rootlogger.get("pytest") + s = "hello world" + log(s) + assert len(out) == 1 + assert "hello world" in out[0] From 4d552bc8eaaae2f10d5f6227fc2dd82cd3f2f5f7 Mon Sep 17 00:00:00 2001 From: Ghanchu Date: Wed, 19 Nov 2025 15:44:15 -0500 Subject: [PATCH 4/5] removed pluggy/testing/* from .coveragerc and removed redundant tests from test_tracer.py --- .coveragerc | 1 - testing/test_tracer.py | 10 ---------- 2 files changed, 11 deletions(-) diff --git a/.coveragerc b/.coveragerc index a2340263..b79041a4 100644 --- a/.coveragerc +++ b/.coveragerc @@ -2,7 +2,6 @@ include = pluggy/* src/pluggy/* - pluggy/testing/* testing/* */lib/python*/site-packages/pluggy/* */pypy*/site-packages/pluggy/* diff --git a/testing/test_tracer.py b/testing/test_tracer.py index 9ed7d539..49744aa0 100644 --- a/testing/test_tracer.py +++ b/testing/test_tracer.py @@ -100,13 +100,3 @@ def test_unicode_surrogate_handling_2(rootlogger: TagTracer) -> None: assert len(out) == 1 assert "\ud800" not in out[0] assert "?" in out[0] - - -def test_unicode_surrogate_handling_normal(rootlogger: TagTracer) -> None: - out: list[str] = [] - rootlogger.setwriter(out.append) - log = rootlogger.get("pytest") - s = "hello world" - log(s) - assert len(out) == 1 - assert "hello world" in out[0] From 2d37a214494fa00231a2cbe2d1c8e8d12430eef0 Mon Sep 17 00:00:00 2001 From: Ghanchu Date: Mon, 1 Dec 2025 19:51:02 -0500 Subject: [PATCH 5/5] Made changes to use repr instead of replace to solve encoding issue --- src/pluggy/_manager.py | 2 +- src/pluggy/_tracing.py | 8 ++++---- testing/test_tracer.py | 37 +++++++++++-------------------------- 3 files changed, 16 insertions(+), 31 deletions(-) diff --git a/src/pluggy/_manager.py b/src/pluggy/_manager.py index ff1e3ce6..b631063b 100644 --- a/src/pluggy/_manager.py +++ b/src/pluggy/_manager.py @@ -501,7 +501,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 6b471600..66479efc 100644 --- a/src/pluggy/_tracing.py +++ b/src/pluggy/_tracing.py @@ -29,20 +29,20 @@ def _format_message(self, tags: Sequence[str], args: Sequence[object]) -> str: else: extra = {} - content = " ".join(map(str, args)) + content = " ".join(repr(a) for a in args) indent = " " * self.indent lines = ["{}{} [{}]\n".format(indent, content, ":".join(tags))] for name, value in extra.items(): - lines.append(f"{indent} {name}: {value}\n") + lines.append(f"{indent} {name}: {value!r}\n") return "".join(lines) def _processmessage(self, tags: tuple[str, ...], args: tuple[object, ...]) -> None: if self._writer is not None and args: - msg = self._format_message(tags, args) - self._writer(msg.encode("utf-8", "replace").decode("utf-8")) + self._writer(self._format_message(tags, args)) + try: processor = self._tags2proc[tags] except KeyError: diff --git a/testing/test_tracer.py b/testing/test_tracer.py index 49744aa0..0bf125ff 100644 --- a/testing/test_tracer.py +++ b/testing/test_tracer.py @@ -15,10 +15,10 @@ def test_simple(rootlogger: TagTracer) -> None: rootlogger.setwriter(out.append) log("world") assert len(out) == 1 - assert out[0] == "world [pytest]\n" + assert out[0] == "'world' [pytest]\n" sublog = log.get("collection") sublog("hello") - assert out[1] == "hello [pytest:collection]\n" + assert out[1] == "'hello' [pytest:collection]\n" def test_indent(rootlogger: TagTracer) -> None: @@ -39,13 +39,13 @@ def test_indent(rootlogger: TagTracer) -> None: assert len(out) == 7 names = [x[: x.rfind(" [")] for x in out] assert names == [ - "hello", - " line1", - " line2", - " line3", - " line4", - " line5", - "last", + "'hello'", + " 'line1'", + " 'line2'", + " 'line3'", + " 'line4'", + " 'line5'", + "'last'", ] @@ -54,7 +54,7 @@ def test_readable_output_dictargs(rootlogger: TagTracer) -> None: assert out == "1 [test]\n" out2 = rootlogger._format_message(["test"], ["test", {"a": 1}]) - assert out2 == "test [test]\n a: 1\n" + assert out2 == "'test' [test]\n a: 1\n" def test_setprocessor(rootlogger: TagTracer) -> None: @@ -84,19 +84,4 @@ def test_unicode_surrogate_handling(rootlogger: TagTracer) -> None: s = "hello \ud800 world" log(s) assert len(out) == 1 - assert "\ud800" not in out - assert "hello ? world" in out[0] - - -def test_unicode_surrogate_handling_2(rootlogger: TagTracer) -> None: - out: list[str] = [] - rootlogger.setwriter(out.append) - log = rootlogger.get("pytest") - - bad = b"\xed\xa0\x80".decode("utf-8", "surrogatepass") - - log(bad) - - assert len(out) == 1 - assert "\ud800" not in out[0] - assert "?" in out[0] + assert "hello \\ud800 world" in out[0]