Compare commits

...

28 Commits

Author SHA1 Message Date
dependabot[bot]
bdf8d254fe
Bump actions/setup-python from 6 to 7 in the github-actions group (#3505)
Bumps the github-actions group with 1 update: [actions/setup-python](https://github.com/actions/setup-python).


Updates `actions/setup-python` from 6 to 7
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 16:38:32 +03:00
Michael Šimáček
f5915fc866
Add graalpy3.12-25.2.4 (#3502) 2026-07-29 05:53:27 +03:00
Michael Newman
84e0f3a887
Fix typos 2026-07-29 05:49:46 +03:00
Ayush
7126870320
pyenv-binary: add the package subcommand; add pyenv install --list --bare (#3498)
Co-authored-by: native-api <vano@mail.mipt.ru>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-07-28 14:31:20 +03:00
Michael Newman
26dd5d9dda
Fix typo 2026-07-27 21:57:58 +03:00
Michael Newman
bac39a0ef8
Fix typo 2026-07-27 08:30:33 +03:00
Ivan Pozdeev
bec9a562a5 2.8.1 2026-07-18 22:02:09 +03:00
pyenv-bot[bot]
ae9e6594d6
Add CPython 3.15.0b4 (#3495)
Co-authored-by: native-api <2670332+native-api@users.noreply.github.com>
2026-07-18 21:59:23 +03:00
native-api
16c7cc6de4
Merge pull request #3488 from macayu17/feat/2334-pyenv-binary-installer
pyenv-binary: add the `generate-installer` subcommand
2026-07-16 10:51:36 +03:00
Ivan Pozdeev
017ad4a573 Add pyenv-binary tests to CI
* README: fix tests instructions
* test/README: -duplicating and unnecessary stuff
* Makefile: Build Docker images and replace bats link conditionally
* Makefile: fix indent irregularity
2026-07-16 10:39:55 +03:00
macayu17
6188e9efa9 pyenv-binary: add the generate-installer subcommand
* Relocation only implemented for Linux (requires patchelf + ldconfig -p)
* Shell-completing file name is not possible with current Pyenv completion logic:
  `pyenv-complete` does not pass info about the word being completed
* Fix `save`-produced tarball to be compatible with Python-Build fetch_tarball()
* Reuse test_helper from core

--

Co-authored-by: Ivan Pozdeev <vano@mail.mipt.ru>
2026-07-16 10:39:50 +03:00
Ivan Pozdeev
286a167cf0 gitignore Vim swap files 2026-07-16 10:22:39 +03:00
Ivan Pozdeev
135adbb192 2.8.0 2026-07-13 21:01:27 +03:00
native-api
b3a3e702f1
version-name: skip redundant checks (#3492) 2026-07-11 08:24:01 +03:00
native-api
958e3aca95
Add miniconda3 26.5.3-1 (#3491) 2026-07-11 08:23:42 +03:00
Ivan Pozdeev
74c6efe2f4 Adapt tests involving current directory for Docker
In Docker CI tests, the default current dir is read-only
2026-07-11 08:02:53 +03:00
native-api
95df7dbc7b
Merge commit from fork 2026-07-11 07:22:46 +03:00
max
e4c462dc70
Fix typos in docs, scripts, and tests (#3490) 2026-07-08 18:03:00 +03:00
OrbisAI Security
790bedd821
CI: fix: package_managers.dependabot.dependabot-missing-cooldown.dependabot-missing-cooldown security vulnerability (#3489)
Automated security fix generated by OrbisAI Security
2026-07-05 13:40:15 +03:00
Ayush
19f4c7cf20
Add an experimental pyenv-binary plugin with a save command (#3487)
* pyenv-binary: add experimental plugin skeleton

Adds a new plugin, decoupled from `pyenv install`, for packaging and installing
relocatable Python binaries. This commit is just the command dispatcher; the
individual subcommands follow.

`pyenv binary <command>` dispatches to the matching script under the plugin's
libexec, so subcommands stay out of the top-level `pyenv commands` list.

Groundwork for the binary distribution support discussed in #2334.

* pyenv-binary: add the `save` subcommand

`pyenv binary save <version> [<output-dir>]` packs an installed version into a
relocatable .tar.gz with relative paths and writes a metadata file recording the
build platform, distro, libc version and the external system libraries the
build links against (via `ldd` on Linux, `otool -L` on macOS).

* pyenv-binary: reject invalid version names and drop readlink -f in save

A version is a single directory name under versions/, so refuse names with a
slash or a dot-dot component before building the prefix path.

Also emit the python paths directly instead of `readlink -f`, which BSD
readlink on macOS does not support; ldd/otool follow the symlinks anyway, so
the resolved paths were never needed.

* pyenv-binary: test the `save` subcommand

Cover argument validation, the version-name guard, and packaging an
installed version into an archive with matching metadata.

* pyenv-binary: test dependency parsing and tighten the version guard

Add tests that feed realistic ldd and otool listings through save and
check only the libraries resolving outside the prefix end up in the
metadata, since that filtering is the fiddliest part of the command.

Narrow the version guard to reject `.' and `..' rather than any name
containing a dot-dot, now that a slash is already refused, and note why
the find still matches *.so.* even though CPython does not produce them.
2026-07-03 09:08:54 +03:00
dependabot[bot]
46cc3e552b
Bump actions/checkout from 6 to 7 in the github-actions group (#3486)
Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-01 10:27:40 +03:00
Michael Šimáček
8f68aab443
Add graalpy-3.12-25.1.3 (#3485)
* Add graalpy-3.12-25.1.3

* Adjust GraalPy version detection with the new naming scheme

---------

Co-authored-by: Ivan Pozdeev <vano@mail.mipt.ru>
2026-06-30 21:25:03 +03:00
Ivan Pozdeev
a27d2723d6 install: reflect the new alias syntax in reference 2026-06-30 04:17:58 +03:00
Ayush
4c78a0dd50
pyenv-install: install a version under a custom name with <version>:<alias> (#3484) 2026-06-30 04:12:16 +03:00
Ayush
91aacc5dbf
Add script to generate sponsors section for release notes (#3478)
* OpenCollectrive API is blocked by a Cloudflare challenge page in some locations so allow to specify the OpenCollective API data manually

---------

Co-authored-by: Ivan Pozdeev <vano@mail.mipt.ru>
2026-06-28 20:06:38 +03:00
Yo Takezawa
3974647f0f
rehash: fix race condition in landlock writability check (#3483) 2026-06-26 21:04:33 +03:00
native-api
ebd788d08c
Add miniforge3-26.3.2-2, 26.3.2-3 (#3481) 2026-06-25 09:52:54 +03:00
native-api
4b77d83d9d
CI: add_version enhancements (#3480)
* Default 30 seems enough for our needs
* comments on critical knowledge
* Cleaner handling of t thunk patches
* Fix an error when adding a release for which there's also a prerelease
* Workaround excepthook not working in Github Actions
* requirements.txt workaround is only needed for old Python
2026-06-25 09:48:57 +03:00
62 changed files with 2122 additions and 158 deletions

View File

@ -4,6 +4,8 @@ updates:
directory: "/" directory: "/"
schedule: schedule:
interval: "monthly" interval: "monthly"
cooldown:
default-days: 7
groups: groups:
github-actions: github-actions:
patterns: patterns:

View File

@ -0,0 +1,364 @@
#!/usr/bin/env python3
"""Generate a "Sponsors since <date>" section for release notes.
Queries GitHub Sponsors and OpenCollective for new sponsors since the latest
pyenv release or one month ago, whichever is longer. The output is Markdown
suitable for GitHub Releases.
Requirements:
* Python 3.8+
* The ``gh`` CLI authenticated with the ``read:user`` scope.
* Network access to https://opencollective.com.
"""
import argparse
import calendar
import datetime
import json
import pathlib
import subprocess
import sys
import typing
import urllib.error
import urllib.request
GITHUB_ORG = "pyenv"
OPENCOLLECTIVE_MEMBERS_URL = "https://opencollective.com/pyenv/members.json"
class SponsorDataError(RuntimeError):
"""Raised when a sponsors data source cannot be queried or parsed."""
def parse_date(value: str) -> datetime.date:
return datetime.datetime.fromisoformat(value).date()
def one_month_ago(today: typing.Optional[datetime.date] = None) -> datetime.date:
today = today or datetime.date.today()
year = today.year
month = today.month - 1
if month == 0:
year -= 1
month = 12
try:
return datetime.date(year, month, today.day)
except ValueError:
last_day = calendar.monthrange(year, month)[1]
return datetime.date(year, month, last_day)
def latest_release_date() -> datetime.date:
"""Return the publish date of the latest GitHub release."""
latest_release_error = None
try:
result = subprocess.run(
["gh", "api", f"repos/{GITHUB_ORG}/{GITHUB_ORG}/releases/latest"],
capture_output=True,
text=True,
check=True,
)
data = json.loads(result.stdout)
published = data["published_at"].replace("Z", "+00:00")
return datetime.datetime.fromisoformat(published).date()
except (
subprocess.CalledProcessError,
OSError,
json.JSONDecodeError,
KeyError,
) as exc:
latest_release_error = exc
try:
tag = subprocess.run(
["git", "describe", "--tags", "--abbrev=0"],
capture_output=True,
text=True,
check=True,
).stdout.strip()
date_str = subprocess.run(
["git", "log", "-1", "--format=%cI", tag],
capture_output=True,
text=True,
check=True,
).stdout.strip()
return datetime.datetime.fromisoformat(date_str).date()
except (subprocess.CalledProcessError, OSError) as exc:
detail = ""
if latest_release_error is not None:
detail = f" GitHub release lookup failed first: {latest_release_error}."
raise SponsorDataError(
"Could not determine the latest release date. "
"Pass --since explicitly or run from a clone with release tags."
f"{detail}"
) from exc
def compute_since_date(explicit_since: typing.Optional[datetime.date]) -> datetime.date:
if explicit_since is not None:
return explicit_since
return min(latest_release_date(), one_month_ago())
def github_sponsors(since: datetime.date) -> typing.List[typing.Dict]:
"""Return GitHub Sponsors created on or after *since*."""
query = """
query($org: String!, $after: String) {
organization(login: $org) {
sponsorshipsAsMaintainer(
first: 100,
after: $after,
activeOnly: false,
orderBy: {field: CREATED_AT, direction: DESC}
) {
pageInfo {
hasNextPage
endCursor
}
nodes {
createdAt
sponsorEntity {
... on User { login, name }
... on Organization { login, name }
}
}
}
}
}
"""
since_dt = datetime.datetime.combine(
since, datetime.time.min, tzinfo=datetime.timezone.utc
)
sponsors = []
cursor = None
while True:
command = [
"gh",
"api",
"graphql",
"-F",
f"org={GITHUB_ORG}",
"-f",
f"query={query}",
]
if cursor is not None:
command.extend(["-F", f"after={cursor}"])
result = subprocess.run(
command,
capture_output=True,
text=True,
)
if result.returncode != 0:
detail = result.stderr.strip() or result.stdout.strip() or "no output"
raise SponsorDataError(
"GitHub Sponsors query failed. "
"Check that `gh auth status` shows access to the pyenv org "
"and that the token has the scopes required to read sponsorships. "
f"`gh api graphql` exited {result.returncode}: {detail}"
)
try:
data = json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise SponsorDataError(
"GitHub Sponsors query returned invalid JSON."
) from exc
if "errors" in data:
raise SponsorDataError(
f"GitHub Sponsors query returned errors: {data['errors']}"
)
try:
sponsorships = data["data"]["organization"]["sponsorshipsAsMaintainer"]
except (TypeError, KeyError) as exc:
raise SponsorDataError(
"GitHub Sponsors query returned an unexpected response shape."
) from exc
try:
nodes = sponsorships["nodes"]
page_info = sponsorships["pageInfo"]
except (TypeError, KeyError) as exc:
raise SponsorDataError(
"GitHub Sponsors query returned incomplete pagination data."
) from exc
for node in nodes:
try:
created = datetime.datetime.fromisoformat(
node["createdAt"].replace("Z", "+00:00")
)
entity = node["sponsorEntity"]
login = entity["login"]
except (AttributeError, KeyError, TypeError, ValueError) as exc:
raise SponsorDataError(
"GitHub Sponsors query returned an unexpected sponsor record."
) from exc
if created < since_dt:
return sponsors
sponsors.append({
"login": login,
"name": entity.get("name") or login,
})
try:
has_next_page = page_info["hasNextPage"]
cursor = page_info["endCursor"]
except (TypeError, KeyError) as exc:
raise SponsorDataError(
"GitHub Sponsors query returned incomplete pagination data."
) from exc
if not has_next_page:
return sponsors
def opencollective_sponsors(since: datetime.date, data: typing.Union[str, None]) -> typing.List[typing.Dict]:
"""Return OpenCollective backers active on or after *since*."""
if data is None:
req = urllib.request.Request(
f"{OPENCOLLECTIVE_MEMBERS_URL}?limit=1000",
headers={"User-Agent": f"{GITHUB_ORG}/release-notes-sponsors"},
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
data = resp.read()
except urllib.error.HTTPError as exc:
raise SponsorDataError(
f"OpenCollective sponsors query failed for {OPENCOLLECTIVE_MEMBERS_URL}: "
f"HTTP {exc.code} {exc.reason}"
) from exc
except urllib.error.URLError as exc:
raise SponsorDataError(
f"OpenCollective sponsors query failed for {OPENCOLLECTIVE_MEMBERS_URL}: "
f"{exc.reason}"
) from exc
try:
members = json.loads(data)
except json.JSONDecodeError as exc:
raise SponsorDataError(
"OpenCollective sponsors query returned invalid JSON."
) from exc
since_dt = datetime.datetime.combine(since, datetime.time.min)
sponsors = []
for member in members:
if member.get("role") != "BACKER":
continue
try:
last_transaction_at = datetime.datetime.strptime(member["lastTransactionAt"], "%Y-%m-%d %H:%M")
profile = member["profile"].rstrip("/")
except (KeyError, TypeError, ValueError) as exc:
raise SponsorDataError(
"OpenCollective sponsors query returned an unexpected member record."
) from exc
if last_transaction_at < since_dt:
continue
slug = profile.split("/")[-1]
if slug == "github-sponsors":
# Listed separately under GitHub Sponsors.
continue
sponsors.append({
"name": member.get("name") or slug,
"profile": profile,
})
return sponsors
def render(
since: datetime.date,
gh_sponsors: typing.List[typing.Dict],
oc_sponsors: typing.List[typing.Dict],
) -> str:
lines = [f"## Sponsors since {since.isoformat()}", ""]
if gh_sponsors:
lines.append("### GitHub Sponsors")
for sponsor in gh_sponsors:
lines.append(
markdown_link(
sponsor["name"],
f"https://github.com/{sponsor['login']}",
)
)
lines.append("")
if oc_sponsors:
lines.append("### Open Collective")
for sponsor in oc_sponsors:
lines.append(markdown_link(sponsor["name"], sponsor["profile"]))
lines.append("")
if not gh_sponsors and not oc_sponsors:
lines.append("*No new sponsors in this period.*")
lines.append("")
return "\n".join(lines)
def markdown_link(text: str, url: str) -> str:
escaped_text = escape_markdown_text(text)
escaped_url = url.replace(")", "%29")
return f"- [{escaped_text}]({escaped_url})"
def escape_markdown_text(text: str) -> str:
escaped = text.replace("\\", "\\\\")
for char in r"`*_{}[]()#+-.!|>":
escaped = escaped.replace(char, f"\\{char}")
return escaped
def main() -> int:
parser = argparse.ArgumentParser(
description="Generate a sponsors section for GitHub release notes."
)
parser.add_argument(
"--since",
type=parse_date,
metavar="YYYY-MM-DD",
help="Include sponsors created on or after this date.",
)
parser.add_argument(
"--output",
metavar="FILE",
help="Write the section to FILE instead of stdout.",
)
parser.add_argument(
"--skip-opencollective",
action="store_true",
help="Skip OpenCollective backers if the members endpoint is unavailable.",
)
parser.add_argument(
"--opencollective-data",
metavar="FILE",
help="Take OpenCollective API reply from FILE.",
)
args = parser.parse_args()
try:
since = compute_since_date(args.since)
oc_sponsors = []
if not args.skip_opencollective:
manual_data = (
pathlib.Path(args.opencollective_data).read_text()) \
if args.opencollective_data \
else None
oc_sponsors = opencollective_sponsors(since, manual_data)
section = render(since, github_sponsors(since), oc_sponsors)
except SponsorDataError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(section)
else:
print(section, end="")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -14,8 +14,8 @@ jobs:
add_cpython: add_cpython:
runs-on: ubuntu-slim runs-on: ubuntu-slim
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- uses: actions/setup-python@v6 - uses: actions/setup-python@v7
with: with:
python-version: 3 python-version: 3
cache: 'pip' cache: 'pip'

View File

@ -20,7 +20,7 @@ jobs:
- "3.14" - "3.14"
runs-on: macos-latest runs-on: macos-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- run: | - run: |
brew install openssl readline sqlite3 xz tcl-tk@8 libb2 zstd brew install openssl readline sqlite3 xz tcl-tk@8 libb2 zstd
- run: | - run: |

View File

@ -8,7 +8,7 @@ jobs:
versions_cpython_only: ${{steps.modified-versions.outputs.versions_cpython_only}} versions_cpython_only: ${{steps.modified-versions.outputs.versions_cpython_only}}
versions_macos_build_exclude: ${{steps.modified-versions.outputs.versions_macos_build_exclude}} versions_macos_build_exclude: ${{steps.modified-versions.outputs.versions_macos_build_exclude}}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- run: git fetch origin "$GITHUB_BASE_REF" - run: git fetch origin "$GITHUB_BASE_REF"
- shell: bash - shell: bash
run: > run: >
@ -59,7 +59,7 @@ jobs:
if name == 'anaconda3' and version >= packaging.version.Version('2025.12'): if name == 'anaconda3' and version >= packaging.version.Version('2025.12'):
result.append({'os':'macos-15-intel','python-version':line}) result.append({'os':'macos-15-intel','python-version':line})
if m:=re.match(r'graalpy-(community-)?(\d+\.\d+.\d+)', line): if m:=re.match(r'graalpy[^-]*-(community-)?(\d+\.\d+.\d+)', line):
version = packaging.version.Version(m.group(2)) version = packaging.version.Version(m.group(2))
# GraalPy dropped MacOS x64 support # GraalPy dropped MacOS x64 support
@ -94,7 +94,7 @@ jobs:
exclude: ${{fromJson(needs.discover_modified_scripts.outputs.versions_macos_build_exclude)}} exclude: ${{fromJson(needs.discover_modified_scripts.outputs.versions_macos_build_exclude)}}
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- run: | - run: |
#envvars #envvars
export PYENV_ROOT="$GITHUB_WORKSPACE" export PYENV_ROOT="$GITHUB_WORKSPACE"
@ -156,7 +156,7 @@ jobs:
os: ["macos-14", "macos-15", "macos-15-intel"] os: ["macos-14", "macos-15", "macos-15-intel"]
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- run: | - run: |
#envvars #envvars
export PYENV_ROOT="$GITHUB_WORKSPACE" export PYENV_ROOT="$GITHUB_WORKSPACE"
@ -205,7 +205,7 @@ jobs:
- ubuntu-24.04 - ubuntu-24.04
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- run: | - run: |
#envvars #envvars
export PYENV_ROOT="$GITHUB_WORKSPACE" export PYENV_ROOT="$GITHUB_WORKSPACE"
@ -268,7 +268,7 @@ jobs:
os: ["ubuntu-latest"] os: ["ubuntu-latest"]
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- run: | - run: |
#envvars #envvars
export PYENV_ROOT="$GITHUB_WORKSPACE" export PYENV_ROOT="$GITHUB_WORKSPACE"

View File

@ -21,7 +21,7 @@ jobs:
- macos-26 - macos-26
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- run: | - run: |
if test "$RUNNER_OS" == "macOS"; then if test "$RUNNER_OS" == "macOS"; then
brew install coreutils fish brew install coreutils fish

View File

@ -20,7 +20,7 @@ jobs:
- "3.14" - "3.14"
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- run: | - run: |
sudo apt-get update -q; sudo apt install -yq make build-essential libssl-dev zlib1g-dev \ sudo apt-get update -q; sudo apt install -yq make build-essential libssl-dev zlib1g-dev \
libbz2-dev libreadline-dev libsqlite3-dev curl \ libbz2-dev libreadline-dev libsqlite3-dev curl \

1
.gitignore vendored
View File

@ -11,3 +11,4 @@
/default-packages /default-packages
.idea .idea
*.un~ *.un~
*.swp

View File

@ -1,5 +1,23 @@
# Version History # Version History
## Release v2.8.1
* pyenv-binary: add the `generate-installer` subcommand by @macayu17 in https://github.com/pyenv/pyenv/pull/3488
* Add CPython 3.15.0b4 by @pyenv-bot[bot] in https://github.com/pyenv/pyenv/pull/3495
## Release v2.8.0
* CI: add_version enhancements by @native-api in https://github.com/pyenv/pyenv/pull/3480
* Add miniforge3-26.3.2-2, 26.3.2-3 by @native-api in https://github.com/pyenv/pyenv/pull/3481
* rehash: fix race condition in landlock writability check by @Sheile in https://github.com/pyenv/pyenv/pull/3483
* Add script to generate sponsors section for release notes by @macayu17 in https://github.com/pyenv/pyenv/pull/3478
* install: Add an ability to install a version under an alias by @macayu17 in https://github.com/pyenv/pyenv/pull/3484
* Add graalpy-3.12-25.1.3 by @msimacek in https://github.com/pyenv/pyenv/pull/3485
* Bump actions/checkout from 6 to 7 in the github-actions group by @dependabot[bot] in https://github.com/pyenv/pyenv/pull/3486
* Add an experimental pyenv-binary plugin with a save command by @macayu17 in https://github.com/pyenv/pyenv/pull/3487
* CI: dependabot: set cooldown by @orbisai0security in https://github.com/pyenv/pyenv/pull/3489
* Fix typos in docs, scripts, and tests by @maxtaran2010 in https://github.com/pyenv/pyenv/pull/3490
* Add miniconda3 26.5.3-1 by @native-api in https://github.com/pyenv/pyenv/pull/3491
* version-name: skip redundant checks by @native-api in https://github.com/pyenv/pyenv/pull/3492
## Release v2.7.3 ## Release v2.7.3
* CI: add_version enhancements by @macayu17 in https://github.com/pyenv/pyenv/pull/3475 * CI: add_version enhancements by @macayu17 in https://github.com/pyenv/pyenv/pull/3475
* Add CPython 3.15.0b3 by @pyenv-bot[bot] in https://github.com/pyenv/pyenv/pull/3479 * Add CPython 3.15.0b3 by @pyenv-bot[bot] in https://github.com/pyenv/pyenv/pull/3479
@ -369,7 +387,7 @@
* Add CPython 3.13.0a4 by @saaketp in https://github.com/pyenv/pyenv/pull/2903 * Add CPython 3.13.0a4 by @saaketp in https://github.com/pyenv/pyenv/pull/2903
* Handle the case where `pyenv-commands --sh` returns nothing by @aphedges in https://github.com/pyenv/pyenv/pull/2908 * Handle the case where `pyenv-commands --sh` returns nothing by @aphedges in https://github.com/pyenv/pyenv/pull/2908
* Document default build configuration customizations by @native-api in https://github.com/pyenv/pyenv/pull/2911 * Document default build configuration customizations by @native-api in https://github.com/pyenv/pyenv/pull/2911
* Use Homebrew in Linux if Pyenv is installled with Homebrew by @native-api in https://github.com/pyenv/pyenv/pull/2906 * Use Homebrew in Linux if Pyenv is installed with Homebrew by @native-api in https://github.com/pyenv/pyenv/pull/2906
* Add miniforge and mambaforge 22.11.1-3, 22.11.1-4, 23.1.0-0 to 23.11.0-0 by @aphedges in https://github.com/pyenv/pyenv/pull/2909 * Add miniforge and mambaforge 22.11.1-3, 22.11.1-4, 23.1.0-0 to 23.11.0-0 by @aphedges in https://github.com/pyenv/pyenv/pull/2909
* Add miniconda3-24.1.2 by @binbjz in https://github.com/pyenv/pyenv/pull/2915 * Add miniconda3-24.1.2 by @binbjz in https://github.com/pyenv/pyenv/pull/2915
* Minor grammar fix in libffi backport patch in 2.5.x by @cuinix in https://github.com/pyenv/pyenv/pull/2922 * Minor grammar fix in libffi backport patch in 2.5.x by @cuinix in https://github.com/pyenv/pyenv/pull/2922
@ -1505,7 +1523,7 @@
* pyenv: Prefer gawk over awk if both are available. * pyenv: Prefer gawk over awk if both are available.
* python-build: Add new PyPy release; pypy-2.3, pypy-2.3-src (#162) * python-build: Add new PyPy release; pypy-2.3, pypy-2.3-src (#162)
* python-build: Add new Anaconda release; anaconda-1.9.2 (#155) * python-build: Add new Anaconda release; anaconda-1.9.2 (#155)
* python-build: Add new Miniconda releases; miniconda-3.3.0, minoconda-3.4.2, miniconda3-3.3.0, miniconda3-3.4.2 * python-build: Add new Miniconda releases; miniconda-3.3.0, miniconda-3.4.2, miniconda3-3.3.0, miniconda3-3.4.2
* python-build: Add new Stackless releases; stackless-2.7.3, stackless-2.7.4, stackless-2.7.5, stackless-2.7.6, stackless-3.2.5, stackless-3.3.5 (#164) * python-build: Add new Stackless releases; stackless-2.7.3, stackless-2.7.4, stackless-2.7.5, stackless-2.7.6, stackless-3.2.5, stackless-3.3.5 (#164)
* python-build: Add IronPython versions (setuptools and pip will work); ironpython-2.7.4, ironpython-dev * python-build: Add IronPython versions (setuptools and pip will work); ironpython-2.7.4, ironpython-dev
* python-build: Add new Jython beta release; jython-2.7-beta2 * python-build: Add new Jython beta release; jython-2.7-beta2

View File

@ -202,8 +202,8 @@ or, if you prefer 3.3.3 over 2.7.6,
Install a Python version (using [`python-build`](https://github.com/pyenv/pyenv/tree/master/plugins/python-build)). Install a Python version (using [`python-build`](https://github.com/pyenv/pyenv/tree/master/plugins/python-build)).
Usage: pyenv install [-f] [-kvp] <version> Usage: pyenv install [-f] [-kvp] <version>[:<alias>]
pyenv install [-f] [-kvp] <definition-file> pyenv install [-f] [-kvp] <definition-file>[:<alias>]
pyenv install -l|--list pyenv install -l|--list
-l/--list List all available versions -l/--list List all available versions

View File

@ -7,6 +7,14 @@ Release checklist:
* Start [drafting a new release on GitHub](https://github.com/pyenv/pyenv/releases) to generate a summary of changes. * Start [drafting a new release on GitHub](https://github.com/pyenv/pyenv/releases) to generate a summary of changes.
Type the would-be tag name in the "Choose a tag" field and press "Generate release notes" Type the would-be tag name in the "Choose a tag" field and press "Generate release notes"
* The summary may need editing. E.g. rephrase entries, delete/merge entries that are too minor or irrelevant to the users (e.g. typo fixes, CI) * The summary may need editing. E.g. rephrase entries, delete/merge entries that are too minor or irrelevant to the users (e.g. typo fixes, CI)
* Add a sponsors section to the release notes by running:
```bash
.github/scripts/generate_release_notes_sponsors.py
```
Paste the output at the end of the release notes.
* This lists new GitHub Sponsors and OpenCollective backers since the last release or within the last month, whichever is longer.
* The GitHub Sponsors query requires the `gh` CLI with the `read:user` scope.
* If OpenCollective is unavailable, pass `--skip-opencollective` and add those backers manually.
* Update `CHANGELOG.md` with the new version number and the edited summary (only the changes section) * Update `CHANGELOG.md` with the new version number and the edited summary (only the changes section)
* Push the version number in `libexec/pyenv---version` and `plugins/python-build/bin/python-build` * Push the version number in `libexec/pyenv---version` and `plugins/python-build/bin/python-build`
* Minor version is pushed if there are significant functional changes (not e.g. bugfixes/formula adaptations/supporting niche use cases). * Minor version is pushed if there are significant functional changes (not e.g. bugfixes/formula adaptations/supporting niche use cases).

View File

@ -1,16 +1,21 @@
TEST_BATS_VERSION = v1.10.0 TEST_BATS_VERSION = v1.10.0
TEST_BASH_VERSIONS = 3.2.57 4.1.17 TEST_BASH_VERSIONS = 3.2.57 4.1.17
TEST_UNIT_DOCKER_PREFIX = test-unit-docker TEST_UNIT_DOCKER_PREFIX = test-unit-docker
TEST_UNIT_DOCKER_TARGETS = $(foreach bash,$(TEST_BASH_VERSIONS),$(addsuffix -$(bash),$(TEST_UNIT_DOCKER_PREFIX)) $(addsuffix -gnu-$(bash),$(TEST_UNIT_DOCKER_PREFIX))) TEST_UNIT_DOCKER_TARGETS = $(foreach bash,$(TEST_BASH_VERSIONS),$(addsuffix -$(bash),$(TEST_UNIT_DOCKER_PREFIX)) $(addsuffix -gnu-$(bash),$(TEST_UNIT_DOCKER_PREFIX)))
TEST_PLUGIN_DOCKER_PREFIX = test-plugin-docker
TEST_PLUGIN_DOCKER_TARGETS = $(foreach bash,$(TEST_BASH_VERSIONS),$(addsuffix -$(bash),$(TEST_PLUGIN_DOCKER_PREFIX)) $(addsuffix -gnu-$(bash),$(TEST_PLUGIN_DOCKER_PREFIX))) TEST_PYTHON_BUILD_DOCKER_PREFIX = test-python-build-docker
TEST_PYTHON_BUILD_DOCKER_TARGETS = $(foreach bash,$(TEST_BASH_VERSIONS),$(addsuffix -$(bash),$(TEST_PYTHON_BUILD_DOCKER_PREFIX)) $(addsuffix -gnu-$(bash),$(TEST_PYTHON_BUILD_DOCKER_PREFIX)))
TEST_BINARY_DOCKER_PREFIX = test-binary-docker
TEST_BINARY_DOCKER_TARGETS = $(foreach bash,$(TEST_BASH_VERSIONS),$(addsuffix -$(bash),$(TEST_BINARY_DOCKER_PREFIX)) $(addsuffix -gnu-$(bash),$(TEST_BINARY_DOCKER_PREFIX)))
TEST_BATS_IMAGE_PREFIX = test-pyenv-docker-image TEST_BATS_IMAGE_PREFIX = test-pyenv-docker-image
TEST_BATS_IMAGE_TARGETS = $(foreach bash,$(TEST_BASH_VERSIONS),$(addsuffix -$(bash),$(TEST_BATS_IMAGE_PREFIX)) $(addsuffix -gnu-$(bash),$(TEST_BATS_IMAGE_PREFIX))) TEST_BATS_IMAGE_TARGETS = $(foreach bash,$(TEST_BASH_VERSIONS),$(addsuffix -$(bash),$(TEST_BATS_IMAGE_PREFIX)) $(addsuffix -gnu-$(bash),$(TEST_BATS_IMAGE_PREFIX)))
.PHONY: .PHONY: test-docker
test-docker: $(TEST_UNIT_DOCKER_PREFIX) $(TEST_PLUGIN_DOCKER_PREFIX) test-docker: $(TEST_UNIT_DOCKER_PREFIX) $(TEST_PYTHON_BUILD_DOCKER_PREFIX) $(TEST_BINARY_DOCKER_PREFIX)
# Run all unit test under bats docker
.PHONY: $(TEST_UNIT_DOCKER_PREFIX) .PHONY: $(TEST_UNIT_DOCKER_PREFIX)
$(TEST_UNIT_DOCKER_PREFIX): $(TEST_UNIT_DOCKER_TARGETS) $(TEST_UNIT_DOCKER_PREFIX): $(TEST_UNIT_DOCKER_TARGETS)
@ -31,23 +36,22 @@ $(TEST_UNIT_DOCKER_TARGETS): $(TEST_UNIT_DOCKER_PREFIX)-% : $(TEST_BATS_IMAGE_PR
-u "$$(id -u $$(whoami)):$$(id -g $$(whoami))" \ -u "$$(id -u $$(whoami)):$$(id -g $$(whoami))" \
$${BATS_TEST_FILTER:+-e BATS_TEST_FILTER="$${BATS_TEST_FILTER}"} \ $${BATS_TEST_FILTER:+-e BATS_TEST_FILTER="$${BATS_TEST_FILTER}"} \
$${BATS_FILE_FILTER:+-e BATS_FILE_FILTER="$${BATS_FILE_FILTER}"} \ $${BATS_FILE_FILTER:+-e BATS_FILE_FILTER="$${BATS_FILE_FILTER}"} \
$${CI+-e CI="$${CI}"} \ $${CI+-e CI="$${CI}"} \
$(INTERACTIVE) \ $(INTERACTIVE) \
$(DOCKER_IMAGE):$(DOCKER_TAG) \ $(DOCKER_IMAGE):$(DOCKER_TAG) \
test/run test/run
# Run all plugin test under bats docker .PHONY: $(TEST_PYTHON_BUILD_DOCKER_PREFIX)
.PHONY: $(TEST_PLUGIN_DOCKER_PREFIX) $(TEST_PYTHON_BUILD_DOCKER_PREFIX): $(TEST_PYTHON_BUILD_DOCKER_TARGETS)
$(TEST_PLUGIN_DOCKER_PREFIX): $(TEST_PLUGIN_DOCKER_TARGETS)
# Run each plugin test under bats docker # Run each plugin test under bats docker
.PHONY: $(TEST_PLUGIN_DOCKER_TARGETS) .PHONY: $(TEST_PYTHON_BUILD_DOCKER_TARGETS)
$(TEST_PLUGIN_DOCKER_TARGETS): DOCKER_IMAGE = $(TEST_BATS_IMAGE_PREFIX) $(TEST_PYTHON_BUILD_DOCKER_TARGETS): DOCKER_IMAGE = $(TEST_BATS_IMAGE_PREFIX)
$(TEST_PLUGIN_DOCKER_TARGETS): GNU = $(if $(findstring -gnu-,$@),True,False) $(TEST_PYTHON_BUILD_DOCKER_TARGETS): GNU = $(if $(findstring -gnu-,$@),True,False)
$(TEST_PLUGIN_DOCKER_TARGETS): BASH = $(filter $(TEST_BASH_VERSIONS),$(subst -, ,$@)) $(TEST_PYTHON_BUILD_DOCKER_TARGETS): BASH = $(filter $(TEST_BASH_VERSIONS),$(subst -, ,$@))
$(TEST_PLUGIN_DOCKER_TARGETS): DOCKER_TAG = bash-$(BASH)-gnu-$(GNU) $(TEST_PYTHON_BUILD_DOCKER_TARGETS): DOCKER_TAG = bash-$(BASH)-gnu-$(GNU)
$(TEST_PLUGIN_DOCKER_TARGETS): INTERACTIVE = $(if $(findstring true,$(CI)),,-ti) $(TEST_PYTHON_BUILD_DOCKER_TARGETS): INTERACTIVE = $(if $(findstring true,$(CI)),,-ti)
$(TEST_PLUGIN_DOCKER_TARGETS): $(TEST_PLUGIN_DOCKER_PREFIX)-% : $(TEST_BATS_IMAGE_PREFIX)-% $(TEST_PYTHON_BUILD_DOCKER_TARGETS): $(TEST_PYTHON_BUILD_DOCKER_PREFIX)-% : $(TEST_BATS_IMAGE_PREFIX)-%
$(info Running test with docker image '$(DOCKER_IMAGE):$(DOCKER_TAG)') $(info Running test with docker image '$(DOCKER_IMAGE):$(DOCKER_TAG)')
docker run \ docker run \
--init \ --init \
@ -55,11 +59,33 @@ $(TEST_PLUGIN_DOCKER_TARGETS): $(TEST_PLUGIN_DOCKER_PREFIX)-% : $(TEST_BATS_IMAG
-v /etc/passwd:/etc/passwd:ro \ -v /etc/passwd:/etc/passwd:ro \
-v /etc/group:/etc/group:ro \ -v /etc/group:/etc/group:ro \
-u "$$(id -u $$(whoami)):$$(id -g $$(whoami))" \ -u "$$(id -u $$(whoami)):$$(id -g $$(whoami))" \
$${CI+-e CI="$${CI}"} \ $${CI+-e CI="$${CI}"} \
$(INTERACTIVE) \ $(INTERACTIVE) \
$(DOCKER_IMAGE):$(DOCKER_TAG) \ $(DOCKER_IMAGE):$(DOCKER_TAG) \
bats $${BATS_TEST_FILTER:+--filter "$${BATS_TEST_FILTER}"} plugins/python-build/test/$${BATS_FILE_FILTER} bats $${BATS_TEST_FILTER:+--filter "$${BATS_TEST_FILTER}"} plugins/python-build/test/$${BATS_FILE_FILTER}
.PHONY: $(TEST_BINARY_DOCKER_PREFIX)
$(TEST_BINARY_DOCKER_PREFIX): $(TEST_BINARY_DOCKER_TARGETS)
.PHONY: $(TEST_BINARY_DOCKER_TARGETS)
$(TEST_BINARY_DOCKER_TARGETS): DOCKER_IMAGE = $(TEST_BATS_IMAGE_PREFIX)
$(TEST_BINARY_DOCKER_TARGETS): GNU = $(if $(findstring -gnu-,$@),True,False)
$(TEST_BINARY_DOCKER_TARGETS): BASH = $(filter $(TEST_BASH_VERSIONS),$(subst -, ,$@))
$(TEST_BINARY_DOCKER_TARGETS): DOCKER_TAG = bash-$(BASH)-gnu-$(GNU)
$(TEST_BINARY_DOCKER_TARGETS): INTERACTIVE = $(if $(findstring true,$(CI)),,-ti)
$(TEST_BINARY_DOCKER_TARGETS): $(TEST_BINARY_DOCKER_PREFIX)-% : $(TEST_BATS_IMAGE_PREFIX)-%
$(info Running test with docker image '$(DOCKER_IMAGE):$(DOCKER_TAG)')
docker run \
--init \
-v $(PWD):/code:ro \
-v /etc/passwd:/etc/passwd:ro \
-v /etc/group:/etc/group:ro \
-u "$$(id -u $$(whoami)):$$(id -g $$(whoami))" \
$${CI+-e CI="$${CI}"} \
$(INTERACTIVE) \
$(DOCKER_IMAGE):$(DOCKER_TAG) \
bats $${BATS_TEST_FILTER:+--filter "$${BATS_TEST_FILTER}"} plugins/pyenv-binary/test/$${BATS_FILE_FILTER}
# Build all images needed for bats under docker # Build all images needed for bats under docker
.PHONY: $(TEST_BATS_IMAGE_PREFIX) .PHONY: $(TEST_BATS_IMAGE_PREFIX)
$(TEST_BATS_IMAGE_PREFIX): $(TEST_BATS_IMAGE_TARGETS) $(TEST_BATS_IMAGE_PREFIX): $(TEST_BATS_IMAGE_TARGETS)
@ -71,7 +97,7 @@ $(TEST_BATS_IMAGE_TARGETS): GNU = $(if $(findstring -gnu-,$@),True,False)
$(TEST_BATS_IMAGE_TARGETS): BASH = $(filter $(TEST_BASH_VERSIONS),$(subst -, ,$@)) $(TEST_BATS_IMAGE_TARGETS): BASH = $(filter $(TEST_BASH_VERSIONS),$(subst -, ,$@))
$(TEST_BATS_IMAGE_TARGETS): DOCKER_TAG = bash-$(BASH)-gnu-$(GNU) $(TEST_BATS_IMAGE_TARGETS): DOCKER_TAG = bash-$(BASH)-gnu-$(GNU)
$(TEST_BATS_IMAGE_TARGETS): $(TEST_BATS_IMAGE_TARGETS):
$(info Building docker image '$(DOCKER_IMAGE):$(DOCKER_TAG)') if [ -z "$$(docker images -q '$(DOCKER_IMAGE):$(DOCKER_TAG)')" ]]; then \
docker build \ docker build \
--quiet \ --quiet \
-f "$(PWD)/test/Dockerfile" \ -f "$(PWD)/test/Dockerfile" \
@ -79,41 +105,32 @@ $(TEST_BATS_IMAGE_TARGETS):
--build-arg BASH="$(BASH)" \ --build-arg BASH="$(BASH)" \
--build-arg BATS_VERSION="$(TEST_BATS_VERSION)" \ --build-arg BATS_VERSION="$(TEST_BATS_VERSION)" \
-t $(DOCKER_IMAGE):$(DOCKER_TAG) \ -t $(DOCKER_IMAGE):$(DOCKER_TAG) \
./ ./ ; \
fi
.PHONY: test test-build test-unit test-plugin .PHONY: test test-unit test-python-build test-binary
# Do not pass in user flags to build tests. # Do not pass in user flags to build tests.
unexport PYTHON_CFLAGS unexport PYTHON_CFLAGS
unexport PYTHON_CONFIGURE_OPTS unexport PYTHON_CONFIGURE_OPTS
test: test-unit test-plugin test: test-unit test-python-build test-binary
test-unit: bats test-unit: bats
PATH="./bats/bin:$$PATH" test/run PATH="./bats/bin:$$PATH" test/run
test-plugin: bats test-python-build: bats
cd plugins/python-build && $(PWD)/bats/bin/bats $${CI:+--tap} $${BATS_TEST_FILTER:+--filter "$${BATS_TEST_FILTER}"} test/$${BATS_FILE_FILTER} cd plugins/python-build && $(PWD)/bats/bin/bats $${CI:+--tap} $${BATS_TEST_FILTER:+--filter "$${BATS_TEST_FILTER}"} test/$${BATS_FILE_FILTER}
PYTHON_BUILD_ROOT := $(CURDIR)/plugins/python-build test-binary: bats
PYTHON_BUILD_OPTS ?= --verbose cd plugins/pyenv-binary && $(PWD)/bats/bin/bats $${CI:+--tap} $${BATS_TEST_FILTER:+--filter "$${BATS_TEST_FILTER}"} test/$${BATS_FILE_FILTER}
PYTHON_BUILD_VERSION ?= 3.8-dev
PYTHON_BUILD_TEST_PREFIX ?= $(PYTHON_BUILD_ROOT)/test/build/tmp/dist
test-build:
$(RM) -r $(PYTHON_BUILD_TEST_PREFIX)
$(PYTHON_BUILD_ROOT)/bin/python-build $(PYTHON_BUILD_OPTS) $(PYTHON_BUILD_VERSION) $(PYTHON_BUILD_TEST_PREFIX)
[ -e $(PYTHON_BUILD_TEST_PREFIX)/bin/python ]
$(PYTHON_BUILD_TEST_PREFIX)/bin/python -V
[ -e $(PYTHON_BUILD_TEST_PREFIX)/bin/pip ]
$(PYTHON_BUILD_TEST_PREFIX)/bin/pip -V
.SECONDARY: bats-$(TEST_BATS_VERSION) .SECONDARY: bats-$(TEST_BATS_VERSION)
bats-$(TEST_BATS_VERSION): bats-$(TEST_BATS_VERSION):
rm -rf bats
ln -sf bats-$(TEST_BATS_VERSION) bats
git clone --depth 1 --branch $(TEST_BATS_VERSION) https://github.com/bats-core/bats-core.git bats-$(TEST_BATS_VERSION) git clone --depth 1 --branch $(TEST_BATS_VERSION) https://github.com/bats-core/bats-core.git bats-$(TEST_BATS_VERSION)
.PHONY: bats .PHONY: bats
bats: bats-$(TEST_BATS_VERSION) bats: bats-$(TEST_BATS_VERSION)
ln -sf bats-$(TEST_BATS_VERSION) bats if [ \( ! -L bats \) -o \( "x$$(readlink bats)" != "xbats-$(TEST_BATS_VERSION)" \) ]; then \
rm -rf bats; ln -s bats-$(TEST_BATS_VERSION) bats; \
fi

View File

@ -672,7 +672,7 @@ to `PATH` in the `<command>`'s environment, the same as what e.g. RVM does.
### Running nested shells from Python-based programs ### Running nested shells from Python-based programs
In addition to altering `PATH`, `pyenv exec` sets `PYENV_VERSION` in the In addition to altering `PATH`, `pyenv exec` sets `PYENV_VERSION` in the
executed program's environment to ensure that it won't spontaneouly switch to executed program's environment to ensure that it won't spontaneously switch to
using a different Python version. using a different Python version.
Some Python-based programs (e.g. Jupyter) can spawn nested shell sessions. Some Python-based programs (e.g. Jupyter) can spawn nested shell sessions.
@ -837,13 +837,10 @@ e.g. `~/.pyenv --install bash`.
## Development ## Development
The pyenv source code is [hosted on The pyenv source code is [hosted on
GitHub](https://github.com/pyenv/pyenv). It's clean, modular, GitHub](https://github.com/pyenv/pyenv).
and easy to understand, even if you're not a shell hacker.
Tests are executed using [Bats](https://github.com/bats-core/bats-core): Tests are executed using [Bats](https://github.com/bats-core/bats-core).
See the [tests README](test/README.md) for details.
bats test
bats/test/<file>.bats
### Contributing ### Contributing

View File

@ -12,7 +12,7 @@
set -e set -e
[ -n "$PYENV_DEBUG" ] && set -x [ -n "$PYENV_DEBUG" ] && set -x
version="2.7.3" version="2.8.1"
git_revision="" git_revision=""
if cd "${BASH_SOURCE%/*}" 2>/dev/null && git remote -v 2>/dev/null | grep -q pyenv; then if cd "${BASH_SOURCE%/*}" 2>/dev/null && git remote -v 2>/dev/null | grep -q pyenv; then

View File

@ -36,12 +36,16 @@ if [ -n "$versions" ]; then
pyenv-version-file-write "$PYENV_VERSION_FILE" "${versions[@]}" pyenv-version-file-write "$PYENV_VERSION_FILE" "${versions[@]}"
else else
OLDIFS="$IFS" OLDIFS="$IFS"
# VAR=() of unquoted value does glob expansion (against the current dir's contents) after IFS-splitting
# which is undesired
set -f
IFS=: versions=($( IFS=: versions=($(
pyenv-version-file-read "$PYENV_VERSION_FILE" || pyenv-version-file-read "$PYENV_VERSION_FILE" ||
pyenv-version-file-read "${PYENV_ROOT}/global" || pyenv-version-file-read "${PYENV_ROOT}/global" ||
pyenv-version-file-read "${PYENV_ROOT}/default" || pyenv-version-file-read "${PYENV_ROOT}/default" ||
echo system echo system
)) ))
set +f
IFS="$OLDIFS" IFS="$OLDIFS"
for version in "${versions[@]}"; do for version in "${versions[@]}"; do
echo "$version" echo "$version"

View File

@ -59,7 +59,11 @@ elif [ -n "$versions" ]; then
pyenv-version-file-write ${FORCE:+-f }.python-version "${versions[@]}" pyenv-version-file-write ${FORCE:+-f }.python-version "${versions[@]}"
else else
if version_file="$(pyenv-version-file "$PWD")"; then if version_file="$(pyenv-version-file "$PWD")"; then
# VAR=() of unquoted value does glob expansion (against the current dir's contents) after IFS-splitting
# which is undesired
set -f
IFS=: versions=($(pyenv-version-file-read "$version_file")) IFS=: versions=($(pyenv-version-file-read "$version_file"))
set +f
for version in "${versions[@]}"; do for version in "${versions[@]}"; do
echo "$version" echo "$version"
done done

View File

@ -28,6 +28,9 @@ fi
PYENV_PREFIX_PATHS=() PYENV_PREFIX_PATHS=()
OLDIFS="$IFS" OLDIFS="$IFS"
{ IFS=: { IFS=:
# Unquoted $VAR does glob expansion (against the current dir's contents) after IFS-splitting
# which is undesired
set -f
for version in ${PYENV_VERSION}; do for version in ${PYENV_VERSION}; do
if [ "$version" = "system" ]; then if [ "$version" = "system" ]; then
if PYTHON_PATH="$(PYENV_VERSION="${version}" pyenv-which python --skip-advice 2>/dev/null)" || \ if PYTHON_PATH="$(PYENV_VERSION="${version}" pyenv-which python --skip-advice 2>/dev/null)" || \
@ -52,6 +55,7 @@ OLDIFS="$IFS"
exit 1 exit 1
fi fi
done done
set +f
} }
IFS="$OLDIFS" IFS="$OLDIFS"

View File

@ -28,7 +28,9 @@ acquire_lock() {
# So check for writablity by trying to write to a different file, # So check for writablity by trying to write to a different file,
# in a way that taxes the usual use case as little as possible. # in a way that taxes the usual use case as little as possible.
if [[ -z $tested_for_other_write_errors ]]; then if [[ -z $tested_for_other_write_errors ]]; then
( t="$(TMPDIR="$SHIM_PATH" mktemp)" && rm "$t" ) \ # if lots of (50+) rehashes run concurrently, another concurrent rehash
# may delete the temporary file in remove_*_shims() before we do so
( t="$(TMPDIR="$SHIM_PATH" mktemp)" && rm -f "$t" ) \
&& tested_for_other_write_errors=1 \ && tested_for_other_write_errors=1 \
|| { echo "pyenv: cannot rehash: $SHIM_PATH isn't writable" >&2 || { echo "pyenv: cannot rehash: $SHIM_PATH isn't writable" >&2
set +o noclobber set +o noclobber

View File

@ -9,7 +9,11 @@ set -e
exitcode=0 exitcode=0
OLDIFS="$IFS" OLDIFS="$IFS"
# VAR=() of unquoted value does glob expansion (against the current dir's contents) after IFS-splitting
# which is undesired
set -f
IFS=: PYENV_VERSION_NAMES=($(pyenv-version-name)) || exitcode=$? IFS=: PYENV_VERSION_NAMES=($(pyenv-version-name)) || exitcode=$?
set +f
IFS="$OLDIFS" IFS="$OLDIFS"
unset bare unset bare

View File

@ -46,17 +46,22 @@ versions=()
OLDIFS="$IFS" OLDIFS="$IFS"
{ IFS=: { IFS=:
any_not_installed=0 any_not_installed=0
normalization_done=
# Unquoted $VAR does glob expansion (against the current dir's contents) after IFS-splitting
# which is undesired
set -f
for version in ${PYENV_VERSION}; do for version in ${PYENV_VERSION}; do
# Remove the explicit 'python-' prefix from versions like 'python-3.12'. # Remove the explicit 'python-' prefix from versions like 'python-3.12'.
normalised_version="${version#python-}" normalised_version="${version#python-}"
if version_exists "${version}" || [ "$version" = "system" ]; then [[ $version != "${normalised_version}" ]] && normalization_done=1
versions+=("${version}") if [[ $version == "system" ]] || version_exists "${normalised_version}" ; then
elif version_exists "${normalised_version}"; then
versions+=("${normalised_version}") versions+=("${normalised_version}")
elif resolved_version="$(pyenv-latest -b "${version}")"; then elif [[ -n $normalization_done ]] && version_exists "${version}"; then
versions+=("${resolved_version}") versions+=("${version}")
elif resolved_version="$(pyenv-latest -b "${normalised_version}")"; then elif resolved_version="$(pyenv-latest -b "${normalised_version}")"; then
versions+=("${resolved_version}") versions+=("${resolved_version}")
elif [[ -n $normalization_done ]] && resolved_version="$(pyenv-latest -b "${version}")"; then
versions+=("${resolved_version}")
else else
if [[ -n $FORCE ]]; then if [[ -n $FORCE ]]; then
versions+=("${normalised_version}") versions+=("${normalised_version}")
@ -66,6 +71,7 @@ OLDIFS="$IFS"
fi fi
fi fi
done done
set +f
} }
IFS="$OLDIFS" IFS="$OLDIFS"

View File

@ -99,6 +99,9 @@ else
miss_prefix=" " miss_prefix=" "
OLDIFS="$IFS" OLDIFS="$IFS"
IFS=: IFS=:
# VAR=() of unquoted value does glob expansion (against the current dir's contents) after IFS-splitting
# which is undesired
set -f
if ((${BASH_VERSINFO[0]} > 3)); then if ((${BASH_VERSINFO[0]} > 3)); then
for i in $(pyenv-version-name || true); do for i in $(pyenv-version-name || true); do
current_versions["$i"]="1" current_versions["$i"]="1"
@ -106,6 +109,7 @@ else
else else
current_versions=($(pyenv-version-name || true)) current_versions=($(pyenv-version-name || true))
fi fi
set +f
IFS="$OLDIFS" IFS="$OLDIFS"
include_system="1" include_system="1"
fi fi
@ -161,11 +165,15 @@ versions_dir_entries=("$versions_dir"/*)
if sort --version-sort </dev/null >/dev/null 2>&1; then if sort --version-sort </dev/null >/dev/null 2>&1; then
# system sort supports version sorting # system sort supports version sorting
OLDIFS="$IFS" OLDIFS="$IFS"
# VAR=() of unquoted value does glob expansion (against the current dir's contents) after IFS-splitting
# which is undesired
set -f
IFS=$'\n' IFS=$'\n'
versions_dir_entries=($( versions_dir_entries=($(
printf "%s\n" "${versions_dir_entries[@]}" | printf "%s\n" "${versions_dir_entries[@]}" |
sort --version-sort sort --version-sort
)) ))
set +f
IFS="$OLDIFS" IFS="$OLDIFS"
fi fi

View File

@ -63,7 +63,11 @@ if [ -z "$PYENV_COMMAND" ]; then
fi fi
OLDIFS="$IFS" OLDIFS="$IFS"
# Unquoted $VAR or VAR=() of unquoted value does glob expansion (against the current dir's contents)
# after IFS-splitting which is undesired
set -f
IFS=: versions=(${PYENV_VERSION:-$(pyenv-version-name -f)}) IFS=: versions=(${PYENV_VERSION:-$(pyenv-version-name -f)})
set +f
IFS="$OLDIFS" IFS="$OLDIFS"
declare -a nonexistent_versions declare -a nonexistent_versions

1
plugins/.gitignore vendored
View File

@ -2,4 +2,5 @@
!/.gitignore !/.gitignore
!/version-ext-compat !/version-ext-compat
!/python-build !/python-build
!/pyenv-binary
/python-build/test/build /python-build/test/build

View File

@ -0,0 +1,75 @@
# pyenv-binary (experimental)
Package an installed Python version into a relocatable archive that can be
installed on another machine.
This is experimental and intentionally decoupled: it does not change
`pyenv install` or any other command. You drive it explicitly through
`pyenv binary`. Run `pyenv binary <command> --help` for details on a command.
## Portability
An archive is portable across machines that share its build platform (OS,
architecture and a compatible libc) and have the recorded system libraries. It
is not portable across, say, glibc and musl, or to an older glibc; the platform
and dependency metadata exist to catch that.
## Commands
### `pyenv binary package <version>:<entry> --archive-base-url <url>`
Installs `<version>` from source under the separate name `<entry>`, 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 `<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 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 <version> [<output-dir>] [--name <name>]`
Packs an installed version into `<version>-<platform>.tar.gz` (relative paths)
and writes `<version>-<platform>.meta` describing the build platform (OS, arch,
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
```
### `pyenv binary generate-installer <metadata-file> --archive-url <url> [-o <output>]`
Reads a `.meta` file and emits a python-build definition. Drop it into
python-build's definition directory and `pyenv install <name>` installs the
archive like any other version. The archive location is a parameter, so you can
host it anywhere (it does not have to be a pyenv location); the archive itself
must sit next to the `.meta` file so its checksum can be baked into the
definition.
The definition refuses to install on a different OS/architecture, or an older
glibc, than the archive was built for, and checks that the system libraries it
needs are present.
```sh
pyenv binary generate-installer ./dist/3.12.7-linux-x86_64.meta \
--archive-url https://example.com/3.12.7-linux-x86_64.tar.gz \
-o "$(pyenv root)/plugins/python-build/share/python-build/3.12.7-linux-x86_64"
pyenv install 3.12.7-linux-x86_64
```
### `pyenv binary relocate <prefix>`
Rewrites the rpaths of a Python tree unpacked into `<prefix>` so the interpreter
and its extension modules load the bundled libraries from there rather than from
the path the archive was built at. Uses `patchelf`. The generated definition
calls this; you rarely run it by hand.
Relocation is implemented for Linux; macOS is not wired up yet.

View File

@ -0,0 +1,65 @@
#!/usr/bin/env bash
#
# Summary: Package an installed Python version as a relocatable binary (experimental)
#
# Usage: pyenv binary <command> [<args>]
#
# `pyenv binary` packages an already-installed Python version into a relocatable
# archive that can be installed on another machine. It is experimental and does
# not touch `pyenv install' or any other command.
#
# Run `pyenv binary' to list its commands, or `pyenv binary <command> --help'
# for command-specific help.
#
set -e
[ -n "$PYENV_DEBUG" ] && set -x
libexec="${BASH_SOURCE%/*}/../libexec"
# The pyenv launcher only puts a plugin's bin on PATH, but `pyenv-help' looks a
# command up there. Add our libexec so the subcommands, and the help text they
# print, can be found.
export PATH="${libexec}:${PATH}"
list_commands() {
local path
for path in "$libexec"/pyenv-binary-*; do
[ -e "$path" ] && echo "${path##*/pyenv-binary-}"
done
}
# Provide pyenv completions
if [ "$1" = "--complete" ]; then
shift
if [ -z "$1" ]; then
list_commands
else
command_path="${libexec}/pyenv-binary-$1"
shift
[ -x "$command_path" ] && exec "$command_path" --complete "$@"
fi
exit
fi
subcommand="$1"
if [ -z "$subcommand" ]; then
{ pyenv-help binary
echo
echo "Commands:"
list_commands | sed 's/^/ /'
} >&2
exit 1
fi
shift
command_path="${libexec}/pyenv-binary-${subcommand}"
if [ ! -x "$command_path" ]; then
echo "pyenv-binary: no such command \`${subcommand}'" >&2
exit 1
fi
if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
exec pyenv-help "binary-${subcommand}"
fi
exec "$command_path" "$@"

View File

@ -0,0 +1,189 @@
#!/usr/bin/env bash
#
# Summary: Generate a python-build definition for a saved binary archive
#
# Usage: pyenv binary generate-installer <metadata-file> --archive-url <url> [-o <output>]
#
# Reads a metadata file written by `pyenv binary save' and emits a python-build
# definition. Dropped into python-build's definition directory, it installs the
# archive like any other version: `pyenv install' downloads it from <url>,
# checks the platform and the required system libraries, unpacks it, then
# rewrites rpaths so the copy runs from its prefix.
#
# The archive must sit next to the metadata file so its checksum can be baked
# into the definition; `pyenv binary save' writes the two together.
#
# <metadata-file> A .meta file written by `pyenv binary save'.
# The corresponsing binary package written by `pyenv binary save'
# needs to be present alongside it.
# --archive-url <url> The URL for the resulting installation script to download
# the package from.
# -o <output> Write the definition here (default: stdout).
#
set -e
[ -n "$PYENV_DEBUG" ] && set -x
# Provide pyenv completions
if [ "$1" = "--complete" ]; then
echo --archive-url
echo -o
exit
fi
metadata=""
archive_url=""
output=""
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 ;;
-o )
[ $# -ge 2 ] || { echo "pyenv-binary: -o needs a value" >&2; exit 1; }
output="$2"; shift 2 ;;
-* )
echo "pyenv-binary: unknown option \`$1'" >&2; exit 1 ;;
* )
[ -z "$metadata" ] || { echo "pyenv-binary: unexpected argument \`$1'" >&2; exit 1; }
metadata="$1"; shift ;;
esac
done
if [ -z "$metadata" ] || [ -z "$archive_url" ]; then
pyenv-help --usage binary-generate-installer >&2
exit 1
fi
version="" os="" arch="" distro="" libc="" archive=""
deps=""
while IFS='=' read -r key value; do
case "$key" in
version ) version="$value" ;;
os ) os="$value" ;;
arch ) arch="$value" ;;
distro ) distro="$value" ;;
libc ) libc="$value" ;;
archive ) archive="$value" ;;
dep ) deps="${deps:+$deps }$value" ;;
esac
done < "$metadata"
# Fail here if the metadata is incomplete, rather than bake a blank into the
# definition where a missing arch would give a confusing platform error.
for field in version os arch archive; do
if [ -z "${!field}" ]; then
echo "pyenv-binary: metadata is missing \`${field}'" >&2
exit 1
fi
done
# macOS relocation needs install_name_tool and a different rpath scheme, so it is
# not supported yet. Other systems relocate with patchelf.
if [ "$os" = "Darwin" ]; then
echo "pyenv-binary: macOS archives are not supported yet" >&2
exit 1
fi
# The glibc floor only applies on Linux; require it there so the definition can
# tell whether the target is new enough to run the binaries.
if [ "$os" = "Linux" ] && [ -z "$libc" ]; then
echo "pyenv-binary: metadata is missing \`libc'" >&2
exit 1
fi
# python-build downloads the archive itself, so the checksum has to be baked in.
# The archive sits next to the metadata `save' wrote it alongside.
archive_path="$(dirname "$metadata")/${archive}"
if [ ! -r "$archive_path" ]; then
echo "pyenv-binary: cannot read the archive \`${archive_path}' to checksum it" >&2
exit 1
fi
if command -v sha256sum >/dev/null 2>&1; then
sha256="$(sha256sum "$archive_path")"; sha256="${sha256%% *}"
elif command -v shasum >/dev/null 2>&1; then
sha256="$(shasum -a 256 "$archive_path")"; sha256="${sha256%% *}"
elif command -v openssl >/dev/null 2>&1; then
sha256="$(openssl dgst -sha256 "$archive_path")"; sha256="${sha256##* }"
else
echo "pyenv-binary: need sha256sum, shasum or openssl to checksum the archive" >&2
exit 1
fi
emit() {
# The values known now go in as %q-quoted assignments so a stray quote or space
# in the URL or metadata cannot break the definition or inject into it. The body
# below is a quoted here-doc, verbatim.
{
printf '# python-build definition for prebuilt Python %s (%s/%s%s).\n' \
"$version" "$os" "$arch" "${distro:+, $distro}"
echo "# Generated by \`pyenv binary generate-installer'."
echo
printf 'EXPECT_OS=%q\n' "$os"
printf 'EXPECT_ARCH=%q\n' "$arch"
printf 'EXPECT_LIBC=%q\n' "$libc"
printf 'VERSION=%q\n' "$version"
printf 'ARCHIVE_URL=%q\n' "$archive_url"
printf 'SHA256=%q\n' "$sha256"
printf 'DEPS=%q\n' "$deps"
}
cat <<'EOF'
os="$(uname -s)"
arch="$(uname -m)"
if [ "$os" != "$EXPECT_OS" ] || [ "$arch" != "$EXPECT_ARCH" ]; then
echo "pyenv-binary: this archive is for ${EXPECT_OS}/${EXPECT_ARCH}, not ${os}/${arch}" >&2
exit 1
fi
# Refuse a target whose glibc is older than the one the archive was built on;
# the dynamic loader would reject the binaries.
case "$EXPECT_LIBC" in
"glibc "* )
build_libc="${EXPECT_LIBC#glibc }"
target_libc="$(getconf GNU_LIBC_VERSION 2>/dev/null || true)"
case "$target_libc" in
"glibc "* )
target_libc="${target_libc#glibc }"
older="$(printf '%s\n%s\n' "$build_libc" "$target_libc" | sort -V | head -n1)"
if [ "$older" = "$target_libc" ] && [ "$target_libc" != "$build_libc" ]; then
echo "pyenv-binary: archive needs glibc ${build_libc} or newer, but this system has ${target_libc}" >&2
exit 1
fi
;;
esac
;;
esac
# Required system libraries must already be present on the target. ldconfig -p
# lists each library as "<soname> (...) => <path>"; match the soname column
# exactly so a longer name like libc.so.6.1 does not satisfy libc.so.6.
if command -v ldconfig &>/dev/null && cache="$(ldconfig -p)"; then
missing=""
for dep in $DEPS; do
printf '%s\n' "$cache" | awk -v d="$dep" '$1 == d { found = 1 } END { exit !found }' \
|| missing="${missing} ${dep}"
done
if [ -n "$missing" ]; then
echo "pyenv-binary: missing required system libraries:${missing}" >&2
exit 1
fi
elif [ -n "$DEPS" ]; then
echo "pyenv-binary: cannot check required system libraries (ldconfig with -p not found)" >&2
fi
build_package_relocate() {
pyenv binary relocate "$PREFIX_PATH"
}
install_package "Python-${VERSION}-binary" "${ARCHIVE_URL}#${SHA256}" copy relocate
EOF
}
if [ -n "$output" ]; then
emit > "$output"
echo "Wrote definition to ${output}"
else
emit
fi

View File

@ -0,0 +1,90 @@
#!/usr/bin/env bash
#
# Summary: Create an installable binary package from a Python version
#
# Usage: pyenv binary package <version>:<entry> --archive-base-url <url>
#
# Installs <version> from source under the separate name <entry>, 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 <entry>.tar.gz, <entry>.meta and <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-base-url <url>
# Where the archive will be hosted; the definition
# downloads it from <url>/<entry>.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 <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
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 `<version>:<alias>' build under versions/<alias>.
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"

View File

@ -0,0 +1,58 @@
#!/usr/bin/env bash
#
# Summary: Rewrite an unpacked Python's rpaths so it runs from its prefix
#
# Usage: pyenv binary relocate <prefix>
#
# Rewrites the rpaths of a Python tree that was unpacked into <prefix> so the
# interpreter and its extension modules load the bundled libraries from their
# new location rather than the path it was built at. Requires patchelf.
#
# The definition that `pyenv binary generate-installer' produces calls this
# after `install_package ... copy' has laid the tree down.
#
set -e
[ -n "$PYENV_DEBUG" ] && set -x
if [ "$1" = "--complete" ]; then
exit
fi
prefix="$1"
if [ -z "$prefix" ]; then
pyenv-help --usage binary-relocate >&2
exit 1
fi
# patchelf can add an rpath and write one of any length; chrpath can only shorten
# an existing one, which is not enough to relocate every build, so require it.
if ! command -v patchelf >/dev/null 2>&1; then
echo "pyenv-binary: need patchelf to relocate the binary" >&2
exit 1
fi
# The interpreter loads libpython from ../lib. Patch every ELF binary in bin/ so
# this holds whatever the interpreter is named (python3, python2.7, ...); the
# scripts alongside it (pip, idle) are not ELF, so `--print-rpath' fails on them
# and they are skipped. A tree with no interpreter at all did not unpack the way
# we expect, so treat that as an error rather than quietly relocating nothing.
found=0
for file in "$prefix"/bin/*; do
[[ -f $file && -x $file && ! -L $file ]] || continue
patchelf --print-rpath "$file" >/dev/null 2>&1 || continue
patchelf --set-rpath "$prefix/lib" "$file"
found=1
done
if [ "$found" -eq 0 ]; then
echo "pyenv-binary: found no interpreter to relocate under \`${prefix}/bin'" >&2
exit 1
fi
# The extension modules CPython built are the only other objects with an rpath
# into the old prefix, so point them at lib/ as well. Anything a wheel installed
# carries an rpath of its own, often into a bundled library directory beside it,
# and would stop loading if we overwrote it. Each match is an ELF object we expect
# to patch, so let a failure stop us rather than swallow it.
while IFS= read -r so; do
patchelf --set-rpath "$prefix/lib" "$so"
done < <(find "$prefix" -path '*/lib-dynload/*.so')

View File

@ -0,0 +1,141 @@
#!/usr/bin/env bash
#
# Summary: Save an installed Python version as a relocatable archive
#
# Usage: pyenv binary save <version> [<output-dir>] [--name <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
# it links against, so an installer can check compatibility before unpacking.
#
# <version> An installed version, as listed by `pyenv versions --bare'.
# <output-dir> Where to write the archive and metadata (default: `.').
# --name <name> Use <name> as the archive and metadata base name instead of
# <version>-<platform>.
#
set -e
[ -n "$PYENV_DEBUG" ] && set -x
# Provide pyenv completions
if [ "$1" = "--complete" ]; then
echo --name
exec pyenv-versions --bare
fi
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 <version> [<output-dir>] [--name <name>]" >&2
exit 1
fi
# A version is a single directory name under versions/. With no slash allowed,
# the only remaining names that could point elsewhere are `.' and `..'.
case "$version" in
*/* | .. | . )
echo "pyenv-binary: invalid version name \`${version}'" >&2
exit 1
;;
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
exit 1
fi
os="$(uname -s)"
arch="$(uname -m)"
platform="$(printf '%s' "$os" | tr '[:upper:]' '[:lower:]')-${arch}"
# Record the distro and libc version. Platform and arch alone are too coarse to
# judge compatibility: a build is only portable to a matching libc (e.g. a
# glibc 2.36 build will not load on an older glibc, nor on musl at all).
distro=""
libc=""
if [ -r /etc/os-release ]; then
distro="$( . /etc/os-release && printf '%s %s' "${ID:-}" "${VERSION_ID:-}" )"
elif [ "$os" = "Darwin" ]; then
distro="macos $(sw_vers -productVersion 2>/dev/null)"
fi
if [ "$os" = "Linux" ]; then
libc="$(getconf GNU_LIBC_VERSION 2>/dev/null || true)"
fi
# List the external shared libraries the install links against: those that
# resolve outside its own prefix, so they must already exist on the target.
# The interpreter plus every bundled shared object are inspected.
system_deps() {
local f
{
for f in "${prefix}"/bin/python*; do
[ -e "$f" ] && printf '%s\n' "$f"
done
# CPython ships its extension modules as *.so (and *.dylib on macOS). A
# bare *.so.* is unusual for CPython itself, but the odd build carries a
# versioned copy alongside, so match it too rather than miss a dependency.
find "${prefix}" -type f \( -name '*.so' -o -name '*.so.*' -o -name '*.dylib' \)
} | sort -u | while IFS= read -r f; do
if [ "$os" = "Darwin" ]; then
otool -L "$f" 2>/dev/null | tail -n +2 | awk -v pfx="${prefix}/" \
'$1 !~ /^@/ && substr($1, 1, length(pfx)) != pfx { print $1 }'
else
ldd "$f" 2>/dev/null | awk -v pfx="${prefix}/" \
'$2 == "=>" && $3 ~ /^\// && substr($3, 1, length(pfx)) != pfx { print $1 }'
fi
done | sort -u
}
mkdir -p "$output_dir"
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")"
{
echo "# pyenv-binary metadata"
echo "version=${version}"
echo "os=${os}"
echo "arch=${arch}"
echo "platform=${platform}"
[ -n "$distro" ] && echo "distro=${distro}"
[ -n "$libc" ] && echo "libc=${libc}"
echo "archive=${archive}"
system_deps | while IFS= read -r dep; do
[ -n "$dep" ] && echo "dep=${dep}"
done
} > "${output_dir}/${metadata}"
echo "Saved ${archive} and ${metadata} to ${output_dir}"

View File

@ -0,0 +1,123 @@
#!/usr/bin/env bats
load test_helper
create_meta() {
local os="${1-Linux}"
local arch="${2-x86_64}"
local libc="${3-glibc 2.17}"
local archive="${BATS_TEST_TMPDIR}/3.12.7.tar.gz"
local meta="${BATS_TEST_TMPDIR}/sample.meta"
rm -rf "${BATS_TEST_TMPDIR}/archive"
mkdir -p "${BATS_TEST_TMPDIR}/archive/3.12.7/bin"
printf '#!/bin/sh\n' > "${BATS_TEST_TMPDIR}/archive/3.12.7/bin/python"
chmod +x "${BATS_TEST_TMPDIR}/archive/3.12.7/bin/python"
tar -C "${BATS_TEST_TMPDIR}/archive" -czf "$archive" 3.12.7
{
echo "version=3.12.7"
echo "os=${os}"
echo "arch=${arch}"
[ -z "$libc" ] || echo "libc=${libc}"
echo "archive=${archive##*/}"
} > "$meta"
echo "$meta"
}
@test "completion lists the options" {
run pyenv-binary-generate-installer --complete
assert_success "--archive-url
-o"
}
@test "fails with no arguments" {
create_stub pyenv-help "echo usage"
run pyenv-binary-generate-installer
assert_failure "usage"
}
@test "fails without an archive url" {
create_stub pyenv-help "echo usage"
run pyenv-binary-generate-installer "$(create_meta)"
assert_failure "usage"
}
@test "fails when --archive-url has no value" {
run pyenv-binary-generate-installer "$(create_meta)" --archive-url
assert_failure "pyenv-binary: --archive-url needs a value"
}
@test "rejects a second positional argument" {
run pyenv-binary-generate-installer "$(create_meta)" extra --archive-url http://x/a.tar.gz
assert_failure "pyenv-binary: unexpected argument \`extra'"
}
@test "fails for a metadata file that cannot be read" {
run pyenv-binary-generate-installer /no/such.meta --archive-url http://x/a.tar.gz
assert_failure
}
@test "refuses macOS metadata" {
run pyenv-binary-generate-installer "$(create_meta Darwin arm64 '')" \
--archive-url http://x/a.tar.gz
assert_failure "pyenv-binary: macOS archives are not supported yet"
}
@test "fails when required metadata is missing" {
local field meta
for field in version os arch archive; do
meta="$(create_meta)"
#cannot use -i: GNU sed requires -i[suf], BSD sed required -i <suf>
sed "/^${field}=/d" "$meta" > "${meta}.tmp"; mv "${meta}"{.tmp,}
run pyenv-binary-generate-installer "$meta" --archive-url http://x/a.tar.gz
assert_failure "pyenv-binary: metadata is missing \`${field}'"
done
}
@test "fails when Linux metadata is missing libc" {
run pyenv-binary-generate-installer "$(create_meta Linux x86_64 '')" \
--archive-url http://x/a.tar.gz
assert_failure "pyenv-binary: metadata is missing \`libc'"
}
@test "refuses a host whose glibc is older than the archive" {
local out="${BATS_TEST_TMPDIR}/definition"
pyenv-binary-generate-installer "$(create_meta Linux x86_64 'glibc 99.0')" \
--archive-url http://example.com/a.tar.gz -o "$out"
create_stub uname 'case "$1" in -s) echo Linux;; -m) echo x86_64;; esac'
create_stub getconf 'echo "glibc 2.31"'
run bash "$out"
assert_failure "pyenv-binary: archive needs glibc 99.0 or newer, but this system has 2.31"
}
@test "fails when the archive is not beside the metadata" {
local meta="$(create_meta)"
rm "${BATS_TEST_TMPDIR}/3.12.7.tar.gz"
run pyenv-binary-generate-installer "$meta" --archive-url http://x/a.tar.gz
assert_failure "pyenv-binary: cannot read the archive \`${BATS_TEST_TMPDIR}/3.12.7.tar.gz' to checksum it"
}
@test "the generated definition is installable with python-build" {
ldconfig -p &>/dev/null || skip "ldconfig with -p is not present"
local archive="${BATS_TEST_TMPDIR}/3.12.7.tar.gz"
local cache="${BATS_TEST_TMPDIR}/cache"
local definition="${BATS_TEST_TMPDIR}/definition"
local prefix="${BATS_TEST_TMPDIR}/install"
pyenv-binary-generate-installer "$(create_meta)" \
--archive-url http://example.com/3.12.7.tar.gz -o "$definition"
mkdir -p "$cache"
cp "$archive" "${cache}/Python-3.12.7-binary.tar.gz"
create_stub pyenv 'echo "pyenv $*"'
create_stub uname 'case "$1" in -s) echo Linux;; -m) echo x86_64;; esac'
PYTHON_BUILD_CACHE_PATH="$cache" run \
"${BATS_TEST_DIRNAME}/../../python-build/bin/python-build" "$definition" "$prefix"
assert_success
assert_line "pyenv binary relocate ${prefix}"
assert_line "Installed Python-3.12.7-binary to ${prefix}"
assert [ -x "${prefix}/bin/python" ]
}

View File

@ -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 <version>:<entry>, 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"
}

View File

@ -0,0 +1,72 @@
#!/usr/bin/env bats
load test_helper
_setup() {
create_stub pyenv-help "echo usage"
}
stub_patchelf() {
create_path_executable patchelf <<STUB
if [ "\$1" = "--print-rpath" ]; then
exit 0
fi
echo "\$*" >> "${BATS_TEST_TMPDIR}/patchelf.log"
STUB
}
create_interpreter() {
mkdir -p "${BATS_TEST_TMPDIR}/prefix/bin"
printf '#!/bin/sh\n' > "${BATS_TEST_TMPDIR}/prefix/bin/python2.7"
chmod +x "${BATS_TEST_TMPDIR}/prefix/bin/python2.7"
}
@test "completion produces nothing" {
run pyenv-binary-relocate --complete
assert_success ""
}
@test "fails without a prefix" {
run pyenv-binary-relocate
assert_failure "usage"
}
@test "fails when patchelf is not available" {
PATH="$(path_without patchelf)" run pyenv-binary-relocate "${BATS_TEST_TMPDIR}/prefix"
assert_failure "pyenv-binary: need patchelf to relocate the binary"
}
@test "fails when the prefix has no interpreter" {
create_path_executable patchelf "exit 0"
mkdir -p "${BATS_TEST_TMPDIR}/prefix"
run pyenv-binary-relocate "${BATS_TEST_TMPDIR}/prefix"
assert_failure "pyenv-binary: found no interpreter to relocate under \`${BATS_TEST_TMPDIR}/prefix/bin'"
}
@test "relocates an executable interpreter" {
stub_patchelf
create_interpreter
run pyenv-binary-relocate "${BATS_TEST_TMPDIR}/prefix"
assert_success
run cat "${BATS_TEST_TMPDIR}/patchelf.log"
assert_output "--set-rpath ${BATS_TEST_TMPDIR}/prefix/lib ${BATS_TEST_TMPDIR}/prefix/bin/python2.7"
}
@test "relocates the extension modules but not what a wheel installed" {
local lib="${BATS_TEST_TMPDIR}/prefix/lib/python3.12"
stub_patchelf
create_interpreter
mkdir -p "${lib}/lib-dynload" "${lib}/site-packages/numpy"
touch "${lib}/lib-dynload/_ssl.cpython-312-x86_64-linux-gnu.so"
# A wheel points its extensions at the libraries it bundles alongside them, so
# its rpath is its own business and must survive relocation.
touch "${lib}/site-packages/numpy/_multiarray.so"
run pyenv-binary-relocate "${BATS_TEST_TMPDIR}/prefix"
assert_success
run cat "${BATS_TEST_TMPDIR}/patchelf.log"
assert_line "--set-rpath ${BATS_TEST_TMPDIR}/prefix/lib ${lib}/lib-dynload/_ssl.cpython-312-x86_64-linux-gnu.so"
refute_line "--set-rpath ${BATS_TEST_TMPDIR}/prefix/lib ${lib}/site-packages/numpy/_multiarray.so"
}

View File

@ -0,0 +1,132 @@
#!/usr/bin/env bats
load test_helper
create_version() {
mkdir -p "${PYENV_ROOT}/versions/$1/bin"
}
platform() {
echo "$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m)"
}
@test "fails with no version given" {
run pyenv-binary-save
assert_failure "Usage: pyenv binary save <version> [<output-dir>] [--name <name>]"
}
@test "fails for a version that is not installed" {
run pyenv-binary-save 9.9.9
assert_failure "pyenv-binary: version \`9.9.9' is not installed"
}
@test "rejects a version name containing a slash" {
run pyenv-binary-save "foo/bar"
assert_failure "pyenv-binary: invalid version name \`foo/bar'"
}
@test "rejects the parent directory reference" {
run pyenv-binary-save ".."
assert_failure "pyenv-binary: invalid version name \`..'"
}
@test "packages an installed version" {
create_version "3.12.7"
local out="${BATS_TEST_TMPDIR}/dist"
local archive="${out}/3.12.7-$(platform).tar.gz"
run pyenv-binary-save "3.12.7" "$out"
assert_success "Saved 3.12.7-$(platform).tar.gz and 3.12.7-$(platform).meta to $out"
assert [ -f "$archive" ]
assert [ -f "${out}/3.12.7-$(platform).meta" ]
run tar -tzf "$archive"
assert_success
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"
pyenv-binary-save "3.12.7" "$out" >/dev/null
run cat "${out}/3.12.7-$(platform).meta"
assert_success
assert_line "version=3.12.7"
assert_line "platform=$(platform)"
assert_line "archive=3.12.7-$(platform).tar.gz"
}
@test "records only the libraries ldd resolves outside the prefix" {
create_version "3.12.7"
touch "${PYENV_ROOT}/versions/3.12.7/bin/python3.12"
create_path_executable uname <<'STUB'
case "$1" in
-s) echo Linux ;;
-m) echo x86_64 ;;
esac
STUB
create_path_executable ldd <<'STUB'
prefix="${PYENV_ROOT}/versions/3.12.7"
cat <<EOF
linux-vdso.so.1 (0x00007ffd1adfe000)
libpython3.12.so.1.0 => ${prefix}/lib/libpython3.12.so.1.0 (0x00007f4a3c000000)
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f4a3bc00000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f4a3b800000)
/lib64/ld-linux-x86-64.so.2 (0x00007f4a3c200000)
EOF
STUB
run pyenv-binary-save "3.12.7" "${BATS_TEST_TMPDIR}/dist"
assert_success
run grep '^dep=' "${BATS_TEST_TMPDIR}/dist/"*.meta
assert_output "dep=libc.so.6
dep=libm.so.6"
}
@test "records only the libraries otool resolves outside the prefix" {
create_version "3.12.7"
touch "${PYENV_ROOT}/versions/3.12.7/bin/python3.12"
create_path_executable uname <<'STUB'
case "$1" in
-s) echo Darwin ;;
-m) echo arm64 ;;
esac
STUB
create_path_executable otool <<'STUB'
prefix="${PYENV_ROOT}/versions/3.12.7"
cat <<EOF
${2}:
@rpath/libpython3.12.dylib (compatibility version 3.12.0, current version 3.12.0)
${prefix}/lib/libcrypto.3.dylib (compatibility version 3.0.0, current version 3.0.0)
/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1345.0.0)
EOF
STUB
run pyenv-binary-save "3.12.7" "${BATS_TEST_TMPDIR}/dist"
assert_success
run grep '^dep=' "${BATS_TEST_TMPDIR}/dist/"*.meta
assert_output "dep=/usr/lib/libSystem.B.dylib"
}

View File

@ -0,0 +1 @@
../../../test/test_helper.bash

View File

@ -67,6 +67,13 @@ exact name of the version you want to install. For example,
Python versions will be installed into a directory of the same name under Python versions will be installed into a directory of the same name under
`~/.pyenv/versions`. `~/.pyenv/versions`.
To install a version under a different name -- for instance, to keep several
builds of the same version side by side -- append `:<alias>` to the version:
pyenv install 3.12.0:3.12-custom
This installs into `~/.pyenv/versions/3.12-custom`.
To see a list of all available Python versions, run `pyenv install --list`. You To see a list of all available Python versions, run `pyenv install --list`. You
may also tab-complete available Python versions if your pyenv installation is may also tab-complete available Python versions if your pyenv installation is
properly configured. properly configured.
@ -262,7 +269,7 @@ would be to make symlinks at the mirror's root:
``` ```
The rationale is to abstract away difference between directory structures of sites The rationale is to abstract away difference between directory structures of sites
of various Python flavors and their occasional changes as well as to accomodate of various Python flavors and their occasional changes as well as to accommodate
people who only wish to cache some select downloads. This also allows to mirror multiple sites at once. people who only wish to cache some select downloads. This also allows to mirror multiple sites at once.
If the mirror being used does not have the same checksum (*e.g.* with a If the mirror being used does not have the same checksum (*e.g.* with a

View File

@ -2,9 +2,9 @@
# #
# Summary: Install a Python version using python-build # Summary: Install a Python version using python-build
# #
# Usage: pyenv install [-f] [-kvp] <version>... # Usage: pyenv install [-f] [-kvp] <version>[:<alias>]...
# pyenv install [-f] [-kvp] <definition-file> # pyenv install [-f] [-kvp] <definition-file>[:<alias>]
# pyenv install -l|--list # pyenv install -l|--list [--bare]
# pyenv install --version # pyenv install --version
# #
# -l/--list List all available versions # -l/--list List all available versions
@ -20,6 +20,13 @@
# --version Show version of python-build # --version Show version of python-build
# -g/--debug Build a debug version # -g/--debug Build a debug version
# #
# Append `:<alias>' to a version to install it under a custom name, so that
# several builds of the same version can coexist:
#
# pyenv install 3.12.0:my-3.12
#
# This installs into $PYENV_ROOT/versions/my-3.12.
#
# For detailed information on installing Python versions with # For detailed information on installing Python versions with
# python-build, including a list of environment variables for adjusting # python-build, including a list of environment variables for adjusting
# compilation, see: https://github.com/pyenv/pyenv#readme # compilation, see: https://github.com/pyenv/pyenv#readme
@ -38,6 +45,7 @@ shopt -u nullglob
# Provide pyenv completions # Provide pyenv completions
if [ "$1" = "--complete" ]; then if [ "$1" = "--complete" ]; then
echo --bare
echo --list echo --list
echo --force echo --force
echo --skip-existing echo --skip-existing
@ -72,6 +80,8 @@ unset KEEP
unset VERBOSE unset VERBOSE
unset HAS_PATCH unset HAS_PATCH
unset DEBUG unset DEBUG
unset BARE
unset LIST
[ -n "$PYENV_DEBUG" ] && VERBOSE="-v" [ -n "$PYENV_DEBUG" ] && VERBOSE="-v"
@ -81,10 +91,11 @@ for option in "${OPTIONS[@]}"; do
"h" | "help" ) "h" | "help" )
usage 0 usage 0
;; ;;
"bare" )
BARE=1
;;
"l" | "list" ) "l" | "list" )
echo "Available versions:" LIST=1
definitions | indent
exit
;; ;;
"f" | "force" ) "f" | "force" )
FORCE=true FORCE=true
@ -113,6 +124,16 @@ for option in "${OPTIONS[@]}"; do
esac esac
done done
if [[ -n $LIST ]]; then
if [[ -n $BARE ]]; then
definitions
else
echo "Available versions:"
definitions | indent
fi
exit
fi
unset VERSION_NAME unset VERSION_NAME
# The first argument contains the definition to install. If the # The first argument contains the definition to install. If the
@ -123,6 +144,20 @@ DEFINITIONS=("${ARGUMENTS[@]}")
[[ "${#DEFINITIONS[*]}" -eq 0 ]] && DEFINITIONS=($(pyenv-local 2>/dev/null || true)) [[ "${#DEFINITIONS[*]}" -eq 0 ]] && DEFINITIONS=($(pyenv-local 2>/dev/null || true))
[[ "${#DEFINITIONS[*]}" -eq 0 ]] && usage 1 >&2 [[ "${#DEFINITIONS[*]}" -eq 0 ]] && usage 1 >&2
# A `<version>:<alias>` argument installs <version> under the custom name
# <alias>, so that several builds of the same version can coexist. The `latest`
# suffix is reserved for latest-version resolution (see the `install` hook), so
# it is left untouched here.
declare -a ALIASES
for i in "${!DEFINITIONS[@]}"; do
definition="${DEFINITIONS[$i]}"
alias="${definition##*:}"
if [[ "$definition" == *:* ]] && [ "$alias" != "latest" ]; then
DEFINITIONS[$i]="${definition%:*}"
ALIASES[$i]="$alias"
fi
done
# Define `before_install` and `after_install` functions that allow # Define `before_install` and `after_install` functions that allow
# plugin hooks to register a string of code for execution before or # plugin hooks to register a string of code for execution before or
# after the installation process. # after the installation process.
@ -152,7 +187,9 @@ IFS="$OLDIFS"
for script in "${scripts[@]}"; do source "$script"; done for script in "${scripts[@]}"; do source "$script"; done
COMBINED_STATUS=0 COMBINED_STATUS=0
for DEFINITION in "${DEFINITIONS[@]}"; do for i in "${!DEFINITIONS[@]}"; do
DEFINITION="${DEFINITIONS[$i]}"
VERSION_ALIAS="${ALIASES[$i]}"
STATUS=0 STATUS=0
# Try to resolve a prefix if user indeed gave a prefix. # Try to resolve a prefix if user indeed gave a prefix.
@ -161,9 +198,12 @@ for DEFINITION in "${DEFINITIONS[@]}"; do
DEFINITION="$(pyenv-latest -f -k "$DEFINITION")" DEFINITION="$(pyenv-latest -f -k "$DEFINITION")"
# Set VERSION_NAME from $DEFINITION. Then compute the installation prefix. # Set VERSION_NAME from $DEFINITION. Then compute the installation prefix.
# With a `<version>:<alias>` argument, install under the alias instead;
# VERSION_NAME still reflects the real version so version-specific build logic
# (e.g. the bootstrap version detection below) keeps working.
VERSION_NAME="${DEFINITION##*/}" VERSION_NAME="${DEFINITION##*/}"
[ -n "$DEBUG" ] && VERSION_NAME="${VERSION_NAME}-debug" [ -n "$DEBUG" ] && VERSION_NAME="${VERSION_NAME}-debug"
PREFIX="${PYENV_ROOT}/versions/${VERSION_NAME}" PREFIX="${PYENV_ROOT}/versions/${VERSION_ALIAS:-$VERSION_NAME}"
[ -d "${PREFIX}" ] && PREFIX_EXISTS=1 [ -d "${PREFIX}" ] && PREFIX_EXISTS=1
@ -188,7 +228,7 @@ for DEFINITION in "${DEFINITIONS[@]}"; do
# If PYENV_BUILD_ROOT is set, always pass keep options to python-build. # If PYENV_BUILD_ROOT is set, always pass keep options to python-build.
if [ -n "${PYENV_BUILD_ROOT}" ]; then if [ -n "${PYENV_BUILD_ROOT}" ]; then
export PYTHON_BUILD_BUILD_PATH="${PYENV_BUILD_ROOT}/${VERSION_NAME}" export PYTHON_BUILD_BUILD_PATH="${PYENV_BUILD_ROOT}/${VERSION_ALIAS:-$VERSION_NAME}"
KEEP="-k" KEEP="-k"
fi fi

View File

@ -14,7 +14,7 @@
# -g/--debug Build a debug version # -g/--debug Build a debug version
# #
PYTHON_BUILD_VERSION="2.7.3" PYTHON_BUILD_VERSION="2.8.1"
OLDIFS="$IFS" OLDIFS="$IFS"

View File

@ -31,13 +31,6 @@ import requests_html
import sortedcontainers import sortedcontainers
import tqdm import tqdm
#CI uses exit code 1 as a signal that no new version is found
#so have to produce a different exit code on an exception
def _excepthook(type,value,traceback):
logging.error("Unhandled exception occured",exc_info=(type,value,traceback))
sys.exit(2)
sys.excepthook = _excepthook
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
CUTOFF_VERSION=packaging.version.Version('3.10') CUTOFF_VERSION=packaging.version.Version('3.10')
@ -176,6 +169,8 @@ def handle_version_patches(
logger.info(f"Copying patches from {previous_version} to {version}") logger.info(f"Copying patches from {previous_version} to {version}")
shutil.copytree(previous_patches, new_patches) shutil.copytree(previous_patches, new_patches)
# Subdir rename as a separate step from upper dir copying/moving
# in case there are patches for dependency packages as well
previous_package_patches = new_patches / f"Python-{previous_version}" previous_package_patches = new_patches / f"Python-{previous_version}"
new_package_patches = new_patches / f"Python-{version}" new_package_patches = new_patches / f"Python-{version}"
if is_prerelease_upgrade: if is_prerelease_upgrade:
@ -187,10 +182,9 @@ def handle_version_patches(
else: else:
previous_package_patches.rename(new_package_patches) previous_package_patches.rename(new_package_patches)
previous_t_patches = patches_dir / f"{previous_version}t" if uses_t_thunks(previous_version) and is_prerelease_upgrade:
if previous_t_patches.exists() or previous_t_patches.is_symlink(): (patches_dir / f"{previous_version}t").unlink(missing_ok=True)
if is_prerelease_upgrade: if uses_t_thunks(version):
previous_t_patches.unlink()
(patches_dir / f"{version}t").symlink_to( (patches_dir / f"{version}t").symlink_to(
str(version), target_is_directory=True str(version), target_is_directory=True
) )
@ -225,7 +219,7 @@ def cleanup_prerelease_upgrade(
def handle_t_thunks(version, previous_version, is_prerelease_upgrade): def handle_t_thunks(version, previous_version, is_prerelease_upgrade):
if (version.major, version.minor) < (3, 13): if not uses_t_thunks(version):
return return
# an old thunk may have older version-specific code # an old thunk may have older version-specific code
@ -244,6 +238,10 @@ def handle_t_thunks(version, previous_version, is_prerelease_upgrade):
thunk_path.write_text(T_THUNK, encoding='utf-8') thunk_path.write_text(T_THUNK, encoding='utf-8')
def uses_t_thunks(version: packaging.version.Version) -> bool:
return (version.major, version.minor) >= (3, 13)
Arguments: argparse.Namespace Arguments: argparse.Namespace
def main(): def main():
@ -262,14 +260,20 @@ def main():
# So until we know the release is out, its directory is a potential prerelease directory. # So until we know the release is out, its directory is a potential prerelease directory.
# Normally, prereleases are only made for initial releases (x.y.0) -- # Normally, prereleases are only made for initial releases (x.y.0) --
# but rarely, they may make them for other releases (e.g. 3.14.5). # but rarely, they may make them for other releases (e.g. 3.14.5).
for release in (v for v in frozenset(VersionDirectory.available.keys()) #refining changes the for release in (v for v in frozenset(VersionDirectory.available.keys()) #refining alters the
#corresponding directory key #corresponding directory key
#which breaks iteration #which breaks iteration
#over the directory --
#so have to iterate over a copy #so have to iterate over a copy
if v not in VersionDirectory.existing): if v not in VersionDirectory.existing):
VersionDirectory.available.get_store_available_source_downloads(release, True) VersionDirectory.available.get_store_available_source_downloads(release, True)
del release del release
# Excluding versions for which there already are PRs.
# This will prevent us from using advanced features of
# peter-evans/create-pull-request Github Action
# like updating a PR and closing a superseded PR
# but we don't really need them as of this writing.
versions_to_add = sorted( versions_to_add = sorted(
VersionDirectory.available.keys() VersionDirectory.available.keys()
- VersionDirectory.existing.keys() - VersionDirectory.existing.keys()
@ -293,9 +297,8 @@ def get_pending_versions() -> typing.Set[packaging.version.Version]:
pending_versions = set() pending_versions = set()
for line in ls_remote.splitlines(): for line in ls_remote.splitlines():
match = AUTO_ADD_VERSION_REF_RE.fullmatch(line) if not (match := AUTO_ADD_VERSION_REF_RE.fullmatch(line)):
if not match: raise ValueError(f"Unexpected git ls-remote output line: {line!r}")
raise ValueError(f"Unexpected git ls-remote output: {line!r}")
pending_versions.update( pending_versions.update(
packaging.version.Version(version) packaging.version.Version(version)
for version in match.group("versions").split("_") for version in match.group("versions").split("_")
@ -453,8 +456,9 @@ class CPythonAvailableVersionsDirectory(KeyedList[_CPythonAvailableVersionInfo,
download_version = packaging.version.Version(m.group("version")) download_version = packaging.version.Version(m.group("version"))
if download_version != version: if download_version != version:
if not refine_mode: if not refine_mode:
raise ValueError(f"Unexpectedly found a download {name} ({download_version}) " logger.warning(f"Ignoring download {name} ({download_version}) "
f"for {version} at page {entry.download_page_url}") f"for {version} at page {entry.download_page_url}")
continue
entry_to_fill = additional_versions_found.get_or_create( entry_to_fill = additional_versions_found.get_or_create(
download_version, download_version,
download_page_url=entry.download_page_url download_page_url=entry.download_page_url
@ -467,7 +471,10 @@ class CPythonAvailableVersionsDirectory(KeyedList[_CPythonAvailableVersionInfo,
m.group("extension"), m.group('package'), url m.group("extension"), m.group('package'), url
)) ))
if not exact_download_found: # XXX: Exact download not found in non-refine mode never happens now
# 'cuz we first call the function in refine mode.
# Decide what's best to do if it starts to after a logic change.
if not exact_download_found and refine_mode:
actual_version = max(additional_versions_found.keys()) actual_version = max(additional_versions_found.keys())
logger.debug(f"Refining available version {version} to {actual_version}") logger.debug(f"Refining available version {version} to {actual_version}")
del self[version] del self[version]
@ -499,7 +506,7 @@ class CPythonExistingScriptsDirectory(KeyedList[_CPythonExistingScriptInfo, pack
v = packaging.version.Version(entry_name) v = packaging.version.Version(entry_name)
if v < CUTOFF_VERSION: if v < CUTOFF_VERSION:
continue continue
# branch tip scrpts are different from release scripts and thus unusable as a pattern # branch tip scripts are different from release scripts and thus unusable as a pattern
if v.dev is not None: if v.dev is not None:
continue continue
logger.debug(f"Existing version {v}") logger.debug(f"Existing version {v}")
@ -533,7 +540,7 @@ class OpenSSLVersionsDirectory(KeyedList[_OpenSSLVersionInfo, packaging.version.
if matching: if matching:
return max(matching, key=lambda release: release.version) return max(matching, key=lambda release: release.version)
url = "https://api.github.com/repos/openssl/openssl/releases?per_page=100" url = "https://api.github.com/repos/openssl/openssl/releases"
while url: while url:
response = requests.get(url, timeout=30) response = requests.get(url, timeout=30)
response.raise_for_status() response.raise_for_status()
@ -657,6 +664,7 @@ class DownloadPage:
if session is None: if session is None:
session = requests_html.HTMLSession() session = requests_html.HTMLSession()
response = session.get(url, timeout=30) response = session.get(url, timeout=30)
response.raise_for_status()
page = response.html page = response.html
table = page.find("pre", first=True) table = page.find("pre", first=True)
# some GNU mirrors format entries as a table # some GNU mirrors format entries as a table
@ -736,4 +744,9 @@ class Url:
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) #sys.excepthook seems to have no effect in Github Actions
try:
sys.exit(main())
except Exception:
logging.exception("Unhandled exception occurred")
sys.exit(2)

View File

@ -1,6 +1,6 @@
more_itertools more_itertools
requests-html requests-html
fake_useragent<2 fake_useragent<2; python_version < "3.9"
lxml[html_clean] lxml[html_clean]
packaging packaging
requests requests

View File

@ -5,7 +5,7 @@ export PYTHON_BUILD_TCLTK_USE_PKGCONFIG=1
install_package "openssl-4.0.1" "https://github.com/openssl/openssl/releases/download/openssl-4.0.1/openssl-4.0.1.tar.gz#2db3f3a0d6ea4b59e1f094ace2c8cd536dffb87cdc39084c5afa1e6f7f37dd09" mac_openssl --if has_broken_mac_openssl install_package "openssl-4.0.1" "https://github.com/openssl/openssl/releases/download/openssl-4.0.1/openssl-4.0.1.tar.gz#2db3f3a0d6ea4b59e1f094ace2c8cd536dffb87cdc39084c5afa1e6f7f37dd09" mac_openssl --if has_broken_mac_openssl
install_package "readline-8.3" "https://ftpmirror.gnu.org/readline/readline-8.3.tar.gz#fe5383204467828cd495ee8d1d3c037a7eba1389c22bc6a041f627976f9061cc" mac_readline --if has_broken_mac_readline install_package "readline-8.3" "https://ftpmirror.gnu.org/readline/readline-8.3.tar.gz#fe5383204467828cd495ee8d1d3c037a7eba1389c22bc6a041f627976f9061cc" mac_readline --if has_broken_mac_readline
if has_tar_xz_support; then if has_tar_xz_support; then
install_package "Python-3.15.0b3" "https://www.python.org/ftp/python/3.15.0/Python-3.15.0b3.tar.xz#6a935ae234a67e6549894373b0cfeb8361182d03b21442328ae9598ab7422127" standard verify_py315 copy_python_gdb ensurepip install_package "Python-3.15.0b4" "https://www.python.org/ftp/python/3.15.0/Python-3.15.0b4.tar.xz#93efb9c88d7b6633368e7f7b8f8db6e98988f7f761c09b77849447262841ce3a" standard verify_py315 copy_python_gdb ensurepip
else else
install_package "Python-3.15.0b3" "https://www.python.org/ftp/python/3.15.0/Python-3.15.0b3.tgz#e5a18806817f912f2c9a1091ac77703f9a969b129c878d436f0f7025bf2f9e97" standard verify_py315 copy_python_gdb ensurepip install_package "Python-3.15.0b4" "https://www.python.org/ftp/python/3.15.0/Python-3.15.0b4.tgz#4825af9329834306ad4a9cb56b94455381b52bcde4b653604a39f387cc559fc9" standard verify_py315 copy_python_gdb ensurepip
fi fi

View File

@ -0,0 +1,61 @@
# Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy of
# this software and associated documentation files (the "Software"), to deal in
# the Software without restriction, including without limitation the rights to
# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
# of the Software, and to permit persons to whom the Software is furnished to do
# so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
VERSION='25.1.3'
BUILD=''
colorize 1 "GraalPy 23.1 and later installed by python-build use the faster Oracle GraalVM distribution" && echo
colorize 1 "Oracle GraalVM uses the GFTC license, which is free for development and production use, see https://medium.com/graalvm/161527df3d76" && echo
colorize 1 "The GraalVM Community Edition variant of GraalPy is also available, under the name graalpy3.12-community-${VERSION}" && echo
graalpy_arch="$(graalpy_architecture 2>/dev/null || true)"
case "$graalpy_arch" in
"linux-amd64" )
checksum="53d46f3ad78229699eb00677f4cb5e3cfe451b58c46970e4d4272a719b53ce82"
;;
"linux-aarch64" )
checksum="947170f0896c8acb02b364caad3e0c01439c12cbdad1b7605cff589b7dd5b26e"
;;
"macos-aarch64" )
checksum="adb17eb3aa3c5701cfc518af26bbaf4e5755ecb62a6a22d490af06523ea02913"
;;
* )
{ echo
colorize 1 "ERROR"
echo ": No binary distribution of GraalPy is available for $(uname -sm)."
echo
} >&2
exit 1
;;
esac
if [ -n "${BUILD}" ]; then
{ echo
colorize 1 "ERROR"
echo "Oracle GraalPy currently doesn't provide snapshot builds. Use graalpy3.12-community if you need snapshots."
echo
} >&2
exit 1
fi
url="https://github.com/oracle/graalpython/releases/download/graal-${VERSION}/graalpy3.12-${VERSION}-${graalpy_arch}.tar.gz#${checksum}"
install_package "graalpy3.12-${VERSION}" "${url}" "copy" ensurepip

View File

@ -0,0 +1,49 @@
# Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy of
# this software and associated documentation files (the "Software"), to deal in
# the Software without restriction, including without limitation the rights to
# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
# of the Software, and to permit persons to whom the Software is furnished to do
# so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
VERSION='25.2.4'
colorize 1 "GraalPy 23.1 and later installed by python-build use the faster Oracle GraalVM distribution" && echo
colorize 1 "Oracle GraalVM uses the GFTC license, which is free for development and production use, see https://medium.com/graalvm/161527df3d76" && echo
colorize 1 "The GraalVM Community Edition variant of GraalPy is also available, under the name graalpy3.12-community-${VERSION}" && echo
graalpy_arch="$(graalpy_architecture 2>/dev/null || true)"
case "$graalpy_arch" in
"linux-amd64" )
checksum="b3d0766ae6d55daa15f0db1f6c884383d7eec506b186d0ba2f702523a78ff28d"
;;
"linux-aarch64" )
checksum="e57472272b1b659ae6ac972117723b0515a49cc9577688ed5563919793170d67"
;;
"macos-aarch64" )
checksum="79f0e1fefb42bb484122ae9e50cb21ce14541d18e6bd66c498e44b459ce652df"
;;
* )
{ echo
colorize 1 "ERROR"
echo ": No binary distribution of GraalPy is available for $(uname -sm)."
echo
} >&2
exit 1
;;
esac
install_package "graalpy3.12-${VERSION}" "https://github.com/oracle/graalpython/releases/download/graal-${VERSION}/graalpy3.12-${VERSION}-${graalpy_arch}.tar.gz#${checksum}" "copy" ensurepip

View File

@ -0,0 +1,51 @@
# Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy of
# this software and associated documentation files (the "Software"), to deal in
# the Software without restriction, including without limitation the rights to
# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
# of the Software, and to permit persons to whom the Software is furnished to do
# so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
VERSION='25.1.3'
BUILD=''
graalpy_arch="$(graalpy_architecture 2>/dev/null || true)"
case "$graalpy_arch" in
"linux-amd64" )
checksum="1705b59e5a3d04364b1fa4cbb31ad6c273b487a87dc67e336fc5476b599c8d3e"
;;
"linux-aarch64" )
checksum="2558dec612e02b1ce5d7807fdd338f2a65dd9a0e62913458a32202289cc211ac"
;;
"macos-aarch64" )
checksum="76c0ccde939b94e6669a0e7eb01df612a070a6dbb966ed70989a851a9b1066e8"
;;
* )
{ echo
colorize 1 "ERROR"
echo ": No binary distribution of GraalPy is available for $(uname -sm)."
echo
} >&2
exit 1
;;
esac
if [ -n "${BUILD}" ]; then
url="https://github.com/graalvm/graalvm-ce-dev-builds/releases/download/${VERSION}-dev-${BUILD}/graalpy3.12-community-dev-${graalpy_arch}.tar.gz"
else
url="https://github.com/oracle/graalpython/releases/download/graal-${VERSION}/graalpy3.12-community-${VERSION}-${graalpy_arch}.tar.gz#${checksum}"
fi
install_package "graalpy3.12-community-${VERSION}${BUILD}" "${url}" "copy" ensurepip

View File

@ -0,0 +1,44 @@
# Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy of
# this software and associated documentation files (the "Software"), to deal in
# the Software without restriction, including without limitation the rights to
# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
# of the Software, and to permit persons to whom the Software is furnished to do
# so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
VERSION='25.2.4'
graalpy_arch="$(graalpy_architecture 2>/dev/null || true)"
case "$graalpy_arch" in
"linux-amd64" )
checksum="98af303a6b714bde39077354733254f78023ddef06d61e14cb66a3a01f10ae82"
;;
"linux-aarch64" )
checksum="dda5e97c8b38158184dec189addaece57a37207a0dad38be162621d605f964f5"
;;
"macos-aarch64" )
checksum="76a182713660ce69f640b9666c2861ec8a5839637a972eaa80c6b5cf38f1357b"
;;
* )
{ echo
colorize 1 "ERROR"
echo ": No binary distribution of GraalPy is available for $(uname -sm)."
echo
} >&2
exit 1
;;
esac
install_package "graalpy3.12-community-${VERSION}" "https://github.com/oracle/graalpython/releases/download/graal-${VERSION}/graalpy3.12-community-${VERSION}-${graalpy_arch}.tar.gz#${checksum}" "copy" ensurepip

View File

@ -0,0 +1,20 @@
export CONDA_PLUGINS_AUTO_ACCEPT_TOS=true
case "$(anaconda_architecture 2>/dev/null || true)" in
"Linux-aarch64" )
install_script "Miniconda3-py310_26.5.3-1-Linux-aarch64" "https://repo.anaconda.com/miniconda/Miniconda3-py310_26.5.3-1-Linux-aarch64.sh#d2988632a30ea71e158874b9c5386d016660908fec38ee58125af7783b1a0bf6" "miniconda" verify_py310
;;
"Linux-x86_64" )
install_script "Miniconda3-py310_26.5.3-1-Linux-x86_64" "https://repo.anaconda.com/miniconda/Miniconda3-py310_26.5.3-1-Linux-x86_64.sh#4a82fe0a50a28e8a9406b3ed8e465b7009aa7d0225566802c3370df96b10d834" "miniconda" verify_py310
;;
"MacOSX-arm64" )
install_script "Miniconda3-py310_26.5.3-1-MacOSX-arm64" "https://repo.anaconda.com/miniconda/Miniconda3-py310_26.5.3-1-MacOSX-arm64.sh#7868e87c7e1ecaa6ff25f5041e0abdf766e5e2d082788b26bcf57554d0036a2d" "miniconda" verify_py310
;;
* )
{ echo
colorize 1 "ERROR"
echo ": The binary distribution of Miniconda is not available for $(anaconda_architecture 2>/dev/null || true)."
echo
} >&2
exit 1
;;
esac

