pyenv-binary: add the build subcommand

Packaging a version by hand means running `pyenv install`, `pyenv binary
save` and `pyenv binary generate-installer` in turn and keeping the names
they produce in step. `build` runs the three in one pass, so the archive,
its metadata and the definition all come out named after the entry.

* The entry name is given explicitly as `<version>:<entry>`, reusing the
  syntax `pyenv install` already accepts, so a packaged build can sit
  beside a plain source install of the same version
* Entry names that `pyenv install` would not install under (`latest`), or
  that would point outside versions/, are refused before anything is
  compiled rather than after
* Linux only for now, matching `save` and `generate-installer`
This commit is contained in:
macayu17 2026-07-22 13:09:07 +05:30
parent bec9a562a5
commit 094c0a80ed
3 changed files with 178 additions and 0 deletions

View file

@ -16,6 +16,22 @@ and dependency metadata exist to catch that.
## Commands
### `pyenv binary build <version>:<entry> --archive-url <url>`
Builds `<version>` from source under the separate name `<entry>`, packages it
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 `<url>` 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 build 3.12.7:3.12.7-debian-12 --archive-url https://example.com/binaries
# writes 3.12.7-debian-12-linux-x86_64.tar.gz, its .meta file and
# a `3.12.7-debian-12' definition
```
### `pyenv binary save <version> [<output-dir>]`
Packs an installed version into `<version>-<platform>.tar.gz` (relative paths)

View file

@ -0,0 +1,83 @@
#!/usr/bin/env bash
#
# Summary: Build a Python version and package it as an installable binary
#
# Usage: pyenv binary build <version>:<entry> --archive-url <url>
#
# Builds <version> from source under the separate name <entry>, packages the
# result with `pyenv binary save', then emits a python-build definition for
# it with `pyenv binary generate-installer'. The archive, metadata and
# definition are written to the current directory, named after <entry>.
#
# <version> A version `pyenv install' knows how to build.
# <entry> The name to build under and to install the binary
# as, e.g. `3.13.14-debian-12'. Keeping it distinct
# from <version> lets the binary sit alongside a
# normal source install of the same version.
# --archive-url <url> Where the archive will be hosted; the definition
# downloads it from <url>/<archive-name>.
#
set -e
[ -n "$PYENV_DEBUG" ] && set -x
# Provide pyenv completions. Only the definitions are useful here: the options
# `pyenv install' takes are not accepted.
if [ "$1" = "--complete" ]; then
echo --archive-url
exec python-build --definitions
fi
spec=""
archive_url=""
while [ $# -gt 0 ]; do
case "$1" in
--archive-url )
[ $# -ge 2 ] || { echo "pyenv-binary: --archive-url needs a value" >&2; exit 1; }
archive_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_url" ]; then
pyenv-help --usage binary-build >&2
exit 1
fi
case "$spec" in
*?:?* ) ;;
* )
echo "pyenv-binary: expected <version>:<entry>, 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
# `pyenv install' puts a `<version>:<alias>' build under versions/<alias>.
pyenv-install "$spec"
pyenv-binary-save "$entry"
# Name the metadata and the archive the way `save' just named them.
platform="$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m)"
pyenv-binary-generate-installer "${entry}-${platform}.meta" \
--archive-url "${archive_url%/}/${entry}-${platform}.tar.gz" -o "$entry"

View file

@ -0,0 +1,79 @@
#!/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 the buildable versions" {
create_stub python-build 'echo 3.12.7'
run pyenv-binary-build --complete
assert_success "--archive-url
3.12.7"
}
@test "fails with no arguments" {
create_stub pyenv-help "echo usage"
run pyenv-binary-build
assert_failure "usage"
}
@test "fails without an archive url" {
create_stub pyenv-help "echo usage"
run pyenv-binary-build 3.12.7:3.12.7-test
assert_failure "usage"
}
@test "fails when --archive-url has no value" {
run pyenv-binary-build 3.12.7:3.12.7-test --archive-url
assert_failure "pyenv-binary: --archive-url needs a value"
}
@test "rejects a second positional argument" {
run pyenv-binary-build 3.12.7:3.12.7-test extra --archive-url http://x/b
assert_failure "pyenv-binary: unexpected argument \`extra'"
}
@test "rejects a bare version without an entry name" {
run pyenv-binary-build 3.12.7 --archive-url http://x/b
assert_failure "pyenv-binary: expected <version>:<entry>, e.g. \`3.13.14:3.13.14-debian-12'"
}
@test "rejects an entry name containing a slash" {
run pyenv-binary-build "3.12.7:foo/bar" --archive-url http://x/b
assert_failure "pyenv-binary: invalid entry name \`foo/bar'"
}
@test "rejects \`latest' as an entry name" {
run pyenv-binary-build 3.12:latest --archive-url http://x/b
assert_failure "pyenv-binary: \`latest' cannot be used as an entry name"
}
@test "builds under the entry name and writes the archive and definition" {
stub_build_environment
cd "${BATS_TEST_TMPDIR}"
run pyenv-binary-build 3.12.7:3.12.7-test --archive-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-linux-x86_64.tar.gz" ]
assert [ -f "${BATS_TEST_TMPDIR}/3.12.7-test-linux-x86_64.meta" ]
run grep '^ARCHIVE_URL=' "${BATS_TEST_TMPDIR}/3.12.7-test"
assert_success "ARCHIVE_URL=http://example.com/binaries/3.12.7-test-linux-x86_64.tar.gz"
}
@test "strips a trailing slash from the archive url" {
stub_build_environment
cd "${BATS_TEST_TMPDIR}"
run pyenv-binary-build 3.12.7:3.12.7-test --archive-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-linux-x86_64.tar.gz"
}