From f90abfc7a863177b6ac7bc08ee285874dd936902 Mon Sep 17 00:00:00 2001 From: nk-batman21 Date: Sat, 24 Oct 2026 03:30:15 +0700 Subject: [PATCH 1/3] fix(backend): escape output the stdout encoding cannot represent The backend prints its module and the hook arguments, which include project and output paths. Before Python 3.15 a Windows pipe uses the ANSI code page, so a path such as a Cyrillic folder name made print() raise UnicodeEncodeError and the build failed. Use backslashreplace for stdout, as stderr already does. --- docs/changelog.d/+backend-output-encoding.bugfix.rst | 1 + src/pyproject_api/_backend.py | 2 ++ tests/test_frontend.py | 12 ++++++++++++ 3 files changed, 15 insertions(+) create mode 100644 docs/changelog.d/+backend-output-encoding.bugfix.rst diff --git a/docs/changelog.d/+backend-output-encoding.bugfix.rst b/docs/changelog.d/+backend-output-encoding.bugfix.rst new file mode 100644 index 0000000..9b581d8 --- /dev/null +++ b/docs/changelog.d/+backend-output-encoding.bugfix.rst @@ -0,0 +1 @@ +Stop the backend from crashing when it prints a path its standard output encoding cannot represent. diff --git a/src/pyproject_api/_backend.py b/src/pyproject_api/_backend.py index b3b6b60..cc6604d 100644 --- a/src/pyproject_api/_backend.py +++ b/src/pyproject_api/_backend.py @@ -150,4 +150,6 @@ def read_line(fd=0): if __name__ == "__main__": + # printed paths may hold characters the stdout encoding cannot represent + sys.stdout.reconfigure(errors="backslashreplace") # type: ignore[unresolved-attribute] sys.exit(run(sys.argv[1:])) diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 8410d90..bb2c3b7 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -150,6 +150,18 @@ def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): assert (wheel, "before\\xffafter" in captured) == ("demo-1.0-py3-none-any.whl", True) +def test_backend_output_with_unencodable_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PYTHONIOENCODING", "ascii") # like a Windows ANSI code page that lacks a path character + root: Final[Path] = tmp_path / "pröject" + root.mkdir() + (root / "build_tester.py").write_text("def build_sdist(sdist_directory, config_settings=None): return 'out.tar.gz'") + frontend: Final[SubprocessFrontend] = SubprocessFrontend(root, (root,), "build_tester", None, ()) + + result = frontend.build_sdist(root / "dist") + + assert (result.sdist, "pr\\xf6ject" in result.out) == (root / "dist" / "out.tar.gz", True) + + def test_large_command_with_failed_backend(local_builder: Callable[[str], Path]) -> None: tmp_path = local_builder('raise RuntimeError("backend import failed")') frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(tmp_path)[:-1]) From a2e037c42a363bbc9c75a48e4539d322c52b728e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 27 Oct 2026 12:34:51 -0700 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=8E=A8=20style(backend):=20give=20the?= =?UTF-8?q?=20stdout=20type=20ignore=20a=20reason?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ty needs the suppression because typeshed declares sys.stdout as TextIO, which has no reconfigure(). Inline the single-use frontend in the test. --- src/pyproject_api/_backend.py | 2 +- tests/test_frontend.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/pyproject_api/_backend.py b/src/pyproject_api/_backend.py index cc6604d..1acb858 100644 --- a/src/pyproject_api/_backend.py +++ b/src/pyproject_api/_backend.py @@ -151,5 +151,5 @@ def read_line(fd=0): if __name__ == "__main__": # printed paths may hold characters the stdout encoding cannot represent - sys.stdout.reconfigure(errors="backslashreplace") # type: ignore[unresolved-attribute] + sys.stdout.reconfigure(errors="backslashreplace") # type: ignore[unresolved-attribute] # stub says TextIO sys.exit(run(sys.argv[1:])) diff --git a/tests/test_frontend.py b/tests/test_frontend.py index bb2c3b7..bcb3e25 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -155,9 +155,8 @@ def test_backend_output_with_unencodable_path(tmp_path: Path, monkeypatch: pytes root: Final[Path] = tmp_path / "pröject" root.mkdir() (root / "build_tester.py").write_text("def build_sdist(sdist_directory, config_settings=None): return 'out.tar.gz'") - frontend: Final[SubprocessFrontend] = SubprocessFrontend(root, (root,), "build_tester", None, ()) - result = frontend.build_sdist(root / "dist") + result: Final = SubprocessFrontend(root, (root,), "build_tester", None, ()).build_sdist(root / "dist") assert (result.sdist, "pr\\xf6ject" in result.out) == (root / "dist" / "out.tar.gz", True) From 7cfeeb4a8a8d26c455d02169baf1ca43867a1ea3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 27 Oct 2026 16:22:32 -0700 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9C=85=20test(backend):=20move=20the=20e?= =?UTF-8?q?ncoding=20test=20next=20to=20its=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fix lives in the backend module, so its test belongs in the backend test file, importing the frontend from the public package. --- tests/test_backend.py | 15 +++++++++++++-- tests/test_frontend.py | 11 ----------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/test_backend.py b/tests/test_backend.py index a8f6437..8925f0f 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -5,11 +5,11 @@ import subprocess # ruff:ignore[suspicious-subprocess-import] import sys from textwrap import dedent -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Final import pytest -from pyproject_api import _backend +from pyproject_api import SubprocessFrontend, _backend from pyproject_api._backend import BackendProxy, read_line, run if TYPE_CHECKING: @@ -222,3 +222,14 @@ def test_read_line_eof_at_the_beginning() -> None: read_line(fd=r) finally: os.close(r) + + +def test_backend_output_with_unencodable_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PYTHONIOENCODING", "ascii") # like a Windows ANSI code page that lacks a path character + root: Final[Path] = tmp_path / "pröject" + root.mkdir() + (root / "build_tester.py").write_text("def build_sdist(sdist_directory, config_settings=None): return 'out.tar.gz'") + + result: Final = SubprocessFrontend(root, (root,), "build_tester", None, ()).build_sdist(root / "dist") + + assert (result.sdist, "pr\\xf6ject" in result.out) == (root / "dist" / "out.tar.gz", True) diff --git a/tests/test_frontend.py b/tests/test_frontend.py index bcb3e25..8410d90 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -150,17 +150,6 @@ def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): assert (wheel, "before\\xffafter" in captured) == ("demo-1.0-py3-none-any.whl", True) -def test_backend_output_with_unencodable_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("PYTHONIOENCODING", "ascii") # like a Windows ANSI code page that lacks a path character - root: Final[Path] = tmp_path / "pröject" - root.mkdir() - (root / "build_tester.py").write_text("def build_sdist(sdist_directory, config_settings=None): return 'out.tar.gz'") - - result: Final = SubprocessFrontend(root, (root,), "build_tester", None, ()).build_sdist(root / "dist") - - assert (result.sdist, "pr\\xf6ject" in result.out) == (root / "dist" / "out.tar.gz", True) - - def test_large_command_with_failed_backend(local_builder: Callable[[str], Path]) -> None: tmp_path = local_builder('raise RuntimeError("backend import failed")') frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(tmp_path)[:-1])