Replace Python bootstrapping script with Rust implementation (#2842)

See https://github.com/astral-sh/uv/issues/2617

Note this also includes:
- #2918 
- #2931 (pending)

A first step towards Python toolchain management in Rust.

First, we add a new crate to manage Python download metadata:

- Adds a new `uv-toolchain` crate
- Adds Rust structs for Python version download metadata
- Duplicates the script which downloads Python version metadata
- Adds a script to generate Rust code from the JSON metadata
- Adds a utility to download and extract the Python version

I explored some alternatives like a build script using things like
`serde` and `uneval` to automatically construct the code from our
structs but deemed it to heavy. Unlike Rye, I don't generate the Rust
directly from the web requests and have an intermediate JSON layer to
speed up iteration on the Rust types.

Next, we add add a `uv-dev` command `fetch-python` to download Python
versions per the bootstrapping script.

- Downloads a requested version or reads from `.python-versions`
- Extracts to `UV_BOOTSTRAP_DIR`
- Links executables for path extension

This command is not really intended to be user facing, but it's a good
PoC for the `uv-toolchain` API. Hash checking (via the sha256) isn't
implemented yet, we can do that in a follow-up.

Finally, we remove the `scripts/bootstrap` directory, update CI to use
the new command, and update the CONTRIBUTING docs.

<img width="1023" alt="Screenshot 2024-04-08 at 17 12 15"
src="57bd3cf1-7477-4bb8-a8e9-802a00d772cb">
This commit is contained in:
Zanie Blue 2024-04-10 11:22:41 -05:00 committed by GitHub
parent 7cd98d2499
commit 44e39bdca3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 8170 additions and 3703 deletions

View file

@ -72,33 +72,23 @@ jobs:
name: "cargo test | ${{ matrix.os }}" name: "cargo test | ${{ matrix.os }}"
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- if: ${{ matrix.os == 'macos' }}
name: "Install bootstrap dependencies"
run: brew install coreutils
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: "Install required Python versions"
run: |
python -m pip install "zstandard==0.22.0"
python scripts/bootstrap/install.py
- name: "Install Rust toolchain" - name: "Install Rust toolchain"
run: rustup show run: rustup show
- name: "Install cargo nextest"
uses: taiki-e/install-action@v2
with:
tool: cargo-nextest
- if: ${{ matrix.os != 'windows' }} - if: ${{ matrix.os != 'windows' }}
uses: rui314/setup-mold@v1 uses: rui314/setup-mold@v1
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
- name: "Install required Python versions"
run: |
cargo run -p uv-dev -- fetch-python
- name: "Install cargo nextest"
uses: taiki-e/install-action@v2
with:
tool: cargo-nextest
- name: "Cargo test" - name: "Cargo test"
run: | run: |
cargo nextest run --workspace --status-level skip --failure-output immediate-final --no-fail-fast -j 12 --final-status-level slow cargo nextest run --workspace --status-level skip --failure-output immediate-final --no-fail-fast -j 12 --final-status-level slow

View file

@ -22,12 +22,6 @@ CMake may be installed with Homebrew:
brew install cmake brew install cmake
``` ```
The Python bootstrapping script requires `coreutils` and `zstd`; we recommend installing them with Homebrew:
```shell
brew install coreutils zstd
```
See the [Python](#python) section for instructions on installing the Python versions. See the [Python](#python) section for instructions on installing the Python versions.
### Windows ### Windows
@ -45,13 +39,13 @@ Testing uv requires multiple specific Python versions. You can install them into
`<project root>/bin` via our bootstrapping script: `<project root>/bin` via our bootstrapping script:
```shell ```shell
pipx run scripts/bootstrap/install.py cargo run -p uv-dev -- fetch-python
``` ```
Alternatively, you can install `zstandard` from PyPI, then run: You may need to add the versions to your `PATH`:
```shell ```shell
python3.12 scripts/bootstrap/install.py source .env
``` ```
You can configure the bootstrapping directory with `UV_BOOTSTRAP_DIR`. You can configure the bootstrapping directory with `UV_BOOTSTRAP_DIR`.

32
Cargo.lock generated
View file

@ -4289,6 +4289,7 @@ dependencies = [
"uv-normalize", "uv-normalize",
"uv-requirements", "uv-requirements",
"uv-resolver", "uv-resolver",
"uv-toolchain",
"uv-types", "uv-types",
"uv-virtualenv", "uv-virtualenv",
"uv-warnings", "uv-warnings",
@ -4456,26 +4457,33 @@ dependencies = [
"pep508_rs", "pep508_rs",
"petgraph", "petgraph",
"poloto", "poloto",
"reqwest",
"resvg", "resvg",
"rustc-hash", "rustc-hash",
"serde", "serde",
"serde_json", "serde_json",
"tagu", "tagu",
"tempfile",
"tikv-jemallocator", "tikv-jemallocator",
"tokio", "tokio",
"tokio-util",
"tracing", "tracing",
"tracing-durations-export", "tracing-durations-export",
"tracing-indicatif", "tracing-indicatif",
"tracing-subscriber", "tracing-subscriber",
"url",
"uv-build", "uv-build",
"uv-cache", "uv-cache",
"uv-client", "uv-client",
"uv-configuration", "uv-configuration",
"uv-dispatch", "uv-dispatch",
"uv-extract",
"uv-fs",
"uv-installer", "uv-installer",
"uv-interpreter", "uv-interpreter",
"uv-normalize", "uv-normalize",
"uv-resolver", "uv-resolver",
"uv-toolchain",
"uv-types", "uv-types",
"walkdir", "walkdir",
] ]
@ -4662,6 +4670,7 @@ dependencies = [
"tracing", "tracing",
"uv-cache", "uv-cache",
"uv-fs", "uv-fs",
"uv-toolchain",
"which", "which",
"winapi", "winapi",
] ]
@ -4754,6 +4763,29 @@ dependencies = [
"uv-warnings", "uv-warnings",
] ]
[[package]]
name = "uv-toolchain"
version = "0.1.0"
dependencies = [
"anyhow",
"fs-err",
"futures",
"once_cell",
"pep440_rs",
"pep508_rs",
"reqwest",
"reqwest-middleware",
"tempfile",
"thiserror",
"tokio",
"tokio-util",
"tracing",
"url",
"uv-client",
"uv-extract",
"uv-fs",
]
[[package]] [[package]]
name = "uv-types" name = "uv-types"
version = "0.0.1" version = "0.0.1"

View file

@ -49,6 +49,7 @@ uv-trampoline = { path = "crates/uv-trampoline" }
uv-version = { path = "crates/uv-version" } uv-version = { path = "crates/uv-version" }
uv-virtualenv = { path = "crates/uv-virtualenv" } uv-virtualenv = { path = "crates/uv-virtualenv" }
uv-warnings = { path = "crates/uv-warnings" } uv-warnings = { path = "crates/uv-warnings" }
uv-toolchain = { path = "crates/uv-toolchain" }
anstream = { version = "0.6.13" } anstream = { version = "0.6.13" }
anyhow = { version = "1.0.80" } anyhow = { version = "1.0.80" }

View file

@ -23,13 +23,16 @@ pep508_rs = { workspace = true }
uv-build = { workspace = true } uv-build = { workspace = true }
uv-cache = { workspace = true, features = ["clap"] } uv-cache = { workspace = true, features = ["clap"] }
uv-client = { workspace = true } uv-client = { workspace = true }
uv-configuration = { workspace = true }
uv-dispatch = { workspace = true } uv-dispatch = { workspace = true }
uv-extract = { workspace = true }
uv-fs = { workspace = true }
uv-installer = { workspace = true } uv-installer = { workspace = true }
uv-interpreter = { workspace = true } uv-interpreter = { workspace = true }
uv-normalize = { workspace = true } uv-normalize = { workspace = true }
uv-resolver = { workspace = true } uv-resolver = { workspace = true }
uv-toolchain = { workspace = true }
uv-types = { workspace = true } uv-types = { workspace = true }
uv-configuration = { workspace = true }
# Any dependencies that are exclusively used in `uv-dev` should be listed as non-workspace # Any dependencies that are exclusively used in `uv-dev` should be listed as non-workspace
# dependencies, to ensure that we're forced to think twice before including them in other crates. # dependencies, to ensure that we're forced to think twice before including them in other crates.
@ -46,14 +49,18 @@ petgraph = { workspace = true }
poloto = { version = "19.1.2" } poloto = { version = "19.1.2" }
resvg = { version = "0.29.0" } resvg = { version = "0.29.0" }
rustc-hash = { workspace = true } rustc-hash = { workspace = true }
reqwest = { workspace = true }
tempfile = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
tagu = { version = "0.1.6" } tagu = { version = "0.1.6" }
tokio = { workspace = true } tokio = { workspace = true }
tokio-util = { workspace = true, features = ["compat"] }
tracing = { workspace = true } tracing = { workspace = true }
tracing-durations-export = { workspace = true, features = ["plot"] } tracing-durations-export = { workspace = true, features = ["plot"] }
tracing-indicatif = { workspace = true } tracing-indicatif = { workspace = true }
tracing-subscriber = { workspace = true } tracing-subscriber = { workspace = true }
url = { workspace = true }
walkdir = { workspace = true } walkdir = { workspace = true }
[target.'cfg(target_os = "windows")'.dependencies] [target.'cfg(target_os = "windows")'.dependencies]

