From 2c54d3929c11fd9b5c8b0235ab201fe9d5501751 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Wed, 13 Aug 2025 14:08:55 -0500 Subject: [PATCH] Allow selection of pyodide interpreters with "pyodide" (#15256) --- crates/uv-python/src/discovery.rs | 33 ++------ crates/uv-python/src/downloads.rs | 68 +++++++++------ crates/uv-python/src/implementation.rs | 21 ++++- crates/uv-python/src/installation.rs | 13 ++- crates/uv-python/src/lib.rs | 24 +++++- crates/uv-python/src/managed.rs | 49 +++++++---- crates/uv/src/commands/python/install.rs | 28 +++++-- crates/uv/tests/it/python_install.rs | 100 +++++++++++++++++++++-- 8 files changed, 243 insertions(+), 93 deletions(-) diff --git a/crates/uv-python/src/discovery.rs b/crates/uv-python/src/discovery.rs index f50d31020..d52563052 100644 --- a/crates/uv-python/src/discovery.rs +++ b/crates/uv-python/src/discovery.rs @@ -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", ], ); diff --git a/crates/uv-python/src/downloads.rs b/crates/uv-python/src/downloads.rs index 62fa37112..6040c2f98 100644 --- a/crates/uv-python/src/downloads.rs +++ b/crates/uv-python/src/downloads.rs @@ -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); } diff --git a/crates/uv-python/src/implementation.rs b/crates/uv-python/src/implementation.rs index 4393d56f4..2bb0cec9d 100644 --- a/crates/uv-python/src/implementation.rs +++ b/crates/uv-python/src/implementation.rs @@ -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 { - ["cpython", "pypy", "graalpy"].into_iter() + ["cpython", "pypy", "graalpy", "pyodide"].into_iter() } pub(crate) fn iter_all() -> impl Iterator { - [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())), } } diff --git a/crates/uv-python/src/installation.rs b/crates/uv-python/src/installation.rs index 9ac1e6284..6c4e59b3a 100644 --- a/crates/uv-python/src/installation.rs +++ b/crates/uv-python/src/installation.rs @@ -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, diff --git a/crates/uv-python/src/lib.rs b/crates/uv-python/src/lib.rs index 09b568a23..a775c5df4 100644 --- a/crates/uv-python/src/lib.rs +++ b/crates/uv-python/src/lib.rs @@ -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(|| { diff --git a/crates/uv-python/src/managed.rs b/crates/uv-python/src/managed.rs index c20dd2b7e..34e239e0a 100644 --- a/crates/uv-python/src/managed.rs +++ b/crates/uv-python/src/managed.rs @@ -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 { 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) } } diff --git a/crates/uv/src/commands/python/install.rs b/crates/uv/src/commands/python/install.rs index 3173133c7..4bdeab474 100644 --- a/crates/uv/src/commands/python/install.rs +++ b/crates/uv/src/commands/python/install.rs @@ -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::>>()? } } 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::>>()? }; @@ -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) diff --git a/crates/uv/tests/it/python_install.rs b/crates/uv/tests/it/python_install.rs index 1bce072b9..a9f947766 100644 --- a/crates/uv/tests/it/python_install.rs +++ b/crates/uv/tests/it/python_install.rs @@ -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@` + 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 ----- + "); }