diff --git a/plugins/pyenv-binary/README.md b/plugins/pyenv-binary/README.md index ac01be58..8e940022 100644 --- a/plugins/pyenv-binary/README.md +++ b/plugins/pyenv-binary/README.md @@ -16,11 +16,29 @@ and dependency metadata exist to catch that. ## Commands -### `pyenv binary save []` +### `pyenv binary package : --archive-base-url ` + +Installs `` from source under the separate name ``, packages +that install with `save`, then emits a python-build definition for it with +`generate-installer`. The archive, metadata and definition land in the current +directory, named after the entry; host the archive under `` and drop the +definition into python-build's definition directory. Keeping the entry name +distinct from the version lets the binary sit alongside a normal source +install of the same version. + +```sh +pyenv binary package 3.12.7:3.12.7-debian-12 \ + --archive-base-url https://example.com/binaries +# writes 3.12.7-debian-12.tar.gz, its .meta file and +# a `3.12.7-debian-12' definition +``` + +### `pyenv binary save [] [--name ]` Packs an installed version into `-.tar.gz` (relative paths) and writes `-.meta` describing the build platform (OS, arch, -distro and libc version) and the system libraries the build links against. +distro and libc version) and the system libraries the build links against. Use +`--name` to set a different base name for both files. ```sh pyenv binary save 3.12.7 ./dist diff --git a/plugins/pyenv-binary/libexec/pyenv-binary-package b/plugins/pyenv-binary/libexec/pyenv-binary-package new file mode 100755 index 00000000..42294ed1 --- /dev/null +++ b/plugins/pyenv-binary/libexec/pyenv-binary-package @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# +# Summary: Create an installable binary package from a Python version +# +# Usage: pyenv binary package : --archive-base-url +# +# Installs from source under the separate name , saves it as +# a binary package, then emits a python-build definition for that package. +# The archive, metadata and definition are written to the current directory +# as .tar.gz, .meta and . +# +# A version `pyenv install' knows how to build. +# The name to build under and to install the binary +# as, e.g. `3.13.14-debian-12'. Keeping it distinct +# from lets the binary sit alongside a +# normal source install of the same version. +# --archive-base-url +# Where the archive will be hosted; the definition +# downloads it from /.tar.gz. +# +set -e +[ -n "$PYENV_DEBUG" ] && set -x + +# Provide pyenv completions +if [ "$1" = "--complete" ]; then + echo --archive-base-url + exec pyenv-install --list --bare +fi + +spec="" +archive_base_url="" + +while [ $# -gt 0 ]; do + case "$1" in + --archive-base-url ) + [ $# -ge 2 ] || { echo "pyenv-binary: --archive-base-url needs a value" >&2; exit 1; } + archive_base_url="$2"; shift 2 ;; + -* ) + echo "pyenv-binary: unknown option \`$1'" >&2; exit 1 ;; + * ) + [ -z "$spec" ] || { echo "pyenv-binary: unexpected argument \`$1'" >&2; exit 1; } + spec="$1"; shift ;; + esac +done + +if [ -z "$spec" ] || [ -z "$archive_base_url" ]; then + pyenv-help --usage binary-package >&2 + exit 1 +fi + +case "$spec" in +*?:?* ) ;; +* ) + echo "pyenv-binary: expected :, e.g. \`3.13.14:3.13.14-debian-12'" >&2 + exit 1 + ;; +esac +entry="${spec##*:}" + +case "$entry" in +# `pyenv install' reads a trailing `:latest' as part of the version rather than +# as an alias, so nothing would end up installed under that name. +latest ) + echo "pyenv-binary: \`latest' cannot be used as an entry name" >&2 + exit 1 + ;; +# The entry becomes a directory name under versions/. `pyenv install' does not +# validate the alias, and `save' only checks once the build is done, so refuse +# a name that could point elsewhere before compiling anything. +*/* | .. | . ) + echo "pyenv-binary: invalid entry name \`${entry}'" >&2 + exit 1 + ;; +esac + +os="$(uname -s)" + +# `generate-installer' refuses a macOS archive, so the last step here cannot +# succeed on one. Give up before compiling rather than after it. +if [ "$os" = "Darwin" ]; then + echo "pyenv-binary: macOS archives are not supported yet" >&2 + exit 1 +fi + +# `pyenv install' puts a `:' build under versions/. +pyenv-install "$spec" +pyenv-binary-save "$entry" "$PWD" --name "$entry" + +pyenv-binary-generate-installer "${entry}.meta" \ + --archive-url "${archive_base_url%/}/${entry}.tar.gz" -o "$entry" diff --git a/plugins/pyenv-binary/libexec/pyenv-binary-save b/plugins/pyenv-binary/libexec/pyenv-binary-save index 7a20b5c4..4b1047a3 100755 --- a/plugins/pyenv-binary/libexec/pyenv-binary-save +++ b/plugins/pyenv-binary/libexec/pyenv-binary-save @@ -2,7 +2,7 @@ # # Summary: Save an installed Python version as a relocatable archive # -# Usage: pyenv binary save [] +# Usage: pyenv binary save [] [--name ] # # Packs an installed version into a relocatable .tar.gz (relative paths) and # writes a metadata file listing the build platform and the system libraries @@ -10,20 +10,46 @@ # # An installed version, as listed by `pyenv versions --bare'. # Where to write the archive and metadata (default: `.'). +# --name Use as the archive and metadata base name instead of +# -. # set -e [ -n "$PYENV_DEBUG" ] && set -x # Provide pyenv completions if [ "$1" = "--complete" ]; then + echo --name exec pyenv-versions --bare fi -version="$1" -output_dir="${2:-$PWD}" +version="" +output_dir="" +package_name="" + +while [ $# -gt 0 ]; do + case "$1" in + --name ) + [ $# -ge 2 ] && [ -n "$2" ] || { echo "pyenv-binary: --name needs a value" >&2; exit 1; } + package_name="$2"; shift 2 ;; + -* ) + echo "pyenv-binary: unknown option \`$1'" >&2; exit 1 ;; + * ) + if [ -z "$version" ]; then + version="$1" + elif [ -z "$output_dir" ]; then + output_dir="$1" + else + echo "pyenv-binary: unexpected argument \`$1'" >&2 + exit 1 + fi + shift ;; + esac +done + +output_dir="${output_dir:-$PWD}" if [ -z "$version" ]; then - echo "Usage: pyenv binary save []" >&2 + echo "Usage: pyenv binary save [] [--name ]" >&2 exit 1 fi @@ -36,6 +62,13 @@ case "$version" in ;; esac +case "$package_name" in +*/* | .. | . | *[[:cntrl:]]* ) + echo "pyenv-binary: invalid package name \`${package_name}'" >&2 + exit 1 + ;; +esac + prefix="${PYENV_ROOT}/versions/${version}" if [ ! -d "${prefix}/bin" ]; then echo "pyenv-binary: version \`${version}' is not installed" >&2 @@ -85,8 +118,9 @@ system_deps() { } mkdir -p "$output_dir" -archive="${version}-${platform}.tar.gz" -metadata="${version}-${platform}.meta" +package_name="${package_name:-${version}-${platform}}" +archive="${package_name}.tar.gz" +metadata="${package_name}.meta" tar -C "$(dirname "$prefix")" -czf "${output_dir}/${archive}" "$(basename "$prefix")" diff --git a/plugins/pyenv-binary/test/package.bats b/plugins/pyenv-binary/test/package.bats new file mode 100644 index 00000000..806e80da --- /dev/null +++ b/plugins/pyenv-binary/test/package.bats @@ -0,0 +1,92 @@ +#!/usr/bin/env bats + +load test_helper + +# Make the build deterministic: `pyenv-install' just creates the prefix, and +# the platform tools report a fixed Linux target so the real `save' and +# `generate-installer' behave the same on any test host. +stub_build_environment() { + create_stub pyenv-install 'mkdir -p "${PYENV_ROOT}/versions/${1##*:}/bin"' + create_stub uname 'case "$1" in -s) echo Linux;; -m) echo x86_64;; esac' + create_stub getconf 'echo "glibc 2.17"' +} + +@test "completion lists the option and definitions provided by another plugin" { + mkdir -p "${PYENV_ROOT}/plugins/example/share/python-build" + touch "${PYENV_ROOT}/plugins/example/share/python-build/3.12.7-example" + PATH="${BATS_TEST_DIRNAME}/../../python-build/bin:${PATH}" + + run pyenv-binary-package --complete + assert_success + assert_line "--archive-base-url" + assert_line "3.12.7-example" + refute_line "Available versions:" +} + +@test "fails with no arguments" { + create_stub pyenv-help "echo usage" + run pyenv-binary-package + assert_failure "usage" +} + +@test "fails without an archive base url" { + create_stub pyenv-help "echo usage" + run pyenv-binary-package 3.12.7:3.12.7-test + assert_failure "usage" +} + +@test "fails when --archive-base-url has no value" { + run pyenv-binary-package 3.12.7:3.12.7-test --archive-base-url + assert_failure "pyenv-binary: --archive-base-url needs a value" +} + +@test "rejects a second positional argument" { + run pyenv-binary-package 3.12.7:3.12.7-test extra --archive-base-url http://x/b + assert_failure "pyenv-binary: unexpected argument \`extra'" +} + +@test "rejects a bare version without an entry name" { + run pyenv-binary-package 3.12.7 --archive-base-url http://x/b + assert_failure "pyenv-binary: expected :, e.g. \`3.13.14:3.13.14-debian-12'" +} + +@test "rejects an entry name containing a slash" { + run pyenv-binary-package "3.12.7:foo/bar" --archive-base-url http://x/b + assert_failure "pyenv-binary: invalid entry name \`foo/bar'" +} + +@test "rejects \`latest' as an entry name" { + run pyenv-binary-package 3.12:latest --archive-base-url http://x/b + assert_failure "pyenv-binary: \`latest' cannot be used as an entry name" +} + +@test "refuses to package on macOS before compiling anything" { + create_stub uname 'case "$1" in -s) echo Darwin;; -m) echo arm64;; esac' + run pyenv-binary-package 3.12.7:3.12.7-test --archive-base-url http://x/b + assert_failure "pyenv-binary: macOS archives are not supported yet" +} + +@test "writes the archive, metadata and definition under the entry name" { + stub_build_environment + cd "${BATS_TEST_TMPDIR}" + + run pyenv-binary-package 3.12.7:3.12.7-test \ + --archive-base-url http://example.com/binaries + assert_success + assert [ -d "${PYENV_ROOT}/versions/3.12.7-test" ] + assert [ -f "${BATS_TEST_TMPDIR}/3.12.7-test.tar.gz" ] + assert [ -f "${BATS_TEST_TMPDIR}/3.12.7-test.meta" ] + run grep '^ARCHIVE_URL=' "${BATS_TEST_TMPDIR}/3.12.7-test" + assert_success "ARCHIVE_URL=http://example.com/binaries/3.12.7-test.tar.gz" +} + +@test "strips a trailing slash from the archive base url" { + stub_build_environment + cd "${BATS_TEST_TMPDIR}" + + run pyenv-binary-package 3.12.7:3.12.7-test \ + --archive-base-url http://example.com/binaries/ + assert_success + run grep '^ARCHIVE_URL=' "${BATS_TEST_TMPDIR}/3.12.7-test" + assert_success "ARCHIVE_URL=http://example.com/binaries/3.12.7-test.tar.gz" +} diff --git a/plugins/pyenv-binary/test/save.bats b/plugins/pyenv-binary/test/save.bats index 97c9f12a..03616856 100644 --- a/plugins/pyenv-binary/test/save.bats +++ b/plugins/pyenv-binary/test/save.bats @@ -12,7 +12,7 @@ platform() { @test "fails with no version given" { run pyenv-binary-save - assert_failure "Usage: pyenv binary save []" + assert_failure "Usage: pyenv binary save [] [--name ]" } @test "fails for a version that is not installed" { @@ -44,6 +44,29 @@ platform() { assert_line 0 "3.12.7/" } +@test "uses an explicit package name" { + create_version "3.12.7" + local out="${BATS_TEST_TMPDIR}/dist" + + run pyenv-binary-save "3.12.7" "$out" --name "custom" + assert_success "Saved custom.tar.gz and custom.meta to $out" + assert [ -f "${out}/custom.tar.gz" ] + run grep '^archive=' "${out}/custom.meta" + assert_success "archive=custom.tar.gz" +} + +@test "fails when --name has no value" { + run pyenv-binary-save "3.12.7" --name + assert_failure "pyenv-binary: --name needs a value" +} + +@test "rejects an invalid package name" { + create_version "3.12.7" + + run pyenv-binary-save "3.12.7" --name "foo/bar" + assert_failure "pyenv-binary: invalid package name \`foo/bar'" +} + @test "records the platform in the metadata" { create_version "3.12.7" local out="${BATS_TEST_TMPDIR}/dist" diff --git a/plugins/python-build/bin/pyenv-install b/plugins/python-build/bin/pyenv-install index 4d0220ed..4a5e71c1 100755 --- a/plugins/python-build/bin/pyenv-install +++ b/plugins/python-build/bin/pyenv-install @@ -4,7 +4,7 @@ # # Usage: pyenv install [-f] [-kvp] [:]... # pyenv install [-f] [-kvp] [:] -# pyenv install -l|--list +# pyenv install -l|--list [--bare] # pyenv install --version # # -l/--list List all available versions @@ -45,6 +45,7 @@ shopt -u nullglob # Provide pyenv completions if [ "$1" = "--complete" ]; then + echo --bare echo --list echo --force echo --skip-existing @@ -79,6 +80,8 @@ unset KEEP unset VERBOSE unset HAS_PATCH unset DEBUG +unset BARE +unset LIST [ -n "$PYENV_DEBUG" ] && VERBOSE="-v" @@ -88,10 +91,11 @@ for option in "${OPTIONS[@]}"; do "h" | "help" ) usage 0 ;; + "bare" ) + BARE=1 + ;; "l" | "list" ) - echo "Available versions:" - definitions | indent - exit + LIST=1 ;; "f" | "force" ) FORCE=true @@ -120,6 +124,16 @@ for option in "${OPTIONS[@]}"; do esac done +if [[ -n $LIST ]]; then + if [[ -n $BARE ]]; then + definitions + else + echo "Available versions:" + definitions | indent + fi + exit +fi + unset VERSION_NAME # The first argument contains the definition to install. If the diff --git a/plugins/python-build/test/pyenv.bats b/plugins/python-build/test/pyenv.bats index 8d4ec144..12d7d2de 100644 --- a/plugins/python-build/test/pyenv.bats +++ b/plugins/python-build/test/pyenv.bats @@ -160,6 +160,22 @@ OUT unstub python-build } +@test "list available versions with --bare" { + stub_python_build_lib + stub_python_build "--definitions : echo 2.6.9 2.7.9-rc1 2.7.9-rc2 3.4.2 | tr ' ' $'\\n'" + + run pyenv-install --list --bare + assert_success + assert_output <