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="https://github.com/astral-sh/uv/assets/2586601/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
31 changed files with 8170 additions and 3703 deletions

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)
}