Repository: scikit-build/scikit-build-core (python)

Issue:
Support scripts properly
See https://packaging.python.org/en/latest/specifications/binary-distribution-format/#recommended-installer-features & https://github.com/mesonbuild/meson-python/issues/220. We should process scripts in `/scripts` to replace a nice shebang with the required one.

This is what setuptools uses (and misses pypy):

```python
shebang_pattern = re.compile('^#!.*python[0-9.]*([ \t].*)?$')
```

Also seems to not suppert `pythonw`. Though I'm not sure you'd indicate that in a source file that you wanted to run on Linux too.

Pull request description:
fix: support script rewriting
Closes #130. Properly processes and rewrites script's shebang lines.

Patch:
diff --git a/src/scikit_build_core/build/_scripts.py b/src/scikit_build_core/build/_scripts.py
new file mode 100644
index 0000000..2509042
--- /dev/null
+++ b/src/scikit_build_core/build/_scripts.py
@@ -0,0 +1,31 @@
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+__all__ = ["process_script_dir"]
+
+
+def __dir__() -> list[str]:
+    return __all__
+
+
+SHEBANG_PATTERN = re.compile(r"^#!.*(?:python|pythonw|pypy)[0-9.]*([ \t].*)?$")
+
+
+def process_script_dir(script_dir: Path) -> None:
+    for item in script_dir.iterdir():
+        content = []
+        with item.open(encoding="utf-8") as f:
+            file_iter = iter(f)
+            try:
+                # TODO: handle empty files
+                first_line = next(file_iter)
+            except StopIteration:
+                first_line = ""
+            match = SHEBANG_PATTERN.match(first_line)
+            if match:
+                content = [f"#!python{match.group(1) or ''}\n", *file_iter]
+        if content:
+            with item.open("w", encoding="utf-8") as f:
+                f.writelines(content)
diff --git a/src/scikit_build_core/build/wheel.py b/src/scikit_build_core/build/wheel.py
index 90d140e..003307e 100644
--- a/src/scikit_build_core/build/wheel.py
+++ b/src/scikit_build_core/build/wheel.py
@@ -20,6 +20,7 @@ from ..settings.metadata import get_standard_metadata
 from ..settings.skbuild_read_settings import SettingsReader
 from ._init import setup_logging
 from ._pathutil import packages_to_file_mapping
+from ._scripts import process_script_dir
 from ._wheelfile import WheelWriter
 
 __all__: list[str] = ["_build_wheel_impl"]
@@ -199,14 +200,7 @@ def _build_wheel_impl(
             Path(package_dir).parent.mkdir(exist_ok=True, parents=True)
             shutil.copyfile(filepath, package_dir)
 
-        for item in wheel_dirs["scripts"].iterdir():
-            with item.open("rb") as f:
-                content = f.read(len(b"#!python"))
-                if content.startswith(b"#!/") and content == b"#!python":
-                    logger.warning(
-                        "Files in scripts/ are not post-processed yet for shabang fixes"
-                    )
-                    break
+        process_script_dir(wheel_dirs["scripts"])
 
         with WheelWriter(metadata, Path(wheel_directory), tags.as_tags_set()) as wheel:
             wheel.build(wheel_dirs)


Test patch:
diff --git a/tests/test_process_scripts.py b/tests/test_process_scripts.py
new file mode 100644
index 0000000..47b263a
--- /dev/null
+++ b/tests/test_process_scripts.py
@@ -0,0 +1,50 @@
+from pathlib import Path
+
+from scikit_build_core.build._scripts import process_script_dir
+
+
+def test_script_dir(tmp_path: Path) -> None:
+    script_dir = tmp_path / "scripts"
+    script_dir.mkdir()
+    script_1 = script_dir / "script1"
+    script_1.write_text("#!/usr/bin/env python3\n\nprint('hello world')")
+    script_1.chmod(0o644)
+    orig_mode_1 = script_1.stat().st_mode
+
+    script_2 = script_dir / "script2"
+    script_2.write_text("#!/usr/bin/env pypy3\n\nprint('hello world')")
+    script_2.chmod(0o755)
+    orig_mode_2 = script_2.stat().st_mode
+
+    script_3 = script_dir / "script3"
+    script_3.write_text("#!/usr/bin/env pythonw3.11\n\nprint('hello world')")
+
+    script_4 = script_dir / "script4"
+    script_4.write_text("#!/usr/bin/python3.11\n\nprint('hello world')")
+
+    script_5 = script_dir / "script5"
+    script_5.write_text("#!/usr/bin/other\n\nprint('hello world')")
+
+    script_6 = script_dir / "script6"
+    script_6.write_text("#!/usr/bin/env python3.11 other\n\nprint('hello world')")
+
+    script_7 = script_dir / "script7"
+    script_7.write_text("#!/usr/bin/env other\n\nprint('hello world')")
+
+    process_script_dir(script_dir)
+
+    assert script_1.read_text() == "#!python\n\nprint('hello world')"
+    assert script_1.stat().st_mode == orig_mode_1
+
+    assert script_2.read_text() == "#!python\n\nprint('hello world')"
+    assert script_2.stat().st_mode == orig_mode_2
+
+    assert script_3.read_text() == "#!python\n\nprint('hello world')"
+
+    assert script_4.read_text() == "#!python\n\nprint('hello world')"
+
+    assert script_5.read_text() == "#!/usr/bin/other\n\nprint('hello world')"
+
+    assert script_6.read_text() == "#!python other\n\nprint('hello world')"
+
+    assert script_7.read_text() == "#!/usr/bin/env other\n\nprint('hello world')"