View File

@ -0,0 +1,20 @@
export CONDA_PLUGINS_AUTO_ACCEPT_TOS=true
case "$(anaconda_architecture 2>/dev/null || true)" in
"Linux-aarch64" )
install_script "Miniconda3-py311_26.5.3-1-Linux-aarch64" "https://repo.anaconda.com/miniconda/Miniconda3-py311_26.5.3-1-Linux-aarch64.sh#4a2e39642fcd0b25cf199f5ada4725120a9a40fa10e864d67b8f93e7f2f44dad" "miniconda" verify_py311
;;
"Linux-x86_64" )
install_script "Miniconda3-py311_26.5.3-1-Linux-x86_64" "https://repo.anaconda.com/miniconda/Miniconda3-py311_26.5.3-1-Linux-x86_64.sh#f1d308a450763ce617f4e4f1609521358f663b2d1c097cfcc11a1c1f09baf680" "miniconda" verify_py311
;;
"MacOSX-arm64" )
install_script "Miniconda3-py311_26.5.3-1-MacOSX-arm64" "https://repo.anaconda.com/miniconda/Miniconda3-py311_26.5.3-1-MacOSX-arm64.sh#721bccd83c53ff56e364faf883c5da9abfdf61daecb4863c533818851b33edc3" "miniconda" verify_py311
;;
* )
{ echo
colorize 1 "ERROR"
echo ": The binary distribution of Miniconda is not available for $(anaconda_architecture 2>/dev/null || true)."
echo
} >&2
exit 1
;;
esac