View file

@ -0,0 +1,142 @@
use anyhow::Result;
use clap::Parser;
use fs_err as fs;
#[cfg(unix)]
use fs_err::tokio::symlink;
use futures::StreamExt;
#[cfg(unix)]
use itertools::Itertools;
use std::str::FromStr;
#[cfg(unix)]
use std::{collections::HashMap, path::PathBuf};
use tokio::time::Instant;
use tracing::{info, info_span, Instrument};
use uv_fs::Simplified;
use uv_toolchain::{
DownloadResult, Error, PythonDownload, PythonDownloadRequest, TOOLCHAIN_DIRECTORY,
};
#[derive(Parser, Debug)]
pub(crate) struct FetchPythonArgs {
versions: Vec<String>,
}
pub(crate) async fn fetch_python(args: FetchPythonArgs) -> Result<()> {
let start = Instant::now();
let bootstrap_dir = &*TOOLCHAIN_DIRECTORY;
fs_err::create_dir_all(bootstrap_dir)?;
let versions = if args.versions.is_empty() {
info!("Reading versions from file...");
read_versions_file().await?
} else {
args.versions
};
let requests = versions
.iter()
.map(|version| {
PythonDownloadRequest::from_str(version).and_then(PythonDownloadRequest::fill)
})
.collect::<Result<Vec<_>, Error>>()?;
let downloads = requests
.iter()
.map(|request| match PythonDownload::from_request(request) {
Some(download) => download,
None => panic!("No download found for request {request:?}"),
})
.collect::<Vec<_>>();
let client = uv_client::BaseClientBuilder::new().build();
info!("Fetching requested versions...");
let mut tasks = futures::stream::iter(downloads.iter())
.map(|download| {
async {
let result = download.fetch(&client, bootstrap_dir).await;
(download.python_version(), result)
}
.instrument(info_span!("download", key = %download))
})
.buffered(4);
let mut results = Vec::new();
let mut downloaded = 0;
while let Some(task) = tasks.next().await {
let (version, result) = task;
let path = match result? {
DownloadResult::AlreadyAvailable(path) => {
info!("Found existing download for v{}", version);
path
}
DownloadResult::Fetched(path) => {
info!("Downloaded v{} to {}", version, path.user_display());
downloaded += 1;
path
}
};
results.push((version, path));
}
if downloaded > 0 {
let s = if downloaded == 1 { "" } else { "s" };
info!(
"Fetched {} in {}s",
format!("{} version{}", downloaded, s),
start.elapsed().as_secs()
);
} else {
info!("All versions downloaded already.");
};
// Order matters here, as we overwrite previous links
info!("Installing to `{}`...", bootstrap_dir.user_display());
// On Windows, linking the executable generally results in broken installations
// and each toolchain path will need to be added to the PATH separately in the
// desired order
#[cfg(unix)]
{
let mut links: HashMap<PathBuf, PathBuf> = HashMap::new();
for (version, path) in results {
// TODO(zanieb): This path should be a part of the download metadata
let executable = path.join("install").join("bin").join("python3");
for target in [
bootstrap_dir.join(format!("python{}", version.python_full_version())),
bootstrap_dir.join(format!("python{}.{}", version.major(), version.minor())),
bootstrap_dir.join(format!("python{}", version.major())),
bootstrap_dir.join("python"),
] {
// Attempt to remove it, we'll fail on link if we couldn't remove it for some reason
// but if it's missing we don't want to error
let _ = fs::remove_file(&target);
symlink(&executable, &target).await?;
links.insert(target, executable.clone());
}
}
for (target, executable) in links.iter().sorted() {
info!(
"Linked `{}` to `{}`",
target.user_display(),
executable.user_display()
);
}
};
info!("Installed {} versions", requests.len());
Ok(())
}
async fn read_versions_file() -> Result<Vec<String>> {
let lines: Vec<String> = fs::tokio::read_to_string(".python-versions")
.await?
.lines()
.map(ToString::to_string)
.collect();
Ok(lines)
}

View file

@ -21,6 +21,7 @@ use resolve_many::ResolveManyArgs;
use crate::build::{build, BuildArgs}; use crate::build::{build, BuildArgs};
use crate::clear_compile::ClearCompileArgs; use crate::clear_compile::ClearCompileArgs;
use crate::compile::CompileArgs; use crate::compile::CompileArgs;
use crate::fetch_python::FetchPythonArgs;
use crate::render_benchmarks::RenderBenchmarksArgs; use crate::render_benchmarks::RenderBenchmarksArgs;
use crate::resolve_cli::ResolveCliArgs; use crate::resolve_cli::ResolveCliArgs;
use crate::wheel_metadata::WheelMetadataArgs; use crate::wheel_metadata::WheelMetadataArgs;
@ -44,6 +45,7 @@ static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
mod build; mod build;
mod clear_compile; mod clear_compile;
mod compile; mod compile;
mod fetch_python;
mod render_benchmarks; mod render_benchmarks;
mod resolve_cli; mod resolve_cli;
mod resolve_many; mod resolve_many;
@ -72,6 +74,8 @@ enum Cli {
Compile(CompileArgs), Compile(CompileArgs),
/// Remove all `.pyc` in the tree. /// Remove all `.pyc` in the tree.
ClearCompile(ClearCompileArgs), ClearCompile(ClearCompileArgs),
/// Fetch Python versions for testing
FetchPython(FetchPythonArgs),
} }
#[instrument] // Anchor span to check for overhead #[instrument] // Anchor span to check for overhead
@ -92,6 +96,7 @@ async fn run() -> Result<()> {
Cli::RenderBenchmarks(args) => render_benchmarks::render_benchmarks(&args)?, Cli::RenderBenchmarks(args) => render_benchmarks::render_benchmarks(&args)?,
Cli::Compile(args) => compile::compile(args).await?, Cli::Compile(args) => compile::compile(args).await?,
Cli::ClearCompile(args) => clear_compile::clear_compile(&args)?, Cli::ClearCompile(args) => clear_compile::clear_compile(&args)?,
Cli::FetchPython(args) => fetch_python::fetch_python(args).await?,
} }
Ok(()) Ok(())
} }

View file

@ -157,6 +157,7 @@ pub async fn untar_gz<R: tokio::io::AsyncRead + Unpin>(
) -> Result<(), Error> { ) -> Result<(), Error> {
let reader = tokio::io::BufReader::new(reader); let reader = tokio::io::BufReader::new(reader);
let decompressed_bytes = async_compression::tokio::bufread::GzipDecoder::new(reader); let decompressed_bytes = async_compression::tokio::bufread::GzipDecoder::new(reader);
let mut archive = tokio_tar::ArchiveBuilder::new(decompressed_bytes) let mut archive = tokio_tar::ArchiveBuilder::new(decompressed_bytes)
.set_preserve_mtime(false) .set_preserve_mtime(false)
.build(); .build();

View file

@ -21,6 +21,7 @@ platform-tags = { workspace = true }
pypi-types = { workspace = true } pypi-types = { workspace = true }
uv-cache = { workspace = true } uv-cache = { workspace = true }
uv-fs = { workspace = true } uv-fs = { workspace = true }
uv-toolchain = { workspace = true }
configparser = { workspace = true } configparser = { workspace = true }
fs-err = { workspace = true, features = ["tokio"] } fs-err = { workspace = true, features = ["tokio"] }

View file

@ -7,10 +7,11 @@ use tracing::{debug, instrument};
use uv_cache::Cache; use uv_cache::Cache;
use uv_fs::normalize_path; use uv_fs::normalize_path;
use uv_toolchain::PythonVersion;
use crate::interpreter::InterpreterInfoError; use crate::interpreter::InterpreterInfoError;
use crate::python_environment::{detect_python_executable, detect_virtual_env}; use crate::python_environment::{detect_python_executable, detect_virtual_env};
use crate::{Error, Interpreter, PythonVersion}; use crate::{Error, Interpreter};
/// Find a Python of a specific version, a binary with a name or a path to a binary. /// Find a Python of a specific version, a binary with a name or a path to a binary.
/// ///
@ -464,7 +465,7 @@ fn find_version(
let version_matches = |interpreter: &Interpreter| -> bool { let version_matches = |interpreter: &Interpreter| -> bool {
if let Some(python_version) = python_version { if let Some(python_version) = python_version {
// If a patch version was provided, check for an exact match // If a patch version was provided, check for an exact match
python_version.is_satisfied_by(interpreter) interpreter.satisfies(python_version)
} else { } else {
// The version always matches if one was not provided // The version always matches if one was not provided
true true

View file

@ -16,6 +16,7 @@ use platform_tags::{Tags, TagsError};
use pypi_types::Scheme; use pypi_types::Scheme;
use uv_cache::{Cache, CacheBucket, CachedByTimestamp, Freshness, Timestamp}; use uv_cache::{Cache, CacheBucket, CachedByTimestamp, Freshness, Timestamp};
use uv_fs::{write_atomic_sync, PythonExt, Simplified}; use uv_fs::{write_atomic_sync, PythonExt, Simplified};
use uv_toolchain::PythonVersion;
use crate::Error; use crate::Error;
use crate::Virtualenv; use crate::Virtualenv;
@ -314,6 +315,18 @@ impl Interpreter {
}, },
} }
} }
/// Check if the interpreter matches the given Python version.
///
/// If a patch version is present, we will require an exact match.
/// Otherwise, just the major and minor version numbers need to match.
pub fn satisfies(&self, version: &PythonVersion) -> bool {
if version.patch().is_some() {
version.version() == self.python_version()
} else {
(version.major(), version.minor()) == self.python_tuple()
}
}
} }
/// The `EXTERNALLY-MANAGED` file in a Python installation. /// The `EXTERNALLY-MANAGED` file in a Python installation.

