Allow selection of pyodide interpreters with "pyodide" (#15256)

This commit is contained in:
Zanie Blue 2025-08-13 14:08:55 -05:00 committed by GitHub
parent 88a7b2d864
commit 2c54d3929c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 243 additions and 93 deletions

View file

@ -1190,11 +1190,7 @@ pub fn find_python_installations<'a>(
cache,
preview,
)
.filter_ok(|(_source, interpreter)| {
interpreter
.implementation_name()
.eq_ignore_ascii_case(implementation.into())
})
.filter_ok(|(_source, interpreter)| implementation.matches_interpreter(interpreter))
.map_ok(|tuple| Ok(PythonInstallation::from_tuple(tuple)))
}),
PythonRequest::ImplementationVersion(implementation, version) => {
@ -1212,11 +1208,7 @@ pub fn find_python_installations<'a>(
cache,
preview,
)
.filter_ok(|(_source, interpreter)| {
interpreter
.implementation_name()
.eq_ignore_ascii_case(implementation.into())
})
.filter_ok(|(_source, interpreter)| implementation.matches_interpreter(interpreter))
.map_ok(|tuple| Ok(PythonInstallation::from_tuple(tuple)))
})
}
@ -1260,7 +1252,6 @@ pub(crate) fn find_python_installation(
let mut first_prerelease = None;
let mut first_managed = None;
let mut first_error = None;
let mut emscripten_installation = None;
for result in installations {
// Iterate until the first critical error or happy result
if !result.as_ref().err().is_none_or(Error::is_critical) {
@ -1278,15 +1269,6 @@ pub(crate) fn find_python_installation(
return result;
};
if installation.os().is_emscripten() {
// We want to pick a native Python over an Emscripten Python if we
// can find any native Python.
if emscripten_installation.is_none() {
emscripten_installation = Some(installation.clone());
}
continue;
}
// Check if we need to skip the interpreter because it is "not allowed", e.g., if it is a
// pre-release version or an alternative implementation, using it requires opt-in.
@ -1363,10 +1345,6 @@ pub(crate) fn find_python_installation(
return Ok(Ok(installation));
}
if let Some(emscripten_python) = emscripten_installation {
return Ok(Ok(emscripten_python));
}
// If we found a Python, but it was unusable for some reason, report that instead of saying we
// couldn't find any Python interpreters.
if let Some(err) = first_error {
@ -1964,8 +1942,10 @@ impl PythonRequest {
Self::Any => true,
Self::Version(_) => false,
Self::Directory(_) | Self::File(_) | Self::ExecutableName(_) => true,
Self::Implementation(_) => true,
Self::ImplementationVersion(_, _) => true,
Self::Implementation(implementation)
| Self::ImplementationVersion(implementation, _) => {
!matches!(implementation, ImplementationName::CPython)
}
Self::Key(request) => request.allows_alternative_implementations(),
}
}
@ -3680,6 +3660,7 @@ mod tests {
"any",
&[
"python", "python3", "cpython", "cpython3", "pypy", "pypy3", "graalpy", "graalpy3",
"pyodide", "pyodide3",
],
);

View file

@ -261,7 +261,17 @@ impl PythonDownloadRequest {
#[must_use]
pub fn with_implementation(mut self, implementation: ImplementationName) -> Self {
self.implementation = Some(implementation);
match implementation {
// Pyodide is actually CPython with an Emscripten OS, we paper over that for usability
ImplementationName::Pyodide => {
self = self.with_os(Os::new(target_lexicon::OperatingSystem::Emscripten));
self = self.with_arch(Arch::new(target_lexicon::Architecture::Wasm32, None));
self = self.with_libc(Libc::Some(target_lexicon::Environment::Musl));
}
_ => {
self.implementation = Some(implementation);
}
}
self
}
@ -438,7 +448,9 @@ impl PythonDownloadRequest {
/// Whether this download request opts-in to alternative Python implementations.
pub fn allows_alternative_implementations(&self) -> bool {
self.implementation.is_some()
self.implementation
.is_some_and(|implementation| !matches!(implementation, ImplementationName::CPython))
|| self.os.is_some_and(|os| os.is_emscripten())
}
pub fn satisfied_by_interpreter(&self, interpreter: &Interpreter) -> bool {
@ -461,12 +473,10 @@ impl PythonDownloadRequest {
return false;
}
if let Some(implementation) = self.implementation() {
let interpreter_implementation = interpreter.implementation_name();
if LenientImplementationName::from(interpreter_implementation)
!= LenientImplementationName::from(*implementation)
{
if !implementation.matches_interpreter(interpreter) {
debug!(
"Skipping interpreter at `{executable}`: implementation `{interpreter_implementation}` does not match request `{implementation}`"
"Skipping interpreter at `{executable}`: implementation `{}` does not match request `{implementation}`",
interpreter.implementation_name(),
);
return false;
}
@ -1512,13 +1522,16 @@ mod tests {
request.version,
Some(VersionRequest::from_str("3.12.0").unwrap())
);
assert_eq!(request.os, Some(Os(target_lexicon::OperatingSystem::Linux)));
assert_eq!(
request.os,
Some(Os::new(target_lexicon::OperatingSystem::Linux))
);
assert_eq!(
request.arch,
Some(ArchRequest::Explicit(Arch {
family: target_lexicon::Architecture::X86_64,
variant: None,
}))
Some(ArchRequest::Explicit(Arch::new(
target_lexicon::Architecture::X86_64,
None
)))
);
assert_eq!(
request.libc,
@ -1540,10 +1553,10 @@ mod tests {
assert_eq!(request.os, None);
assert_eq!(
request.arch,
Some(ArchRequest::Explicit(Arch {
family: target_lexicon::Architecture::X86_64,
variant: None,
}))
Some(ArchRequest::Explicit(Arch::new(
target_lexicon::Architecture::X86_64,
None
)))
);
assert_eq!(request.libc, None);
}
@ -1556,7 +1569,10 @@ mod tests {
assert_eq!(request.implementation, Some(ImplementationName::PyPy));
assert_eq!(request.version, None);
assert_eq!(request.os, Some(Os(target_lexicon::OperatingSystem::Linux)));
assert_eq!(
request.os,
Some(Os::new(target_lexicon::OperatingSystem::Linux))
);
assert_eq!(request.arch, None);
assert_eq!(request.libc, None);
}
@ -1598,14 +1614,14 @@ mod tests {
assert_eq!(request.version, None);
assert_eq!(
request.os,
Some(Os(target_lexicon::OperatingSystem::Windows))
Some(Os::new(target_lexicon::OperatingSystem::Windows))
);
assert_eq!(
request.arch,
Some(ArchRequest::Explicit(Arch {
family: target_lexicon::Architecture::X86_64,
variant: None,
}))
Some(ArchRequest::Explicit(Arch::new(
target_lexicon::Architecture::X86_64,
None
)))
);
assert_eq!(request.libc, None);
}
@ -1669,10 +1685,10 @@ mod tests {
assert_eq!(request.os, None);
assert_eq!(
request.arch,
Some(ArchRequest::Explicit(Arch {
family: target_lexicon::Architecture::X86_64,
variant: None,
}))
Some(ArchRequest::Explicit(Arch::new(
target_lexicon::Architecture::X86_64,
None
)))
);
assert_eq!(request.libc, None);
}

View file

@ -4,6 +4,8 @@ use std::{
};
use thiserror::Error;
use crate::Interpreter;
#[derive(Error, Debug)]
pub enum Error {
#[error("Unknown Python implementation `{0}`")]
@ -12,6 +14,7 @@ pub enum Error {
#[derive(Debug, Eq, PartialEq, Clone, Copy, Default, PartialOrd, Ord, Hash)]
pub enum ImplementationName {
Pyodide,
GraalPy,
PyPy,
#[default]
@ -30,11 +33,11 @@ impl ImplementationName {
}
pub(crate) fn long_names() -> impl Iterator<Item = &'static str> {
["cpython", "pypy", "graalpy"].into_iter()
["cpython", "pypy", "graalpy", "pyodide"].into_iter()
}
pub(crate) fn iter_all() -> impl Iterator<Item = Self> {
[Self::CPython, Self::PyPy, Self::GraalPy].into_iter()
[Self::CPython, Self::PyPy, Self::GraalPy, Self::Pyodide].into_iter()
}
pub fn pretty(self) -> &'static str {
@ -42,15 +45,25 @@ impl ImplementationName {
Self::CPython => "CPython",
Self::PyPy => "PyPy",
Self::GraalPy => "GraalPy",
Self::Pyodide => "Pyodide",
}
}
pub fn executable_name(self) -> &'static str {
match self {
Self::CPython => "python",
Self::CPython | Self::Pyodide => "python",
Self::PyPy | Self::GraalPy => self.into(),
}
}
pub fn matches_interpreter(self, interpreter: &Interpreter) -> bool {
match self {
Self::Pyodide => interpreter.os().is_emscripten(),
_ => interpreter
.implementation_name()
.eq_ignore_ascii_case(self.into()),
}
}
}
impl LenientImplementationName {
@ -75,6 +88,7 @@ impl From<&ImplementationName> for &'static str {
ImplementationName::CPython => "cpython",
ImplementationName::PyPy => "pypy",
ImplementationName::GraalPy => "graalpy",
ImplementationName::Pyodide => "pyodide",
}
}
}
@ -105,6 +119,7 @@ impl FromStr for ImplementationName {
"cpython" | "cp" => Ok(Self::CPython),
"pypy" | "pp" => Ok(Self::PyPy),
"graalpy" | "gp" => Ok(Self::GraalPy),
"pyodide" => Ok(Self::Pyodide),
_ => Err(Error::UnknownImplementation(s.to_string())),
}
}

View file

@ -1,3 +1,4 @@
use std::borrow::Cow;
use std::fmt;
use std::hash::{Hash, Hasher};
use std::str::FromStr;
@ -310,7 +311,7 @@ impl PythonInstallation {
!matches!(
self.implementation(),
LenientImplementationName::Known(ImplementationName::CPython)
)
) || self.os().is_emscripten()
}
/// Return the [`Arch`] of the Python installation as reported by its interpreter.
@ -394,8 +395,12 @@ impl PythonInstallationKey {
}
}
pub fn implementation(&self) -> &LenientImplementationName {
&self.implementation
pub fn implementation(&self) -> Cow<'_, LenientImplementationName> {
if self.os().is_emscripten() {
Cow::Owned(LenientImplementationName::from(ImplementationName::Pyodide))
} else {
Cow::Borrowed(&self.implementation)
}
}
pub fn version(&self) -> PythonVersion {
@ -484,7 +489,7 @@ impl fmt::Display for PythonInstallationKey {
write!(
f,
"{}-{}.{}.{}{}{}-{}",
self.implementation,
self.implementation(),
self.major,
self.minor,
self.patch,

View file

@ -457,7 +457,7 @@ mod tests {
fn add_pyodide_version(&mut self, version: &'static str) -> Result<()> {
let path = self.new_search_path_directory(format!("pyodide-{version}"))?;
let python = format!("python{}", env::consts::EXE_SUFFIX);
let python = format!("pyodide{}", env::consts::EXE_SUFFIX);
Self::create_mock_pyodide_interpreter(
&path.join(python),
&PythonVersion::from_str(version).unwrap(),
@ -2705,7 +2705,9 @@ mod tests {
let mut context = TestContext::new()?;
context.add_pyodide_version("3.13.2")?;
let python = context.run(|| {
// We should not find the Pyodide interpreter by default
let result = context.run(|| {
find_python_installation(
&PythonRequest::Default,
EnvironmentPreference::Any,
@ -2713,14 +2715,28 @@ mod tests {
&context.cache,
Preview::default(),
)
})?;
assert!(
result.is_err(),
"We should not find an python; got {result:?}"
);
// With `Any`, it should be discoverable
let python = context.run(|| {
find_python_installation(
&PythonRequest::Any,
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
Preview::default(),
)
})??;
// We should find the Pyodide interpreter
assert_eq!(
python.interpreter().python_full_version().to_string(),
"3.13.2"
);
// We should prefer any native Python to the Pyodide Python
// We should prefer the native Python to the Pyodide Python
context.add_python_versions(&["3.15.7"])?;
let python = context.run(|| {

View file

@ -17,7 +17,7 @@ use uv_configuration::{Preview, PreviewFeatures};
use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT;
use uv_fs::{LockedFile, Simplified, replace_symlink, symlink_or_copy_file};
use uv_platform::Error as PlatformError;
use uv_platform::{Error as PlatformError, Os};
use uv_platform::{LibcDetectionError, Platform};
use uv_state::{StateBucket, StateStore};
use uv_static::EnvVars;
@ -358,8 +358,6 @@ impl ManagedPythonInstallation {
/// If windowed is true, `pythonw.exe` is selected over `python.exe` on windows, with no changes
/// on non-windows.
pub fn executable(&self, windowed: bool) -> PathBuf {
let implementation = self.implementation().executable_name();
let version = match self.implementation() {
ImplementationName::CPython => {
if cfg!(unix) {
@ -370,12 +368,14 @@ impl ManagedPythonInstallation {
}
// PyPy uses a full version number, even on Windows.
ImplementationName::PyPy => format!("{}.{}", self.key.major, self.key.minor),
// Pyodide and GraalPy do not have a version suffix.
ImplementationName::Pyodide => String::new(),
ImplementationName::GraalPy => String::new(),
};
// On Windows, the executable is just `python.exe` even for alternative variants
// GraalPy always uses `graalpy.exe` as the main executable
let variant = if *self.implementation() == ImplementationName::GraalPy {
let variant = if self.implementation() == ImplementationName::GraalPy {
""
} else if cfg!(unix) {
self.key.variant.suffix()
@ -388,13 +388,15 @@ impl ManagedPythonInstallation {
let name = format!(
"{implementation}{version}{variant}{exe}",
implementation = self.implementation().executable_name(),
exe = std::env::consts::EXE_SUFFIX
);
let executable = executable_path_from_base(
self.python_dir().as_path(),
&name,
&LenientImplementationName::from(*self.implementation()),
&LenientImplementationName::from(self.implementation()),
*self.key.os(),
);
// Workaround for python-build-standalone v20241016 which is missing the standard
@ -431,8 +433,8 @@ impl ManagedPythonInstallation {
self.key.version()
}
pub fn implementation(&self) -> &ImplementationName {
match self.key.implementation() {
pub fn implementation(&self) -> ImplementationName {
match self.key.implementation().into_owned() {
LenientImplementationName::Known(implementation) => implementation,
LenientImplementationName::Unknown(_) => {
panic!("Managed Python installations should have a known implementation")
@ -466,10 +468,10 @@ impl ManagedPythonInstallation {
.file_name()
.is_some_and(|filename| filename.to_string_lossy() == *name),
PythonRequest::Implementation(implementation) => {
implementation == self.implementation()
*implementation == self.implementation()
}
PythonRequest::ImplementationVersion(implementation, version) => {
implementation == self.implementation() && version.matches_version(&self.version())
*implementation == self.implementation() && version.matches_version(&self.version())
}
PythonRequest::Version(version) => version.matches_version(&self.version()),
PythonRequest::Key(request) => request.satisfied_by_key(self.key()),
@ -579,7 +581,7 @@ impl ManagedPythonInstallation {
// sysconfig directly
return Ok(());
}
if *self.implementation() == ImplementationName::CPython {
if self.implementation() == ImplementationName::CPython {
sysconfig::update_sysconfig(
self.path(),
self.key.major,
@ -600,7 +602,7 @@ impl ManagedPythonInstallation {
pub fn ensure_dylib_patched(&self) -> Result<(), macos_dylib::Error> {
if cfg!(target_os = "macos") {
if self.key().os().is_like_darwin() {
if *self.implementation() == ImplementationName::CPython {
if self.implementation() == ImplementationName::CPython {
let dylib_path = self.python_dir().join("lib").join(format!(
"{}python{}{}{}",
std::env::consts::DLL_PREFIX,
@ -716,7 +718,7 @@ impl PythonMinorVersionLink {
) -> Option<Self> {
let implementation = key.implementation();
if !matches!(
implementation,
implementation.as_ref(),
LenientImplementationName::Known(ImplementationName::CPython)
) {
// We don't currently support transparent upgrades for PyPy or GraalPy.
@ -755,7 +757,8 @@ impl PythonMinorVersionLink {
let symlink_executable = executable_path_from_base(
symlink_directory.as_path(),
&executable_name.to_string_lossy(),
implementation,
&implementation,
*key.os(),
);
let minor_version_link = Self {
symlink_directory,
@ -839,18 +842,28 @@ fn executable_path_from_base(
base: &Path,
executable_name: &str,
implementation: &LenientImplementationName,
os: Os,
) -> PathBuf {
if cfg!(unix)
if matches!(
implementation,
&LenientImplementationName::Known(ImplementationName::GraalPy)
) {
// GraalPy is always in `bin/` regardless of the os
base.join("bin").join(executable_name)
} else if os.is_emscripten()
|| matches!(
implementation,
&LenientImplementationName::Known(ImplementationName::GraalPy)
&LenientImplementationName::Known(ImplementationName::Pyodide)
)
{
base.join("bin").join(executable_name)
} else if cfg!(windows) {
// Emscripten's canonical executable is in the base directory
base.join(executable_name)
} else if os.is_windows() {
// On Windows, the executable is in the base directory
base.join(executable_name)
} else {
unimplemented!("Only Windows and Unix systems are supported.")
// On Unix, the executable is in `bin/`
base.join("bin").join(executable_name)
}
}

View file

@ -96,7 +96,13 @@ impl InstallRequest {
impl std::fmt::Display for InstallRequest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.request)
let request = self.request.to_canonical_string();
let download = self.download_request.to_string();
if request != download {
write!(f, "{request} ({download})")
} else {
write!(f, "{request}")
}
}
}
@ -223,6 +229,12 @@ pub(crate) async fn install(
.with_preference(VersionFilePreference::Versions),
)
.await?
.inspect(|file| {
debug!(
"Found Python version file at: {}",
file.path().user_display()
);
})
.map(PythonVersionFile::into_versions)
.unwrap_or_else(|| {
// If no version file is found and no requests were made
@ -237,14 +249,14 @@ pub(crate) async fn install(
}]
})
.into_iter()
.map(|a| InstallRequest::new(a, python_downloads_json_url.as_deref()))
.map(|request| InstallRequest::new(request, python_downloads_json_url.as_deref()))
.collect::<Result<Vec<_>>>()?
}
} else {
targets
.iter()
.map(|target| PythonRequest::parse(target.as_str()))
.map(|a| InstallRequest::new(a, python_downloads_json_url.as_deref()))
.map(|request| InstallRequest::new(request, python_downloads_json_url.as_deref()))
.collect::<Result<Vec<_>>>()?
};
@ -565,10 +577,14 @@ pub(crate) async fn install(
if !changelog.installed.is_empty() {
for install_key in &changelog.installed {
// Make a note if the selected python is non-native for the architecture,
// if none of the matching user requests were explicit
// Make a note if the selected python is non-native for the architecture, if none of the
// matching user requests were explicit.
//
// Emscripten is exempted as it is always "emulated".
let native_arch = Arch::from_env();
if install_key.arch().family() != native_arch.family() {
if install_key.arch().family() != native_arch.family()
&& !install_key.os().is_emscripten()
{
let not_explicit =
requests_by_new_installation
.get(install_key)

View file

@ -2991,8 +2991,9 @@ fn uninstall_last_patch() {
#[cfg(unix)] // Pyodide cannot be used on Windows
#[test]
fn python_install_pyodide() {
use assert_cmd::assert::OutputAssertExt;
let context: TestContext = TestContext::new_with_versions(&[])
.with_filtered_python_keys()
.with_filtered_exe_suffix()
.with_managed_python_dirs()
.with_python_download_cache();
@ -3004,7 +3005,7 @@ fn python_install_pyodide() {
----- stderr -----
Installed Python 3.13.2 in [TIME]
+ cpython-3.13.2-[PLATFORM] (python3.13)
+ pyodide-3.13.2-emscripten-wasm32-musl (python3.13)
");
let bin_python = context
@ -3014,8 +3015,7 @@ fn python_install_pyodide() {
// The executable should be installed in the bin directory
bin_python.assert(predicate::path::exists());
// On Unix, it should be a link
#[cfg(unix)]
// It should be a link
bin_python.assert(predicate::path::is_symlink());
// The link should be a path to the binary
@ -3023,7 +3023,7 @@ fn python_install_pyodide() {
filters => context.filters(),
}, {
insta::assert_snapshot!(
read_link(&bin_python), @"[TEMP_DIR]/managed/cpython-3.13.2-[PLATFORM]/bin/python3.13"
read_link(&bin_python), @"[TEMP_DIR]/managed/pyodide-3.13.2-emscripten-wasm32-musl/python"
);
});
@ -3043,7 +3043,7 @@ fn python_install_pyodide() {
success: true
exit_code: 0
----- stdout -----
[TEMP_DIR]/managed/cpython-3.13.2-[PLATFORM]/bin/python3.13
[TEMP_DIR]/managed/pyodide-3.13.2-emscripten-wasm32-musl/python
----- stderr -----
");
@ -3069,4 +3069,92 @@ fn python_install_pyodide() {
----- stderr -----
");
context.python_uninstall().arg("--all").assert().success();
fs_err::remove_dir_all(&context.venv).unwrap();
// Install via `pyodide`
uv_snapshot!(context.filters(), context.python_install().arg("pyodide"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.13.2 in [TIME]
+ pyodide-3.13.2-emscripten-wasm32-musl (python3.13)
");
context.python_uninstall().arg("--all").assert().success();
// Install via `pyodide@<version>`
uv_snapshot!(context.filters(), context.python_install().arg("pyodide@3.13"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.13.2 in [TIME]
+ pyodide-3.13.2-emscripten-wasm32-musl (python3.13)
");
// Find via `pyodide``
uv_snapshot!(context.filters(), context.python_find().arg("pyodide"), @r"
success: true
exit_code: 0
----- stdout -----
[TEMP_DIR]/managed/pyodide-3.13.2-emscripten-wasm32-musl/python
----- stderr -----
");
// Find without a request should fail
uv_snapshot!(context.filters(), context.python_find(), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: No interpreter found in virtual environments, managed installations, or search path
");
// Find with "cpython" should also fail
uv_snapshot!(context.filters(), context.python_find().arg("cpython"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: No interpreter found for CPython in virtual environments, managed installations, or search path
");
// Install a CPython interpreter
let context = context.with_filtered_python_keys();
uv_snapshot!(context.filters(), context.python_install().arg("cpython"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.13.6 in [TIME]
+ cpython-3.13.6-[PLATFORM]
");
// Now, we should prefer that
uv_snapshot!(context.filters(), context.python_find().arg("any"), @r"
success: true
exit_code: 0
----- stdout -----
[TEMP_DIR]/managed/cpython-3.13.6-[PLATFORM]/bin/python3.13
----- stderr -----
");
// Unless we request pyodide
uv_snapshot!(context.filters(), context.python_find().arg("pyodide"), @r"
success: true
exit_code: 0
----- stdout -----
[TEMP_DIR]/managed/pyodide-3.13.2-emscripten-wasm32-musl/python
----- stderr -----
");
}