View File

@ -0,0 +1,20 @@
export CONDA_PLUGINS_AUTO_ACCEPT_TOS=true
case "$(anaconda_architecture 2>/dev/null || true)" in
"Linux-aarch64" )
install_script "Miniconda3-py312_26.5.3-1-Linux-aarch64" "https://repo.anaconda.com/miniconda/Miniconda3-py312_26.5.3-1-Linux-aarch64.sh#9e48caecbad9cc43d8a483584f0a5e1da2754189416a8811aaada3f58aceeb67" "miniconda" verify_py312
;;
"Linux-x86_64" )
install_script "Miniconda3-py312_26.5.3-1-Linux-x86_64" "https://repo.anaconda.com/miniconda/Miniconda3-py312_26.5.3-1-Linux-x86_64.sh#ecb43ee4ae30a7a5af87737e9548ceb21f0a10ec55b8dc40d247aa925b80bfec" "miniconda" verify_py312
;;
"MacOSX-arm64" )
install_script "Miniconda3-py312_26.5.3-1-MacOSX-arm64" "https://repo.anaconda.com/miniconda/Miniconda3-py312_26.5.3-1-MacOSX-arm64.sh#f5767f038d5aa8299254b96b7e0db4f086c29e4b3a620fc38fea51974f66abdd" "miniconda" verify_py312
;;
* )
{ echo
colorize 1 "ERROR"
echo ": The binary distribution of Miniconda is not available for $(anaconda_architecture 2>/dev/null || true)."
echo
} >&2
exit 1
;;
esac

