diff --git a/pm/workspace.py b/pm/workspace.py index 1f0f321592..2494eabd2c 100644 --- a/pm/workspace.py +++ b/pm/workspace.py @@ -382,6 +382,8 @@ def install_node_sidecar( cwd=str(plugin_dir), capture_output=True, text=True, + encoding="utf-8", + errors="replace", timeout=900, ) except Exception as exc: @@ -469,7 +471,8 @@ def lock_and_sync( if not frozen: lock = subprocess.run( - [uv_bin, "lock"], cwd=str(generated), env=run_env, capture_output=True, text=True, timeout=1800 + [uv_bin, "lock"], cwd=str(generated), env=run_env, + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=1800, ) if lock.returncode != 0: raise classify_uv_failure("lock", lock.returncode, lock.stderr or lock.stdout) @@ -484,7 +487,10 @@ def lock_and_sync( cmd = [uv_bin, "sync", "--frozen", "--all-packages"] for extra in sorted(set(extras or [])): cmd += ["--extra", extra] - sync = subprocess.run(cmd, cwd=str(generated), env=run_env, capture_output=True, text=True, timeout=1800) + sync = subprocess.run( + cmd, cwd=str(generated), env=run_env, + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=1800, + ) if sync.returncode != 0: # --frozen means the lock already resolved; a sync failure here is # install/download/tooling, never a NEW resolution conflict. diff --git a/tests/pm/test_workspace_output_encoding.py b/tests/pm/test_workspace_output_encoding.py new file mode 100644 index 0000000000..52d79340c8 --- /dev/null +++ b/tests/pm/test_workspace_output_encoding.py @@ -0,0 +1,97 @@ +"""Captured package-tool output must not depend on the host's ANSI code page.""" + +import importlib +import locale +import os +import subprocess +import sys + +import pytest + +import pm.workspace as ws +from pm.package import InstallError + + +@pytest.fixture +def legacy_locale_child(monkeypatch): + # The canonical runner enables UTF-8 mode. Pin only subprocess's default + # encoding seam to a legacy locale; keep the real host and pipe I/O. + monkeypatch.setattr(locale, "getencoding", lambda: "cp1252") + monkeypatch.setattr(subprocess, "_text_encoding", locale.getencoding) + real_run = subprocess.run + + def run(*, stdout=b"", stderr=b"", returncode=0, **kwargs): + script = ( + "import sys; " + f"sys.stdout.buffer.write({stdout!r}); " + f"sys.stderr.buffer.write({stderr!r}); " + f"sys.exit({returncode})" + ) + return real_run([sys.executable, "-c", script], **kwargs) + + return run + + +@pytest.mark.parametrize("stage", ["lock", "sync"]) +@pytest.mark.parametrize("stream", ["stdout", "stderr"]) +@pytest.mark.parametrize("suffix", [b"", b"\xff"], ids=["utf8", "invalid-byte"]) +def test_uv_failure_retains_utf8_build_diagnostic( + tmp_path, monkeypatch, legacy_locale_child, stage, stream, suffix +): + diagnostic = "🔍 cryptography: OpenSSL headers not found" + raw = diagnostic.encode("utf-8") + suffix + b"\n" + expected = diagnostic + ("�" if suffix else "") + monkeypatch.setattr(ws, "_generate_pyproject", lambda *a, **k: (tmp_path, False)) + monkeypatch.setattr( + importlib.import_module("pm.ensure"), "uv", + lambda **kwargs: (sys.executable, dict(os.environ)), + ) + completed = [] + + def run_uv(cmd, **kwargs): + # Successful lock output must also be decoded before sync can run. + output = {stream: raw} if cmd[1] == stage else {"stdout": raw, "stderr": raw} + result = legacy_locale_child( + **output, returncode=17 if cmd[1] == stage else 0, **kwargs + ) + completed.append(result) + return result + + monkeypatch.setattr(ws.subprocess, "run", run_uv) + with pytest.raises(InstallError) as excinfo: + ws.lock_and_sync([], venv_dir=tmp_path / "venv", root=tmp_path) + + assert type(excinfo.value) is InstallError # A build error is not a resolver conflict. + assert excinfo.value.cause == f"uv {stage} exited 17: {expected}" + assert getattr(completed[-1], stream) == expected + "\n" + + +@pytest.mark.parametrize("install_cmd", ["ci", "install"]) +@pytest.mark.parametrize("stream", ["stdout", "stderr"]) +@pytest.mark.parametrize("returncode", [0, 17]) +def test_node_sidecar_retains_output_and_exit_status( + tmp_path, monkeypatch, legacy_locale_child, install_cmd, stream, returncode +): + (tmp_path / "package.json").write_text("{}", encoding="utf-8") + if install_cmd == "ci": + (tmp_path / "package-lock.json").write_text("{}", encoding="utf-8") + monkeypatch.setattr( + importlib.import_module("pm.ensure"), "lazy_installs_allowed", lambda: True + ) + diagnostic = "🔍 node-gyp: build toolchain unavailable" + raw = diagnostic.encode("utf-8") + b"\xff\n" + completed = [] + + def run_npm(cmd, **kwargs): + result = legacy_locale_child(**{stream: raw}, returncode=returncode, **kwargs) + completed.append(result) + return result + + error = ws.install_node_sidecar(tmp_path, npm_bin=sys.executable, runner=run_npm) + + expected = diagnostic + "�" + if returncode: + assert error == f"npm {install_cmd} exited {returncode}: {expected}" + else: + assert error is None + assert getattr(completed[-1], stream) == expected + "\n" \ No newline at end of file