diff --git a/.github/workflows/build-distributions.yml b/.github/workflows/build-distributions.yml index 4cede0416..94fb3b45b 100644 --- a/.github/workflows/build-distributions.yml +++ b/.github/workflows/build-distributions.yml @@ -94,6 +94,11 @@ jobs: /home/python/bin/python -m pip install ${{ inputs.version && format('hatch=={0}', inputs.version) || '/home/hatch' }} + - name: Make scripts portable + run: >- + docker exec builder + /home/python/bin/python /home/hatch/release/unix/make_scripts_portable.py + - name: Strip debug symbols run: >- docker exec builder @@ -155,6 +160,11 @@ jobs: -m pip install ${{ inputs.version && format('hatch=={0}', inputs.version) || '.' }} + - name: Make scripts portable + run: >- + ${{ startsWith(matrix.job.os, 'windows-') && '.\\python\\python.exe' || './python/bin/python' }} + release/${{ startsWith(matrix.job.os, 'windows-') && 'windows' || 'unix' }}/make_scripts_portable.py + - name: Strip debug symbols if: startsWith(matrix.job.os, 'macos-') run: find python -name '*.so' | xargs strip -S diff --git a/.github/workflows/build-hatch.yml b/.github/workflows/build-hatch.yml index cf10f4599..23f29aef0 100644 --- a/.github/workflows/build-hatch.yml +++ b/.github/workflows/build-hatch.yml @@ -86,7 +86,7 @@ jobs: skip-existing: true binaries: - name: ${{ matrix.job.target }} (${{ matrix.job.os }}) + name: Binary ${{ matrix.job.target }} (${{ matrix.job.os }}) needs: - python-artifacts runs-on: ${{ matrix.job.os }} diff --git a/docs/history/hatch.md b/docs/history/hatch.md index 7a20ed51c..98c8dbfcf 100644 --- a/docs/history/hatch.md +++ b/docs/history/hatch.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## Unreleased +***Fixed:*** + +- Fix entry points in the pre-built distribution that binaries use + ## [1.11.0](https://github.com/pypa/hatch/releases/tag/hatch-v1.11.0) - 2024-05-14 ## {: #hatch-v1.11.0 } ***Added:*** diff --git a/release/unix/make_scripts_portable.py b/release/unix/make_scripts_portable.py new file mode 100644 index 000000000..a82457356 --- /dev/null +++ b/release/unix/make_scripts_portable.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import sys +import sysconfig +from io import BytesIO +from pathlib import Path + + +def main(): + interpreter = Path(sys.executable).resolve() + + # https://github.com/indygreg/python-build-standalone/blob/20240415/cpython-unix/build-cpython.sh#L812-L813 + portable_shebang = b'#!/bin/sh\n"exec" "$(dirname $0)/%s" "$0" "$@"\n' % interpreter.name.encode() + + scripts_dir = Path(sysconfig.get_path('scripts')) + for script in scripts_dir.iterdir(): + if not script.is_file(): + continue + + with script.open('rb') as f: + data = BytesIO() + for line in f: + # Ignore leading blank lines + if not line.strip(): + continue + + # Ignore binaries + if not line.startswith(b'#'): + break + + if line.startswith(b'#!%s' % interpreter.parent): + executable = Path(line[2:].rstrip().decode()).resolve() + data.write(portable_shebang if executable == interpreter else line) + else: + data.write(line) + + data.write(f.read()) + break + + contents = data.getvalue() + if not contents: + continue + + with script.open('wb') as f: + f.write(contents) + + +if __name__ == '__main__': + main() diff --git a/release/windows/make_scripts_portable.py b/release/windows/make_scripts_portable.py new file mode 100644 index 000000000..b1226a783 --- /dev/null +++ b/release/windows/make_scripts_portable.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import sys +import sysconfig +from contextlib import closing +from importlib.metadata import entry_points +from io import BytesIO +from os.path import relpath +from pathlib import Path +from tempfile import TemporaryDirectory +from urllib.request import urlopen +from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo + +LAUNCHERS_URL = 'https://raw.githubusercontent.com/astral-sh/uv/main/crates/uv-trampoline/trampolines' +SCRIPT_TEMPLATE = """\ +#!{executable} +# -*- coding: utf-8 -*- +import re +import sys +from {module} import {import_name} +if __name__ == "__main__": + sys.argv[0] = re.sub(r"(-script\\.pyw|\\.exe)?$", "", sys.argv[0]) + sys.exit({function}()) +""" + + +def select_entry_points(ep, group): + return ep.select(group=group) if sys.version_info[:2] >= (3, 10) else ep.get(group, []) + + +def fetch_launcher(launcher_name): + with urlopen(f'{LAUNCHERS_URL}/{launcher_name}') as f: # noqa: S310 + return f.read() + + +def main(): + interpreters_dir = Path(sys.executable).parent + scripts_dir = Path(sysconfig.get_path('scripts')) + + ep = entry_points() + for group, interpreter_name, launcher_name in ( + ('console_scripts', 'python.exe', 'uv-trampoline-x86_64-console.exe'), + ('gui_scripts', 'pythonw.exe', 'uv-trampoline-x86_64-gui.exe'), + ): + interpreter = interpreters_dir / interpreter_name + relative_interpreter_path = relpath(interpreter, scripts_dir) + launcher_data = fetch_launcher(launcher_name) + + for script in select_entry_points(ep, group): + # https://github.com/astral-sh/uv/tree/main/crates/uv-trampoline#how-do-you-use-it + with closing(BytesIO()) as buf: + # Launcher + buf.write(launcher_data) + + # Zipped script + with TemporaryDirectory() as td: + zip_path = Path(td) / 'script.zip' + with ZipFile(zip_path, 'w') as zf: + # Ensure reproducibility + zip_info = ZipInfo('__main__.py', (2020, 2, 2, 0, 0, 0)) + zip_info.external_attr = (0o644 & 0xFFFF) << 16 + + module, _, attrs = script.value.partition(':') + contents = SCRIPT_TEMPLATE.format( + executable=relative_interpreter_path, + module=module, + import_name=attrs.split('.')[0], + function=attrs, + ) + zf.writestr(zip_info, contents, compress_type=ZIP_DEFLATED) + + buf.write(zip_path.read_bytes()) + + # Interpreter path + interpreter_path = relative_interpreter_path.encode('utf-8') + buf.write(interpreter_path) + + # Interpreter path length + interpreter_path_length = len(interpreter_path).to_bytes(4, 'little') + buf.write(interpreter_path_length) + + # Magic number + buf.write(b'UVUV') + + script_data = buf.getvalue() + + script_path = scripts_dir / f'{script.name}.exe' + script_path.write_bytes(script_data) + + +if __name__ == '__main__': + main() diff --git a/ruff.toml b/ruff.toml index 9d0e1b1c6..44c57859c 100644 --- a/ruff.toml +++ b/ruff.toml @@ -18,7 +18,7 @@ ignore = [ "backend/src/hatchling/bridge/app.py" = ["T201"] "backend/tests/downstream/integrate.py" = ["INP001", "T201"] "docs/.hooks/*" = ["INP001", "T201"] -"release/macos/build_pkg.py" = ["INP001"] +"release/**/*" = ["INP001"] [lint.isort] known-first-party = ["hatch", "hatchling"]