View File

@ -0,0 +1,20 @@
export CONDA_PLUGINS_AUTO_ACCEPT_TOS=true
case "$(anaconda_architecture 2>/dev/null || true)" in
"Linux-aarch64" )
install_script "Miniconda3-py313_26.5.3-1-Linux-aarch64" "https://repo.anaconda.com/miniconda/Miniconda3-py313_26.5.3-1-Linux-aarch64.sh#4eaf1f2d83ede3ad010afa6ad19bef69893ca4667ba5996f51efb3080c08a70d" "miniconda" verify_py313
;;
"Linux-x86_64" )
install_script "Miniconda3-py313_26.5.3-1-Linux-x86_64" "https://repo.anaconda.com/miniconda/Miniconda3-py313_26.5.3-1-Linux-x86_64.sh#7358a5961dc6a4941d087281cd70313728fcc68695735e18a337321bc31c7f51" "miniconda" verify_py313
;;
"MacOSX-arm64" )
install_script "Miniconda3-py313_26.5.3-1-MacOSX-arm64" "https://repo.anaconda.com/miniconda/Miniconda3-py313_26.5.3-1-MacOSX-arm64.sh#c73b91d59c872f472d7a21dadaf0cc70dcff1fadf5bd98200bd15341be2bcbd0" "miniconda" verify_py313
;;
* )
{ echo
colorize 1 "ERROR"
echo ": The binary distribution of Miniconda is not available for $(anaconda_architecture 2>/dev/null || true)."
echo
} >&2
exit 1
;;
esac