View file

@ -19,14 +19,12 @@ pub use crate::find_python::{find_best_python, find_default_python, find_request
pub use crate::interpreter::Interpreter; pub use crate::interpreter::Interpreter;
use crate::interpreter::InterpreterInfoError; use crate::interpreter::InterpreterInfoError;
pub use crate::python_environment::PythonEnvironment; pub use crate::python_environment::PythonEnvironment;
pub use crate::python_version::PythonVersion;
pub use crate::virtualenv::Virtualenv; pub use crate::virtualenv::Virtualenv;
mod cfg; mod cfg;
mod find_python; mod find_python;
mod interpreter; mod interpreter;
mod python_environment; mod python_environment;
mod python_version;
mod virtualenv; mod virtualenv;
#[derive(Debug, Error)] #[derive(Debug, Error)]

View file

@ -0,0 +1,33 @@
[package]
name = "uv-toolchain"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
homepage.workspace = true
documentation.workspace = true
repository.workspace = true
authors.workspace = true
license.workspace = true
[dependencies]
uv-client = { workspace = true }
uv-extract = { workspace = true }
uv-fs = { workspace = true }
pep440_rs = { workspace = true }
pep508_rs = { workspace = true }
anyhow = { workspace = true }
fs-err = { workspace = true }
futures = { workspace = true }
once_cell = {workspace = true}
reqwest = { workspace = true }
reqwest-middleware = { workspace = true }
tempfile = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tokio-util = { workspace = true, features = ["compat"] }
tracing = { workspace = true }
url = { workspace = true }
[lints]
workspace = true

View file

@ -2,7 +2,7 @@
""" """
Fetch Python version metadata. Fetch Python version metadata.
Generates the bootstrap `versions.json` file. Generates the `python-version-metadata.json` file.
Usage: Usage:
@ -30,7 +30,7 @@ RELEASE_URL = "https://api.github.com/repos/indygreg/python-build-standalone/rel
HEADERS = { HEADERS = {
"X-GitHub-Api-Version": "2022-11-28", "X-GitHub-Api-Version": "2022-11-28",
} }
VERSIONS_FILE = SELF_DIR / "versions.json" VERSIONS_FILE = SELF_DIR / "python-version-metadata.json"
FLAVOR_PREFERENCES = [ FLAVOR_PREFERENCES = [
"shared-pgo", "shared-pgo",
"shared-noopt", "shared-noopt",
@ -53,6 +53,8 @@ SPECIAL_TRIPLES = {
"linux64": "x86_64-unknown-linux-gnu", "linux64": "x86_64-unknown-linux-gnu",
"windows-amd64": "x86_64-pc-windows", "windows-amd64": "x86_64-pc-windows",
"windows-x86": "i686-pc-windows", "windows-x86": "i686-pc-windows",
"windows-amd64-shared": "x86_64-pc-windows",
"windows-x86-shared": "i686-pc-windows",
"linux64-musl": "x86_64-unknown-linux-musl", "linux64-musl": "x86_64-unknown-linux-musl",
} }
@ -78,8 +80,14 @@ _suffix_re = re.compile(
) )
) )
# to match the output of the `arch` command # Normalized mappings to match the Rust types
ARCH_MAP = {"aarch64": "arm64"} ARCH_MAP = {
"ppc64": "powerpc64",
"ppc64le": "powerpc64le",
"i686": "x86",
"i386": "x86",
}
OS_MAP = {"darwin": "macos"}
def parse_filename(filename): def parse_filename(filename):
@ -104,10 +112,8 @@ def normalize_triple(triple):
triple = SPECIAL_TRIPLES.get(triple, triple) triple = SPECIAL_TRIPLES.get(triple, triple)
pieces = triple.split("-") pieces = triple.split("-")
try: try:
arch = pieces[0] arch = normalize_arch(pieces[0])
# Normalize operating_system = normalize_os(pieces[2])
arch = ARCH_MAP.get(arch, arch)
platform = pieces[2]
if pieces[2] == "linux": if pieces[2] == "linux":
# On linux, the triple has four segments, the last one is the libc # On linux, the triple has four segments, the last one is the libc
libc = pieces[3] libc = pieces[3]
@ -116,7 +122,18 @@ def normalize_triple(triple):
except IndexError: except IndexError:
logging.debug("Skipping %r: unknown triple", triple) logging.debug("Skipping %r: unknown triple", triple)
return return
return "%s-%s-%s" % (arch, platform, libc) return "%s-%s-%s" % (arch, operating_system, libc)
def normalize_arch(arch):
arch = ARCH_MAP.get(arch, arch)
pieces = arch.split("_")
# Strip `_vN` from `x86_64`
return "_".join(pieces[:2])
def normalize_os(os):
return OS_MAP.get(os, os)
def read_sha256(url): def read_sha256(url):
@ -125,7 +142,7 @@ def read_sha256(url):
except urllib.error.HTTPError: except urllib.error.HTTPError:
return None return None
assert resp.status == 200 assert resp.status == 200
return resp.read().strip() return resp.read().decode().strip()
def sha256(path): def sha256(path):
@ -142,8 +159,8 @@ def sha256(path):
return h.hexdigest() return h.hexdigest()
def _sort_key(info): def _sort_by_flavor_preference(info):
triple, flavor, url = info _triple, flavor, _url = info
try: try:
pref = FLAVOR_PREFERENCES.index(flavor) pref = FLAVOR_PREFERENCES.index(flavor)
except ValueError: except ValueError:
@ -151,12 +168,18 @@ def _sort_key(info):
return pref return pref
def _sort_by_interpreter_and_version(info):
interpreter, version_tuple, _ = info
return (interpreter, version_tuple)
def find(): def find():
""" """
Find available Python versions and write metadata to a file. Find available Python versions and write metadata to a file.
""" """
results = {} results = {}
# Collect all available Python downloads
for page in range(1, 100): for page in range(1, 100):
logging.debug("Reading release page %s...", page) logging.debug("Reading release page %s...", page)
resp = urllib.request.urlopen("%s?page=%d" % (RELEASE_URL, page)) resp = urllib.request.urlopen("%s?page=%d" % (RELEASE_URL, page))
@ -180,34 +203,47 @@ def find():
continue continue
results.setdefault(py_ver, []).append((triple, flavor, url)) results.setdefault(py_ver, []).append((triple, flavor, url))
cpython_results = {} # Collapse CPython variants to a single URL flavor per triple
cpython_results: dict[tuple[int, int, int], dict[tuple[str, str, str], str]] = {}
for py_ver, choices in results.items(): for py_ver, choices in results.items():
choices.sort(key=_sort_key)
urls = {} urls = {}
for triple, flavor, url in choices: for triple, flavor, url in sorted(choices, key=_sort_by_flavor_preference):
triple = tuple(triple.split("-")) triple = tuple(triple.split("-"))
# Skip existing triples, preferring the first flavor
if triple in urls: if triple in urls:
continue continue
urls[triple] = url urls[triple] = url
cpython_results[tuple(map(int, py_ver.split(".")))] = urls cpython_results[tuple(map(int, py_ver.split(".")))] = urls
# Collect variants across interpreter kinds
# TODO(zanieb): Note we only support CPython downloads at this time
# but this will include PyPy chain in the future.
final_results = {} final_results = {}
for interpreter, py_ver, choices in sorted( for interpreter, py_ver, choices in sorted(
chain( chain(
(("cpython",) + x for x in cpython_results.items()), (("cpython",) + x for x in cpython_results.items()),
), ),
key=lambda x: x[:2], key=_sort_by_interpreter_and_version,
# Reverse the ordering so newer versions are first
reverse=True, reverse=True,
): ):
for (arch, platform, libc), url in sorted(choices.items()): # Sort by the remaining information for determinism
key = "%s-%s.%s.%s-%s-%s-%s" % (interpreter, *py_ver, platform, arch, libc) # This groups download metadata in triple component order
for (arch, operating_system, libc), url in sorted(choices.items()):
key = "%s-%s.%s.%s-%s-%s-%s" % (
interpreter,
*py_ver,
operating_system,
arch,
libc,
)
logging.info("Found %s", key) logging.info("Found %s", key)
sha256 = read_sha256(url) sha256 = read_sha256(url)
final_results[key] = { final_results[key] = {
"name": interpreter, "name": interpreter,
"arch": arch, "arch": arch,
"os": platform, "os": operating_system,
"libc": libc, "libc": libc,
"major": py_ver[0], "major": py_ver[0],
"minor": py_ver[1], "minor": py_ver[1],

View file

@ -0,0 +1,438 @@
use std::fmt::{self, Display};
use std::io;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use crate::PythonVersion;
use thiserror::Error;
use uv_client::BetterReqwestError;
use futures::TryStreamExt;
use tokio_util::compat::FuturesAsyncReadCompatExt;
use tracing::debug;
use url::Url;
use uv_fs::Simplified;
#[derive(Error, Debug)]
pub enum Error {
#[error("operating system not supported: {0}")]
OsNotSupported(String),
#[error("architecture not supported: {0}")]
ArchNotSupported(String),
#[error("libc type could not be detected")]
LibcNotDetected(),
#[error("invalid python version: {0}")]
InvalidPythonVersion(String),
#[error("download failed")]
NetworkError(#[from] BetterReqwestError),
#[error("download failed")]
NetworkMiddlewareError(#[source] anyhow::Error),
#[error(transparent)]
ExtractError(#[from] uv_extract::Error),
#[error("invalid download url")]
InvalidUrl(#[from] url::ParseError),
#[error("failed to create download directory")]
DownloadDirError(#[source] io::Error),
#[error("failed to copy to: {0}", to.user_display())]
CopyError {
to: PathBuf,
#[source]
err: io::Error,
},
#[error("failed to read toolchain directory: {0}", dir.user_display())]
ReadError {
dir: PathBuf,
#[source]
err: io::Error,
},
}
#[derive(Debug, PartialEq)]
pub struct PythonDownload {
key: &'static str,
implementation: ImplementationName,
arch: Arch,
os: Os,
libc: Libc,
major: u8,
minor: u8,
patch: u8,
url: &'static str,
sha256: Option<&'static str>,
}
#[derive(Debug)]
pub struct PythonDownloadRequest {
version: Option<PythonVersion>,
implementation: Option<ImplementationName>,
arch: Option<Arch>,
os: Option<Os>,
libc: Option<Libc>,
}
impl PythonDownloadRequest {
pub fn new(
version: Option<PythonVersion>,
implementation: Option<ImplementationName>,
arch: Option<Arch>,
os: Option<Os>,
libc: Option<Libc>,
) -> Self {
Self {
version,
implementation,
arch,
os,
libc,
}
}
#[must_use]
pub fn with_implementation(mut self, implementation: ImplementationName) -> Self {
self.implementation = Some(implementation);
self
}
#[must_use]
pub fn with_arch(mut self, arch: Arch) -> Self {
self.arch = Some(arch);
self
}
#[must_use]
pub fn with_os(mut self, os: Os) -> Self {
self.os = Some(os);
self
}
#[must_use]
pub fn with_libc(mut self, libc: Libc) -> Self {
self.libc = Some(libc);
self
}
pub fn fill(mut self) -> Result<Self, Error> {
if self.implementation.is_none() {
self.implementation = Some(ImplementationName::Cpython);
}
if self.arch.is_none() {
self.arch = Some(Arch::from_env()?);
}
if self.os.is_none() {
self.os = Some(Os::from_env()?);
}
if self.libc.is_none() {
self.libc = Some(Libc::from_env()?);
}
Ok(self)
}
}
impl FromStr for PythonDownloadRequest {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
// TOOD(zanieb): Implement parsing of additional request parts
let version = PythonVersion::from_str(s).map_err(Error::InvalidPythonVersion)?;
Ok(Self::new(Some(version), None, None, None, None))
}
}
#[derive(Debug, PartialEq)]
pub enum Libc {
Gnu,
Musl,
None,
}
#[derive(Debug, PartialEq)]
pub enum ImplementationName {
Cpython,
}
#[derive(Debug, PartialEq)]
pub struct Platform {
os: Os,
arch: Arch,
libc: Libc,
}
include!("python_versions.inc");
pub enum DownloadResult {
AlreadyAvailable(PathBuf),
Fetched(PathBuf),
}
impl PythonDownload {
/// Return the [`PythonDownload`] corresponding to the key, if it exists.
pub fn from_key(key: &str) -> Option<&PythonDownload> {
PYTHON_DOWNLOADS.iter().find(|&value| value.key == key)
}
pub fn from_request(request: &PythonDownloadRequest) -> Option<&'static PythonDownload> {
for download in PYTHON_DOWNLOADS {
if let Some(arch) = &request.arch {
if download.arch != *arch {
continue;
}
}
if let Some(os) = &request.os {
if download.os != *os {
continue;
}
}
if let Some(implementation) = &request.implementation {
if download.implementation != *implementation {
continue;
}
}
if let Some(version) = &request.version {
if download.major != version.major() {
continue;
}
if download.minor != version.minor() {
continue;
}
if let Some(patch) = version.patch() {
if download.patch != patch {
continue;
}
}
}
return Some(download);
}
None
}
pub fn url(&self) -> &str {
self.url
}
pub fn sha256(&self) -> Option<&str> {
self.sha256
}
/// Download and extract
pub async fn fetch(
&self,
client: &uv_client::BaseClient,
path: &Path,
) -> Result<DownloadResult, Error> {
let url = Url::parse(self.url)?;
let path = path.join(self.key).clone();
// If it already exists, return it
if path.is_dir() {
return Ok(DownloadResult::AlreadyAvailable(path));
}
let filename = url.path_segments().unwrap().last().unwrap();
let response = client.get(url.clone()).send().await?;
// Ensure the request was successful.
response.error_for_status_ref()?;
// Download and extract into a temporary directory.
let temp_dir = tempfile::tempdir().map_err(Error::DownloadDirError)?;
debug!(
"Downloading {url} to temporary location {}",
temp_dir.path().display()
);
let reader = response
.bytes_stream()
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))
.into_async_read();
debug!("Extracting {filename}");
uv_extract::stream::archive(reader.compat(), filename, temp_dir.path()).await?;
// Extract the top-level directory.
let extracted = match uv_extract::strip_component(temp_dir.path()) {
Ok(top_level) => top_level,
Err(uv_extract::Error::NonSingularArchive(_)) => temp_dir.into_path(),
Err(err) => return Err(err.into()),
};
// Persist it to the target
debug!("Moving {} to {}", extracted.display(), path.user_display());
fs_err::tokio::rename(extracted, &path)
.await
.map_err(|err| Error::CopyError {
to: path.clone(),
err,
})?;
Ok(DownloadResult::Fetched(path))
}
pub fn python_version(&self) -> PythonVersion {
PythonVersion::from_str(&format!("{}.{}.{}", self.major, self.minor, self.patch))
.expect("Python downloads should always have valid versions")
}
}
impl Platform {
pub fn new(os: Os, arch: Arch, libc: Libc) -> Self {
Self { os, arch, libc }
}
pub fn from_env() -> Result<Self, Error> {
Ok(Self::new(
Os::from_env()?,
Arch::from_env()?,
Libc::from_env()?,
))
}
}
/// All supported operating systems.
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum Os {
Windows,
Linux,
Macos,
FreeBsd,
NetBsd,
OpenBsd,
Dragonfly,
Illumos,
Haiku,
}
impl fmt::Display for Os {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
Self::Windows => write!(f, "Windows"),
Self::Macos => write!(f, "MacOS"),
Self::FreeBsd => write!(f, "FreeBSD"),
Self::NetBsd => write!(f, "NetBSD"),
Self::Linux => write!(f, "Linux"),
Self::OpenBsd => write!(f, "OpenBSD"),
Self::Dragonfly => write!(f, "DragonFly"),
Self::Illumos => write!(f, "Illumos"),
Self::Haiku => write!(f, "Haiku"),
}
}
}
impl Os {
pub(crate) fn from_env() -> Result<Self, Error> {
Self::from_str(std::env::consts::OS)
}
}
impl FromStr for Os {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"windows" => Ok(Self::Windows),
"linux" => Ok(Self::Linux),
"macos" => Ok(Self::Macos),
"freebsd" => Ok(Self::FreeBsd),
"netbsd" => Ok(Self::NetBsd),
"openbsd" => Ok(Self::OpenBsd),
"dragonfly" => Ok(Self::Dragonfly),
"illumos" => Ok(Self::Illumos),
"haiku" => Ok(Self::Haiku),
_ => Err(Error::OsNotSupported(s.to_string())),
}
}
}
/// All supported CPU architectures
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum Arch {
Aarch64,
Armv6L,
Armv7L,
Powerpc64Le,
Powerpc64,
X86,
X86_64,
S390X,
}
impl fmt::Display for Arch {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
Self::Aarch64 => write!(f, "aarch64"),
Self::Armv6L => write!(f, "armv6l"),
Self::Armv7L => write!(f, "armv7l"),
Self::Powerpc64Le => write!(f, "ppc64le"),
Self::Powerpc64 => write!(f, "ppc64"),
Self::X86 => write!(f, "i686"),
Self::X86_64 => write!(f, "x86_64"),
Self::S390X => write!(f, "s390x"),
}
}
}
impl FromStr for Arch {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"aarch64" | "arm64" => Ok(Self::Aarch64),
"armv6l" => Ok(Self::Armv6L),
"armv7l" => Ok(Self::Armv7L),
"powerpc64le" | "ppc64le" => Ok(Self::Powerpc64Le),
"powerpc64" | "ppc64" => Ok(Self::Powerpc64),
"x86" | "i686" | "i386" => Ok(Self::X86),
"x86_64" | "amd64" => Ok(Self::X86_64),
"s390x" => Ok(Self::S390X),
_ => Err(Error::ArchNotSupported(s.to_string())),
}
}
}
impl Arch {
pub(crate) fn from_env() -> Result<Self, Error> {
Self::from_str(std::env::consts::ARCH)
}
}
impl Libc {
pub(crate) fn from_env() -> Result<Self, Error> {
// TODO(zanieb): Perform this lookup
match std::env::consts::OS {
"linux" => Ok(Libc::Gnu),
"windows" | "macos" => Ok(Libc::None),
_ => Err(Error::LibcNotDetected()),
}
}
}
impl fmt::Display for Libc {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Libc::Gnu => f.write_str("gnu"),
Libc::None => f.write_str("none"),
Libc::Musl => f.write_str("musl"),
}
}
}
impl From<reqwest::Error> for Error {
fn from(error: reqwest::Error) -> Self {
Self::NetworkError(BetterReqwestError::from(error))
}
}
impl From<reqwest_middleware::Error> for Error {
fn from(error: reqwest_middleware::Error) -> Self {
match error {
reqwest_middleware::Error::Middleware(error) => Self::NetworkMiddlewareError(error),
reqwest_middleware::Error::Reqwest(error) => {
Self::NetworkError(BetterReqwestError::from(error))
}
}
}
}
impl Display for PythonDownload {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.key)
}
}

