Run the test suite on windows in CI (#1262)

Run `cargo test` on windows in CI, pulling the switch on tier 1 windows
support.

These changes make the bootstrap script virtually required for running
the tests. This gives us consistency between and CI, but it also locks
our tests to python-build-standalone and an articificial `PATH`.

I've deleted the shell bootstrap script in favor of only the python one,
which also runs on windows. I've left the (sym)link creation of the
bootstrap in place, even though it is not used by the tests anymore.

I've reactivated the three tests that would previously stack overflow by
doubling their stack sizes. The stack overflows only happen in debug
mode, so this is neither a user facing problem nor an actual problem
with our code and this workaround seems better than optimizing our code
for case that the (release) compiler can optimize much better for.

The handling of patch versions will be fixed in a follow-up PR.

Closes #1160 
Closes #1161

---------

Co-authored-by: Charlie Marsh <charlie.r.marsh@gmail.com>
This commit is contained in:
konsti 2024-02-08 16:09:55 -05:00 committed by GitHub
parent 96276d9e3e
commit 1dc9904f8c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 228 additions and 357 deletions

2
.env
View file

@ -1,2 +1,2 @@
PATH=$PWD/bin:$PATH PATH=$PWD/bin:$PATH
PUFFIN_PYTHON_PATH=$PWD/bin PUFFIN_TEST_PYTHON_PATH=$PWD/bin

View file

@ -30,12 +30,14 @@ jobs:
cargo-clippy: cargo-clippy:
name: "cargo clippy" name: "cargo clippy"
runs-on: ubuntu-latest strategy:
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: "Install Rust toolchain" - name: "Install Rust toolchain"
run: | run: rustup component add clippy
rustup component add clippy
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
with: with:
save-if: ${{ github.ref == 'refs/heads/main' }} save-if: ${{ github.ref == 'refs/heads/main' }}
@ -45,21 +47,32 @@ jobs:
cargo-test: cargo-test:
strategy: strategy:
matrix: matrix:
os: [ubuntu-latest] include:
# We use the large GitHub actions runners for faster testing
# For Ubuntu and Windows, this requires Organization-level configuration
# See: https://docs.github.com/en/actions/using-github-hosted-runners/about-larger-runners/about-larger-runners#about-ubuntu-and-windows-larger-runners
- { os: "ubuntu", runner: "ubuntu-latest-large" }
- { os: "windows", runner: "windows-latest-large" }
- { os: "macos", runner: "macos-14" }
runs-on: runs-on:
# We use the large GitHub actions runners for faster testing labels: ${{ matrix.runner }}
# For Ubuntu and Windows, this requires Organization-level configuration
# See: https://docs.github.com/en/actions/using-github-hosted-runners/about-larger-runners/about-larger-runners#about-ubuntu-and-windows-larger-runners
labels: ${{ matrix.os }}-large
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 pipx"
run: pip install pipx
- name: "Install required Python versions" - name: "Install required Python versions"
run: | run: pipx run scripts/bootstrap/install.py
scripts/bootstrap/install.sh
- name: "Install Rust toolchain" - name: "Install Rust toolchain"
run: rustup show run: rustup show
- uses: rui314/setup-mold@v1 - if: ${{ matrix.os != 'windows' }}
uses: rui314/setup-mold@v1
- name: "Install cargo nextest" - name: "Install cargo nextest"
uses: taiki-e/install-action@v2 uses: taiki-e/install-action@v2
with: with:
@ -67,59 +80,8 @@ jobs:
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
with: with:
save-if: ${{ github.ref == 'refs/heads/main' }} save-if: ${{ github.ref == 'refs/heads/main' }}
- name: "Tests" - name: "Cargo Test"
run: | run: cargo nextest run --all --status-level skip --failure-output immediate-final --no-fail-fast -j 12
source .env
cargo nextest run --all --status-level skip --failure-output immediate-final --no-fail-fast -j 12
macos:
runs-on:
labels: macos-14
name: "cargo test | macos"
steps:
- uses: actions/checkout@v4
- name: "Install bootstrap dependencies"
run: |
brew install coreutils
- name: "Install required Python versions"
run: |
scripts/bootstrap/install.sh
- name: "Install Rust toolchain"
run: rustup show
- uses: rui314/setup-mold@v1
- name: "Install cargo nextest"
uses: taiki-e/install-action@v2
with:
tool: cargo-nextest
- uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: "Tests"
run: |
source .env
cargo nextest run --all --status-level skip --failure-output immediate-final --no-fail-fast -j 12
# TODO(konstin): Merge with the cargo-test job once the tests pass
windows:
runs-on: windows-latest-large
name: "cargo check | windows"
steps:
- uses: actions/checkout@v4
- name: "Install Rust toolchain"
run: rustup component add clippy
- name: "Install Python for bootstrapping"
uses: actions/setup-python@v4
with:
python-version: 3.12
- name: "Install Python binaries"
run: |
pip install zstandard==0.22.0
python scripts/bootstrap/install.py
# ex) The path needs to be updated downstream
$env:Path = "$pwd\bin" + $env:Path
- uses: rui314/setup-mold@v1
- uses: Swatinem/rust-cache@v2
- run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
# Separate job for the nightly crate # Separate job for the nightly crate
windows-trampoline: windows-trampoline:

View file

@ -4,8 +4,6 @@
[Rust](https://rustup.rs/), a C compiler, and CMake are required to build Puffin. [Rust](https://rustup.rs/), a C compiler, and CMake are required to build Puffin.
Testing Puffin requires multiple specific Python versions. We provide a script to bootstrap development by downloading the required versions.
### Linux ### Linux
@ -35,31 +33,22 @@ See the [Python](#python) section for instructions on installing the Python vers
You can install CMake from the [installers](https://cmake.org/download/) or with `pipx install cmake` (make sure that the pipx install path is in `PATH`, pipx complains if it isn't). You can install CMake from the [installers](https://cmake.org/download/) or with `pipx install cmake` (make sure that the pipx install path is in `PATH`, pipx complains if it isn't).
### Python
Install required Python versions with the bootstrapping script:
```
scripts/bootstrap/install.sh
```
The installed Python binaries will be available in `<repo>/bin` and must be added to your path to be used. We
provide a `.env` file with the proper environment variables for development. You may activate it with:
```
source .env
```
Or, if you use `direnv` to manage your environment:
```
echo "dotenv" >> .envrc
direnv allow
```
## Testing ## Testing
To run the tests we recommend [nextest](https://nexte.st/). Make sure to run the tests with `--all-features`, otherwise you'll miss most of our integration tests. Testing Puffin requires multiple specific Python versions. You can install them into
`<project root>/bin` via our bootstrapping script:
```shell
pipx run scripts/bootstrap/install.py
```
Alternatively, you can install `zstandard` from PyPI, then run:
```
python3.12 scripts/bootstrap/install.py
```
For running tests, we recommend [nextest](https://nexte.st/).
## Running inside a docker container ## Running inside a docker container

View file

@ -137,7 +137,7 @@ impl Interpreter {
/// - If a python version is given: `pythonx.y` /// - If a python version is given: `pythonx.y`
/// - `python3` (unix) or `python.exe` (windows) /// - `python3` (unix) or `python.exe` (windows)
/// ///
/// If `PUFFIN_PYTHON_PATH` is set, we will not check for Python versions in the /// If `PUFFIN_TEST_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 /// global PATH, instead we will search using the provided path. Virtual environments
/// will still be respected. /// will still be respected.
/// ///
@ -220,7 +220,7 @@ impl Interpreter {
pub fn find_executable<R: AsRef<OsStr> + Into<OsString> + Copy>( pub fn find_executable<R: AsRef<OsStr> + Into<OsString> + Copy>(
requested: R, requested: R,
) -> Result<PathBuf, Error> { ) -> Result<PathBuf, Error> {
if let Some(isolated) = std::env::var_os("PUFFIN_PYTHON_PATH") { if let Some(isolated) = std::env::var_os("PUFFIN_TEST_PYTHON_PATH") {
if let Ok(cwd) = std::env::current_dir() { if let Ok(cwd) = std::env::current_dir() {
which::which_in(requested, Some(isolated), cwd) which::which_in(requested, Some(isolated), cwd)
.map_err(|err| Error::from_which_error(requested.into(), err)) .map_err(|err| Error::from_which_error(requested.into(), err))

View file

@ -38,20 +38,30 @@ pub fn find_requested_python(request: &str) -> Result<PathBuf, Error> {
let formatted = PathBuf::from(format!("python{request}")); let formatted = PathBuf::from(format!("python{request}"));
Interpreter::find_executable(&formatted) Interpreter::find_executable(&formatted)
} else if cfg!(windows) { } else if cfg!(windows) {
if let Some(python_overwrite) = env::var_os("PUFFIN_PYTHON_PATH") {
for path in env::split_paths(&python_overwrite) {
if path
.as_os_str()
.to_str()
// Good enough since we control the bootstrap directory
.is_some_and(|path| path.contains(&format!("@{request}")))
{
return Ok(path);
}
}
}
if let [major, minor] = versions.as_slice() { if let [major, minor] = versions.as_slice() {
if let Some(python_overwrite) = env::var_os("PUFFIN_TEST_PYTHON_PATH") {
let executable_dir = env::split_paths(&python_overwrite).find(|path| {
path.as_os_str()
.to_str()
// Good enough since we control the bootstrap directory
.is_some_and(|path| path.contains(&format!("@{request}")))
});
return if let Some(path) = executable_dir {
Ok(path.join(if cfg!(unix) {
"python3"
} else if cfg!(windows) {
"python.exe"
} else {
unimplemented!("Only Windows and Unix are supported")
}))
} else {
Err(Error::NoSuchPython {
major: *major,
minor: *minor,
})
};
}
find_python_windows(*major, *minor)?.ok_or(Error::NoSuchPython { find_python_windows(*major, *minor)?.ok_or(Error::NoSuchPython {
major: *major, major: *major,
minor: *minor, minor: *minor,
@ -73,21 +83,27 @@ pub fn find_requested_python(request: &str) -> Result<PathBuf, Error> {
/// Pick a sensible default for the python a user wants when they didn't specify a version. /// Pick a sensible default for the python a user wants when they didn't specify a version.
/// ///
/// We prefer the test overwrite `PUFFIN_PYTHON_PATH` if it is set, otherwise `python3`/`python` or /// We prefer the test overwrite `PUFFIN_TEST_PYTHON_PATH` if it is set, otherwise `python3`/`python` or
/// `python.exe` respectively. /// `python.exe` respectively.
#[instrument] #[instrument]
pub fn find_default_python() -> Result<PathBuf, Error> { pub fn find_default_python() -> Result<PathBuf, Error> {
let current_dir = env::current_dir()?; let current_dir = env::current_dir()?;
let python = if cfg!(unix) { let python = if cfg!(unix) {
which::which_in("python3", env::var_os("PUFFIN_PYTHON_PATH"), current_dir) which::which_in(
.or_else(|_| which::which("python")) "python3",
.map_err(|_| Error::NoPythonInstalledUnix)? env::var_os("PUFFIN_TEST_PYTHON_PATH"),
current_dir,
)
.or_else(|_| which::which("python"))
.map_err(|_| Error::NoPythonInstalledUnix)?
} else if cfg!(windows) { } else if cfg!(windows) {
// TODO(konstin): Is that the right order, or should we look for `py --list-paths` first? With the current way // TODO(konstin): Is that the right order, or should we look for `py --list-paths` first? With the current way
// it works even if the python launcher is not installed. // it works even if the python launcher is not installed.
if let Ok(python) = if let Ok(python) = which::which_in(
which::which_in("python.exe", env::var_os("PUFFIN_PYTHON_PATH"), current_dir) "python.exe",
{ env::var_os("PUFFIN_TEST_PYTHON_PATH"),
current_dir,
) {
python python
} else { } else {
installed_pythons_windows()? installed_pythons_windows()?
@ -107,7 +123,7 @@ pub fn find_default_python() -> Result<PathBuf, Error> {
/// The command takes 8ms on my machine. TODO(konstin): Implement <https://peps.python.org/pep-0514/> to read python /// The command takes 8ms on my machine. TODO(konstin): Implement <https://peps.python.org/pep-0514/> to read python
/// installations from the registry instead. /// installations from the registry instead.
fn installed_pythons_windows() -> Result<Vec<(u8, u8, PathBuf)>, Error> { fn installed_pythons_windows() -> Result<Vec<(u8, u8, PathBuf)>, Error> {
// TODO(konstin): We're not checking PUFFIN_PYTHON_PATH here, no test currently depends on it. // TODO(konstin): We're not checking PUFFIN_TEST_PYTHON_PATH here, no test currently depends on it.
// TODO(konstin): Special case the not found error // TODO(konstin): Special case the not found error
let output = info_span!("py_list_paths") let output = info_span!("py_list_paths")
@ -148,6 +164,24 @@ fn installed_pythons_windows() -> Result<Vec<(u8, u8, PathBuf)>, Error> {
} }
pub(crate) fn find_python_windows(major: u8, minor: u8) -> Result<Option<PathBuf>, Error> { pub(crate) fn find_python_windows(major: u8, minor: u8) -> Result<Option<PathBuf>, Error> {
if let Some(python_overwrite) = env::var_os("PUFFIN_TEST_PYTHON_PATH") {
let executable_dir = env::split_paths(&python_overwrite).find(|path| {
path.as_os_str()
.to_str()
// Good enough since we control the bootstrap directory
.is_some_and(|path| path.contains(&format!("@{major}.{minor}")))
});
return Ok(executable_dir.map(|path| {
path.join(if cfg!(unix) {
"python3"
} else if cfg!(windows) {
"python.exe"
} else {
unimplemented!("Only Windows and Unix are supported")
})
}));
}
Ok(installed_pythons_windows()? Ok(installed_pythons_windows()?
.into_iter() .into_iter()
.find(|(major_, minor_, _path)| *major_ == major && *minor_ == minor) .find(|(major_, minor_, _path)| *major_ == major && *minor_ == minor)
@ -170,23 +204,20 @@ mod tests {
.join("\n Caused by: ") .join("\n Caused by: ")
} }
#[cfg(unix)]
#[test] #[test]
fn python312() { fn no_such_python_version() {
assert_eq!( assert_snapshot!(
find_requested_python("3.12").unwrap(), format_err(find_requested_python("3.1000")),
find_requested_python("python3.12").unwrap() @"Couldn't find `3.1000` in PATH. Is this Python version installed?"
); );
} }
#[test]
fn no_such_python_version() {
assert_snapshot!(format_err(find_requested_python("3.1000")), @"Couldn't find `3.1000` in PATH. Is this Python version installed?");
}
#[test] #[test]
fn no_such_python_binary() { fn no_such_python_binary() {
assert_display_snapshot!(format_err(find_requested_python("python3.1000")), @"Couldn't find `python3.1000` in PATH. Is this Python version installed?"); assert_display_snapshot!(
format_err(find_requested_python("python3.1000")),
@"Couldn't find `python3.1000` in PATH. Is this Python version installed?"
);
} }
#[cfg(unix)] #[cfg(unix)]

View file

@ -1,6 +1,8 @@
// The `unreachable_pub` is to silence false positives in RustRover. // The `unreachable_pub` is to silence false positives in RustRover.
#![allow(dead_code, unreachable_pub)] #![allow(dead_code, unreachable_pub)]
use std::borrow::BorrowMut;
use std::env;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Output; use std::process::Output;
@ -9,8 +11,14 @@ use assert_cmd::Command;
use assert_fs::assert::PathAssert; use assert_fs::assert::PathAssert;
use assert_fs::fixture::PathChild; use assert_fs::fixture::PathChild;
use assert_fs::TempDir; use assert_fs::TempDir;
#[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 puffin_interpreter::find_requested_python;
// Exclude any packages uploaded after this date. // Exclude any packages uploaded after this date.
pub static EXCLUDE_NEWER: &str = "2023-11-18T12:00:00Z"; pub static EXCLUDE_NEWER: &str = "2023-11-18T12:00:00Z";
@ -173,16 +181,50 @@ pub fn get_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_puffin")) PathBuf::from(env!("CARGO_BIN_EXE_puffin"))
} }
/// Create a directory with the requested Python binaries available.
pub fn create_bin_with_executables(
temp_dir: &assert_fs::TempDir,
python_versions: &[&str],
) -> anyhow::Result<PathBuf> {
if let Some(bootstrapped_pythons) = bootstrapped_pythons() {
let selected_pythons = bootstrapped_pythons.into_iter().filter(|path| {
python_versions.iter().any(|python_version| {
// Good enough since we control the directory
path.to_str()
.unwrap()
.contains(&format!("@{python_version}"))
})
});
return Ok(env::join_paths(selected_pythons)?.into());
}
let bin = temp_dir.child("bin");
fs_err::create_dir(&bin)?;
for request in python_versions {
let executable = find_requested_python(request)?;
let name = executable
.file_name()
.expect("Discovered executable must have a filename");
symlink_file(&executable, bin.child(name))?;
}
Ok(bin.canonicalize()?)
}
/// 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.
/// ///
/// This function is derived from `insta_cmd`s `spawn_with_info`. /// This function is derived from `insta_cmd`s `spawn_with_info`.
pub fn run_and_format<'a>( pub fn run_and_format<'a>(
command: &mut std::process::Command, mut command: impl BorrowMut<std::process::Command>,
filters: impl AsRef<[(&'a str, &'a str)]>, filters: impl AsRef<[(&'a str, &'a str)]>,
windows_filters: bool, windows_filters: bool,
) -> (String, Output) { ) -> (String, Output) {
let program = command.get_program().to_string_lossy().to_string(); let program = command
.borrow_mut()
.get_program()
.to_string_lossy()
.to_string();
let output = command let output = command
.borrow_mut()
.output() .output()
.unwrap_or_else(|_| panic!("Failed to spawn {program}")); .unwrap_or_else(|_| panic!("Failed to spawn {program}"));

View file

@ -6,52 +6,17 @@
#![cfg(all(feature = "python", feature = "pypi"))] #![cfg(all(feature = "python", feature = "pypi"))]
use std::env; use std::env;
use std::path::PathBuf;
use std::process::Command; use std::process::Command;
use anyhow::Result; use anyhow::Result;
use assert_cmd::assert::OutputAssertExt; use assert_cmd::assert::OutputAssertExt;
use assert_fs::fixture::{FileWriteStr, PathChild}; use assert_fs::fixture::{FileWriteStr, PathChild};
#[cfg(unix)]
use fs_err::os::unix::fs::symlink as symlink_file;
#[cfg(windows)]
use fs_err::os::windows::fs::symlink_file;
use predicates::prelude::predicate; use predicates::prelude::predicate;
use common::{bootstrapped_pythons, get_bin, puffin_snapshot, TestContext, INSTA_FILTERS}; use common::{create_bin_with_executables, get_bin, puffin_snapshot, TestContext, INSTA_FILTERS};
use puffin_interpreter::find_requested_python;
mod common; mod common;
/// Create a directory with the requested Python binaries available.
pub(crate) fn create_bin_with_executables(
temp_dir: &assert_fs::TempDir,
python_versions: &[&str],
) -> Result<PathBuf> {
if let Some(bootstrapped_pythons) = bootstrapped_pythons() {
let selected_pythons = bootstrapped_pythons.into_iter().filter(|path| {
python_versions.iter().any(|python_version| {
// Good enough since we control the directory
path.to_str()
.unwrap()
.contains(&format!("@{python_version}"))
})
});
return Ok(env::join_paths(selected_pythons)?.into());
}
let bin = temp_dir.child("bin");
fs_err::create_dir(&bin)?;
for request in python_versions {
let executable = find_requested_python(request)?;
let name = executable
.file_name()
.expect("Discovered executable must have a filename");
symlink_file(&executable, bin.child(name))?;
}
Ok(bin.canonicalize()?)
}
/// 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 bin = create_bin_with_executables(&context.temp_dir, python_versions)
@ -67,7 +32,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("PUFFIN_NO_WRAP", "1") .env("PUFFIN_NO_WRAP", "1")
.env("PUFFIN_PYTHON_PATH", bin) .env("PUFFIN_TEST_PYTHON_PATH", bin)
.current_dir(&context.temp_dir); .current_dir(&context.temp_dir);
command command
} }

View file

@ -664,14 +664,17 @@ fn install_no_index_version() {
/// Install a package without using pre-built wheels. /// Install a package without using pre-built wheels.
#[test] #[test]
#[cfg(not(all(windows, debug_assertions)))] // Stack overflow on debug on windows -.-
fn install_no_binary() { fn install_no_binary() {
let context = TestContext::new("3.12"); let context = TestContext::new("3.12");
puffin_snapshot!(command(&context) let mut command = command(&context);
.arg("Flask") command.arg("Flask").arg("--no-binary").arg("--strict");
.arg("--no-binary") if cfg!(all(windows, debug_assertions)) {
.arg("--strict"), @r###" // TODO(konstin): Reduce stack usage in debug mode enough that the tests pass with the
// default windows stack of 1MB
command.env("PUFFIN_STACK_SIZE", (2 * 1024 * 1024).to_string());
}
puffin_snapshot!(command, @r###"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
@ -728,14 +731,18 @@ fn install_no_binary_subset() {
/// Install a package without using pre-built wheels. /// Install a package without using pre-built wheels.
#[test] #[test]
#[cfg(not(all(windows, debug_assertions)))] // Stack overflow on debug on windows -.-
fn reinstall_no_binary() { fn reinstall_no_binary() {
let context = TestContext::new("3.12"); let context = TestContext::new("3.12");
// The first installation should use a pre-built wheel // The first installation should use a pre-built wheel
puffin_snapshot!(command(&context) let mut command = command(&context);
.arg("Flask") command.arg("Flask").arg("--strict");
.arg("--strict"), @r###" if cfg!(all(windows, debug_assertions)) {
// TODO(konstin): Reduce stack usage in debug mode enough that the tests pass with the
// default windows stack of 1MB
command.env("PUFFIN_STACK_SIZE", (2 * 1024 * 1024).to_string());
}
puffin_snapshot!(command, @r###"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
@ -757,10 +764,15 @@ fn reinstall_no_binary() {
context.assert_command("import flask").success(); context.assert_command("import flask").success();
// Running installation again with `--no-binary` should be a no-op // Running installation again with `--no-binary` should be a no-op
puffin_snapshot!(command(&context) // The first installation should use a pre-built wheel
.arg("Flask") let mut command = crate::command(&context);
.arg("--no-binary") command.arg("Flask").arg("--no-binary").arg("--strict");
.arg("--strict"), @r###" if cfg!(all(windows, debug_assertions)) {
// TODO(konstin): Reduce stack usage in debug mode enough that the tests pass with the
// default windows stack of 1MB
command.env("PUFFIN_STACK_SIZE", (2 * 1024 * 1024).to_string());
}
puffin_snapshot!(command, @r###"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
@ -773,12 +785,29 @@ fn reinstall_no_binary() {
context.assert_command("import flask").success(); context.assert_command("import flask").success();
// With `--reinstall`, `--no-binary` should have an affect // With `--reinstall`, `--no-binary` should have an affect
puffin_snapshot!(command(&context) let filters = if cfg!(windows) {
// Remove the colorama count on windows
INSTA_FILTERS
.iter()
.copied()
.chain([("Resolved 8 packages", "Resolved 7 packages")])
.collect()
} else {
INSTA_FILTERS.to_vec()
};
let mut command = crate::command(&context);
command
.arg("Flask") .arg("Flask")
.arg("--no-binary") .arg("--no-binary")
.arg("--reinstall-package") .arg("--reinstall-package")
.arg("Flask") .arg("Flask")
.arg("--strict"), @r###" .arg("--strict");
if cfg!(all(windows, debug_assertions)) {
// TODO(konstin): Reduce stack usage in debug mode enough that the tests pass with the
// default windows stack of 1MB
command.env("PUFFIN_STACK_SIZE", (2 * 1024 * 1024).to_string());
}
puffin_snapshot!(filters, command, @r###"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----

View file

@ -10,7 +10,9 @@ use assert_fs::prelude::*;
use indoc::indoc; use indoc::indoc;
use url::Url; use url::Url;
use common::{create_venv, puffin_snapshot, venv_to_interpreter, INSTA_FILTERS}; use common::{
create_bin_with_executables, create_venv, puffin_snapshot, venv_to_interpreter, INSTA_FILTERS,
};
use puffin_fs::NormalizedDisplay; use puffin_fs::NormalizedDisplay;
use crate::common::{get_bin, TestContext}; use crate::common::{get_bin, TestContext};
@ -318,6 +320,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"])
.expect("Failed to create bin dir");
Command::new(get_bin()) Command::new(get_bin())
.arg("venv") .arg("venv")
.arg(venv2.as_os_str()) .arg(venv2.as_os_str())
@ -325,6 +329,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("PUFFIN_TEST_PYTHON_PATH", bin)
.current_dir(&context.temp_dir) .current_dir(&context.temp_dir)
.assert() .assert()
.success(); .success();
@ -805,7 +810,6 @@ fn install_numpy_py38() -> Result<()> {
/// Install a package without using pre-built wheels. /// Install a package without using pre-built wheels.
#[test] #[test]
#[cfg(not(all(windows, debug_assertions)))] // Stack overflow on debug on windows -.-
fn install_no_binary() -> Result<()> { fn install_no_binary() -> Result<()> {
let context = TestContext::new("3.12"); let context = TestContext::new("3.12");
@ -813,10 +817,17 @@ fn install_no_binary() -> Result<()> {
requirements_txt.touch()?; requirements_txt.touch()?;
requirements_txt.write_str("MarkupSafe==2.1.3")?; requirements_txt.write_str("MarkupSafe==2.1.3")?;
puffin_snapshot!(command(&context) let mut command = command(&context);
command
.arg("requirements.txt") .arg("requirements.txt")
.arg("--no-binary") .arg("--no-binary")
.arg("--strict"), @r###" .arg("--strict");
if cfg!(all(windows, debug_assertions)) {
// TODO(konstin): Reduce stack usage in debug mode enough that the tests pass with the
// default windows stack of 1MB
command.env("PUFFIN_STACK_SIZE", (2 * 1024 * 1024).to_string());
}
puffin_snapshot!(command, @r###"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----

View file

@ -7,7 +7,7 @@ use assert_fs::prelude::*;
use puffin_fs::NormalizedDisplay; use puffin_fs::NormalizedDisplay;
use crate::common::{get_bin, puffin_snapshot}; use crate::common::{create_bin_with_executables, get_bin, puffin_snapshot};
mod common; mod common;
@ -15,6 +15,7 @@ mod common;
fn create_venv() -> Result<()> { fn create_venv() -> Result<()> {
let temp_dir = assert_fs::TempDir::new()?; let temp_dir = assert_fs::TempDir::new()?;
let cache_dir = assert_fs::TempDir::new()?; let cache_dir = assert_fs::TempDir::new()?;
let bin = create_bin_with_executables(&temp_dir, &["3.12"]).expect("Failed to create bin dir");
let venv = temp_dir.child(".venv"); let venv = temp_dir.child(".venv");
let filter_venv = regex::escape(&venv.normalized_display().to_string()); let filter_venv = regex::escape(&venv.normalized_display().to_string());
@ -32,6 +33,7 @@ fn create_venv() -> Result<()> {
.arg("3.12") .arg("3.12")
.arg("--cache-dir") .arg("--cache-dir")
.arg(cache_dir.path()) .arg(cache_dir.path())
.env("PUFFIN_TEST_PYTHON_PATH", bin)
.current_dir(&temp_dir), @r###" .current_dir(&temp_dir), @r###"
success: true success: true
exit_code: 0 exit_code: 0
@ -52,6 +54,7 @@ fn create_venv() -> Result<()> {
fn create_venv_defaults_to_cwd() -> Result<()> { fn create_venv_defaults_to_cwd() -> Result<()> {
let temp_dir = assert_fs::TempDir::new()?; let temp_dir = assert_fs::TempDir::new()?;
let cache_dir = assert_fs::TempDir::new()?; let cache_dir = assert_fs::TempDir::new()?;
let bin = create_bin_with_executables(&temp_dir, &["3.12"]).expect("Failed to create bin dir");
let venv = temp_dir.child(".venv"); let venv = temp_dir.child(".venv");
let filter_venv = regex::escape(&venv.normalized_display().to_string()); let filter_venv = regex::escape(&venv.normalized_display().to_string());
@ -68,6 +71,7 @@ fn create_venv_defaults_to_cwd() -> Result<()> {
.arg("3.12") .arg("3.12")
.arg("--cache-dir") .arg("--cache-dir")
.arg(cache_dir.path()) .arg(cache_dir.path())
.env("PUFFIN_TEST_PYTHON_PATH", bin)
.current_dir(&temp_dir), @r###" .current_dir(&temp_dir), @r###"
success: true success: true
exit_code: 0 exit_code: 0
@ -88,6 +92,7 @@ fn create_venv_defaults_to_cwd() -> Result<()> {
fn seed() -> Result<()> { fn seed() -> Result<()> {
let temp_dir = assert_fs::TempDir::new()?; let temp_dir = assert_fs::TempDir::new()?;
let cache_dir = assert_fs::TempDir::new()?; let cache_dir = assert_fs::TempDir::new()?;
let bin = create_bin_with_executables(&temp_dir, &["3.12"]).expect("Failed to create bin dir");
let venv = temp_dir.child(".venv"); let venv = temp_dir.child(".venv");
let filter_venv = regex::escape(&venv.normalized_display().to_string()); let filter_venv = regex::escape(&venv.normalized_display().to_string());
@ -106,6 +111,7 @@ fn seed() -> Result<()> {
.arg("3.12") .arg("3.12")
.arg("--cache-dir") .arg("--cache-dir")
.arg(cache_dir.path()) .arg(cache_dir.path())
.env("PUFFIN_TEST_PYTHON_PATH", bin)
.current_dir(&temp_dir), @r###" .current_dir(&temp_dir), @r###"
success: true success: true
exit_code: 0 exit_code: 0
@ -129,6 +135,7 @@ fn seed() -> Result<()> {
fn create_venv_unknown_python_minor() -> Result<()> { fn create_venv_unknown_python_minor() -> Result<()> {
let temp_dir = assert_fs::TempDir::new()?; let temp_dir = assert_fs::TempDir::new()?;
let cache_dir = assert_fs::TempDir::new()?; let cache_dir = assert_fs::TempDir::new()?;
let bin = create_bin_with_executables(&temp_dir, &["3.12"]).expect("Failed to create bin dir");
let venv = temp_dir.child(".venv"); let venv = temp_dir.child(".venv");
let mut command = Command::new(get_bin()); let mut command = Command::new(get_bin());
@ -139,6 +146,7 @@ fn create_venv_unknown_python_minor() -> Result<()> {
.arg("3.15") .arg("3.15")
.arg("--cache-dir") .arg("--cache-dir")
.arg(cache_dir.path()) .arg(cache_dir.path())
.env("PUFFIN_TEST_PYTHON_PATH", bin)
.current_dir(&temp_dir); .current_dir(&temp_dir);
if cfg!(windows) { if cfg!(windows) {
puffin_snapshot!(&mut command, @r###" puffin_snapshot!(&mut command, @r###"
@ -172,6 +180,7 @@ fn create_venv_unknown_python_minor() -> Result<()> {
fn create_venv_unknown_python_patch() -> Result<()> { fn create_venv_unknown_python_patch() -> Result<()> {
let temp_dir = assert_fs::TempDir::new()?; let temp_dir = assert_fs::TempDir::new()?;
let cache_dir = assert_fs::TempDir::new()?; let cache_dir = assert_fs::TempDir::new()?;
let bin = create_bin_with_executables(&temp_dir, &["3.12"]).expect("Failed to create bin dir");
let venv = temp_dir.child(".venv"); let venv = temp_dir.child(".venv");
let filter_venv = regex::escape(&venv.normalized_display().to_string()); let filter_venv = regex::escape(&venv.normalized_display().to_string());
@ -189,6 +198,7 @@ fn create_venv_unknown_python_patch() -> Result<()> {
.arg("3.8.0") .arg("3.8.0")
.arg("--cache-dir") .arg("--cache-dir")
.arg(cache_dir.path()) .arg(cache_dir.path())
.env("PUFFIN_TEST_PYTHON_PATH", bin)
.current_dir(&temp_dir), @r###" .current_dir(&temp_dir), @r###"
success: false success: false
exit_code: 1 exit_code: 1
@ -205,10 +215,13 @@ fn create_venv_unknown_python_patch() -> Result<()> {
} }
#[test] #[test]
#[ignore] // TODO(konstin): Switch patch version strategy
#[cfg(unix)] // TODO(konstin): Support patch versions on Windows #[cfg(unix)] // TODO(konstin): Support patch versions on Windows
fn create_venv_python_patch() -> Result<()> { fn create_venv_python_patch() -> Result<()> {
let temp_dir = assert_fs::TempDir::new()?; let temp_dir = assert_fs::TempDir::new()?;
let cache_dir = assert_fs::TempDir::new()?; let cache_dir = assert_fs::TempDir::new()?;
let bin =
create_bin_with_executables(&temp_dir, &["3.12.1"]).expect("Failed to create bin dir");
let venv = temp_dir.child(".venv"); let venv = temp_dir.child(".venv");
let filter_venv = regex::escape(&venv.normalized_display().to_string()); let filter_venv = regex::escape(&venv.normalized_display().to_string());
@ -223,6 +236,7 @@ fn create_venv_python_patch() -> Result<()> {
.arg("3.12.1") .arg("3.12.1")
.arg("--cache-dir") .arg("--cache-dir")
.arg(cache_dir.path()) .arg(cache_dir.path())
.env("PUFFIN_TEST_PYTHON_PATH", bin)
.current_dir(&temp_dir), @r###" .current_dir(&temp_dir), @r###"
success: true success: true
exit_code: 0 exit_code: 0

View file

@ -1,137 +0,0 @@
#!/usr/bin/env bash
#
# Download required Python versions and install to `bin`
# Uses prebuilt Python distributions from indygreg/python-build-standalone
#
# Requirements
#
# macOS
#
# brew install zstd jq coreutils
#
# Ubuntu
#
# apt install zstd jq
#
# Arch Linux
#
# pacman -S zstd jq libxcrypt-compat
#
# Windows
#
# winget install jqlang.jq
#
# Usage
#
# ./scripts/bootstrap/install.sh
#
# 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` which requires Python 3.12
set -euo pipefail
# Convenience function for displaying URLs
function urldecode() { : "${*//+/ }"; echo -e "${_//%/\\x}"; }
# Convenience function for checking that a command exists.
requires() {
cmd="$1"
if ! command -v "$cmd" > /dev/null 2>&1; then
echo "DEPENDENCY MISSING: $(basename $0) requires $cmd to be installed" >&2
exit 1
fi
}
requires jq
requires zstd
# Setup some file paths
this_dir=$(realpath "$(dirname "$0")")
root_dir=$(dirname "$(dirname "$this_dir")")
bin_dir="$root_dir/bin"
install_dir="$bin_dir/versions"
versions_file="$root_dir/.python-versions"
versions_metadata="$this_dir/versions.json"
# Determine system metadata
os=$(uname -s | tr '[:upper:]' '[:lower:]')
arch=$(uname -m)
interpreter='cpython'
# On macOS, we need a newer version of `realpath` for `--relative-to` support
realpath="$(which grealpath || which realpath)"
# Utility for linking executables for the version being installed
# We need to update executables even if the version is already downloaded and extracted
# to ensure that changes to the precedence of versions are respected
link_executables() {
# Use relative paths for links so if the bin is moved they don't break
local link=$($realpath --relative-to="$bin_dir" "$install_key/install/bin/python3")
local minor=$(jq --arg key "$key" '.[$key] | .minor' -r < "$versions_metadata")
# Link as all version tuples, later versions in the file will take precedence
ln -sf "./$link" "$bin_dir/python$version"
ln -sf "./$link" "$bin_dir/python3.$minor"
ln -sf "./$link" "$bin_dir/python3"
ln -sf "./$link" "$bin_dir/python"
}
# Read requested versions into an array
versions=()
while IFS= read -r version; do
versions+=("$version")
done < "$versions_file"
# Install each version
for version in "${versions[@]}"; do
key="$interpreter-$version-$os-$arch"
install_key="$install_dir/$interpreter@$version"
echo "Installing $key"
if [ -d "$install_key" ]; then
echo "Already available, skipping download"
link_executables
echo "Updated executables for python$version"
continue
fi
url=$(jq --arg key "$key" '.[$key] | .url' -r < "$versions_metadata")
if [ "$url" == 'null' ]; then
echo "No matching download for $key"
exit 1
fi
filename=$(basename "$url")
echo "Downloading $(urldecode "$filename")"
curl -L --progress-bar -o "$filename" "$url" --output-dir "$this_dir"
expected_sha=$(jq --arg key "$key" '.[$key] | .sha256' -r < "$versions_metadata")
if [ "$expected_sha" == 'null' ]; then
echo "WARNING: no checksum for $key"
else
echo -n "Verifying checksum..."
echo "$expected_sha $this_dir/$filename" | sha256sum -c --quiet
echo " OK"
fi
rm -rf "$install_key"
echo "Extracting to $($realpath --relative-to="$root_dir" "$install_key")"
mkdir -p "$install_key"
zstd -d "$this_dir/$filename" --stdout | tar -x -C "$install_key"
# Setup the installation
mv "$install_key/python/"* "$install_key"
link_executables
echo "Installed executables for python$version"
# Cleanup
rmdir "$install_key/python/"
rm "$this_dir/$filename"
done
echo "Done!"

View file

@ -6,52 +6,17 @@
#![cfg(all(feature = "python", feature = "pypi"))] #![cfg(all(feature = "python", feature = "pypi"))]
use std::env; use std::env;
use std::path::PathBuf;
use std::process::Command; use std::process::Command;
use anyhow::Result; use anyhow::Result;
use assert_cmd::assert::OutputAssertExt; use assert_cmd::assert::OutputAssertExt;
use assert_fs::fixture::{FileWriteStr, PathChild}; use assert_fs::fixture::{FileWriteStr, PathChild};
#[cfg(unix)]
use fs_err::os::unix::fs::symlink as symlink_file;
#[cfg(windows)]
use fs_err::os::windows::fs::symlink_file;
use predicates::prelude::predicate; use predicates::prelude::predicate;
use common::{bootstrapped_pythons, get_bin, puffin_snapshot, TestContext, INSTA_FILTERS}; use common::{create_bin_with_executables, get_bin, puffin_snapshot, TestContext, INSTA_FILTERS};
use puffin_interpreter::find_requested_python;
mod common; mod common;
/// Create a directory with the requested Python binaries available.
pub(crate) fn create_bin_with_executables(
temp_dir: &assert_fs::TempDir,
python_versions: &[&str],
) -> Result<PathBuf> {
if let Some(bootstrapped_pythons) = bootstrapped_pythons() {
let selected_pythons = bootstrapped_pythons.into_iter().filter(|path| {
python_versions.iter().any(|python_version| {
// Good enough since we control the directory
path.to_str()
.unwrap()
.contains(&format!("@{python_version}"))
})
});
return Ok(env::join_paths(selected_pythons)?.into());
}
let bin = temp_dir.child("bin");
fs_err::create_dir(&bin)?;
for request in python_versions {
let executable = find_requested_python(request)?;
let name = executable
.file_name()
.expect("Discovered executable must have a filename");
symlink_file(&executable, bin.child(name))?;
}
Ok(bin.canonicalize()?)
}
/// 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 bin = create_bin_with_executables(&context.temp_dir, python_versions)
@ -67,7 +32,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("PUFFIN_NO_WRAP", "1") .env("PUFFIN_NO_WRAP", "1")
.env("PUFFIN_PYTHON_PATH", bin) .env("PUFFIN_TEST_PYTHON_PATH", bin)
.current_dir(&context.temp_dir); .current_dir(&context.temp_dir);
command command
} }