View File

@ -0,0 +1,20 @@
export CONDA_PLUGINS_AUTO_ACCEPT_TOS=true
case "$(anaconda_architecture 2>/dev/null || true)" in
"Linux-aarch64" )
install_script "Miniconda3-py314_26.5.3-1-Linux-aarch64" "https://repo.anaconda.com/miniconda/Miniconda3-py314_26.5.3-1-Linux-aarch64.sh#781abf4cec9b842f5ed508e7c364ed9951380ba11187b80fe5d9c3dbf9ffccea" "miniconda" verify_py314
;;
"Linux-x86_64" )
install_script "Miniconda3-py314_26.5.3-1-Linux-x86_64" "https://repo.anaconda.com/miniconda/Miniconda3-py314_26.5.3-1-Linux-x86_64.sh#42cfece170da342364a78d629e06b94dfd81b0f2717d7655729100d888d606b4" "miniconda" verify_py314
;;
"MacOSX-arm64" )
install_script "Miniconda3-py314_26.5.3-1-MacOSX-arm64" "https://repo.anaconda.com/miniconda/Miniconda3-py314_26.5.3-1-MacOSX-arm64.sh#0cb1e1d43810d3118f7b6cd0095aff48dbde8312a19cb8c44e9a79c38bb48be3" "miniconda" verify_py314
;;
* )
{ echo
colorize 1 "ERROR"
echo ": The binary distribution of Miniconda is not available for $(anaconda_architecture 2>/dev/null || true)."
echo
} >&2
exit 1
;;
esac

