From 6da10379828821a4df6e4a94d8aa0860d056b0fe Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Sun, 11 May 2025 16:22:55 +0200 Subject: [PATCH 1/6] Re-use shebang code and add test --- src/pip/_internal/operations/install/wheel.py | 11 ++++--- tests/functional/test_install_wheel.py | 31 ++++++++++++++++--- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/src/pip/_internal/operations/install/wheel.py b/src/pip/_internal/operations/install/wheel.py index 3ada586c218..4b088c127f1 100644 --- a/src/pip/_internal/operations/install/wheel.py +++ b/src/pip/_internal/operations/install/wheel.py @@ -90,14 +90,15 @@ 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(br"^#!python[^\s]*(\s.*)?$", prelude)) is None: return False - exename = sys.executable.encode(sys.getfilesystemencoding()) - firstline = b"#!" + exename + os.linesep.encode("ascii") + sm = ScriptMaker(None, None) + sm.executable = sys.executable + prelude = sm._get_shebang("utf-8", m.group(1) or b"") 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..852f7d97013 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,13 +375,22 @@ def test_wheel_record_lines_have_hash_for_data_files( ] +@pytest.mark.parametrize( + "ws_dirname", ["work space", "workspace"], ids=["spaces", "no_spaces"] +) 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, ) -> 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", @@ -388,7 +406,12 @@ 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 f"#!{script.bin_path}{os.path.sep}python".encode() + ) + assert script_contents.startswith(expected_prefix) script_digest = hashlib.sha256(script_contents).digest() script_digest_b64 = ( From b5917be5364468ae64c0eecb794482834d37bed1 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Sun, 11 May 2025 16:29:23 +0200 Subject: [PATCH 2/6] relnote --- news/13389.bugfix.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 news/13389.bugfix.rst diff --git a/news/13389.bugfix.rst b/news/13389.bugfix.rst new file mode 100644 index 00000000000..eac04cecdcf --- /dev/null +++ b/news/13389.bugfix.rst @@ -0,0 +1,2 @@ +Python scripts added as :file:`data` files (e.g. via ``setuptools``\ ’ ``scripts=`` parameter) +now get their shebang modified like regular scripts, and no longer break for venv paths with spaces. From cb9d513a8178d016e93be3a64e0ad97bb53cb302 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Sun, 11 May 2025 16:33:35 +0200 Subject: [PATCH 3/6] fmt --- src/pip/_internal/operations/install/wheel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pip/_internal/operations/install/wheel.py b/src/pip/_internal/operations/install/wheel.py index 4b088c127f1..a981e78e39f 100644 --- a/src/pip/_internal/operations/install/wheel.py +++ b/src/pip/_internal/operations/install/wheel.py @@ -91,7 +91,7 @@ def fix_script(path: str) -> bool: with open(path, "rb") as script: prelude = script.readline() - if (m := re.match(br"^#!python[^\s]*(\s.*)?$", prelude)) is None: + if (m := re.match(rb"^#!python[^\s]*(\s.*)?$", prelude)) is None: return False sm = ScriptMaker(None, None) sm.executable = sys.executable From 1f3452f62824dd4026879c49ea5be718b0d13a26 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Sun, 11 May 2025 16:36:01 +0200 Subject: [PATCH 4/6] remove redundant code --- src/pip/_internal/operations/install/wheel.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/pip/_internal/operations/install/wheel.py b/src/pip/_internal/operations/install/wheel.py index a981e78e39f..77097b3287f 100644 --- a/src/pip/_internal/operations/install/wheel.py +++ b/src/pip/_internal/operations/install/wheel.py @@ -93,9 +93,7 @@ def fix_script(path: str) -> bool: prelude = script.readline() if (m := re.match(rb"^#!python[^\s]*(\s.*)?$", prelude)) is None: return False - sm = ScriptMaker(None, None) - sm.executable = sys.executable - prelude = sm._get_shebang("utf-8", m.group(1) or b"") + prelude = ScriptMaker(None, None)._get_shebang("utf-8", m.group(1) or b"") rest = script.read() with open(path, "wb") as script: script.write(prelude) From a460e1fefa2f609b2552f7881b6707835b4b4583 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Sun, 11 May 2025 18:01:39 +0200 Subject: [PATCH 5/6] WIP pythonw support --- src/pip/_internal/operations/install/wheel.py | 7 +++++-- tests/functional/test_install_wheel.py | 10 +++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/pip/_internal/operations/install/wheel.py b/src/pip/_internal/operations/install/wheel.py index 77097b3287f..bdd026204b1 100644 --- a/src/pip/_internal/operations/install/wheel.py +++ b/src/pip/_internal/operations/install/wheel.py @@ -91,9 +91,12 @@ def fix_script(path: str) -> bool: with open(path, "rb") as script: prelude = script.readline() - if (m := re.match(rb"^#!python[^\s]*(\s.*)?$", prelude)) is None: + if (m := re.match(rb"^#!(python[^\s]*)(\s.*)?$", prelude)) is None: return False - prelude = ScriptMaker(None, None)._get_shebang("utf-8", m.group(1) or b"") + 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(prelude) diff --git a/tests/functional/test_install_wheel.py b/tests/functional/test_install_wheel.py index 852f7d97013..190d11c89d7 100644 --- a/tests/functional/test_install_wheel.py +++ b/tests/functional/test_install_wheel.py @@ -378,11 +378,13 @@ 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( 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; @@ -395,7 +397,7 @@ def test_wheel_record_lines_have_updated_hash_for_scripts( "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) @@ -407,10 +409,8 @@ def test_wheel_record_lines_have_updated_hash_for_scripts( script_path = script.bin_path / "dostuff" script_contents = script_path.read_bytes() expected_prefix = ( - b"#!/bin/sh\n'''exec'" - if " " in ws_dirname - else f"#!{script.bin_path}{os.path.sep}python".encode() - ) + 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() From fa92deff20f5fc52589056c0ea82ee7f2c457bd7 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Sun, 11 May 2025 18:19:15 +0200 Subject: [PATCH 6/6] reword relnote --- news/13389.bugfix.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/news/13389.bugfix.rst b/news/13389.bugfix.rst index eac04cecdcf..d2433cb65a7 100644 --- a/news/13389.bugfix.rst +++ b/news/13389.bugfix.rst @@ -1,2 +1 @@ -Python scripts added as :file:`data` files (e.g. via ``setuptools``\ ’ ``scripts=`` parameter) -now get their shebang modified like regular scripts, and no longer break for venv paths with spaces. +Rewriting of ``#!python`` headers in scripts has been improved to handle unusual paths more robustly.