View file

@ -0,0 +1,110 @@
use std::collections::BTreeSet;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use crate::downloads::{Arch, Error, Libc, Os};
use crate::python_version::PythonVersion;
use once_cell::sync::Lazy;
/// The directory where Python toolchains we install are stored.
pub static TOOLCHAIN_DIRECTORY: Lazy<PathBuf> = Lazy::new(|| {
std::env::var_os("UV_BOOTSTRAP_DIR").map_or(
Path::new(&std::env::var("CARGO_MANIFEST_DIR").unwrap())
.parent()
.expect("CARGO_MANIFEST_DIR should be nested in workspace")
.parent()
.expect("CARGO_MANIFEST_DIR should be doubly nested in workspace")
.join("bin"),
PathBuf::from,
)
});
/// An installed Python toolchain.
#[derive(Debug, Clone)]
pub struct Toolchain {
/// The path to the top-level directory of the installed toolchain.
path: PathBuf,
}
impl Toolchain {
pub fn executable(&self) -> PathBuf {
if cfg!(windows) {
self.path.join("install").join("python.exe")
} else if cfg!(unix) {
self.path.join("install").join("bin").join("python3")
} else {
unimplemented!("Only Windows and Unix systems are supported.")
}
}
}
/// Return the toolchains that satisfy the given Python version on this platform.
///
/// ## Errors
///
/// - The platform metadata cannot be read
/// - A directory in the toolchain directory cannot be read
pub fn toolchains_for_version(version: &PythonVersion) -> Result<Vec<Toolchain>, Error> {
let platform_key = platform_key_from_env()?;
// TODO(zanieb): Consider returning an iterator instead of a `Vec`
// Note we need to collect paths regardless for sorting by version.
let toolchain_dirs = match fs_err::read_dir(TOOLCHAIN_DIRECTORY.to_path_buf()) {
Ok(toolchain_dirs) => {
// Collect sorted directory paths; `read_dir` is not stable across platforms
let directories: BTreeSet<_> = toolchain_dirs
.filter_map(|read_dir| match read_dir {
Ok(entry) => match entry.file_type() {
Ok(file_type) => file_type.is_dir().then_some(Ok(entry.path())),
Err(err) => Some(Err(err)),
},
Err(err) => Some(Err(err)),
})
.collect::<Result<_, std::io::Error>>()
.map_err(|err| Error::ReadError {
dir: TOOLCHAIN_DIRECTORY.to_path_buf(),
err,
})?;
directories
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok(Vec::new());
}
Err(err) => {
return Err(Error::ReadError {
dir: TOOLCHAIN_DIRECTORY.to_path_buf(),
err,
})
}
};
Ok(toolchain_dirs
.into_iter()
// Sort "newer" versions of Python first
.rev()
.filter_map(|path| {
if path
.file_name()
.map(OsStr::to_string_lossy)
.is_some_and(|filename| {
filename.starts_with(&format!("cpython-{version}"))
&& filename.ends_with(&platform_key)
})
{
Some(Toolchain { path })
} else {
None
}
})
.collect::<Vec<_>>())
}
/// Generate a platform portion of a key from the environment.
fn platform_key_from_env() -> Result<String, Error> {
let os = Os::from_env()?;
let arch = Arch::from_env()?;
let libc = Libc::from_env()?;
Ok(format!("{os}-{arch}-{libc}").to_lowercase())
}

