Add bootstrapping and isolation of development Python versions (#1105)

Replaces https://github.com/astral-sh/puffin/pull/1068 and #1070 which
were more complicated than I wanted.

- Introduces a `.python-versions` file which defines the Python versions
needed for development
- Adds a Bash script at `scripts/bootstrap/install` which installs the
required Python versions from `python-build-standalone` to `./bin`
- Checks in a `versions.json` file with metadata about available
versions on each platform and a `fetch-version` Python script derived
from `rye` for updating the versions
- Updates CI to use these Python builds instead of the `setup-python`
action
- Updates to the latest packse scenarios which require Python 3.8+
instead of 3.7+ since we cannot use 3.7 anymore and includes new test
coverage of patch Python version requests
- Adds a `PUFFIN_PYTHON_PATH` variable to prevent lookup of system
Python versions for isolation during development

Tested on Linux (via CI) and macOS (locally) — presumably it will be a
bit more complicated to do proper Windows support.
This commit is contained in:
Zanie Blue 2024-01-26 12:12:48 -06:00 committed by GitHub
parent cc0e211074
commit 21577ad002
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 5789 additions and 444 deletions

View file

@ -1,3 +1,4 @@
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use std::process::Command;
@ -136,6 +137,10 @@ impl Interpreter {
/// - If a python version is given: `pythonx.y`
/// - `python3` (unix) or `python.exe` (windows)
///
/// If `PUFFIN_PYTHON_PATH` is set, we will not check for Python versions in the
/// global PATH, instead we will search using the provided path. Virtual environments
/// will still be respected.
///
/// If a version is provided and an interpreter cannot be found with the given version,
/// we will return [`None`].
pub fn find_version(
@ -170,7 +175,8 @@ impl Interpreter {
python_version.major(),
python_version.minor()
);
if let Ok(executable) = which::which(&requested) {
if let Ok(executable) = Interpreter::find_executable(&requested) {
debug!("Resolved {requested} to {}", executable.display());
let interpreter = Interpreter::query(&executable, &platform.0, cache)?;
if version_matches(&interpreter) {
@ -179,7 +185,7 @@ impl Interpreter {
}
}
if let Ok(executable) = which::which("python3") {
if let Ok(executable) = Interpreter::find_executable("python3") {
debug!("Resolved python3 to {}", executable.display());
let interpreter = Interpreter::query(&executable, &platform.0, cache)?;
if version_matches(&interpreter) {
@ -198,7 +204,7 @@ impl Interpreter {
}
}
if let Ok(executable) = which::which("python.exe") {
if let Ok(executable) = Interpreter::find_executable("python.exe") {
let interpreter = Interpreter::query(&executable, &platform.0, cache)?;
if version_matches(&interpreter) {
return Ok(Some(interpreter));
@ -211,6 +217,23 @@ impl Interpreter {
Ok(None)
}
pub fn find_executable<R: AsRef<OsStr> + Into<OsString> + Copy>(
requested: R,
) -> Result<PathBuf, Error> {
if let Some(isolated) = std::env::var_os("PUFFIN_PYTHON_PATH") {
if let Ok(cwd) = std::env::current_dir() {
which::which_in(requested, Some(isolated), cwd)
.map_err(|err| Error::Which(requested.into(), err))
} else {
which::which_in_global(requested, Some(isolated))
.map_err(|err| Error::Which(requested.into(), err))
.and_then(|mut paths| paths.next().ok_or(Error::PythonNotFound))
}
} else {
which::which(requested).map_err(|err| Error::Which(requested.into(), err))
}
}
/// Returns the path to the Python virtual environment.
#[inline]
pub fn platform(&self) -> &Platform {

View file

@ -1,3 +1,4 @@
use std::ffi::OsString;
use std::io;
use std::path::PathBuf;
use std::time::SystemTimeError;
@ -49,6 +50,8 @@ pub enum Error {
NoPythonInstalledUnix,
#[error("Could not find `python.exe` in PATH and `py --list-paths` did not list any Python versions. Do you need to install Python?")]
NoPythonInstalledWindows,
#[error("Patch versions cannot be requested on Windows")]
PatchVersionRequestedWindows,
#[error("{message}:\n--- stdout:\n{stdout}\n--- stderr:\n{stderr}\n---")]
PythonSubcommandOutput {
message: String,
@ -63,6 +66,6 @@ pub enum Error {
Encode(#[from] rmp_serde::encode::Error),
#[error("Failed to parse pyvenv.cfg")]
Cfg(#[from] cfg::Error),
#[error("Couldn't find `{0}` in PATH")]
Which(PathBuf, #[source] which::Error),
#[error("Couldn't find `{}` in PATH", _0.to_string_lossy())]
Which(OsString, #[source] which::Error),
}

View file

@ -7,7 +7,7 @@ use once_cell::sync::Lazy;
use regex::Regex;
use tracing::{info_span, instrument};
use crate::Error;
use crate::{Error, Interpreter};
/// ```text
/// -V:3.12 C:\Users\Ferris\AppData\Local\Programs\Python\Python312\python.exe
@ -27,23 +27,30 @@ static PY_LIST_PATHS: Lazy<Regex> = Lazy::new(|| {
/// * `-p /home/ferris/.local/bin/python3.10` uses this exact Python.
#[instrument]
pub fn find_requested_python(request: &str) -> Result<PathBuf, Error> {
let major_minor = request
.split_once('.')
.and_then(|(major, minor)| Some((major.parse::<u8>().ok()?, minor.parse::<u8>().ok()?)));
if let Some((major, minor)) = major_minor {
// `-p 3.10`
let versions = request
.splitn(3, '.')
.map(str::parse::<u8>)
.collect::<Result<Vec<_>, _>>();
if let Ok(versions) = versions {
// `-p 3.10` or `-p 3.10.1`
if cfg!(unix) {
let formatted = PathBuf::from(format!("python{major}.{minor}"));
which::which(&formatted).map_err(|err| Error::Which(formatted, err))
let formatted = PathBuf::from(format!("python{request}"));
Interpreter::find_executable(&formatted)
} else if cfg!(windows) {
find_python_windows(major, minor)?.ok_or(Error::NoSuchPython { major, minor })
if let [major, minor] = versions.as_slice() {
find_python_windows(*major, *minor)?.ok_or(Error::NoSuchPython {
major: *major,
minor: *minor,
})
} else {
Err(Error::PatchVersionRequestedWindows)
}
} else {
unimplemented!("Only Windows and Unix are supported")
}
} else if !request.contains(std::path::MAIN_SEPARATOR) {
// `-p python3.10`; Generally not used on windows because all Python are `python.exe`.
let request = PathBuf::from(request);
which::which(&request).map_err(|err| Error::Which(request, err))
Interpreter::find_executable(request)
} else {
// `-p /home/ferris/.local/bin/python3.10`
Ok(fs_err::canonicalize(request)?)