View File

@ -0,0 +1,25 @@
case "$(anaconda_architecture 2>/dev/null || true)" in
"Linux-aarch64" )
install_script "Miniforge3-26.3.2-2-Linux-aarch64.sh" "https://github.com/conda-forge/miniforge/releases/download/26.3.2-2/Miniforge3-26.3.2-2-Linux-aarch64.sh#f4096a92482b30f04534cddb63d8bc929118318deffac71d90fb89dc52359d22" "miniconda" verify_py313
;;
"Linux-ppc64le" )
install_script "Miniforge3-26.3.2-2-Linux-ppc64le.sh" "https://github.com/conda-forge/miniforge/releases/download/26.3.2-2/Miniforge3-26.3.2-2-Linux-ppc64le.sh#8b3527cd4c70e6eb4a6c2c4c41de34c2480f0a5200de2ecb7fe385d186af0869" "miniconda" verify_py313
;;
"Linux-x86_64" )
install_script "Miniforge3-26.3.2-2-Linux-x86_64.sh" "https://github.com/conda-forge/miniforge/releases/download/26.3.2-2/Miniforge3-26.3.2-2-Linux-x86_64.sh#42260ffe3830fb953d5eee1bbb32229ff06aa7c3833c1ed7a9a0420a95685d94" "miniconda" verify_py313
;;
"MacOSX-arm64" )
install_script "Miniforge3-26.3.2-2-MacOSX-arm64.sh" "https://github.com/conda-forge/miniforge/releases/download/26.3.2-2/Miniforge3-26.3.2-2-MacOSX-arm64.sh#2657d94152343cff7c06159ac9fc09624d7879fa9575c5a0a324c571c4df0ade" "miniconda" verify_py313
;;
"MacOSX-x86_64" )
install_script "Miniforge3-26.3.2-2-MacOSX-x86_64.sh" "https://github.com/conda-forge/miniforge/releases/download/26.3.2-2/Miniforge3-26.3.2-2-MacOSX-x86_64.sh#a755192103de19bb2782685ac78820c2e00702e5f33e6e4f0a3bf3c214f45d69" "miniconda" verify_py313
;;
* )
{ echo
colorize 1 "ERROR"
echo ": The binary distribution of Miniforge is not available for $(anaconda_architecture 2>/dev/null || true)."
echo
} >&2
exit 1
;;
esac