View file

@ -0,0 +1,9 @@
pub use crate::downloads::{
DownloadResult, Error, Platform, PythonDownload, PythonDownloadRequest,
};
pub use crate::find::{toolchains_for_version, Toolchain, TOOLCHAIN_DIRECTORY};
pub use crate::python_version::PythonVersion;
mod downloads;
mod find;
mod python_version;

View file

@ -5,8 +5,6 @@ use std::str::FromStr;
use pep440_rs::Version; use pep440_rs::Version;
use pep508_rs::{MarkerEnvironment, StringVersion}; use pep508_rs::{MarkerEnvironment, StringVersion};
use crate::Interpreter;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct PythonVersion(StringVersion); pub struct PythonVersion(StringVersion);
@ -142,18 +140,6 @@ impl PythonVersion {
Self::from_str(format!("{}.{}", self.major(), self.minor()).as_str()) Self::from_str(format!("{}.{}", self.major(), self.minor()).as_str())
.expect("dropping a patch should always be valid") .expect("dropping a patch should always be valid")
} }
/// Check if this Python version is satisfied by the given interpreter.
///
/// If a patch version is present, we will require an exact match.
/// Otherwise, just the major and minor version numbers need to match.
pub fn is_satisfied_by(&self, interpreter: &Interpreter) -> bool {
if self.patch().is_some() {
self.version() == interpreter.python_version()
} else {
(self.major(), self.minor()) == interpreter.python_tuple()
}
}
} }
#[cfg(test)] #[cfg(test)]

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,26 @@
// DO NOT EDIT
//
// Generated with `{{generated_with}}`
// From template at `{{generated_from}}`
pub(crate) const PYTHON_DOWNLOADS: &[PythonDownload] = &[
{{#versions}}
PythonDownload {
key: "{{key}}",
major: {{value.major}},
minor: {{value.minor}},
patch: {{value.patch}},
implementation: ImplementationName::{{value.name}},
arch: Arch::{{value.arch}},
os: Os::{{value.os}},
libc: Libc::{{value.libc}},
url: "{{value.url}}",
{{#value.sha256}}
sha256: Some("{{.}}")
{{/value.sha256}}
{{^value.sha256}}
sha256: None
{{/value.sha256}}
},
{{/versions}}
];

View file

@ -0,0 +1,99 @@
#!/usr/bin/env python3.12
"""
Generate static Rust code from Python version metadata.
Generates the `python_versions.rs` file from the `python_versions.rs.mustache` template.
Usage:
python template-version-metadata.py
"""
import sys
import logging
import argparse
import json
import subprocess
from pathlib import Path
CRATE_ROOT = Path(__file__).parent
WORKSPACE_ROOT = CRATE_ROOT.parent.parent
VERSION_METADATA = CRATE_ROOT / "python-version-metadata.json"
TEMPLATE = CRATE_ROOT / "src" / "python_versions.inc.mustache"
TARGET = TEMPLATE.with_suffix("")
try:
import chevron_blue
except ImportError:
print(
"missing requirement `chevron-blue`",
file=sys.stderr,
)
exit(1)
def prepare_value(value: dict) -> dict:
# Convert fields from snake case to camel case for enums
for key in ["arch", "os", "libc", "name"]:
value[key] = value[key].title()
return value
def main():
debug = logging.getLogger().getEffectiveLevel() <= logging.DEBUG
data = {}
data["generated_with"] = Path(__file__).relative_to(WORKSPACE_ROOT)
data["generated_from"] = TEMPLATE.relative_to(WORKSPACE_ROOT)
data["versions"] = [
{"key": key, "value": prepare_value(value)}
for key, value in json.loads(VERSION_METADATA.read_text()).items()
]
# Render the template
logging.info(f"Rendering `{TEMPLATE.name}`...")
output = chevron_blue.render(
template=TEMPLATE.read_text(), data=data, no_escape=True, warn=debug
)
# Update the file
logging.info(f"Updating `{TARGET}`...")
TARGET.write_text(output)
subprocess.check_call(
["rustfmt", str(TARGET)],
stderr=subprocess.STDOUT,
stdout=sys.stderr if debug else subprocess.DEVNULL,
)
logging.info("Done!")
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Generates Rust code for Python version metadata.",
)
parser.add_argument(
"-v",
"--verbose",
action="store_true",
help="Enable debug logging",
)
parser.add_argument(
"-q",
"--quiet",
action="store_true",
help="Disable logging",
)
args = parser.parse_args()
if args.quiet:
log_level = logging.CRITICAL
elif args.verbose:
log_level = logging.DEBUG
else:
log_level = logging.INFO
logging.basicConfig(level=log_level, format="%(message)s")
main()

View file

@ -34,6 +34,7 @@ uv-resolver = { workspace = true, features = ["clap"] }
uv-types = { workspace = true, features = ["clap"] } uv-types = { workspace = true, features = ["clap"] }
uv-configuration = { workspace = true, features = ["clap"] } uv-configuration = { workspace = true, features = ["clap"] }
uv-virtualenv = { workspace = true } uv-virtualenv = { workspace = true }
uv-toolchain = { workspace = true }
uv-warnings = { workspace = true } uv-warnings = { workspace = true }
anstream = { workspace = true } anstream = { workspace = true }

View file

@ -29,7 +29,7 @@ use uv_configuration::{
use uv_dispatch::BuildDispatch; use uv_dispatch::BuildDispatch;
use uv_fs::Simplified; use uv_fs::Simplified;
use uv_installer::Downloader; use uv_installer::Downloader;
use uv_interpreter::{find_best_python, PythonEnvironment, PythonVersion}; use uv_interpreter::{find_best_python, PythonEnvironment};
use uv_normalize::{ExtraName, PackageName}; use uv_normalize::{ExtraName, PackageName};
use uv_requirements::{ use uv_requirements::{
upgrade::read_lockfile, ExtrasSpecification, LookaheadResolver, NamedRequirementsResolver, upgrade::read_lockfile, ExtrasSpecification, LookaheadResolver, NamedRequirementsResolver,
@ -39,6 +39,7 @@ use uv_resolver::{
AnnotationStyle, DependencyMode, DisplayResolutionGraph, Exclusions, InMemoryIndex, Manifest, AnnotationStyle, DependencyMode, DisplayResolutionGraph, Exclusions, InMemoryIndex, Manifest,
OptionsBuilder, PreReleaseMode, PythonRequirement, ResolutionMode, Resolver, OptionsBuilder, PreReleaseMode, PythonRequirement, ResolutionMode, Resolver,
}; };
use uv_toolchain::PythonVersion;
use uv_types::{BuildIsolation, EmptyInstalledPackages, InFlight}; use uv_types::{BuildIsolation, EmptyInstalledPackages, InFlight};
use uv_warnings::warn_user; use uv_warnings::warn_user;

View file

@ -21,10 +21,10 @@ use uv_configuration::{
Upgrade, Upgrade,
}; };
use uv_configuration::{IndexStrategy, NoBinary}; use uv_configuration::{IndexStrategy, NoBinary};
use uv_interpreter::PythonVersion;
use uv_normalize::{ExtraName, PackageName}; use uv_normalize::{ExtraName, PackageName};
use uv_requirements::{ExtrasSpecification, RequirementsSource}; use uv_requirements::{ExtrasSpecification, RequirementsSource};
use uv_resolver::{AnnotationStyle, DependencyMode, PreReleaseMode, ResolutionMode}; use uv_resolver::{AnnotationStyle, DependencyMode, PreReleaseMode, ResolutionMode};
use uv_toolchain::PythonVersion;
use crate::commands::{extra_name_with_clap_error, ExitStatus, ListFormat, VersionFormat}; use crate::commands::{extra_name_with_clap_error, ExitStatus, ListFormat, VersionFormat};
use crate::compat::CompatArgs; use crate::compat::CompatArgs;

View file

@ -4,11 +4,8 @@
use assert_cmd::assert::{Assert, OutputAssertExt}; use assert_cmd::assert::{Assert, OutputAssertExt};
use assert_cmd::Command; use assert_cmd::Command;
use assert_fs::assert::PathAssert; use assert_fs::assert::PathAssert;
use assert_fs::fixture::PathChild; use assert_fs::fixture::PathChild;
#[cfg(unix)]
use fs_err::os::unix::fs::symlink as symlink_file;
#[cfg(windows)]
use fs_err::os::windows::fs::symlink_file;
use regex::Regex; use regex::Regex;
use std::borrow::BorrowMut; use std::borrow::BorrowMut;
use std::env; use std::env;
@ -16,10 +13,11 @@ use std::ffi::OsString;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Output; use std::process::Output;
use std::str::FromStr; use std::str::FromStr;
use uv_fs::Simplified; use uv_interpreter::find_requested_python;
use uv_cache::Cache; use uv_cache::Cache;
use uv_interpreter::{find_requested_python, PythonVersion}; use uv_fs::Simplified;
use uv_toolchain::{toolchains_for_version, PythonVersion};
// Exclude any packages uploaded after this date. // Exclude any packages uploaded after this date.
pub static EXCLUDE_NEWER: &str = "2024-03-25T00:00:00Z"; pub static EXCLUDE_NEWER: &str = "2024-03-25T00:00:00Z";
@ -316,81 +314,23 @@ pub fn venv_to_interpreter(venv: &Path) -> PathBuf {
} }
} }
/// If bootstrapped python build standalone pythons exists in `<project root>/bin`,
/// return the paths to the directories containing the python binaries (i.e. as paths that
/// `which::which_in` can use).
///
/// Use `scripts/bootstrap/install.py` to bootstrap.
///
/// Python versions are sorted from newest to oldest.
pub fn bootstrapped_pythons() -> Option<Vec<PathBuf>> {
// Current dir is `<project root>/crates/uv`.
let project_root = std::env::current_dir()
.unwrap()
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
let bootstrap_dir = if let Some(bootstrap_dir) = env::var_os("UV_BOOTSTRAP_DIR") {
let bootstrap_dir = PathBuf::from(bootstrap_dir);
if bootstrap_dir.is_absolute() {
bootstrap_dir
} else {
// cargo test changes directory to the test crate, but doesn't tell us from where the user is running the
// tests. We'll assume that it's the project root.
project_root.join(bootstrap_dir)
}
} else {
project_root.join("bin")
};
let bootstrapped_pythons = bootstrap_dir.join("versions");
let Ok(bootstrapped_pythons) = fs_err::read_dir(bootstrapped_pythons) else {
return None;
};
let mut bootstrapped_pythons: Vec<PathBuf> = bootstrapped_pythons
.map(Result::unwrap)
.filter(|entry| entry.metadata().unwrap().is_dir())
.map(|entry| {
if cfg!(unix) {
entry.path().join("install").join("bin")
} else if cfg!(windows) {
entry.path().join("install")
} else {
unimplemented!("Only Windows and Unix are supported")
}
})
.collect();
bootstrapped_pythons.sort();
// Prefer the most recent patch version.
bootstrapped_pythons.reverse();
Some(bootstrapped_pythons)
}
/// Create a virtual environment named `.venv` in a temporary directory with the given /// Create a virtual environment named `.venv` in a temporary directory with the given
/// Python version. Expected format for `python` is "python<version>". /// Python version. Expected format for `python` is "<version>".
pub fn create_venv<Parent: assert_fs::prelude::PathChild + AsRef<std::path::Path>>( pub fn create_venv<Parent: assert_fs::prelude::PathChild + AsRef<std::path::Path>>(
temp_dir: &Parent, temp_dir: &Parent,
cache_dir: &assert_fs::TempDir, cache_dir: &assert_fs::TempDir,
python: &str, python: &str,
) -> PathBuf { ) -> PathBuf {
let python = if let Some(bootstrapped_pythons) = bootstrapped_pythons() { let python = toolchains_for_version(
bootstrapped_pythons &PythonVersion::from_str(python).expect("Tests should use a valid Python version"),
.into_iter() )
// Good enough since we control the directory .expect("Tests are run on a supported platform")
.find(|path| path.to_str().unwrap().contains(&format!("@{python}"))) .first()
.expect("Missing python bootstrap version") .map(uv_toolchain::Toolchain::executable)
.join(if cfg!(unix) { // We'll search for the request Python on the PATH if not found in the toolchain versions
"python3" // We hack this into a `PathBuf` to satisfy the compiler but it's just a string
} else if cfg!(windows) { .unwrap_or(PathBuf::from(python));
"python.exe"
} else {
unimplemented!("Only Windows and Unix are supported")
})
} else {
PathBuf::from(python)
};
let venv = temp_dir.child(".venv"); let venv = temp_dir.child(".venv");
Command::new(get_bin()) Command::new(get_bin())
.arg("venv") .arg("venv")
@ -414,34 +354,48 @@ pub fn get_bin() -> PathBuf {
} }
/// Create a `PATH` with the requested Python versions available in order. /// Create a `PATH` with the requested Python versions available in order.
pub fn create_bin_with_executables( ///
/// Generally this should be used with `UV_TEST_PYTHON_PATH`.
pub fn python_path_with_versions(
temp_dir: &assert_fs::TempDir, temp_dir: &assert_fs::TempDir,
python_versions: &[&str], python_versions: &[&str],
) -> anyhow::Result<OsString> { ) -> anyhow::Result<OsString> {
if let Some(bootstrapped_pythons) = bootstrapped_pythons() { let cache = Cache::from_path(temp_dir.child("cache").to_path_buf())?;
let selected_pythons = python_versions.iter().flat_map(|python_version| { let selected_pythons = python_versions
bootstrapped_pythons.iter().filter(move |path| { .iter()
// Good enough since we control the directory .flat_map(|python_version| {
path.to_str() let inner = toolchains_for_version(
.unwrap() &PythonVersion::from_str(python_version)
.contains(&format!("@{python_version}")) .expect("Tests should use a valid Python version"),
)
.expect("Tests are run on a supported platform")
.iter()
.map(|toolchain| {
toolchain
.executable()
.parent()
.expect("Executables must exist in a directory")
.to_path_buf()
}) })
}); .collect::<Vec<_>>();
return Ok(env::join_paths(selected_pythons)?); if inner.is_empty() {
} // Fallback to a system lookup if we failed to find one in the toolchain directory
if let Some(interpreter) = find_requested_python(python_version, &cache).unwrap() {
vec![interpreter
.sys_executable()
.parent()
.expect("Python executable should always be in a directory")
.to_path_buf()]
} else {
panic!("Could not find Python {python_version} for test");
}
} else {
inner
}
})
.collect::<Vec<_>>();
let bin = temp_dir.child("bin"); Ok(env::join_paths(selected_pythons)?)
fs_err::create_dir(&bin)?;
for &request in python_versions {
let interpreter = find_requested_python(request, &Cache::temp().unwrap())?
.ok_or(uv_interpreter::Error::NoSuchPython(request.to_string()))?;
let name = interpreter
.sys_executable()
.file_name()
.expect("Discovered executable must have a filename");
symlink_file(interpreter.sys_executable(), bin.child(name))?;
}
Ok(bin.canonicalize()?.into())
} }
/// Execute the command and format its output status, stdout and stderr into a snapshot string. /// Execute the command and format its output status, stdout and stderr into a snapshot string.

