diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7e532e37..772a1c4b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1493,7 +1493,7 @@ jobs: # newer versions. ./uv pip install -p venv-native/bin/python pyodide-build==0.30.3 pip - - name: "Install pyodide interpreter" + - name: "Install Pyodide interpreter" run: | source ./venv-native/bin/activate pyodide xbuildenv install 0.27.5 @@ -1502,13 +1502,25 @@ jobs: echo "PYODIDE_PYTHON=$PYODIDE_PYTHON" >> $GITHUB_ENV echo "PYODIDE_INDEX=$PYODIDE_INDEX" >> $GITHUB_ENV - - name: "Create pyodide virtual environment" + - name: "Create Pyodide virtual environment" run: | ./uv venv -p $PYODIDE_PYTHON venv-pyodide source ./venv-pyodide/bin/activate ./uv pip install --extra-index-url=$PYODIDE_INDEX --no-build numpy python -c 'import numpy' + - name: "Install Pyodide with uv python" + run: | + ./uv python install cpython-3.13.2-emscripten-wasm32-musl + + - name: "Create a Pyodide virtual environment using uv installed Python" + run: | + ./uv venv -p cpython-3.13.2-emscripten-wasm32-musl venv-pyodide2 + # TODO: be able to install Emscripten wheels here... + source ./venv-pyodide2/bin/activate + ./uv pip install packaging + python -c 'import packaging' + integration-test-github-actions: timeout-minutes: 10 needs: build-binary-linux-libc diff --git a/crates/uv-platform/src/arch.rs b/crates/uv-platform/src/arch.rs index 39ea6ca0d..ab35f9ef7 100644 --- a/crates/uv-platform/src/arch.rs +++ b/crates/uv-platform/src/arch.rs @@ -94,6 +94,10 @@ impl Arch { pub fn is_arm(&self) -> bool { matches!(self.family, target_lexicon::Architecture::Arm(_)) } + + pub fn is_wasm(&self) -> bool { + matches!(self.family, target_lexicon::Architecture::Wasm32) + } } impl std::fmt::Display for Arch { diff --git a/crates/uv-platform/src/lib.rs b/crates/uv-platform/src/lib.rs index 3d1f89f81..3fba0493a 100644 --- a/crates/uv-platform/src/lib.rs +++ b/crates/uv-platform/src/lib.rs @@ -4,6 +4,7 @@ use std::cmp; use std::fmt; use std::str::FromStr; use thiserror::Error; +use tracing::trace; pub use crate::arch::{Arch, ArchVariant}; pub use crate::libc::{Libc, LibcDetectionError, LibcVersion}; @@ -68,13 +69,20 @@ impl Platform { return true; } - // OS must match exactly - if self.os != other.os { + if !self.os.supports(other.os) { + trace!( + "Operating system `{}` is not compatible with `{}`", + self.os, other.os + ); return false; } - // Libc must match exactly - if self.libc != other.libc { + // Libc must match exactly, unless we're on emscripten — in which case it doesn't matter + if self.libc != other.libc && !(other.os.is_emscripten() || self.os.is_emscripten()) { + trace!( + "Libc `{}` is not compatible with `{}`", + self.libc, other.libc + ); return false; } @@ -94,10 +102,23 @@ impl Platform { return true; } + // Wasm32 can run on any architecture + if other.arch.is_wasm() { + return true; + } + // TODO: Allow inequal variants, as we don't implement variant support checks yet. // See https://github.com/astral-sh/uv/pull/9788 // For now, allow same architecture family as a fallback - self.arch.family() == other.arch.family() + if self.arch.family() != other.arch.family() { + trace!( + "Architecture `{}` is not compatible with `{}`", + self.arch, other.arch + ); + return false; + } + + true } } diff --git a/crates/uv-platform/src/libc.rs b/crates/uv-platform/src/libc.rs index 76fe92997..edf05255e 100644 --- a/crates/uv-platform/src/libc.rs +++ b/crates/uv-platform/src/libc.rs @@ -127,6 +127,7 @@ impl From<&uv_platform_tags::Os> for Libc { match value { uv_platform_tags::Os::Manylinux { .. } => Self::Some(target_lexicon::Environment::Gnu), uv_platform_tags::Os::Musllinux { .. } => Self::Some(target_lexicon::Environment::Musl), + uv_platform_tags::Os::Pyodide { .. } => Self::Some(target_lexicon::Environment::Musl), _ => Self::None, } } diff --git a/crates/uv-platform/src/os.rs b/crates/uv-platform/src/os.rs index 89493adc0..95372bed9 100644 --- a/crates/uv-platform/src/os.rs +++ b/crates/uv-platform/src/os.rs @@ -20,9 +20,27 @@ impl Os { matches!(self.0, target_lexicon::OperatingSystem::Windows) } + pub fn is_emscripten(&self) -> bool { + matches!(self.0, target_lexicon::OperatingSystem::Emscripten) + } + pub fn is_macos(&self) -> bool { matches!(self.0, target_lexicon::OperatingSystem::Darwin(_)) } + + /// Whether this OS can run the other OS. + pub fn supports(&self, other: Self) -> bool { + // Emscripten cannot run on Windows, but all other OSes can run Emscripten. + if other.is_emscripten() { + return !self.is_windows(); + } + if self.is_windows() && other.is_emscripten() { + return false; + } + + // Otherwise, we require an exact match + *self == other + } } impl Display for Os { diff --git a/crates/uv-python/download-metadata.json b/crates/uv-python/download-metadata.json index 0c088efe5..718869975 100644 --- a/crates/uv-python/download-metadata.json +++ b/crates/uv-python/download-metadata.json @@ -10511,6 +10511,22 @@ "sha256": "e5a904ecfb4061389773dd655d3b5665447c80cbf2948fcb1c07e92716eed955", "variant": null }, + "cpython-3.13.2-emscripten-wasm32-musl": { + "name": "cpython", + "arch": { + "family": "wasm32", + "variant": null + }, + "os": "emscripten", + "libc": "musl", + "major": 3, + "minor": 13, + "patch": 2, + "prerelease": "", + "url": "https://github.com/pyodide/pyodide/releases/download/0.28.0/xbuildenv-0.28.0.tar.bz2", + "sha256": null, + "variant": null + }, "cpython-3.13.2-linux-aarch64-gnu": { "name": "cpython", "arch": { @@ -15055,6 +15071,22 @@ "sha256": "848405b92bda20fad1f9bba99234c7d3f11e0b31e46f89835d1cb3d735e932aa", "variant": null }, + "cpython-3.12.7-emscripten-wasm32-musl": { + "name": "cpython", + "arch": { + "family": "wasm32", + "variant": null + }, + "os": "emscripten", + "libc": "musl", + "major": 3, + "minor": 12, + "patch": 7, + "prerelease": "", + "url": "https://github.com/pyodide/pyodide/releases/download/0.27.7/xbuildenv-0.27.7.tar.bz2", + "sha256": null, + "variant": null + }, "cpython-3.12.7-linux-aarch64-gnu": { "name": "cpython", "arch": { @@ -17103,6 +17135,22 @@ "sha256": "eca96158c1568dedd9a0b3425375637a83764d1fa74446438293089a8bfac1f8", "variant": null }, + "cpython-3.12.1-emscripten-wasm32-musl": { + "name": "cpython", + "arch": { + "family": "wasm32", + "variant": null + }, + "os": "emscripten", + "libc": "musl", + "major": 3, + "minor": 12, + "patch": 1, + "prerelease": "", + "url": "https://github.com/pyodide/pyodide/releases/download/0.26.4/xbuildenv-0.26.4.tar.bz2", + "sha256": null, + "variant": null + }, "cpython-3.12.1-linux-aarch64-gnu": { "name": "cpython", "arch": { @@ -21439,6 +21487,22 @@ "sha256": "f710b8d60621308149c100d5175fec39274ed0b9c99645484fd93d1716ef4310", "variant": null }, + "cpython-3.11.3-emscripten-wasm32-musl": { + "name": "cpython", + "arch": { + "family": "wasm32", + "variant": null + }, + "os": "emscripten", + "libc": "musl", + "major": 3, + "minor": 11, + "patch": 3, + "prerelease": "", + "url": "https://github.com/pyodide/pyodide/releases/download/0.25.1/xbuildenv-0.25.1.tar.bz2", + "sha256": null, + "variant": null + }, "cpython-3.11.3-linux-aarch64-gnu": { "name": "cpython", "arch": { diff --git a/crates/uv-python/fetch-download-metadata.py b/crates/uv-python/fetch-download-metadata.py index ec2b4835e..71dd56a13 100755 --- a/crates/uv-python/fetch-download-metadata.py +++ b/crates/uv-python/fetch-download-metadata.py @@ -550,6 +550,92 @@ class PyPyFinder(Finder): download.sha256 = checksums.get(download.filename) +class PyodideFinder(Finder): + implementation = ImplementationName.CPYTHON + + RELEASE_URL = "https://api.github.com/repos/pyodide/pyodide/releases" + METADATA_URL = ( + "https://pyodide.github.io/pyodide/api/pyodide-cross-build-environments.json" + ) + + TRIPLE = PlatformTriple( + platform="emscripten", + arch=Arch("wasm32"), + libc="musl", + ) + + def __init__(self, client: httpx.AsyncClient): + self.client = client + + async def find(self) -> list[PythonDownload]: + downloads = await self._fetch_downloads() + await self._fetch_checksums(downloads, n=10) + return downloads + + async def _fetch_downloads(self) -> list[PythonDownload]: + # This will only download the first page, i.e., ~30 releases + [release_resp, meta_resp] = await asyncio.gather( + self.client.get(self.RELEASE_URL), self.client.get(self.METADATA_URL) + ) + release_resp.raise_for_status() + meta_resp.raise_for_status() + releases = release_resp.json() + metadata = meta_resp.json()["releases"] + + maj_minor_seen = set() + results = [] + for release in releases: + pyodide_version = release["tag_name"] + meta = metadata.get(pyodide_version, None) + if meta is None: + continue + + maj_min = pyodide_version.rpartition(".")[0] + # Only keep latest + if maj_min in maj_minor_seen: + continue + maj_minor_seen.add(maj_min) + + python_version = Version.from_str(meta["python_version"]) + # Find xbuildenv asset + for asset in release["assets"]: + if asset["name"].startswith("xbuildenv"): + break + + url = asset["browser_download_url"] + results.append( + PythonDownload( + release=0, + version=python_version, + triple=self.TRIPLE, + flavor=pyodide_version, + implementation=self.implementation, + filename=asset["name"], + url=url, + ) + ) + + return results + + async def _fetch_checksums(self, downloads: list[PythonDownload], n: int) -> None: + for idx, batch in enumerate(batched(downloads, n)): + logging.info("Fetching Pyodide checksums: %d/%d", idx * n, len(downloads)) + checksum_requests = [] + for download in batch: + url = download.url + ".sha256" + checksum_requests.append(self.client.get(url)) + for download, resp in zip( + batch, await asyncio.gather(*checksum_requests), strict=False + ): + try: + resp.raise_for_status() + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + continue + raise + download.sha256 = resp.text.strip() + + class GraalPyFinder(Finder): implementation = ImplementationName.GRAALPY @@ -751,6 +837,7 @@ async def find() -> None: CPythonFinder(client), PyPyFinder(client), GraalPyFinder(client), + PyodideFinder(client), ] downloads = [] diff --git a/crates/uv-python/src/discovery.rs b/crates/uv-python/src/discovery.rs index a658e6b25..f50d31020 100644 --- a/crates/uv-python/src/discovery.rs +++ b/crates/uv-python/src/discovery.rs @@ -342,7 +342,8 @@ fn python_executables_from_installed<'a>( installed_installations.root().user_display() ); let installations = installed_installations.find_matching_current_platform()?; - // Check that the Python version and platform satisfy the request to avoid unnecessary interpreter queries later + // Check that the Python version and platform satisfy the request to avoid + // unnecessary interpreter queries later Ok(installations .into_iter() .filter(move |installation| { @@ -351,7 +352,7 @@ fn python_executables_from_installed<'a>( return false; } if !platform.matches(installation.platform()) { - debug!("Skipping managed installation `{installation}`: does not satisfy `{platform}`"); + debug!("Skipping managed installation `{installation}`: does not satisfy requested platform `{platform}`"); return false; } true @@ -1259,6 +1260,7 @@ 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) { @@ -1276,6 +1278,15 @@ 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. @@ -1352,6 +1363,10 @@ 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 { diff --git a/crates/uv-python/src/downloads.rs b/crates/uv-python/src/downloads.rs index 223ee72f0..093f768e8 100644 --- a/crates/uv-python/src/downloads.rs +++ b/crates/uv-python/src/downloads.rs @@ -176,7 +176,7 @@ impl PlatformRequest { /// Check if this platform request is satisfied by a platform. pub fn matches(&self, platform: &Platform) -> bool { if let Some(os) = self.os { - if platform.os != os { + if !platform.os.supports(os) { return false; } } @@ -452,24 +452,13 @@ impl PythonDownloadRequest { return false; } } - if let Some(os) = self.os() { - if &interpreter.os() != os { - debug!( - "Skipping interpreter at `{executable}`: operating system `{}` does not match request `{os}`", - interpreter.os() - ); - return false; - } - } - if let Some(arch) = self.arch() { - let interpreter_platform = Platform::from(interpreter.platform()); - if !arch.satisfied_by(&interpreter_platform) { - debug!( - "Skipping interpreter at `{executable}`: architecture `{}` does not match request `{arch}`", - interpreter.arch() - ); - return false; - } + let platform = self.platform(); + let interpreter_platform = Platform::from(interpreter.platform()); + if !platform.matches(&interpreter_platform) { + debug!( + "Skipping interpreter at `{executable}`: platform `{interpreter_platform}` does not match request `{platform}`", + ); + return false; } if let Some(implementation) = self.implementation() { let interpreter_implementation = interpreter.implementation_name(); @@ -482,15 +471,6 @@ impl PythonDownloadRequest { return false; } } - if let Some(libc) = self.libc() { - if &interpreter.libc() != libc { - debug!( - "Skipping interpreter at `{executable}`: libc `{}` does not match request `{libc}`", - interpreter.libc() - ); - return false; - } - } true } @@ -1066,13 +1046,32 @@ impl ManagedPythonDownload { // If the distribution is a `full` archive, the Python installation is in the `install` directory. if extracted.join("install").is_dir() { extracted = extracted.join("install"); + // If the distribution is a Pyodide archive, the Python installation is in the `pyodide-root/dist` directory. + } else if self.os().is_emscripten() { + extracted = extracted.join("pyodide-root").join("dist"); } - // If the distribution is missing a `python`-to-`pythonX.Y` symlink, add it. PEP 394 permits - // it, and python-build-standalone releases after `20240726` include it, but releases prior - // to that date do not. #[cfg(unix)] { + // Pyodide distributions require all of the supporting files to be alongside the Python + // executable, so they don't have a `bin` directory. We create it and link + // `bin/pythonX.Y` to `dist/python`. + if self.os().is_emscripten() { + fs_err::create_dir_all(extracted.join("bin"))?; + fs_err::os::unix::fs::symlink( + "../python", + extracted + .join("bin") + .join(format!("python{}.{}", self.key.major, self.key.minor)), + )?; + } + + // If the distribution is missing a `python` -> `pythonX.Y` symlink, add it. + // + // Pyodide releases never contain this link by default. + // + // PEP 394 permits it, and python-build-standalone releases after `20240726` include it, + // but releases prior to that date do not. match fs_err::os::unix::fs::symlink( format!("python{}.{}", self.key.major, self.key.minor), extracted.join("bin").join("python"), diff --git a/crates/uv-python/src/lib.rs b/crates/uv-python/src/lib.rs index 0b3309e1a..09b568a23 100644 --- a/crates/uv-python/src/lib.rs +++ b/crates/uv-python/src/lib.rs @@ -325,6 +325,89 @@ mod tests { Ok(()) } + fn create_mock_pyodide_interpreter(path: &Path, version: &PythonVersion) -> Result<()> { + let json = indoc! {r##" + { + "result": "success", + "platform": { + "os": { + "name": "pyodide", + "major": 2025, + "minor": 0 + }, + "arch": "wasm32" + }, + "manylinux_compatible": false, + "standalone": false, + "markers": { + "implementation_name": "cpython", + "implementation_version": "{FULL_VERSION}", + "os_name": "posix", + "platform_machine": "wasm32", + "platform_python_implementation": "CPython", + "platform_release": "4.0.9", + "platform_system": "Emscripten", + "platform_version": "#1", + "python_full_version": "{FULL_VERSION}", + "python_version": "{VERSION}", + "sys_platform": "emscripten" + }, + "sys_base_exec_prefix": "/", + "sys_base_prefix": "/", + "sys_prefix": "/", + "sys_executable": "{PATH}", + "sys_path": [ + "", + "/lib/python313.zip", + "/lib/python{VERSION}", + "/lib/python{VERSION}/lib-dynload", + "/lib/python{VERSION}/site-packages" + ], + "site_packages": [ + "/lib/python{VERSION}/site-packages" + ], + "stdlib": "//lib/python{VERSION}", + "scheme": { + "platlib": "//lib/python{VERSION}/site-packages", + "purelib": "//lib/python{VERSION}/site-packages", + "include": "//include/python{VERSION}", + "scripts": "//bin", + "data": "/" + }, + "virtualenv": { + "purelib": "lib/python{VERSION}/site-packages", + "platlib": "lib/python{VERSION}/site-packages", + "include": "include/site/python{VERSION}", + "scripts": "bin", + "data": "" + }, + "pointer_size": "32", + "gil_disabled": false + } + "##}; + + let json = json + .replace( + "{PATH}", + path.to_str().expect("Path can be represented as string"), + ) + .replace("{FULL_VERSION}", &version.to_string()) + .replace("{VERSION}", &version.without_patch().to_string()); + + fs_err::create_dir_all(path.parent().unwrap())?; + fs_err::write( + path, + formatdoc! {r" + #!/bin/sh + echo '{json}' + "}, + )?; + + fs_err::set_permissions(path, std::os::unix::fs::PermissionsExt::from_mode(0o770))?; + + Ok(()) + } + /// Create a mock Python 2 interpreter executable which returns a fixed error message mocking /// invocation of Python 2 with the `-I` flag as done by our query script. fn create_mock_python2_interpreter(path: &Path) -> Result<()> { @@ -372,6 +455,16 @@ 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); + Self::create_mock_pyodide_interpreter( + &path.join(python), + &PythonVersion::from_str(version).unwrap(), + )?; + Ok(()) + } + /// Create fake Python interpreters the given Python versions. /// /// Adds them to the test context search path. @@ -2606,4 +2699,44 @@ mod tests { Ok(()) } + + #[test] + fn find_python_pyodide() -> Result<()> { + let mut context = TestContext::new()?; + + context.add_pyodide_version("3.13.2")?; + let python = context.run(|| { + find_python_installation( + &PythonRequest::Default, + 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 + context.add_python_versions(&["3.15.7"])?; + + let python = context.run(|| { + find_python_installation( + &PythonRequest::Default, + EnvironmentPreference::Any, + PythonPreference::OnlySystem, + &context.cache, + Preview::default(), + ) + })??; + assert_eq!( + python.interpreter().python_full_version().to_string(), + "3.15.7" + ); + + Ok(()) + } } diff --git a/crates/uv-python/src/managed.rs b/crates/uv-python/src/managed.rs index 7207e354a..c20dd2b7e 100644 --- a/crates/uv-python/src/managed.rs +++ b/crates/uv-python/src/managed.rs @@ -263,7 +263,13 @@ impl ManagedPythonInstallations { let iter = Self::from_settings(None)? .find_all()? - .filter(move |installation| platform.supports(installation.platform())); + .filter(move |installation| { + if !platform.supports(installation.platform()) { + debug!("Skipping managed installation `{installation}`: not supported by current platform `{platform}`"); + return false; + } + true + }); Ok(iter) } @@ -538,6 +544,11 @@ impl ManagedPythonInstallation { /// Ensure the environment is marked as externally managed with the /// standard `EXTERNALLY-MANAGED` file. pub fn ensure_externally_managed(&self) -> Result<(), Error> { + if self.key.os().is_emscripten() { + // Emscripten's stdlib is a zip file so we can't put an + // EXTERNALLY-MANAGED inside. + return Ok(()); + } // Construct the path to the `stdlib` directory. let stdlib = if self.key.os().is_windows() { self.python_dir().join("Lib") @@ -563,6 +574,11 @@ impl ManagedPythonInstallation { /// Ensure that the `sysconfig` data is patched to match the installation path. pub fn ensure_sysconfig_patched(&self) -> Result<(), Error> { if cfg!(unix) { + if self.key.os().is_emscripten() { + // Emscripten's stdlib is a zip file so we can't update the + // sysconfig directly + return Ok(()); + } if *self.implementation() == ImplementationName::CPython { sysconfig::update_sysconfig( self.path(), diff --git a/crates/uv/tests/it/pip_compile.rs b/crates/uv/tests/it/pip_compile.rs index 345ea3c70..6ebf21398 100644 --- a/crates/uv/tests/it/pip_compile.rs +++ b/crates/uv/tests/it/pip_compile.rs @@ -1790,14 +1790,14 @@ fn compile_python_build_version_different_than_target() -> Result<()> { .arg("3.12") .arg("-p") .arg("3.13") - .env_remove(EnvVars::VIRTUAL_ENV), @r###" + .env_remove(EnvVars::VIRTUAL_ENV), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: No interpreter found for Python 3.13 in [PYTHON SOURCES] - "### + " ); // `UV_PYTHON` is ignored if `--python-version` is set diff --git a/crates/uv/tests/it/pip_sync.rs b/crates/uv/tests/it/pip_sync.rs index 3c4bd8243..5baa2d2b4 100644 --- a/crates/uv/tests/it/pip_sync.rs +++ b/crates/uv/tests/it/pip_sync.rs @@ -5411,7 +5411,7 @@ fn target_system() -> Result<()> { uv_snapshot!(context.filters(), context.pip_sync() .arg("requirements.in") .arg("--target") - .arg("target"), @r###" + .arg("target"), @r" success: true exit_code: 0 ----- stdout ----- @@ -5422,7 +5422,7 @@ fn target_system() -> Result<()> { Prepared 1 package in [TIME] Installed 1 package in [TIME] + iniconfig==2.0.0 - "###); + "); // Ensure that the package is present in the target directory. assert!(context.temp_dir.child("target").child("iniconfig").is_dir()); diff --git a/crates/uv/tests/it/python_find.rs b/crates/uv/tests/it/python_find.rs index aaf349910..b25e60447 100644 --- a/crates/uv/tests/it/python_find.rs +++ b/crates/uv/tests/it/python_find.rs @@ -23,14 +23,14 @@ fn python_find() { "); // We find the first interpreter on the path - uv_snapshot!(context.filters(), context.python_find(), @r###" + uv_snapshot!(context.filters(), context.python_find(), @r" success: true exit_code: 0 ----- stdout ----- [PYTHON-3.11] ----- stderr ----- - "###); + "); // Request Python 3.12 uv_snapshot!(context.filters(), context.python_find().arg("3.12"), @r###" @@ -53,14 +53,14 @@ fn python_find() { "###); // Request CPython - uv_snapshot!(context.filters(), context.python_find().arg("cpython"), @r###" + uv_snapshot!(context.filters(), context.python_find().arg("cpython"), @r" success: true exit_code: 0 ----- stdout ----- [PYTHON-3.11] ----- stderr ----- - "###); + "); // Request CPython 3.12 uv_snapshot!(context.filters(), context.python_find().arg("cpython@3.12"), @r###" @@ -119,14 +119,14 @@ fn python_find() { // Swap the order of the Python versions context.python_versions.reverse(); - uv_snapshot!(context.filters(), context.python_find(), @r###" + uv_snapshot!(context.filters(), context.python_find(), @r" success: true exit_code: 0 ----- stdout ----- [PYTHON-3.12] ----- stderr ----- - "###); + "); // Request Python 3.11 uv_snapshot!(context.filters(), context.python_find().arg("3.11"), @r###" @@ -174,14 +174,14 @@ fn python_find_pin() { "###); // Or `--no-config` is used - uv_snapshot!(context.filters(), context.python_find().arg("--no-config"), @r###" + uv_snapshot!(context.filters(), context.python_find().arg("--no-config"), @r" success: true exit_code: 0 ----- stdout ----- [PYTHON-3.11] ----- stderr ----- - "###); + "); let child_dir = context.temp_dir.child("child"); child_dir.create_dir_all().unwrap(); @@ -308,14 +308,14 @@ fn python_find_project() { .unwrap(); // We should respect the project's required version, not the first on the path - uv_snapshot!(context.filters(), context.python_find(), @r###" + uv_snapshot!(context.filters(), context.python_find(), @r" success: true exit_code: 0 ----- stdout ----- [PYTHON-3.11] ----- stderr ----- - "###); + "); // Unless explicitly requested uv_snapshot!(context.filters(), context.python_find().arg("3.10"), @r" @@ -329,14 +329,14 @@ fn python_find_project() { "); // Or `--no-project` is used - uv_snapshot!(context.filters(), context.python_find().arg("--no-project"), @r###" + uv_snapshot!(context.filters(), context.python_find().arg("--no-project"), @r" success: true exit_code: 0 ----- stdout ----- [PYTHON-3.10] ----- stderr ----- - "###); + "); // But a pin should take precedence uv_snapshot!(context.filters(), context.python_pin().arg("3.12"), @r###" @@ -393,14 +393,14 @@ fn python_find_project() { "#}) .unwrap(); - uv_snapshot!(context.filters(), context.python_find().current_dir(&child_dir), @r###" + uv_snapshot!(context.filters(), context.python_find().current_dir(&child_dir), @r" success: true exit_code: 0 ----- stdout ----- [PYTHON-3.11] ----- stderr ----- - "###); + "); } #[test] @@ -517,14 +517,14 @@ fn python_find_venv() { fs_err::remove_dir_all(context.temp_dir.child(".venv")).unwrap(); // And query from there... we should not find the child virtual environment - uv_snapshot!(context.filters(), context.python_find(), @r###" + uv_snapshot!(context.filters(), context.python_find(), @r" success: true exit_code: 0 ----- stdout ----- [PYTHON-3.11] ----- stderr ----- - "###); + "); // Unless, it is requested by path #[cfg(not(windows))] @@ -588,14 +588,14 @@ fn python_find_venv() { ) .unwrap(); - uv_snapshot!(context.filters(), context.python_find().env(EnvVars::UV_TEST_PYTHON_PATH, path.as_os_str()), @r###" + uv_snapshot!(context.filters(), context.python_find().env(EnvVars::UV_TEST_PYTHON_PATH, path.as_os_str()), @r" success: true exit_code: 0 ----- stdout ----- [PYTHON-3.11] ----- stderr ----- - "###); + "); } } diff --git a/crates/uv/tests/it/python_install.rs b/crates/uv/tests/it/python_install.rs index 2a20b5d3d..1bce072b9 100644 --- a/crates/uv/tests/it/python_install.rs +++ b/crates/uv/tests/it/python_install.rs @@ -2987,3 +2987,86 @@ fn uninstall_last_patch() { "# ); } + +#[cfg(unix)] // Pyodide cannot be used on Windows +#[test] +fn python_install_pyodide() { + let context: TestContext = TestContext::new_with_versions(&[]) + .with_filtered_python_keys() + .with_filtered_exe_suffix() + .with_managed_python_dirs() + .with_python_download_cache(); + + uv_snapshot!(context.filters(), context.python_install().arg("cpython-3.13.2-emscripten-wasm32-musl"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Installed Python 3.13.2 in [TIME] + + cpython-3.13.2-[PLATFORM] (python3.13) + "); + + let bin_python = context + .bin_dir + .child(format!("python3.13{}", std::env::consts::EXE_SUFFIX)); + + // The executable should be installed in the bin directory + bin_python.assert(predicate::path::exists()); + + // On Unix, it should be a link + #[cfg(unix)] + bin_python.assert(predicate::path::is_symlink()); + + // The link should be a path to the binary + insta::with_settings!({ + filters => context.filters(), + }, { + insta::assert_snapshot!( + read_link(&bin_python), @"[TEMP_DIR]/managed/cpython-3.13.2-[PLATFORM]/bin/python3.13" + ); + }); + + // The executable should "work" + uv_snapshot!(context.filters(), Command::new(bin_python.as_os_str()) + .arg("-c").arg("import subprocess; print('hello world')"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + hello world + + ----- stderr ----- + "###); + + // We should be able to find the Pyodide interpreter + uv_snapshot!(context.filters(), context.python_find().arg("cpython-3.13.2-emscripten-wasm32-musl"), @r" + success: true + exit_code: 0 + ----- stdout ----- + [TEMP_DIR]/managed/cpython-3.13.2-[PLATFORM]/bin/python3.13 + + ----- stderr ----- + "); + + // We should be able to create a virtual environment with it + uv_snapshot!(context.filters(), context.venv().arg("--python").arg("cpython-3.13.2-emscripten-wasm32-musl"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.13.2 + Creating virtual environment at: .venv + Activate with: source .venv/[BIN]/activate + "); + + // We should be able to run the Python in the virtual environment + uv_snapshot!(context.filters(), context.python_command().arg("-c").arg("import subprocess; print('hello world')"), @r" + success: true + exit_code: 0 + ----- stdout ----- + hello world + + ----- stderr ----- + "); +}