View File

@ -0,0 +1,25 @@
case "$(anaconda_architecture 2>/dev/null || true)" in
"Linux-aarch64" )
install_script "Miniforge3-26.3.2-3-Linux-aarch64.sh" "https://github.com/conda-forge/miniforge/releases/download/26.3.2-3/Miniforge3-26.3.2-3-Linux-aarch64.sh#2c113a69297e612b01ca0f320c22a3107a11f2ab9b573d79ac868a175945ce29" "miniconda" verify_py313
;;
"Linux-ppc64le" )
install_script "Miniforge3-26.3.2-3-Linux-ppc64le.sh" "https://github.com/conda-forge/miniforge/releases/download/26.3.2-3/Miniforge3-26.3.2-3-Linux-ppc64le.sh#df7e80ee070ccc6031e2710eb4cf81ee0012264b587306b5aa3890d3d89edd97" "miniconda" verify_py313
;;
"Linux-x86_64" )
install_script "Miniforge3-26.3.2-3-Linux-x86_64.sh" "https://github.com/conda-forge/miniforge/releases/download/26.3.2-3/Miniforge3-26.3.2-3-Linux-x86_64.sh#848194851a98903134187fbb4ab50efe87b003e0c0f808f97644b7524a62bf2c" "miniconda" verify_py313
;;
"MacOSX-arm64" )
install_script "Miniforge3-26.3.2-3-MacOSX-arm64.sh" "https://github.com/conda-forge/miniforge/releases/download/26.3.2-3/Miniforge3-26.3.2-3-MacOSX-arm64.sh#59168f1e24d0a4ad9932021170809fca836cd240e183eeeb331d5bcfc0098168" "miniconda" verify_py313
;;
"MacOSX-x86_64" )
install_script "Miniforge3-26.3.2-3-MacOSX-x86_64.sh" "https://github.com/conda-forge/miniforge/releases/download/26.3.2-3/Miniforge3-26.3.2-3-MacOSX-x86_64.sh#39273e4c89a0a1af4538010615d44ae8f44e1af41007e02def593d20f316b003" "miniconda" verify_py313
;;
* )
{ echo
colorize 1 "ERROR"
echo ": The binary distribution of Miniforge is not available for $(anaconda_architecture 2>/dev/null || true)."
echo
} >&2
exit 1
;;
esac

