diff --git a/news/13389.bugfix.rst b/news/13389.bugfix.rst new file mode 100644 index 00000000000..d2433cb65a7 --- /dev/null +++ b/news/13389.bugfix.rst @@ -0,0 +1 @@ +Rewriting of ``#!python`` headers in scripts has been improved to handle unusual paths more robustly. diff --git a/src/pip/_internal/operations/install/wheel.py b/src/pip/_internal/operations/install/wheel.py index 3ada586c218..bdd026204b1 100644 --- a/src/pip/_internal/operations/install/wheel.py +++ b/src/pip/_internal/operations/install/wheel.py @@ -90,14 +90,16 @@ def fix_script(path: str) -> bool: assert os.path.isfile(path) with open(path, "rb") as script: - firstline = script.readline() - if not firstline.startswith(b"#!python"): + prelude = script.readline() + if (m := re.match(rb"^#!(python[^\s]*)(\s.*)?$", prelude)) is None: return False - exename = sys.executable.encode(sys.getfilesystemencoding()) - firstline = b"#!" + exename + os.linesep.encode("ascii") + exe, post_interp = m.groups() + options = {"gui": exe.startswith(b"pythonw")} + sm = ScriptMaker(None, None) + prelude = sm._get_shebang("utf-8", post_interp or b"", options) rest = script.read() with open(path, "wb") as script: - script.write(firstline) + script.write(prelude) script.write(rest) return True diff --git a/tests/functional/test_install_wheel.py b/tests/functional/test_install_wheel.py index e01ecfb57f3..190d11c89d7 100644 --- a/tests/functional/test_install_wheel.py +++ b/tests/functional/test_install_wheel.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import base64 import csv import hashlib @@ -5,13 +7,20 @@ import shutil import sysconfig from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any import pytest -from tests.lib import PipTestEnvironment, TestData, create_basic_wheel_for_package +from tests.lib import create_basic_wheel_for_package from tests.lib.wheel import WheelBuilder, make_wheel +from ..lib.venv import VirtualEnvironment + +if TYPE_CHECKING: + from collections.abc import Callable + + from tests.lib import PipTestEnvironment, ScriptFactory, TestData + # assert_installed expects a package subdirectory, so give it to them def make_wheel_with_file(name: str, version: str, **kwargs: Any) -> WheelBuilder: @@ -366,18 +375,29 @@ def test_wheel_record_lines_have_hash_for_data_files( ] +@pytest.mark.parametrize( + "ws_dirname", ["work space", "workspace"], ids=["spaces", "no_spaces"] +) +@pytest.mark.parametrize("executable", ["python", "pythonw"]) def test_wheel_record_lines_have_updated_hash_for_scripts( - script: PipTestEnvironment, + tmpdir: Path, + virtualenv_factory: Callable[[Path], VirtualEnvironment], + script_factory: ScriptFactory, + ws_dirname: str, + executable: str, ) -> None: """ pip rewrites "#!python" shebang lines in scripts when it installs them; make sure it updates the RECORD file correspondingly. """ + (tmpdir / ws_dirname).mkdir(exist_ok=True, parents=True) + virtualenv = virtualenv_factory(tmpdir / ws_dirname / "venv") + script = script_factory(tmpdir / ws_dirname, virtualenv) package = make_wheel( "simple", "0.1.0", extra_data_files={ - "scripts/dostuff": "#!python\n", + "scripts/dostuff": f"#!{executable}\n", }, ).save_to_dir(script.scratch_path) script.pip("install", package) @@ -388,7 +408,10 @@ def test_wheel_record_lines_have_updated_hash_for_scripts( script_path = script.bin_path / "dostuff" script_contents = script_path.read_bytes() - assert not script_contents.startswith(b"#!python\n") + expected_prefix = ( + b"#!/bin/sh\n'''exec' \"" if " " in ws_dirname else b"#!" + ) + f"{script.bin_path}{os.path.sep}{executable}".encode() + assert script_contents.startswith(expected_prefix) script_digest = hashlib.sha256(script_contents).digest() script_digest_b64 = (