View file

@ -13,14 +13,14 @@ use assert_cmd::assert::OutputAssertExt;
use assert_fs::fixture::{FileWriteStr, PathChild}; use assert_fs::fixture::{FileWriteStr, PathChild};
use predicates::prelude::predicate; use predicates::prelude::predicate;
use common::{create_bin_with_executables, get_bin, uv_snapshot, TestContext}; use common::{get_bin, python_path_with_versions, uv_snapshot, TestContext};
mod common; mod common;
/// Provision python binaries and return a `pip compile` command with options shared across all scenarios. /// Provision python binaries and return a `pip compile` command with options shared across all scenarios.
fn command(context: &TestContext, python_versions: &[&str]) -> Command { fn command(context: &TestContext, python_versions: &[&str]) -> Command {
let bin = create_bin_with_executables(&context.temp_dir, python_versions) let python_path = python_path_with_versions(&context.temp_dir, python_versions)
.expect("Failed to create bin dir"); .expect("Failed to create Python test path");
let mut command = Command::new(get_bin()); let mut command = Command::new(get_bin());
command command
.arg("pip") .arg("pip")
@ -34,7 +34,7 @@ fn command(context: &TestContext, python_versions: &[&str]) -> Command {
.arg(context.cache_dir.path()) .arg(context.cache_dir.path())
.env("VIRTUAL_ENV", context.venv.as_os_str()) .env("VIRTUAL_ENV", context.venv.as_os_str())
.env("UV_NO_WRAP", "1") .env("UV_NO_WRAP", "1")
.env("UV_TEST_PYTHON_PATH", bin) .env("UV_TEST_PYTHON_PATH", python_path)
.current_dir(&context.temp_dir); .current_dir(&context.temp_dir);
if cfg!(all(windows, debug_assertions)) { if cfg!(all(windows, debug_assertions)) {

View file

@ -13,7 +13,7 @@ use indoc::indoc;
use predicates::Predicate; use predicates::Predicate;
use url::Url; use url::Url;
use common::{create_bin_with_executables, create_venv, uv_snapshot, venv_to_interpreter}; use common::{create_venv, python_path_with_versions, uv_snapshot, venv_to_interpreter};
use uv_fs::Simplified; use uv_fs::Simplified;
use crate::common::{copy_dir_all, get_bin, TestContext}; use crate::common::{copy_dir_all, get_bin, TestContext};
@ -338,8 +338,8 @@ fn link() -> Result<()> {
.success(); .success();
let venv2 = context.temp_dir.child(".venv2"); let venv2 = context.temp_dir.child(".venv2");
let bin = create_bin_with_executables(&context.temp_dir, &["3.12"]) let python_path = python_path_with_versions(&context.temp_dir, &["3.12"])
.expect("Failed to create bin dir"); .expect("Failed to create Python test path");
Command::new(get_bin()) Command::new(get_bin())
.arg("venv") .arg("venv")
.arg(venv2.as_os_str()) .arg(venv2.as_os_str())
@ -347,7 +347,7 @@ fn link() -> Result<()> {
.arg(context.cache_dir.path()) .arg(context.cache_dir.path())
.arg("--python") .arg("--python")
.arg("3.12") .arg("3.12")
.env("UV_TEST_PYTHON_PATH", bin) .env("UV_TEST_PYTHON_PATH", python_path)
.current_dir(&context.temp_dir) .current_dir(&context.temp_dir)
.assert() .assert()
.success(); .success();

View file

@ -9,11 +9,9 @@ use assert_fs::fixture::ChildPath;
use assert_fs::prelude::*; use assert_fs::prelude::*;
use fs_err::PathExt; use fs_err::PathExt;
use uv_fs::Simplified; use uv_fs::Simplified;
use uv_interpreter::PythonVersion; use uv_toolchain::PythonVersion;
use crate::common::{ use crate::common::{get_bin, python_path_with_versions, uv_snapshot, TestContext, EXCLUDE_NEWER};
create_bin_with_executables, get_bin, uv_snapshot, TestContext, EXCLUDE_NEWER,
};
mod common; mod common;
@ -21,15 +19,15 @@ struct VenvTestContext {
cache_dir: assert_fs::TempDir, cache_dir: assert_fs::TempDir,
temp_dir: assert_fs::TempDir, temp_dir: assert_fs::TempDir,
venv: ChildPath, venv: ChildPath,
bin: OsString, python_path: OsString,
python_versions: Vec<PythonVersion>, python_versions: Vec<PythonVersion>,
} }
impl VenvTestContext { impl VenvTestContext {
fn new(python_versions: &[&str]) -> Self { fn new(python_versions: &[&str]) -> Self {
let temp_dir = assert_fs::TempDir::new().unwrap(); let temp_dir = assert_fs::TempDir::new().unwrap();
let bin = create_bin_with_executables(&temp_dir, python_versions) let python_path = python_path_with_versions(&temp_dir, python_versions)
.expect("Failed to create bin dir"); .expect("Failed to create Python test path");
let venv = temp_dir.child(".venv"); let venv = temp_dir.child(".venv");
let python_versions = python_versions let python_versions = python_versions
.iter() .iter()
@ -41,7 +39,7 @@ impl VenvTestContext {
cache_dir: assert_fs::TempDir::new().unwrap(), cache_dir: assert_fs::TempDir::new().unwrap(),
temp_dir, temp_dir,
venv, venv,
bin, python_path,
python_versions, python_versions,
} }
} }
@ -54,7 +52,7 @@ impl VenvTestContext {
.arg(self.cache_dir.path()) .arg(self.cache_dir.path())
.arg("--exclude-newer") .arg("--exclude-newer")
.arg(EXCLUDE_NEWER) .arg(EXCLUDE_NEWER)
.env("UV_TEST_PYTHON_PATH", self.bin.clone()) .env("UV_TEST_PYTHON_PATH", self.python_path.clone())
.current_dir(self.temp_dir.path()); .current_dir(self.temp_dir.path());
command command
} }
@ -397,9 +395,9 @@ fn windows_shims() -> Result<()> {
let context = VenvTestContext::new(&["3.9", "3.8"]); let context = VenvTestContext::new(&["3.9", "3.8"]);
let shim_path = context.temp_dir.child("shim"); let shim_path = context.temp_dir.child("shim");
let py38 = std::env::split_paths(&context.bin) let py38 = std::env::split_paths(&context.python_path)
.last() .last()
.expect("create_bin_with_executables to set up the python versions"); .expect("python_path_with_versions to set up the python versions");
// We want 3.8 and the first version should be 3.9. // We want 3.8 and the first version should be 3.9.
// Picking the last is necessary to prove that shims work because the python version selects // Picking the last is necessary to prove that shims work because the python version selects
// the python version from the first path segment by default, so we take the last to prove it's not // the python version from the first path segment by default, so we take the last to prove it's not
@ -417,7 +415,7 @@ fn windows_shims() -> Result<()> {
uv_snapshot!(context.filters(), context.venv_command() uv_snapshot!(context.filters(), context.venv_command()
.arg(context.venv.as_os_str()) .arg(context.venv.as_os_str())
.arg("--clear") .arg("--clear")
.env("UV_TEST_PYTHON_PATH", format!("{};{}", shim_path.display(), context.bin.simplified_display())), @r###" .env("UV_TEST_PYTHON_PATH", format!("{};{}", shim_path.display(), context.python_path.simplified_display())), @r###"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----

View file

@ -1,204 +0,0 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "zstandard==0.22.0",
# ]
# ///
#
# Download required Python versions and install to `bin`
# Uses prebuilt Python distributions from indygreg/python-build-standalone
#
# This script can be run without Python installed via `install.sh`
#
# Requirements
#
# pip install zstandard==0.22.0
#
# Usage
#
# python scripts/bootstrap/install.py
#
# Or
#
# pipx run scripts/bootstrap/install.py
#
# The Python versions are installed from `.python_versions`.
# Python versions are linked in-order such that the _last_ defined version will be the default.
#
# Version metadata can be updated with `fetch-version-metadata.py`
import concurrent.futures
import hashlib
import json
import os
import platform
import shutil
import sys
import sysconfig
import tarfile
import tempfile
import urllib.parse
import urllib.request
from pathlib import Path
try:
import zstandard
except ImportError:
print("ERROR: zstandard is required; install with `pip install zstandard==0.22.0`")
sys.exit(1)
# Setup some file paths
THIS_DIR = Path(__file__).parent
ROOT_DIR = THIS_DIR.parent.parent
if bin_dir := os.environ.get("UV_BOOTSTRAP_DIR"):
BIN_DIR = Path(bin_dir)
else:
BIN_DIR = ROOT_DIR / "bin"
INSTALL_DIR = BIN_DIR / "versions"
VERSIONS_FILE = ROOT_DIR / ".python-versions"
VERSIONS_METADATA_FILE = THIS_DIR / "versions.json"
# Map system information to those in the versions metadata
ARCH_MAP = {"aarch64": "arm64", "amd64": "x86_64"}
PLATFORM_MAP = {"win32": "windows"}
PLATFORM = sys.platform
ARCH = platform.machine().lower()
INTERPRETER = "cpython"
def decompress_file(archive_path: Path, output_path: Path):
if str(archive_path).endswith(".tar.zst"):
dctx = zstandard.ZstdDecompressor()
with tempfile.TemporaryFile(suffix=".tar") as ofh:
with archive_path.open("rb") as ifh:
dctx.copy_stream(ifh, ofh)
ofh.seek(0)
with tarfile.open(fileobj=ofh) as z:
z.extractall(output_path)
else:
raise ValueError(f"Unknown archive type {archive_path.suffix}")
def sha256_file(path: Path):
h = hashlib.sha256()
with open(path, "rb") as file:
while True:
# Reading is buffered, so we can read smaller chunks.
chunk = file.read(h.block_size)
if not chunk:
break
h.update(chunk)
return h.hexdigest()
versions_metadata = json.loads(VERSIONS_METADATA_FILE.read_text())
versions = VERSIONS_FILE.read_text().splitlines()
def get_key(version):
if platform.system() == "Linux":
libc = sysconfig.get_config_var("SOABI").split("-")[-1]
else:
libc = "none"
key = f"{INTERPRETER}-{version}-{PLATFORM_MAP.get(PLATFORM, PLATFORM)}-{ARCH_MAP.get(ARCH, ARCH)}-{libc}"
return key
def download(version):
key = get_key(version)
install_dir = INSTALL_DIR / f"{INTERPRETER}@{version}"
print(f"Downloading {key}")
url = versions_metadata[key]["url"]
if not url:
print(f"No matching download for {key}")
sys.exit(1)
filename = url.split("/")[-1]
print(f"Downloading {urllib.parse.unquote(filename)}")
download_path = THIS_DIR / filename
with urllib.request.urlopen(url) as response:
with download_path.open("wb") as download_file:
shutil.copyfileobj(response, download_file)
sha = versions_metadata[key]["sha256"]
if not sha:
print(f"WARNING: no checksum for {key}")
else:
print("Verifying checksum...", end="")
if sha256_file(download_path) != sha:
print(" FAILED!")
sys.exit(1)
print(" OK")
if install_dir.exists():
shutil.rmtree(install_dir)
print("Extracting to", install_dir)
install_dir.parent.mkdir(parents=True, exist_ok=True)
# n.b. do not use `.with_suffix` as it will replace the patch Python version
extract_dir = Path(str(install_dir) + ".tmp")
decompress_file(THIS_DIR / filename, extract_dir)
(extract_dir / "python").rename(install_dir)
(THIS_DIR / filename).unlink()
extract_dir.rmdir()
return install_dir
def install(version, install_dir):
key = get_key(version)
if PLATFORM == "win32":
executable = install_dir / "install" / "python.exe"
else:
# Use relative paths for links so if the bin is moved they don't break
executable = (
"." / install_dir.relative_to(BIN_DIR) / "install" / "bin" / "python3"
)
major = versions_metadata[key]["major"]
minor = versions_metadata[key]["minor"]
# Link as all version tuples, later versions in the file will take precedence
BIN_DIR.mkdir(parents=True, exist_ok=True)
targets = [
(BIN_DIR / f"python{version}"),
(BIN_DIR / f"python{major}.{minor}"),
(BIN_DIR / f"python{major}"),
(BIN_DIR / "python"),
]
for target in targets:
target.unlink(missing_ok=True)
if PLATFORM == "win32":
target.hardlink_to(executable)
else:
target.symlink_to(executable)
print(f"Installed executables for python{version}")
if __name__ == "__main__":
if INSTALL_DIR.exists():
print("Removing existing installations...")
shutil.rmtree(INSTALL_DIR)
# Download in parallel
with concurrent.futures.ProcessPoolExecutor(max_workers=len(versions)) as executor:
futures = [
(version, executor.submit(download, version)) for version in versions
]
# Install sequentially so overrides are respected
for version, future in futures:
install_dir = future.result()
install(version, install_dir)
print("Done!")

View file

@ -13,14 +13,14 @@ use assert_cmd::assert::OutputAssertExt;
use assert_fs::fixture::{FileWriteStr, PathChild}; use assert_fs::fixture::{FileWriteStr, PathChild};
use predicates::prelude::predicate; use predicates::prelude::predicate;
use common::{create_bin_with_executables, get_bin, uv_snapshot, TestContext}; use common::{python_path_with_versions, get_bin, uv_snapshot, TestContext};
mod common; mod common;
/// Provision python binaries and return a `pip compile` command with options shared across all scenarios. /// Provision python binaries and return a `pip compile` command with options shared across all scenarios.
fn command(context: &TestContext, python_versions: &[&str]) -> Command { fn command(context: &TestContext, python_versions: &[&str]) -> Command {
let bin = create_bin_with_executables(&context.temp_dir, python_versions) let python_path = python_path_with_versions(&context.temp_dir, python_versions)
.expect("Failed to create bin dir"); .expect("Failed to create Python test path");
let mut command = Command::new(get_bin()); let mut command = Command::new(get_bin());
command command
.arg("pip") .arg("pip")
@ -34,7 +34,7 @@ fn command(context: &TestContext, python_versions: &[&str]) -> Command {
.arg(context.cache_dir.path()) .arg(context.cache_dir.path())
.env("VIRTUAL_ENV", context.venv.as_os_str()) .env("VIRTUAL_ENV", context.venv.as_os_str())
.env("UV_NO_WRAP", "1") .env("UV_NO_WRAP", "1")
.env("UV_TEST_PYTHON_PATH", bin) .env("UV_TEST_PYTHON_PATH", python_path)
.current_dir(&context.temp_dir); .current_dir(&context.temp_dir);
if cfg!(all(windows, debug_assertions)) { if cfg!(all(windows, debug_assertions)) {