View File

@ -31,6 +31,32 @@ stub_python_build() {
unstub python-build unstub python-build
} }
@test "install a single version under an alias" {
stub_python_build_lib
stub_python_build
run pyenv-install 3.4.2:3.4.2-custom
assert_success "python-build 3.4.2 ${PYENV_ROOT}/versions/3.4.2-custom"
unstub python-build
}
@test "install multiple versions, each under its own alias" {
stub_python_build_lib
stub_python_build
stub_python_build
run pyenv-install 3.4.1:custom1 3.4.2:custom2
assert_success
assert_output <<OUT
python-build 3.4.1 ${PYENV_ROOT}/versions/custom1
python-build 3.4.2 ${PYENV_ROOT}/versions/custom2
OUT
unstub python-build
unstub pyenv-latest
}
@test "install multiple versions" { @test "install multiple versions" {
stub_python_build_lib stub_python_build_lib
stub_python_build stub_python_build
@ -134,6 +160,22 @@ OUT
unstub python-build 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 <<OUT
2.6.9
2.7.9-rc1
2.7.9-rc2
3.4.2
OUT
unstub python-build
}
@test "upgrade instructions given for a nonexistent version" { @test "upgrade instructions given for a nonexistent version" {
stub brew false stub brew false
stub_python_build_lib stub_python_build_lib
@ -239,6 +281,7 @@ OUT
run pyenv-install --complete run pyenv-install --complete
assert_success assert_success
assert_output <<OUT assert_output <<OUT
--bare
--list --list
--force --force
--skip-existing --skip-existing

View File

@ -15,62 +15,25 @@ Under the hood, `pyenv` test suites use `bats` as a test framework and are run o
- `test` - `test`
- Run the whole test suite on the local host - Run the whole test suite on the local host
- `test-docker` - `test-docker`
- Run the whole test suite on docker - Run the whole test suite in Docker (in all environments)
- Some volumes are used in read-only mode
- `test-unit` - `test-unit`
- Run the unit test - Run core tests
- `test-plugin` - `test-python-build`
- Run the plugin test - Run Python-Build tests
- `test-unit-docker-[BASH_VERSION]` - `test-binary`
- Run the unit test under **official** bash docker container (alpine/busybox) with the specified bash version if present is in the `Makefile` - Run Pyenv-Binary tests
- Some volumes are used in read-only mode - `test-*-docker`
- `test-unit-docker-gnu-[BASH_VERSION]` - Run the corresponding test suite in Docker
- Run the unit test under **official** bash docker container (alpine/busybox), completed by **GNU Tools**, with the specified bash version if present is in the `Makefile` - `test-*-docker-[BASH_VERSION]`, `test-*-docker-gnu-[BASH_VERSION]`
- Some volumes are used in read-only mode - Run the corresponding test suite in the official Bash Docker container (alpine/busybox)
- `test-plugin-docker-[BASH_VERSION]` against either Busybox tools or GNU tools, with the specified Bash version
- Run the plugin test under **official** bash docker container (alpine/busybox), completed by **GNU Tools**, with the specified bash version if present is in the `Makefile` among those listed in the `Makefile`
- Some volumes are used in read-only mode
- `test-plugin-docker-gnu-[BASH_VERSION]`
- Run the plugin test under **official** bash docker container (alpine/busybox), completed by **GNU Tools**, with the specified bash version if present is in the `Makefile`
- Some volumes are used in read-only mode
## Targeting specific test / test file ## Targeting specific test / test file
By setting some environment variables, it is possible to filtering which test and/or test file who will be tested with bats By setting some environment variables, it is possible to filter which test and/or test file will be run
- `BATS_FILE_FILTER` - `BATS_FILE_FILTER`
- Run tests from the specified file
- Run test only with the specified file
- `BATS_TEST_FILTER` - `BATS_TEST_FILTER`
- Run test only who corresponding to the filter provided - Run tests with the names corresponding to the filter
### Examples
```bash
$ BATS_TEST_FILTER=".*installed.*" BATS_FILE_FILTER="build.bats" make test-plugin-docker-gnu-3.2.57
build.bats
✓ yaml is installed for python
✓ homebrew is used in Linux if Pyenv is installed with Homebrew
✓ homebrew is not used in Linux if Pyenv is not installed with Homebrew
3 tests, 0 failures
$ BATS_TEST_FILTER=".*installed.*" BATS_FILE_FILTER="build.bats" make test-plugin
build.bats
✓ yaml is installed for python
✓ homebrew is used in Linux if Pyenv is installed with Homebrew
✓ homebrew is not used in Linux if Pyenv is not installed with Homebrew
3 tests, 0 failures
```
## Writing test
To be reproducible, each test use/should use its own `TMPDIR` .
It's achieved by using the environment variable `BATS_TEST_TMPDIR` provided by bats that is automatically deleted at the end of each test. More info [here](https://bats-core.readthedocs.io/en/stable/writing-tests.html#special-variables)
Another variable who could be used to source some file who need to be tested is `BATS_TEST_DIRNAME` who point to the directory in which the bats test file is located.

View File

@ -16,6 +16,14 @@ load test_helper
assert_output "1.2.3" assert_output "1.2.3"
} }
@test "version with glob characters is handled correctly" {
cd "$BATS_TEST_TMPDIR"; touch 1.1
mkdir -p "$PYENV_ROOT"
echo "[1-9].?*" > "$PYENV_ROOT/version"
run pyenv-global
assert_success "[1-9].?*"
}
@test "set PYENV_ROOT/version" { @test "set PYENV_ROOT/version" {
mkdir -p "$PYENV_ROOT/versions/1.2.3" mkdir -p "$PYENV_ROOT/versions/1.2.3"
run pyenv-global "1.2.3" run pyenv-global "1.2.3"

View File

@ -19,6 +19,13 @@ _setup() {
assert_success "1.2.3" assert_success "1.2.3"
} }
@test "version with glob characters is handled correctly" {
cd "$BATS_TEST_TMPDIR"; touch 1.1
echo "[1-9].?*" > .python-version
run pyenv-local
assert_success "[1-9].?*"
}
@test "discovers version file in parent directory" { @test "discovers version file in parent directory" {
echo "1.2.3" > .python-version echo "1.2.3" > .python-version
mkdir -p "subdir" && cd "subdir" mkdir -p "subdir" && cd "subdir"

View File

@ -16,6 +16,12 @@ load test_helper
assert_failure "pyenv: version \`1.2.3' not installed" assert_failure "pyenv: version \`1.2.3' not installed"
} }
@test "version with glob characters is handled correctly" {
cd "$BATS_TEST_TMPDIR"; touch 1.1
PYENV_VERSION="[1-9].?*" run pyenv-prefix
assert_failure "pyenv: version \`[1-9].?*' not installed"
}
@test "prefix for system" { @test "prefix for system" {
mkdir -p "${PYENV_TEST_DIR}/bin" mkdir -p "${PYENV_TEST_DIR}/bin"
touch "${PYENV_TEST_DIR}/bin/python" touch "${PYENV_TEST_DIR}/bin/python"

View File

@ -35,7 +35,7 @@ setup() {
unset xdg_var unset xdg_var
# Workaround for Powershell. When tests are run from a terminal, # Workaround for Powershell. When tests are run from a terminal,
# and running a script fron a here-document, # and running a script from a here-document,
# Powershell 7.5.4 erroneously prints ANSI escape sequences # Powershell 7.5.4 erroneously prints ANSI escape sequences
# even if its output is redirected, breaking the comparison logic # even if its output is redirected, breaking the comparison logic
export NO_COLOR=1 export NO_COLOR=1

View File

@ -22,6 +22,14 @@ _setup() {
assert_success "system" assert_success "system"
} }
@test "version with glob characters is handled correctly" {
cd "$BATS_TEST_TMPDIR"; touch 1.1
PYENV_VERSION="[1-9].?*" run pyenv-version-name
assert_failure <<'!'
pyenv: version `[1-9].?*' is not installed (set by PYENV_VERSION environment variable)
!
}
@test "PYENV_VERSION can be overridden by hook" { @test "PYENV_VERSION can be overridden by hook" {
create_version "2.7.11" create_version "2.7.11"
create_version "3.5.1" create_version "3.5.1"

View File

@ -79,3 +79,12 @@ OUT
3.3.3 3.3.3
OUT OUT
} }
@test "version with glob characters is handled correctly" {
cd "$BATS_TEST_TMPDIR"; touch 1.1
PYENV_VERSION="[1-9].?*" run pyenv-version
assert_failure
assert_output <<OUT
pyenv: version \`[1-9].?*' is not installed (set by PYENV_VERSION environment variable)
OUT
}

View File

@ -63,6 +63,18 @@ OUT
assert_success "3.3" assert_success "3.3"
} }
@test "version with glob characters is handled correctly" {
stub_system_python
create_version "[1-9].?*"
cd "$BATS_TEST_TMPDIR"; touch 1.1
run pyenv-versions
assert_success
assert_output <<OUT
* system (set by ${PYENV_ROOT}/version)
[1-9].?*
OUT
}
@test "multiple versions and envs" { @test "multiple versions and envs" {
stub_system_python stub_system_python
create_version "2.7.6" create_version "2.7.6"

View File

@ -90,6 +90,17 @@ Note: See 'pyenv help global' for tips on allowing multiple
OUT OUT
} }
@test "version with glob characters is handled correctly" {
bats_require_minimum_version 1.5.0
cd "$BATS_TEST_TMPDIR"; touch 1.1
PATH="$(path_without foo)" PYENV_VERSION="[1-9].?*" run -127 pyenv-which foo
assert_failure
assert_output <<!
pyenv: version \`[1-9].?*' is not installed (set by PYENV_VERSION environment variable)
pyenv: foo: command not found
!
}
@test "no executable found" { @test "no executable found" {
bats_require_minimum_version 1.5.0 bats_require_minimum_version 1.5.0
create_alt_executable_in_version "2.7" "py.test" create_alt_executable_in_version "2.7" "py.test"
@ -181,7 +192,7 @@ exit
assert_success "version=3.4.2" assert_success "version=3.4.2"
} }
@test "skip advice supresses error messages" { @test "skip advice suppresses error messages" {
bats_require_minimum_version 1.5.0 bats_require_minimum_version 1.5.0
create_alt_executable_in_version "2.7" "python" create_alt_executable_in_version "2.7" "python"
create_alt_executable_in_version "3.3" "py.test" create_alt_executable_in_version "3.3" "py.test"