From 91b033fd37fbed92051d7ce645922b92808001da Mon Sep 17 00:00:00 2001 From: Hood Chatham Date: Wed, 9 Jul 2025 12:30:33 +0200 Subject: [PATCH 01/15] Add support for installing pyodide Pythons WIP --- crates/uv-platform/src/arch.rs | 9 ++ crates/uv-platform/src/libc.rs | 1 + crates/uv-platform/src/os.rs | 4 + crates/uv-python/download-metadata.json | 64 ++++++++++ crates/uv-python/fetch-download-metadata.py | 87 +++++++++++++ crates/uv-python/src/downloads.rs | 130 +++++++++++++------- crates/uv-python/src/managed.rs | 18 ++- 7 files changed, 263 insertions(+), 50 deletions(-) diff --git a/crates/uv-platform/src/arch.rs b/crates/uv-platform/src/arch.rs index f643124897ce4..2dd30d8eebf6d 100644 --- a/crates/uv-platform/src/arch.rs +++ b/crates/uv-platform/src/arch.rs @@ -93,6 +93,15 @@ impl Arch { return true; } + if other + == (Arch { + family: target_lexicon::Architecture::Wasm32, + variant: None, + }) + { + return true; + } + // TODO: Implement `variant` support checks // Windows ARM64 runs emulated x86_64 binaries transparently diff --git a/crates/uv-platform/src/libc.rs b/crates/uv-platform/src/libc.rs index 184f0487ce1da..4631932347cb2 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 { .. } => Libc::Some(target_lexicon::Environment::Gnu), uv_platform_tags::Os::Musllinux { .. } => Libc::Some(target_lexicon::Environment::Musl), + uv_platform_tags::Os::Pyodide { .. } => Self::Some(target_lexicon::Environment::Musl), _ => Libc::None, } } diff --git a/crates/uv-platform/src/os.rs b/crates/uv-platform/src/os.rs index 01f896f3f759f..f8d4cded97d64 100644 --- a/crates/uv-platform/src/os.rs +++ b/crates/uv-platform/src/os.rs @@ -19,6 +19,10 @@ impl Os { pub fn is_windows(&self) -> bool { matches!(self.0, target_lexicon::OperatingSystem::Windows) } + + pub fn is_emscripten(&self) -> bool { + matches!(self.0, target_lexicon::OperatingSystem::Emscripten) + } } impl Display for Os { diff --git a/crates/uv-python/download-metadata.json b/crates/uv-python/download-metadata.json index f37c33ca1b3c0..5e631bb5b5984 100644 --- a/crates/uv-python/download-metadata.json +++ b/crates/uv-python/download-metadata.json @@ -9679,6 +9679,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": { @@ -14223,6 +14239,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": { @@ -16271,6 +16303,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": { @@ -20607,6 +20655,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 ec2b4835e365c..71dd56a13a968 100755 --- a/crates/uv-python/fetch-download-metadata.py +++ b/crates/uv-python/fetch-download-metadata.py @@ -550,6 +550,92 @@ async def _fetch_checksums(self, downloads: list[PythonDownload]) -> None: 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/downloads.rs b/crates/uv-python/src/downloads.rs index 9e1e03c912a7e..7f5731ab5d18f 100644 --- a/crates/uv-python/src/downloads.rs +++ b/crates/uv-python/src/downloads.rs @@ -378,21 +378,31 @@ impl PythonDownloadRequest { /// Whether this request is satisfied by an installation key. pub fn satisfied_by_key(&self, key: &PythonInstallationKey) -> bool { - if let Some(os) = &self.os { - if key.os != *os { - return false; - } + let key_is_emscripten = key.os().is_emscripten(); + let target_is_windows = self.os().filter(|x| x.is_windows()).is_some(); + // Emscripten is not compatible with windows + if key_is_emscripten && target_is_windows { + return false; } + // Emscripten is compatible with all other platforms so skip platform + // check in this case. + if !key_is_emscripten { + if let Some(os) = &self.os { + if key.os != *os { + return false; + } + } - if let Some(arch) = &self.arch { - if !arch.satisfied_by(key.arch) { - return false; + if let Some(arch) = &self.arch { + if !arch.satisfied_by(key.arch) { + return false; + } } - } - if let Some(libc) = &self.libc { - if key.libc != *libc { - return false; + if let Some(libc) = &self.libc { + if key.libc != *libc { + return false; + } } } if let Some(implementation) = &self.implementation { @@ -452,24 +462,6 @@ impl PythonDownloadRequest { return false; } } - if let Some(os) = self.os() { - let interpreter_os = Os::from(interpreter.platform().os()); - if &interpreter_os != os { - debug!( - "Skipping interpreter at `{executable}`: operating system `{interpreter_os}` does not match request `{os}`" - ); - return false; - } - } - if let Some(arch) = self.arch() { - let interpreter_arch = Arch::from(&interpreter.platform().arch()); - if !arch.satisfied_by(interpreter_arch) { - debug!( - "Skipping interpreter at `{executable}`: architecture `{interpreter_arch}` does not match request `{arch}`" - ); - return false; - } - } if let Some(implementation) = self.implementation() { let interpreter_implementation = interpreter.implementation_name(); if LenientImplementationName::from(interpreter_implementation) @@ -481,13 +473,40 @@ impl PythonDownloadRequest { return false; } } - if let Some(libc) = self.libc() { - let interpreter_libc = Libc::from(interpreter.platform().os()); - if &interpreter_libc != libc { - debug!( - "Skipping interpreter at `{executable}`: libc `{interpreter_libc}` does not match request `{libc}`" - ); - return false; + let interpreter_os = Os::from(interpreter.platform().os()); + let interp_is_emscripten = interpreter_os.is_emscripten(); + let target_is_windows = self.os().filter(|os| os.is_windows()).is_some(); + // Emscripten does not work on windows + if interp_is_emscripten && target_is_windows { + return false; + } + // Emscripten works on all other platforms + if !interp_is_emscripten { + if let Some(os) = self.os() { + if &interpreter_os != os { + debug!( + "Skipping interpreter at `{executable}`: operating system `{interpreter_os}` does not match request `{os}`" + ); + return false; + } + } + if let Some(arch) = self.arch() { + let interpreter_arch = Arch::from(&interpreter.platform().arch()); + if !arch.satisfied_by(interpreter_arch) { + debug!( + "Skipping interpreter at `{executable}`: architecture `{interpreter_arch}` does not match request `{arch}`" + ); + return false; + } + } + if let Some(libc) = self.libc() { + let interpreter_libc = Libc::from(interpreter.platform().os()); + if &interpreter_libc != libc { + debug!( + "Skipping interpreter at `{executable}`: libc `{interpreter_libc}` does not match request `{libc}`" + ); + return false; + } } } true @@ -789,7 +808,7 @@ impl ManagedPythonDownload { reporter: Option<&dyn Reporter>, ) -> Result { let url = self.download_url(python_install_mirror, pypy_install_mirror)?; - let path = installation_dir.join(self.key().to_string()); + let mut path = installation_dir.join(self.key().to_string()); // If it is not a reinstall and the dir already exists, return it. if !reinstall && path.is_dir() { @@ -915,18 +934,22 @@ impl ManagedPythonDownload { extracted = extracted.join("install"); } - // 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. + let is_emscripten: bool = self.os().is_emscripten(); + #[cfg(unix)] { - match fs_err::os::unix::fs::symlink( - format!("python{}.{}", self.key.major, self.key.minor), - extracted.join("bin").join("python"), - ) { - Ok(()) => {} - Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {} - Err(err) => return Err(err.into()), + let discard_already_exists_error = |res: io::Result<()>| match res { + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => Ok(()), + _ => res, + }; + if !is_emscripten { + // 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. + discard_already_exists_error(fs_err::os::unix::fs::symlink( + format!("python{}.{}", self.key.major, self.key.minor), + extracted.join("bin").join("python"), + ))?; } } @@ -936,6 +959,16 @@ impl ManagedPythonDownload { fs_err::tokio::remove_dir_all(&path).await?; } + if is_emscripten { + fs_err::create_dir(&path)?; + extracted.push("pyodide-root/dist"); + path.push("bin"); + fs_err::copy( + extracted.join("python"), + extracted.join(format!("python{}.{}", self.key.major, self.key.minor)), + )?; + } + // Persist it to the target. debug!("Moving {} to {}", extracted.display(), path.user_display()); rename_with_retry(extracted, &path) @@ -945,6 +978,9 @@ impl ManagedPythonDownload { err, })?; + if is_emscripten { + path.pop(); + } Ok(DownloadResult::Fetched(path)) } diff --git a/crates/uv-python/src/managed.rs b/crates/uv-python/src/managed.rs index 69d12a0a37553..765d897ec24d7 100644 --- a/crates/uv-python/src/managed.rs +++ b/crates/uv-python/src/managed.rs @@ -266,12 +266,14 @@ impl ManagedPythonInstallations { let iter = ManagedPythonInstallations::from_settings(None)? .find_all()? .filter(move |installation| { - installation.key.os == os - && (arch.supports(installation.key.arch) + // Emscripten works on every non-windows platform + installation.key.os.is_emscripten() && !os.is_windows() + || installation.key.os == os + && (arch.supports(installation.key.arch) // TODO(zanieb): Allow inequal variants, as `Arch::supports` does not // implement this yet. See https://github.com/astral-sh/uv/pull/9788 || arch.family() == installation.key.arch.family()) - && installation.key.libc == libc + && installation.key.libc == libc }); Ok(iter) @@ -543,6 +545,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") @@ -568,6 +575,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(), From a585006bc61ff3dae31bdb2a4a2488c24b3e2243 Mon Sep 17 00:00:00 2001 From: Hood Chatham Date: Fri, 1 Aug 2025 13:17:17 +0200 Subject: [PATCH 02/15] Add Pyodide discovery test --- crates/uv-python/src/lib.rs | 131 ++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/crates/uv-python/src/lib.rs b/crates/uv-python/src/lib.rs index f08198d970016..e87d8e40beb8d 100644 --- a/crates/uv-python/src/lib.rs +++ b/crates/uv-python/src/lib.rs @@ -322,6 +322,86 @@ 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" + ], + "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<()> { @@ -369,6 +449,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. @@ -2603,4 +2693,45 @@ 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 intepreter + 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(), + ) + })??; + // TODO: How to fix this? + assert_eq!( + python.interpreter().python_full_version().to_string(), + "3.15.7" + ); + + Ok(()) + } } From 6e58c534d28cf7927ddc7ed667cfcc722a20e589 Mon Sep 17 00:00:00 2001 From: Hood Chatham Date: Fri, 1 Aug 2025 14:34:31 +0200 Subject: [PATCH 03/15] Prefer any native Python to the Emscripten Python --- crates/uv-python/src/discovery.rs | 14 ++++++++++++++ crates/uv-python/src/lib.rs | 1 - 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/uv-python/src/discovery.rs b/crates/uv-python/src/discovery.rs index 4961918183158..9e05fd3c9185f 100644 --- a/crates/uv-python/src/discovery.rs +++ b/crates/uv-python/src/discovery.rs @@ -1260,6 +1260,7 @@ pub(crate) fn find_python_installation( find_python_installations(request, environments, preference, cache, preview); let mut first_prerelease = 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) { @@ -1277,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. @@ -1324,6 +1334,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/lib.rs b/crates/uv-python/src/lib.rs index e87d8e40beb8d..fb52b0835226a 100644 --- a/crates/uv-python/src/lib.rs +++ b/crates/uv-python/src/lib.rs @@ -2726,7 +2726,6 @@ mod tests { Preview::default(), ) })??; - // TODO: How to fix this? assert_eq!( python.interpreter().python_full_version().to_string(), "3.15.7" From 160a2b4786ddb8d6fe1abcfc62ca1cebba3fcf79 Mon Sep 17 00:00:00 2001 From: Hood Chatham Date: Fri, 1 Aug 2025 14:42:22 +0200 Subject: [PATCH 04/15] Add integration test --- .github/workflows/ci.yml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23efa58d2cab7..4e54156927449 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1417,7 +1417,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 @@ -1426,13 +1426,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 From c97d5c9f3f661e414a7eed6b2775ec9f2b0e53f6 Mon Sep 17 00:00:00 2001 From: Hood Chatham Date: Fri, 1 Aug 2025 14:43:10 +0200 Subject: [PATCH 05/15] Fix typo --- crates/uv-python/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/uv-python/src/lib.rs b/crates/uv-python/src/lib.rs index fb52b0835226a..df0bfe204f0a9 100644 --- a/crates/uv-python/src/lib.rs +++ b/crates/uv-python/src/lib.rs @@ -2708,7 +2708,7 @@ mod tests { Preview::default(), ) })??; - // We should find the Pyodide intepreter + // We should find the Pyodide interpreter assert_eq!( python.interpreter().python_full_version().to_string(), "3.13.2" From b596a40b7f12b76a2d4dbb7afe25cab7af9e5f28 Mon Sep 17 00:00:00 2001 From: Hood Chatham Date: Fri, 1 Aug 2025 14:49:29 +0200 Subject: [PATCH 06/15] uv => ./uv --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e54156927449..13e54f72826b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1442,7 +1442,7 @@ jobs: ./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 + ./uv pip install packaging python -c 'import packaging' integration-test-github-actions: From f6f04df02a2028c8c08df73487ab9850ebeb6c64 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Sat, 2 Aug 2025 07:37:22 -0500 Subject: [PATCH 07/15] Refactor os, arch, and libc information into a shared `Platform` type --- crates/uv-platform/src/arch.rs | 50 +----- crates/uv-platform/src/lib.rs | 236 +++++++++++++++++++++++++++ crates/uv-python/src/discovery.rs | 2 +- crates/uv-python/src/downloads.rs | 70 ++++---- crates/uv-python/src/installation.rs | 117 ++++++------- crates/uv-python/src/interpreter.rs | 7 +- crates/uv-python/src/managed.rs | 24 +-- 7 files changed, 324 insertions(+), 182 deletions(-) diff --git a/crates/uv-platform/src/arch.rs b/crates/uv-platform/src/arch.rs index f643124897ce4..b023924e45ffc 100644 --- a/crates/uv-platform/src/arch.rs +++ b/crates/uv-platform/src/arch.rs @@ -1,7 +1,7 @@ use crate::Error; +use std::fmt; use std::fmt::Display; use std::str::FromStr; -use std::{cmp, fmt}; /// Architecture variants, e.g., with support for different instruction sets #[derive(Debug, Eq, PartialEq, Clone, Copy, Hash, Ord, PartialOrd)] @@ -23,54 +23,6 @@ pub struct Arch { pub(crate) variant: Option, } -impl Ord for Arch { - fn cmp(&self, other: &Self) -> cmp::Ordering { - if self.family == other.family { - return self.variant.cmp(&other.variant); - } - - // For the time being, manually make aarch64 windows disfavored - // on its own host platform, because most packages don't have wheels for - // aarch64 windows, making emulation more useful than native execution! - // - // The reason we do this in "sorting" and not "supports" is so that we don't - // *refuse* to use an aarch64 windows pythons if they happen to be installed - // and nothing else is available. - // - // Similarly if someone manually requests an aarch64 windows install, we - // should respect that request (this is the way users should "override" - // this behaviour). - let preferred = if cfg!(all(windows, target_arch = "aarch64")) { - Arch { - family: target_lexicon::Architecture::X86_64, - variant: None, - } - } else { - // Prefer native architectures - Arch::from_env() - }; - - match ( - self.family == preferred.family, - other.family == preferred.family, - ) { - (true, true) => unreachable!(), - (true, false) => cmp::Ordering::Less, - (false, true) => cmp::Ordering::Greater, - (false, false) => { - // Both non-preferred, fallback to lexicographic order - self.family.to_string().cmp(&other.family.to_string()) - } - } - } -} - -impl PartialOrd for Arch { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - impl Arch { pub fn new(family: target_lexicon::Architecture, variant: Option) -> Self { Self { family, variant } diff --git a/crates/uv-platform/src/lib.rs b/crates/uv-platform/src/lib.rs index 7eb23875a5c61..d06c7b9bceb82 100644 --- a/crates/uv-platform/src/lib.rs +++ b/crates/uv-platform/src/lib.rs @@ -1,5 +1,8 @@ //! Platform detection for operating system, architecture, and libc. +use std::cmp; +use std::fmt; +use std::str::FromStr; use thiserror::Error; pub use crate::arch::{Arch, ArchVariant}; @@ -23,4 +26,237 @@ pub enum Error { UnsupportedVariant(String, String), #[error(transparent)] LibcDetectionError(#[from] crate::libc::LibcDetectionError), + #[error("Invalid platform format: {0}")] + InvalidPlatformFormat(String), +} + +/// A platform identifier that combines operating system, architecture, and libc. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Platform { + pub os: Os, + pub arch: Arch, + pub libc: Libc, +} + +impl Platform { + /// Create a new platform with the given components. + pub fn new(os: Os, arch: Arch, libc: Libc) -> Self { + Self { os, arch, libc } + } + + /// Detect the platform from the current environment. + pub fn from_env() -> Result { + let os = Os::from_env(); + let arch = Arch::from_env(); + let libc = Libc::from_env()?; + Ok(Self { os, arch, libc }) + } + + /// Check if this platform supports running another platform. + pub fn supports(&self, other: &Self) -> bool { + // If platforms are exactly equal, they're compatible + if self == other { + return true; + } + + // OS must match exactly + if self.os != other.os { + return false; + } + + // Libc must match exactly + if self.libc != other.libc { + return false; + } + + // Check architecture support + // This includes transparent emulation (e.g., x86_64 on ARM64 Windows/macOS) + if self.arch.supports(other.arch) { + return true; + } + + // TODO(zanieb): Allow inequal variants, as `Arch::supports` does not + // implement this 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() + } +} + +impl fmt::Display for Platform { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}-{}-{}", self.os, self.arch, self.libc) + } +} + +impl FromStr for Platform { + type Err = Error; + + fn from_str(s: &str) -> Result { + let parts: Vec<&str> = s.split('-').collect(); + + if parts.len() < 3 { + return Err(Error::InvalidPlatformFormat(format!( + "expected at least 3 parts separated by '-', got {}", + parts.len() + ))); + } + + // Handle potential arch variants (e.g., x86_64_v3) + let os = Os::from_str(parts[0])?; + + // Join middle parts for arch in case it contains dashes (like arch variants) + let arch_parts = &parts[1..parts.len() - 1]; + let arch_str = arch_parts.join("-"); + let arch = Arch::from_str(&arch_str)?; + + let libc = Libc::from_str(parts[parts.len() - 1])?; + + Ok(Self { os, arch, libc }) + } +} + +impl Ord for Platform { + fn cmp(&self, other: &Self) -> cmp::Ordering { + self.os + .to_string() + .cmp(&other.os.to_string()) + // Then architecture + .then_with(|| { + if self.arch.family == other.arch.family { + return self.arch.variant.cmp(&other.arch.variant); + } + + // For the time being, manually make aarch64 windows disfavored on its own host + // platform, because most packages don't have wheels for aarch64 windows, making + // emulation more useful than native execution! + // + // The reason we do this in "sorting" and not "supports" is so that we don't + // *refuse* to use an aarch64 windows pythons if they happen to be installed and + // nothing else is available. + // + // Similarly if someone manually requests an aarch64 windows install, we should + // respect that request (this is the way users should "override" this behaviour). + let preferred = if self.os.is_windows() { + Arch { + family: target_lexicon::Architecture::X86_64, + variant: None, + } + } else { + // Prefer native architectures + Arch::from_env() + }; + + match ( + self.arch.family == preferred.family, + other.arch.family == preferred.family, + ) { + (true, true) => unreachable!(), + (true, false) => cmp::Ordering::Less, + (false, true) => cmp::Ordering::Greater, + (false, false) => { + // Both non-preferred, fallback to lexicographic order + self.arch + .family + .to_string() + .cmp(&other.arch.family.to_string()) + } + } + }) + // Finally compare libc + .then_with(|| self.libc.to_string().cmp(&other.libc.to_string())) + } +} + +impl PartialOrd for Platform { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_platform_display() { + let platform = Platform { + os: Os::from_str("linux").unwrap(), + arch: Arch::from_str("x86_64").unwrap(), + libc: Libc::from_str("gnu").unwrap(), + }; + assert_eq!(platform.to_string(), "linux-x86_64-gnu"); + } + + #[test] + fn test_platform_from_str() { + let platform = Platform::from_str("macos-aarch64-none").unwrap(); + assert_eq!(platform.os.to_string(), "macos"); + assert_eq!(platform.arch.to_string(), "aarch64"); + assert_eq!(platform.libc.to_string(), "none"); + } + + #[test] + fn test_platform_from_str_with_arch_variant() { + let platform = Platform::from_str("linux-x86_64_v3-gnu").unwrap(); + assert_eq!(platform.os.to_string(), "linux"); + assert_eq!(platform.arch.to_string(), "x86_64_v3"); + assert_eq!(platform.libc.to_string(), "gnu"); + } + + #[test] + fn test_platform_from_str_error() { + assert!(Platform::from_str("linux-x86_64").is_err()); + assert!(Platform::from_str("invalid").is_err()); + } + + #[test] + fn test_platform_sorting() { + let p1 = Platform::from_str("linux-x86_64-gnu").unwrap(); + let p2 = Platform::from_str("linux-aarch64-gnu").unwrap(); + let p3 = Platform::from_str("macos-x86_64-none").unwrap(); + let p4 = Platform::from_str("linux-x86_64-musl").unwrap(); + + // OS sorting takes precedence + assert!(p1 < p3); + assert!(p2 < p3); + + // Same OS, architecture comparison + // This will depend on the current architecture + let _ = p1.cmp(&p2); + + // Same OS and arch, libc comparison + assert!(p1 < p4); // "gnu" < "musl" lexicographically + } + + #[test] + fn test_platform_supports() { + let native = Platform::from_str("linux-x86_64-gnu").unwrap(); + let same = Platform::from_str("linux-x86_64-gnu").unwrap(); + let different_arch = Platform::from_str("linux-aarch64-gnu").unwrap(); + let different_os = Platform::from_str("macos-x86_64-none").unwrap(); + let different_libc = Platform::from_str("linux-x86_64-musl").unwrap(); + + // Exact match + assert!(native.supports(&same)); + + // Different OS - not supported + assert!(!native.supports(&different_os)); + + // Different libc - not supported + assert!(!native.supports(&different_libc)); + + // Different architecture but same family + // x86_64 doesn't support aarch64 even though both are 64-bit + assert!(!native.supports(&different_arch)); + + // Test architecture family support + let x86_64_v2 = Platform::from_str("linux-x86_64_v2-gnu").unwrap(); + let x86_64_v3 = Platform::from_str("linux-x86_64_v3-gnu").unwrap(); + // These have the same architecture family (both x86_64) + assert_eq!(native.arch.family(), x86_64_v2.arch.family()); + assert_eq!(native.arch.family(), x86_64_v3.arch.family()); + // Due to the family check, these should support each other + assert!(native.supports(&x86_64_v2)); + assert!(native.supports(&x86_64_v3)); + } } diff --git a/crates/uv-python/src/discovery.rs b/crates/uv-python/src/discovery.rs index 4961918183158..d0f5fff81e4b7 100644 --- a/crates/uv-python/src/discovery.rs +++ b/crates/uv-python/src/discovery.rs @@ -354,7 +354,7 @@ fn python_executables_from_installed<'a>( debug!("Skipping managed installation `{installation}`: does not satisfy `{version}`"); return false; } - if !platform.matches(installation.key()) { + if !platform.matches(installation.key().platform()) { debug!("Skipping managed installation `{installation}`: does not satisfy `{platform}`"); return false; } diff --git a/crates/uv-python/src/downloads.rs b/crates/uv-python/src/downloads.rs index 9e1e03c912a7e..9672f3d28aa09 100644 --- a/crates/uv-python/src/downloads.rs +++ b/crates/uv-python/src/downloads.rs @@ -25,7 +25,7 @@ use uv_client::{BaseClient, WrappedReqwestError, is_extended_transient_error}; use uv_distribution_filename::{ExtensionError, SourceDistExtension}; use uv_extract::hash::Hasher; use uv_fs::{Simplified, rename_with_retry}; -use uv_platform::{self as platform, Arch, Libc, Os}; +use uv_platform::{self as platform, Arch, Libc, Os, Platform}; use uv_pypi_types::{HashAlgorithm, HashDigest}; use uv_redacted::DisplaySafeUrl; use uv_static::EnvVars; @@ -171,22 +171,22 @@ pub struct PlatformRequest { } impl PlatformRequest { - /// Check if this platform request is satisfied by an installation key. - pub fn matches(&self, key: &PythonInstallationKey) -> bool { + /// Check if this platform request is satisfied by a platform. + pub fn matches(&self, platform: &Platform) -> bool { if let Some(os) = self.os { - if key.os != os { + if platform.os != os { return false; } } if let Some(arch) = self.arch { - if !arch.satisfied_by(key.arch) { + if !arch.satisfied_by(platform.arch) { return false; } } if let Some(libc) = self.libc { - if key.libc != libc { + if platform.libc != libc { return false; } } @@ -327,14 +327,15 @@ impl PythonDownloadRequest { /// /// Platform information is pulled from the environment. pub fn fill_platform(mut self) -> Result { + let platform = Platform::from_env()?; if self.arch.is_none() { - self.arch = Some(ArchRequest::Environment(Arch::from_env())); + self.arch = Some(ArchRequest::Environment(platform.arch)); } if self.os.is_none() { - self.os = Some(Os::from_env()); + self.os = Some(platform.os); } if self.libc.is_none() { - self.libc = Some(Libc::from_env()?); + self.libc = Some(platform.libc); } Ok(self) } @@ -378,23 +379,16 @@ impl PythonDownloadRequest { /// Whether this request is satisfied by an installation key. pub fn satisfied_by_key(&self, key: &PythonInstallationKey) -> bool { - if let Some(os) = &self.os { - if key.os != *os { - return false; - } - } - - if let Some(arch) = &self.arch { - if !arch.satisfied_by(key.arch) { - return false; - } + // Check platform requirements + let request = PlatformRequest { + os: self.os, + arch: self.arch, + libc: self.libc, + }; + if !request.matches(key.platform()) { + return false; } - if let Some(libc) = &self.libc { - if key.libc != *libc { - return false; - } - } if let Some(implementation) = &self.implementation { if key.implementation != LenientImplementationName::from(*implementation) { return false; @@ -453,19 +447,19 @@ impl PythonDownloadRequest { } } if let Some(os) = self.os() { - let interpreter_os = Os::from(interpreter.platform().os()); - if &interpreter_os != os { + if &interpreter.os() != os { debug!( - "Skipping interpreter at `{executable}`: operating system `{interpreter_os}` does not match request `{os}`" + "Skipping interpreter at `{executable}`: operating system `{}` does not match request `{os}`", + interpreter.os() ); return false; } } if let Some(arch) = self.arch() { - let interpreter_arch = Arch::from(&interpreter.platform().arch()); - if !arch.satisfied_by(interpreter_arch) { + if !arch.satisfied_by(interpreter.arch()) { debug!( - "Skipping interpreter at `{executable}`: architecture `{interpreter_arch}` does not match request `{arch}`" + "Skipping interpreter at `{executable}`: architecture `{}` does not match request `{arch}`", + interpreter.arch() ); return false; } @@ -482,10 +476,10 @@ impl PythonDownloadRequest { } } if let Some(libc) = self.libc() { - let interpreter_libc = Libc::from(interpreter.platform().os()); - if &interpreter_libc != libc { + if &interpreter.libc() != libc { debug!( - "Skipping interpreter at `{executable}`: libc `{interpreter_libc}` does not match request `{libc}`" + "Skipping interpreter at `{executable}`: libc `{}` does not match request `{libc}`", + interpreter.libc() ); return false; } @@ -514,9 +508,9 @@ impl From<&ManagedPythonInstallation> for PythonDownloadRequest { "Managed Python installations are expected to always have known implementation names, found {name}" ), }, - Some(ArchRequest::Explicit(key.arch)), - Some(key.os), - Some(key.libc), + Some(ArchRequest::Explicit(*key.arch())), + Some(*key.os()), + Some(*key.libc()), Some(key.prerelease.is_some()), ) } @@ -1184,9 +1178,7 @@ fn parse_json_downloads( key: PythonInstallationKey::new_from_version( implementation, &version, - os, - arch, - libc, + Platform::new(os, arch, libc), variant, ), url, diff --git a/crates/uv-python/src/installation.rs b/crates/uv-python/src/installation.rs index 8cdc33106c488..5f605d1d6dd4e 100644 --- a/crates/uv-python/src/installation.rs +++ b/crates/uv-python/src/installation.rs @@ -10,7 +10,7 @@ use uv_cache::Cache; use uv_client::BaseClientBuilder; use uv_configuration::Preview; use uv_pep440::{Prerelease, Version}; -use uv_platform::{Arch, Libc, Os}; +use uv_platform::{Arch, Libc, Os, Platform}; use crate::discovery::{ EnvironmentPreference, PythonRequest, find_best_python_installation, find_python_installation, @@ -352,9 +352,7 @@ pub struct PythonInstallationKey { pub(crate) minor: u8, pub(crate) patch: u8, pub(crate) prerelease: Option, - pub(crate) os: Os, - pub(crate) arch: Arch, - pub(crate) libc: Libc, + pub(crate) platform: Platform, pub(crate) variant: PythonVariant, } @@ -365,9 +363,7 @@ impl PythonInstallationKey { minor: u8, patch: u8, prerelease: Option, - os: Os, - arch: Arch, - libc: Libc, + platform: Platform, variant: PythonVariant, ) -> Self { Self { @@ -376,9 +372,7 @@ impl PythonInstallationKey { minor, patch, prerelease, - os, - arch, - libc, + platform, variant, } } @@ -386,9 +380,7 @@ impl PythonInstallationKey { pub fn new_from_version( implementation: LenientImplementationName, version: &PythonVersion, - os: Os, - arch: Arch, - libc: Libc, + platform: Platform, variant: PythonVariant, ) -> Self { Self { @@ -397,9 +389,7 @@ impl PythonInstallationKey { minor: version.minor(), patch: version.patch().unwrap_or_default(), prerelease: version.pre(), - os, - arch, - libc, + platform, variant, } } @@ -434,16 +424,20 @@ impl PythonInstallationKey { self.minor } + pub fn platform(&self) -> &Platform { + &self.platform + } + pub fn arch(&self) -> &Arch { - &self.arch + &self.platform.arch } pub fn os(&self) -> &Os { - &self.os + &self.platform.os } pub fn libc(&self) -> &Libc { - &self.libc + &self.platform.libc } pub fn variant(&self) -> &PythonVariant { @@ -489,7 +483,7 @@ impl fmt::Display for PythonInstallationKey { }; write!( f, - "{}-{}.{}.{}{}{}-{}-{}-{}", + "{}-{}.{}.{}{}{}-{}", self.implementation, self.major, self.minor, @@ -498,9 +492,7 @@ impl fmt::Display for PythonInstallationKey { .map(|pre| pre.to_string()) .unwrap_or_default(), variant, - self.os, - self.arch, - self.libc + self.platform ) } } @@ -510,31 +502,18 @@ impl FromStr for PythonInstallationKey { fn from_str(key: &str) -> Result { let parts = key.split('-').collect::>(); - let [implementation, version, os, arch, libc] = parts.as_slice() else { + + // We need at least implementation-version-platform (where platform is os-arch-libc) + if parts.len() < 5 { return Err(PythonInstallationKeyError::ParseError( key.to_string(), "not enough `-`-separated values".to_string(), )); - }; - - let implementation = LenientImplementationName::from(*implementation); - - let os = Os::from_str(os).map_err(|err| { - PythonInstallationKeyError::ParseError(key.to_string(), format!("invalid OS: {err}")) - })?; - - let arch = Arch::from_str(arch).map_err(|err| { - PythonInstallationKeyError::ParseError( - key.to_string(), - format!("invalid architecture: {err}"), - ) - })?; + } - let libc = Libc::from_str(libc).map_err(|err| { - PythonInstallationKeyError::ParseError(key.to_string(), format!("invalid libc: {err}")) - })?; + let implementation = LenientImplementationName::from(parts[0]); - let (version, variant) = match version.split_once('+') { + let (version, variant) = match parts[1].split_once('+') { Some((version, variant)) => { let variant = PythonVariant::from_str(variant).map_err(|()| { PythonInstallationKeyError::ParseError( @@ -544,7 +523,7 @@ impl FromStr for PythonInstallationKey { })?; (version, variant) } - None => (*version, PythonVariant::Default), + None => (parts[1], PythonVariant::Default), }; let version = PythonVersion::from_str(version).map_err(|err| { @@ -554,14 +533,24 @@ impl FromStr for PythonInstallationKey { ) })?; - Ok(Self::new_from_version( + // Join the remaining parts as the platform string + let platform_str = parts[2..].join("-"); + let platform = Platform::from_str(&platform_str).map_err(|err| { + PythonInstallationKeyError::ParseError( + key.to_string(), + format!("invalid platform: {err}"), + ) + })?; + + Ok(Self { implementation, - &version, - os, - arch, - libc, + major: version.major(), + minor: version.minor(), + patch: version.patch().unwrap_or_default(), + prerelease: version.pre(), + platform, variant, - )) + }) } } @@ -576,10 +565,8 @@ impl Ord for PythonInstallationKey { self.implementation .cmp(&other.implementation) .then_with(|| self.version().cmp(&other.version())) - .then_with(|| self.os.to_string().cmp(&other.os.to_string())) - // Architectures are sorted in preferred order, with native architectures first - .then_with(|| self.arch.cmp(&other.arch).reverse()) - .then_with(|| self.libc.to_string().cmp(&other.libc.to_string())) + // Platforms are sorted in preferred order for the target + .then_with(|| self.platform.cmp(&other.platform).reverse()) // Python variants are sorted in preferred order, with `Default` first .then_with(|| self.variant.cmp(&other.variant).reverse()) } @@ -632,14 +619,8 @@ impl fmt::Display for PythonInstallationMinorVersionKey { }; write!( f, - "{}-{}.{}{}-{}-{}-{}", - self.0.implementation, - self.0.major, - self.0.minor, - variant, - self.0.os, - self.0.arch, - self.0.libc, + "{}-{}.{}{}-{}", + self.0.implementation, self.0.major, self.0.minor, variant, self.0.platform, ) } } @@ -653,9 +634,9 @@ impl fmt::Debug for PythonInstallationMinorVersionKey { .field("major", &self.0.major) .field("minor", &self.0.minor) .field("variant", &self.0.variant) - .field("os", &self.0.os) - .field("arch", &self.0.arch) - .field("libc", &self.0.libc) + .field("os", &self.0.platform.os) + .field("arch", &self.0.platform.arch) + .field("libc", &self.0.platform.libc) .finish() } } @@ -667,9 +648,7 @@ impl PartialEq for PythonInstallationMinorVersionKey { self.0.implementation == other.0.implementation && self.0.major == other.0.major && self.0.minor == other.0.minor - && self.0.os == other.0.os - && self.0.arch == other.0.arch - && self.0.libc == other.0.libc + && self.0.platform == other.0.platform && self.0.variant == other.0.variant } } @@ -681,9 +660,7 @@ impl Hash for PythonInstallationMinorVersionKey { self.0.implementation.hash(state); self.0.major.hash(state); self.0.minor.hash(state); - self.0.os.hash(state); - self.0.arch.hash(state); - self.0.libc.hash(state); + self.0.platform.hash(state); self.0.variant.hash(state); } } diff --git a/crates/uv-python/src/interpreter.rs b/crates/uv-python/src/interpreter.rs index 3a7cce3f08e1b..dfbd8048e354f 100644 --- a/crates/uv-python/src/interpreter.rs +++ b/crates/uv-python/src/interpreter.rs @@ -22,8 +22,7 @@ use uv_install_wheel::Layout; use uv_pep440::Version; use uv_pep508::{MarkerEnvironment, StringVersion}; use uv_platform::{Arch, Libc, Os}; -use uv_platform_tags::Platform; -use uv_platform_tags::{Tags, TagsError}; +use uv_platform_tags::{Platform, Tags, TagsError}; use uv_pypi_types::{ResolverMarkerEnvironment, Scheme}; use crate::implementation::LenientImplementationName; @@ -203,9 +202,7 @@ impl Interpreter { self.python_minor(), self.python_patch(), self.python_version().pre(), - self.os(), - self.arch(), - self.libc(), + uv_platform::Platform::new(self.os(), self.arch(), self.libc()), self.variant(), ) } diff --git a/crates/uv-python/src/managed.rs b/crates/uv-python/src/managed.rs index 69d12a0a37553..dbb20175c72b7 100644 --- a/crates/uv-python/src/managed.rs +++ b/crates/uv-python/src/managed.rs @@ -18,7 +18,7 @@ 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::{Arch, Libc, LibcDetectionError, Os}; +use uv_platform::{LibcDetectionError, Platform}; use uv_state::{StateBucket, StateStore}; use uv_static::EnvVars; use uv_trampoline_builder::{Launcher, windows_python_launcher}; @@ -259,20 +259,11 @@ impl ManagedPythonInstallations { pub fn find_matching_current_platform( &self, ) -> Result + use<>, Error> { - let os = Os::from_env(); - let arch = Arch::from_env(); - let libc = Libc::from_env()?; + let platform = Platform::from_env()?; let iter = ManagedPythonInstallations::from_settings(None)? .find_all()? - .filter(move |installation| { - installation.key.os == os - && (arch.supports(installation.key.arch) - // TODO(zanieb): Allow inequal variants, as `Arch::supports` does not - // implement this yet. See https://github.com/astral-sh/uv/pull/9788 - || arch.family() == installation.key.arch.family()) - && installation.key.libc == libc - }); + .filter(move |installation| platform.supports(installation.key.platform())); Ok(iter) } @@ -544,7 +535,7 @@ impl ManagedPythonInstallation { /// standard `EXTERNALLY-MANAGED` file. pub fn ensure_externally_managed(&self) -> Result<(), Error> { // Construct the path to the `stdlib` directory. - let stdlib = if self.key.os.is_windows() { + let stdlib = if self.key.os().is_windows() { self.python_dir().join("Lib") } else { let lib_suffix = self.key.variant.suffix(); @@ -588,7 +579,7 @@ impl ManagedPythonInstallation { /// See for more information. pub fn ensure_dylib_patched(&self) -> Result<(), macos_dylib::Error> { if cfg!(target_os = "macos") { - if self.key().os.is_like_darwin() { + if self.key().os().is_like_darwin() { if *self.implementation() == ImplementationName::CPython { let dylib_path = self.python_dir().join("lib").join(format!( "{}python{}{}{}", @@ -890,10 +881,7 @@ pub fn create_link_to_executable(link: &Path, executable: &Path) -> Result<(), E // TODO(zanieb): Only used in tests now. /// Generate a platform portion of a key from the environment. pub fn platform_key_from_env() -> Result { - let os = Os::from_env(); - let arch = Arch::from_env(); - let libc = Libc::from_env()?; - Ok(format!("{os}-{arch}-{libc}").to_lowercase()) + Ok(Platform::from_env()?.to_string().to_lowercase()) } impl fmt::Display for ManagedPythonInstallation { From e88b5480832268aadca1d9d0cf85ca15ddba77a3 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Tue, 5 Aug 2025 15:02:13 -0500 Subject: [PATCH 08/15] Fix ordering --- crates/uv-platform/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/uv-platform/src/lib.rs b/crates/uv-platform/src/lib.rs index d06c7b9bceb82..23b1de018c5c0 100644 --- a/crates/uv-platform/src/lib.rs +++ b/crates/uv-platform/src/lib.rs @@ -151,8 +151,8 @@ impl Ord for Platform { other.arch.family == preferred.family, ) { (true, true) => unreachable!(), - (true, false) => cmp::Ordering::Less, - (false, true) => cmp::Ordering::Greater, + (true, false) => cmp::Ordering::Greater, + (false, true) => cmp::Ordering::Less, (false, false) => { // Both non-preferred, fallback to lexicographic order self.arch From b617e6d3ae98291762728be0193250d3aa094504 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Thu, 7 Aug 2025 15:34:28 -0500 Subject: [PATCH 09/15] Improve testing --- crates/uv-platform/src/arch.rs | 72 ++++++++++- crates/uv-platform/src/lib.rs | 184 ++++++++++++++++++++++----- crates/uv-python/src/installation.rs | 90 +++++++++++-- 3 files changed, 301 insertions(+), 45 deletions(-) diff --git a/crates/uv-platform/src/arch.rs b/crates/uv-platform/src/arch.rs index 4cf244736c2d4..325d23977926c 100644 --- a/crates/uv-platform/src/arch.rs +++ b/crates/uv-platform/src/arch.rs @@ -1,6 +1,4 @@ use crate::Error; -use std::fmt; -use std::fmt::Display; use std::str::FromStr; /// Architecture variants, e.g., with support for different instruction sets @@ -76,6 +74,13 @@ impl Arch { } pub fn from_env() -> Self { + #[cfg(test)] + { + if let Some(arch) = test_support::get_mock_arch() { + return arch; + } + } + Self { family: target_lexicon::HOST.architecture, variant: None, @@ -116,8 +121,8 @@ impl Arch { } } -impl Display for Arch { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { +impl std::fmt::Display for Arch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self.family { target_lexicon::Architecture::X86_32(target_lexicon::X86_32Architecture::I686) => { write!(f, "x86")?; @@ -191,8 +196,8 @@ impl FromStr for ArchVariant { } } -impl Display for ArchVariant { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { +impl std::fmt::Display for ArchVariant { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::V2 => write!(f, "v2"), Self::V3 => write!(f, "v3"), @@ -246,3 +251,58 @@ impl From<&uv_platform_tags::Arch> for Arch { } } } + +#[cfg(test)] +pub(crate) mod test_support { + use super::*; + use std::cell::RefCell; + + thread_local! { + static MOCK_ARCH: RefCell> = RefCell::new(None); + } + + pub(crate) fn get_mock_arch() -> Option { + MOCK_ARCH.with(|arch| *arch.borrow()) + } + + fn set_mock_arch(arch: Option) { + MOCK_ARCH.with(|mock| *mock.borrow_mut() = arch); + } + + pub(crate) struct MockArchGuard; + + impl MockArchGuard { + pub(crate) fn new(arch: Arch) -> Self { + set_mock_arch(Some(arch)); + MockArchGuard + } + } + + impl Drop for MockArchGuard { + fn drop(&mut self) { + set_mock_arch(None); + } + } + + /// Run a function with a mocked architecture. + /// The mock is automatically cleaned up after the function returns. + pub(crate) fn run_with_arch(arch: Arch, f: F) -> R + where + F: FnOnce() -> R, + { + let _guard = MockArchGuard::new(arch); + f() + } + + /// Helper to create common architectures for testing + pub(crate) fn x86_64() -> Arch { + Arch::new(target_lexicon::Architecture::X86_64, None) + } + + pub(crate) fn aarch64() -> Arch { + Arch::new( + target_lexicon::Architecture::Aarch64(target_lexicon::Aarch64Architecture::Aarch64), + None, + ) + } +} diff --git a/crates/uv-platform/src/lib.rs b/crates/uv-platform/src/lib.rs index d06c7b9bceb82..385d78ac13823 100644 --- a/crates/uv-platform/src/lib.rs +++ b/crates/uv-platform/src/lib.rs @@ -44,6 +44,15 @@ impl Platform { Self { os, arch, libc } } + /// Create a platform from string parts (os, arch, libc). + pub fn from_parts(os: &str, arch: &str, libc: &str) -> Result { + Ok(Self { + os: Os::from_str(os)?, + arch: Arch::from_str(arch)?, + libc: Libc::from_str(libc)?, + }) + } + /// Detect the platform from the current environment. pub fn from_env() -> Result { let os = Os::from_env(); @@ -94,24 +103,14 @@ impl FromStr for Platform { fn from_str(s: &str) -> Result { let parts: Vec<&str> = s.split('-').collect(); - if parts.len() < 3 { + if parts.len() != 3 { return Err(Error::InvalidPlatformFormat(format!( - "expected at least 3 parts separated by '-', got {}", + "expected exactly 3 parts separated by '-', got {}", parts.len() ))); } - // Handle potential arch variants (e.g., x86_64_v3) - let os = Os::from_str(parts[0])?; - - // Join middle parts for arch in case it contains dashes (like arch variants) - let arch_parts = &parts[1..parts.len() - 1]; - let arch_str = arch_parts.join("-"); - let arch = Arch::from_str(&arch_str)?; - - let libc = Libc::from_str(parts[parts.len() - 1])?; - - Ok(Self { os, arch, libc }) + Self::from_parts(parts[0], parts[1], parts[2]) } } @@ -195,6 +194,25 @@ mod tests { assert_eq!(platform.libc.to_string(), "none"); } + #[test] + fn test_platform_from_parts() { + let platform = Platform::from_parts("linux", "x86_64", "gnu").unwrap(); + assert_eq!(platform.os.to_string(), "linux"); + assert_eq!(platform.arch.to_string(), "x86_64"); + assert_eq!(platform.libc.to_string(), "gnu"); + + // Test with arch variant + let platform = Platform::from_parts("linux", "x86_64_v3", "musl").unwrap(); + assert_eq!(platform.os.to_string(), "linux"); + assert_eq!(platform.arch.to_string(), "x86_64_v3"); + assert_eq!(platform.libc.to_string(), "musl"); + + // Test error cases + assert!(Platform::from_parts("invalid_os", "x86_64", "gnu").is_err()); + assert!(Platform::from_parts("linux", "invalid_arch", "gnu").is_err()); + assert!(Platform::from_parts("linux", "x86_64", "invalid_libc").is_err()); + } + #[test] fn test_platform_from_str_with_arch_variant() { let platform = Platform::from_str("linux-x86_64_v3-gnu").unwrap(); @@ -205,27 +223,70 @@ mod tests { #[test] fn test_platform_from_str_error() { + // Too few parts assert!(Platform::from_str("linux-x86_64").is_err()); assert!(Platform::from_str("invalid").is_err()); + + // Too many parts (would have been accepted by the old code) + assert!(Platform::from_str("linux-x86-64-gnu").is_err()); + assert!(Platform::from_str("linux-x86_64-gnu-extra").is_err()); } #[test] - fn test_platform_sorting() { - let p1 = Platform::from_str("linux-x86_64-gnu").unwrap(); - let p2 = Platform::from_str("linux-aarch64-gnu").unwrap(); - let p3 = Platform::from_str("macos-x86_64-none").unwrap(); - let p4 = Platform::from_str("linux-x86_64-musl").unwrap(); - - // OS sorting takes precedence - assert!(p1 < p3); - assert!(p2 < p3); - - // Same OS, architecture comparison - // This will depend on the current architecture - let _ = p1.cmp(&p2); - - // Same OS and arch, libc comparison - assert!(p1 < p4); // "gnu" < "musl" lexicographically + fn test_platform_sorting_os_precedence() { + let linux = Platform::from_str("linux-x86_64-gnu").unwrap(); + let macos = Platform::from_str("macos-x86_64-none").unwrap(); + let windows = Platform::from_str("windows-x86_64-none").unwrap(); + + // OS sorting takes precedence (alphabetical) + assert!(linux < macos); + assert!(macos < windows); + } + + #[test] + fn test_platform_sorting_libc() { + let gnu = Platform::from_str("linux-x86_64-gnu").unwrap(); + let musl = Platform::from_str("linux-x86_64-musl").unwrap(); + + // Same OS and arch, libc comparison (alphabetical) + assert!(gnu < musl); + } + + #[test] + fn test_platform_sorting_arch_linux() { + // Test that Linux prefers the native architecture + use crate::arch::test_support::{aarch64, run_with_arch, x86_64}; + + let linux_x86_64 = Platform::from_str("linux-x86_64-gnu").unwrap(); + let linux_aarch64 = Platform::from_str("linux-aarch64-gnu").unwrap(); + + // On x86_64 Linux, x86_64 should be preferred over aarch64 + run_with_arch(x86_64(), || { + assert!(linux_x86_64 < linux_aarch64); + }); + + // On aarch64 Linux, aarch64 should be preferred over x86_64 + run_with_arch(aarch64(), || { + assert!(linux_aarch64 < linux_x86_64); + }); + } + + #[test] + fn test_platform_sorting_arch_macos() { + use crate::arch::test_support::{aarch64, run_with_arch, x86_64}; + + let macos_x86_64 = Platform::from_str("macos-x86_64-none").unwrap(); + let macos_aarch64 = Platform::from_str("macos-aarch64-none").unwrap(); + + // On x86_64 macOS, x86_64 should be preferred over aarch64 + run_with_arch(x86_64(), || { + assert!(macos_x86_64 < macos_aarch64); + }); + + // On aarch64 macOS, aarch64 should be preferred over x86_64 + run_with_arch(aarch64(), || { + assert!(macos_aarch64 < macos_x86_64); + }); } #[test] @@ -246,17 +307,80 @@ mod tests { assert!(!native.supports(&different_libc)); // Different architecture but same family - // x86_64 doesn't support aarch64 even though both are 64-bit + // x86_64 doesn't support aarch64 on Linux assert!(!native.supports(&different_arch)); // Test architecture family support let x86_64_v2 = Platform::from_str("linux-x86_64_v2-gnu").unwrap(); let x86_64_v3 = Platform::from_str("linux-x86_64_v3-gnu").unwrap(); + // These have the same architecture family (both x86_64) assert_eq!(native.arch.family(), x86_64_v2.arch.family()); assert_eq!(native.arch.family(), x86_64_v3.arch.family()); + // Due to the family check, these should support each other assert!(native.supports(&x86_64_v2)); assert!(native.supports(&x86_64_v3)); } + + #[test] + fn test_windows_aarch64_platform_sorting() { + // Test that on Windows, x86_64 is preferred over aarch64 + let windows_x86_64 = Platform::from_str("windows-x86_64-none").unwrap(); + let windows_aarch64 = Platform::from_str("windows-aarch64-none").unwrap(); + + // x86_64 should sort before aarch64 on Windows (preferred) + assert!(windows_x86_64 < windows_aarch64); + + // Test with multiple Windows platforms + let mut platforms = vec![ + Platform::from_str("windows-aarch64-none").unwrap(), + Platform::from_str("windows-x86_64-none").unwrap(), + Platform::from_str("windows-x86-none").unwrap(), + ]; + + platforms.sort(); + + // After sorting on Windows, the order should be: x86_64 (preferred), aarch64, x86 + // x86_64 is preferred on Windows regardless of native architecture + assert_eq!(platforms[0].arch.to_string(), "x86_64"); + assert_eq!(platforms[1].arch.to_string(), "aarch64"); + assert_eq!(platforms[2].arch.to_string(), "x86"); + } + + #[test] + fn test_windows_sorting_always_prefers_x86_64() { + // Test that Windows always prefers x86_64 regardless of host architecture + use crate::arch::test_support::{aarch64, run_with_arch, x86_64}; + + let windows_x86_64 = Platform::from_str("windows-x86_64-none").unwrap(); + let windows_aarch64 = Platform::from_str("windows-aarch64-none").unwrap(); + + // Even with aarch64 as host, Windows should still prefer x86_64 + run_with_arch(aarch64(), || { + assert!(windows_x86_64 < windows_aarch64); + }); + + // With x86_64 as host, Windows should still prefer x86_64 + run_with_arch(x86_64(), || { + assert!(windows_x86_64 < windows_aarch64); + }); + } + + #[test] + fn test_windows_aarch64_supports() { + // Test that Windows aarch64 can run x86_64 binaries through emulation + let windows_aarch64 = Platform::from_str("windows-aarch64-none").unwrap(); + let windows_x86_64 = Platform::from_str("windows-x86_64-none").unwrap(); + + // aarch64 Windows supports x86_64 through transparent emulation + assert!(windows_aarch64.supports(&windows_x86_64)); + + // But x86_64 doesn't support aarch64 + assert!(!windows_x86_64.supports(&windows_aarch64)); + + // Self-support should always work + assert!(windows_aarch64.supports(&windows_aarch64)); + assert!(windows_x86_64.supports(&windows_x86_64)); + } } diff --git a/crates/uv-python/src/installation.rs b/crates/uv-python/src/installation.rs index 51b9eb71a60b9..3bb01fae8d9df 100644 --- a/crates/uv-python/src/installation.rs +++ b/crates/uv-python/src/installation.rs @@ -503,17 +503,24 @@ impl FromStr for PythonInstallationKey { fn from_str(key: &str) -> Result { let parts = key.split('-').collect::>(); - // We need at least implementation-version-platform (where platform is os-arch-libc) - if parts.len() < 5 { + // We need exactly implementation-version-os-arch-libc + if parts.len() != 5 { return Err(PythonInstallationKeyError::ParseError( key.to_string(), - "not enough `-`-separated values".to_string(), + format!( + "expected exactly 5 `-`-separated values, got {}", + parts.len() + ), )); } - let implementation = LenientImplementationName::from(parts[0]); + let [implementation_str, version_str, os, arch, libc] = parts.as_slice() else { + unreachable!() + }; + + let implementation = LenientImplementationName::from(*implementation_str); - let (version, variant) = match parts[1].split_once('+') { + let (version, variant) = match version_str.split_once('+') { Some((version, variant)) => { let variant = PythonVariant::from_str(variant).map_err(|()| { PythonInstallationKeyError::ParseError( @@ -523,7 +530,7 @@ impl FromStr for PythonInstallationKey { })?; (version, variant) } - None => (parts[1], PythonVariant::Default), + None => (*version_str, PythonVariant::Default), }; let version = PythonVersion::from_str(version).map_err(|err| { @@ -533,9 +540,7 @@ impl FromStr for PythonInstallationKey { ) })?; - // Join the remaining parts as the platform string - let platform_str = parts[2..].join("-"); - let platform = Platform::from_str(&platform_str).map_err(|err| { + let platform = Platform::from_parts(os, arch, libc).map_err(|err| { PythonInstallationKeyError::ParseError( key.to_string(), format!("invalid platform: {err}"), @@ -670,3 +675,70 @@ impl From for PythonInstallationMinorVersionKey { Self(key) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_python_installation_key_from_str() { + // Test basic parsing + let key = PythonInstallationKey::from_str("cpython-3.12.0-linux-x86_64-gnu").unwrap(); + assert_eq!(key.implementation.to_string(), "cpython"); + assert_eq!(key.major, 3); + assert_eq!(key.minor, 12); + assert_eq!(key.patch, 0); + assert_eq!(key.platform.to_string(), "linux-x86_64-gnu"); + + // Test with architecture variant + let key = PythonInstallationKey::from_str("cpython-3.11.2-linux-x86_64_v3-musl").unwrap(); + assert_eq!(key.implementation.to_string(), "cpython"); + assert_eq!(key.major, 3); + assert_eq!(key.minor, 11); + assert_eq!(key.patch, 2); + assert_eq!(key.platform.to_string(), "linux-x86_64_v3-musl"); + + // Test with Python variant (freethreaded) + let key = PythonInstallationKey::from_str("cpython-3.13.0+freethreaded-macos-aarch64-none") + .unwrap(); + assert_eq!(key.implementation.to_string(), "cpython"); + assert_eq!(key.major, 3); + assert_eq!(key.minor, 13); + assert_eq!(key.patch, 0); + assert_eq!(key.variant, PythonVariant::Freethreaded); + assert_eq!(key.platform.to_string(), "macos-aarch64-none"); + + // Test error cases + assert!(PythonInstallationKey::from_str("cpython-3.12.0-linux-x86_64").is_err()); // Missing libc + assert!(PythonInstallationKey::from_str("cpython-3.12.0").is_err()); // Missing platform + assert!(PythonInstallationKey::from_str("cpython").is_err()); // Missing everything + } + + #[test] + fn test_python_installation_key_display() { + let key = PythonInstallationKey { + implementation: LenientImplementationName::from("cpython"), + major: 3, + minor: 12, + patch: 0, + prerelease: None, + platform: Platform::from_str("linux-x86_64-gnu").unwrap(), + variant: PythonVariant::Default, + }; + assert_eq!(key.to_string(), "cpython-3.12.0-linux-x86_64-gnu"); + + let key_with_variant = PythonInstallationKey { + implementation: LenientImplementationName::from("cpython"), + major: 3, + minor: 13, + patch: 0, + prerelease: None, + platform: Platform::from_str("macos-aarch64-none").unwrap(), + variant: PythonVariant::Freethreaded, + }; + assert_eq!( + key_with_variant.to_string(), + "cpython-3.13.0+freethreaded-macos-aarch64-none" + ); + } +} From a203c1fbe1246db6d5543156afab969176b51a85 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Fri, 8 Aug 2025 10:05:16 -0500 Subject: [PATCH 10/15] Fix architecture support checks --- crates/uv-platform/src/arch.rs | 29 ++------------- crates/uv-platform/src/lib.rs | 60 +++++++++++++++++++++++++++---- crates/uv-platform/src/os.rs | 4 +++ crates/uv-python/src/downloads.rs | 15 +++++--- 4 files changed, 70 insertions(+), 38 deletions(-) diff --git a/crates/uv-platform/src/arch.rs b/crates/uv-platform/src/arch.rs index 325d23977926c..a161d3da1dbc8 100644 --- a/crates/uv-platform/src/arch.rs +++ b/crates/uv-platform/src/arch.rs @@ -87,31 +87,6 @@ impl Arch { } } - /// Does the current architecture support running the other? - /// - /// When the architecture is equal, this is always true. Otherwise, this is true if the - /// architecture is transparently emulated or is a microarchitecture with worse performance - /// characteristics. - pub fn supports(self, other: Self) -> bool { - if self == other { - return true; - } - - // TODO: Implement `variant` support checks - - // Windows ARM64 runs emulated x86_64 binaries transparently - // Similarly, macOS aarch64 runs emulated x86_64 binaries transparently if you have Rosetta - // installed. We don't try to be clever and check if that's the case here, we just assume - // that if x86_64 distributions are available, they're usable. - if (cfg!(windows) || cfg!(target_os = "macos")) - && matches!(self.family, target_lexicon::Architecture::Aarch64(_)) - { - return other.family == target_lexicon::Architecture::X86_64; - } - - false - } - pub fn family(&self) -> target_lexicon::Architecture { self.family } @@ -258,7 +233,7 @@ pub(crate) mod test_support { use std::cell::RefCell; thread_local! { - static MOCK_ARCH: RefCell> = RefCell::new(None); + static MOCK_ARCH: RefCell> = const { RefCell::new(None) }; } pub(crate) fn get_mock_arch() -> Option { @@ -274,7 +249,7 @@ pub(crate) mod test_support { impl MockArchGuard { pub(crate) fn new(arch: Arch) -> Self { set_mock_arch(Some(arch)); - MockArchGuard + Self } } diff --git a/crates/uv-platform/src/lib.rs b/crates/uv-platform/src/lib.rs index 385d78ac13823..3d1f89f81efca 100644 --- a/crates/uv-platform/src/lib.rs +++ b/crates/uv-platform/src/lib.rs @@ -78,14 +78,24 @@ impl Platform { return false; } - // Check architecture support - // This includes transparent emulation (e.g., x86_64 on ARM64 Windows/macOS) - if self.arch.supports(other.arch) { + // Check architecture compatibility + if self.arch == other.arch { return true; } - // TODO(zanieb): Allow inequal variants, as `Arch::supports` does not - // implement this yet. See https://github.com/astral-sh/uv/pull/9788 + // Windows ARM64 runs emulated x86_64 binaries transparently + // Similarly, macOS aarch64 runs emulated x86_64 binaries transparently if you have Rosetta + // installed. We don't try to be clever and check if that's the case here, we just assume + // that if x86_64 distributions are available, they're usable. + if (self.os.is_windows() || self.os.is_macos()) + && matches!(self.arch.family(), target_lexicon::Architecture::Aarch64(_)) + && matches!(other.arch.family(), target_lexicon::Architecture::X86_64) + { + 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() } @@ -172,6 +182,16 @@ impl PartialOrd for Platform { } } +impl From<&uv_platform_tags::Platform> for Platform { + fn from(value: &uv_platform_tags::Platform) -> Self { + Self { + os: Os::from(value.os()), + arch: Arch::from(&value.arch()), + libc: Libc::from(value.os()), + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -333,7 +353,7 @@ mod tests { assert!(windows_x86_64 < windows_aarch64); // Test with multiple Windows platforms - let mut platforms = vec![ + let mut platforms = [ Platform::from_str("windows-aarch64-none").unwrap(), Platform::from_str("windows-x86_64-none").unwrap(), Platform::from_str("windows-x86-none").unwrap(), @@ -383,4 +403,32 @@ mod tests { assert!(windows_aarch64.supports(&windows_aarch64)); assert!(windows_x86_64.supports(&windows_x86_64)); } + + #[test] + fn test_from_platform_tags_platform() { + // Test conversion from uv_platform_tags::Platform to uv_platform::Platform + let tags_platform = uv_platform_tags::Platform::new( + uv_platform_tags::Os::Windows, + uv_platform_tags::Arch::X86_64, + ); + let platform = Platform::from(&tags_platform); + + assert_eq!(platform.os.to_string(), "windows"); + assert_eq!(platform.arch.to_string(), "x86_64"); + assert_eq!(platform.libc.to_string(), "none"); + + // Test with manylinux + let tags_platform_linux = uv_platform_tags::Platform::new( + uv_platform_tags::Os::Manylinux { + major: 2, + minor: 17, + }, + uv_platform_tags::Arch::Aarch64, + ); + let platform_linux = Platform::from(&tags_platform_linux); + + assert_eq!(platform_linux.os.to_string(), "linux"); + assert_eq!(platform_linux.arch.to_string(), "aarch64"); + assert_eq!(platform_linux.libc.to_string(), "gnu"); + } } diff --git a/crates/uv-platform/src/os.rs b/crates/uv-platform/src/os.rs index 245d799f3a038..89493adc0a448 100644 --- a/crates/uv-platform/src/os.rs +++ b/crates/uv-platform/src/os.rs @@ -19,6 +19,10 @@ impl Os { pub fn is_windows(&self) -> bool { matches!(self.0, target_lexicon::OperatingSystem::Windows) } + + pub fn is_macos(&self) -> bool { + matches!(self.0, target_lexicon::OperatingSystem::Darwin(_)) + } } impl Display for Os { diff --git a/crates/uv-python/src/downloads.rs b/crates/uv-python/src/downloads.rs index 48b49c13da57c..4104e1d49ce7c 100644 --- a/crates/uv-python/src/downloads.rs +++ b/crates/uv-python/src/downloads.rs @@ -180,7 +180,7 @@ impl PlatformRequest { } if let Some(arch) = self.arch { - if !arch.satisfied_by(platform.arch) { + if !arch.satisfied_by(platform) { return false; } } @@ -220,10 +220,14 @@ impl Display for ArchRequest { } impl ArchRequest { - pub(crate) fn satisfied_by(self, arch: Arch) -> bool { + pub(crate) fn satisfied_by(self, platform: &Platform) -> bool { match self { - Self::Explicit(request) => request == arch, - Self::Environment(env) => env.supports(arch), + Self::Explicit(request) => request == platform.arch, + Self::Environment(env) => { + // Check if the environment's platform can run the target platform + let env_platform = Platform::new(platform.os, env, platform.libc); + env_platform.supports(platform) + } } } @@ -456,7 +460,8 @@ impl PythonDownloadRequest { } } if let Some(arch) = self.arch() { - if !arch.satisfied_by(interpreter.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() From 0b21a9ca62dbf2de9ac5581b6d3ec16c97f62657 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Tue, 12 Aug 2025 16:55:15 -0500 Subject: [PATCH 11/15] Small cleanups --- crates/uv-platform/src/arch.rs | 10 +++-- crates/uv-python/src/discovery.rs | 2 +- crates/uv-python/src/installation.rs | 61 ++++++++++++++++++++++++---- crates/uv-python/src/managed.rs | 6 ++- 4 files changed, 64 insertions(+), 15 deletions(-) diff --git a/crates/uv-platform/src/arch.rs b/crates/uv-platform/src/arch.rs index a161d3da1dbc8..39ea6ca0d1cea 100644 --- a/crates/uv-platform/src/arch.rs +++ b/crates/uv-platform/src/arch.rs @@ -244,18 +244,21 @@ pub(crate) mod test_support { MOCK_ARCH.with(|mock| *mock.borrow_mut() = arch); } - pub(crate) struct MockArchGuard; + pub(crate) struct MockArchGuard { + previous: Option, + } impl MockArchGuard { pub(crate) fn new(arch: Arch) -> Self { + let previous = get_mock_arch(); set_mock_arch(Some(arch)); - Self + Self { previous } } } impl Drop for MockArchGuard { fn drop(&mut self) { - set_mock_arch(None); + set_mock_arch(self.previous); } } @@ -269,7 +272,6 @@ pub(crate) mod test_support { f() } - /// Helper to create common architectures for testing pub(crate) fn x86_64() -> Arch { Arch::new(target_lexicon::Architecture::X86_64, None) } diff --git a/crates/uv-python/src/discovery.rs b/crates/uv-python/src/discovery.rs index a79a0d130c164..a658e6b2505a7 100644 --- a/crates/uv-python/src/discovery.rs +++ b/crates/uv-python/src/discovery.rs @@ -350,7 +350,7 @@ fn python_executables_from_installed<'a>( debug!("Skipping managed installation `{installation}`: does not satisfy `{version}`"); return false; } - if !platform.matches(installation.key().platform()) { + if !platform.matches(installation.platform()) { debug!("Skipping managed installation `{installation}`: does not satisfy `{platform}`"); return false; } diff --git a/crates/uv-python/src/installation.rs b/crates/uv-python/src/installation.rs index 3bb01fae8d9df..9ac1e6284a5b3 100644 --- a/crates/uv-python/src/installation.rs +++ b/crates/uv-python/src/installation.rs @@ -679,39 +679,82 @@ impl From for PythonInstallationMinorVersionKey { #[cfg(test)] mod tests { use super::*; + use uv_platform::ArchVariant; #[test] fn test_python_installation_key_from_str() { // Test basic parsing let key = PythonInstallationKey::from_str("cpython-3.12.0-linux-x86_64-gnu").unwrap(); - assert_eq!(key.implementation.to_string(), "cpython"); + assert_eq!( + key.implementation, + LenientImplementationName::Known(ImplementationName::CPython) + ); assert_eq!(key.major, 3); assert_eq!(key.minor, 12); assert_eq!(key.patch, 0); - assert_eq!(key.platform.to_string(), "linux-x86_64-gnu"); + assert_eq!( + key.platform.os, + Os::new(target_lexicon::OperatingSystem::Linux) + ); + assert_eq!( + key.platform.arch, + Arch::new(target_lexicon::Architecture::X86_64, None) + ); + assert_eq!( + key.platform.libc, + Libc::Some(target_lexicon::Environment::Gnu) + ); // Test with architecture variant let key = PythonInstallationKey::from_str("cpython-3.11.2-linux-x86_64_v3-musl").unwrap(); - assert_eq!(key.implementation.to_string(), "cpython"); + assert_eq!( + key.implementation, + LenientImplementationName::Known(ImplementationName::CPython) + ); assert_eq!(key.major, 3); assert_eq!(key.minor, 11); assert_eq!(key.patch, 2); - assert_eq!(key.platform.to_string(), "linux-x86_64_v3-musl"); + assert_eq!( + key.platform.os, + Os::new(target_lexicon::OperatingSystem::Linux) + ); + assert_eq!( + key.platform.arch, + Arch::new(target_lexicon::Architecture::X86_64, Some(ArchVariant::V3)) + ); + assert_eq!( + key.platform.libc, + Libc::Some(target_lexicon::Environment::Musl) + ); // Test with Python variant (freethreaded) let key = PythonInstallationKey::from_str("cpython-3.13.0+freethreaded-macos-aarch64-none") .unwrap(); - assert_eq!(key.implementation.to_string(), "cpython"); + assert_eq!( + key.implementation, + LenientImplementationName::Known(ImplementationName::CPython) + ); assert_eq!(key.major, 3); assert_eq!(key.minor, 13); assert_eq!(key.patch, 0); assert_eq!(key.variant, PythonVariant::Freethreaded); - assert_eq!(key.platform.to_string(), "macos-aarch64-none"); + assert_eq!( + key.platform.os, + Os::new(target_lexicon::OperatingSystem::Darwin(None)) + ); + assert_eq!( + key.platform.arch, + Arch::new( + target_lexicon::Architecture::Aarch64(target_lexicon::Aarch64Architecture::Aarch64), + None + ) + ); + assert_eq!(key.platform.libc, Libc::None); // Test error cases - assert!(PythonInstallationKey::from_str("cpython-3.12.0-linux-x86_64").is_err()); // Missing libc - assert!(PythonInstallationKey::from_str("cpython-3.12.0").is_err()); // Missing platform - assert!(PythonInstallationKey::from_str("cpython").is_err()); // Missing everything + assert!(PythonInstallationKey::from_str("cpython-3.12.0-linux-x86_64").is_err()); + assert!(PythonInstallationKey::from_str("cpython-3.12.0").is_err()); + assert!(PythonInstallationKey::from_str("cpython").is_err()); } #[test] diff --git a/crates/uv-python/src/managed.rs b/crates/uv-python/src/managed.rs index 92abf885e5a31..7207e354a0bba 100644 --- a/crates/uv-python/src/managed.rs +++ b/crates/uv-python/src/managed.rs @@ -263,7 +263,7 @@ impl ManagedPythonInstallations { let iter = Self::from_settings(None)? .find_all()? - .filter(move |installation| platform.supports(installation.key.platform())); + .filter(move |installation| platform.supports(installation.platform())); Ok(iter) } @@ -442,6 +442,10 @@ impl ManagedPythonInstallation { &self.key } + pub fn platform(&self) -> &Platform { + self.key.platform() + } + pub fn minor_version_key(&self) -> &PythonInstallationMinorVersionKey { PythonInstallationMinorVersionKey::ref_cast(&self.key) } From c2e92a09fa6dd22beac278e9f9c3a11d8b6920c9 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Wed, 13 Aug 2025 10:16:02 -0500 Subject: [PATCH 12/15] Review --- crates/uv-platform/src/lib.rs | 23 ++++++- crates/uv-platform/src/os.rs | 3 + crates/uv-python/src/downloads.rs | 58 +++++++++--------- crates/uv-python/src/lib.rs | 3 + crates/uv-python/src/managed.rs | 8 ++- crates/uv/tests/it/python_install.rs | 91 ++++++++++++++++++++++++++++ 6 files changed, 154 insertions(+), 32 deletions(-) diff --git a/crates/uv-platform/src/lib.rs b/crates/uv-platform/src/lib.rs index ee4cdbae7b6f5..27925a90a011e 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}; @@ -69,11 +70,19 @@ impl Platform { } 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; } @@ -101,7 +110,15 @@ impl Platform { // 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/os.rs b/crates/uv-platform/src/os.rs index 5590d53a4f3d4..95372bed98411 100644 --- a/crates/uv-platform/src/os.rs +++ b/crates/uv-platform/src/os.rs @@ -34,6 +34,9 @@ impl Os { 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 diff --git a/crates/uv-python/src/downloads.rs b/crates/uv-python/src/downloads.rs index ce333b69dedba..8cd98b5f6639f 100644 --- a/crates/uv-python/src/downloads.rs +++ b/crates/uv-python/src/downloads.rs @@ -768,7 +768,7 @@ impl ManagedPythonDownload { reporter: Option<&dyn Reporter>, ) -> Result { let url = self.download_url(python_install_mirror, pypy_install_mirror)?; - let mut path = installation_dir.join(self.key().to_string()); + let path = installation_dir.join(self.key().to_string()); // If it is not a reinstall and the dir already exists, return it. if !reinstall && path.is_dir() { @@ -892,24 +892,39 @@ 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"); } - let is_emscripten: bool = self.os().is_emscripten(); - #[cfg(unix)] { - let discard_already_exists_error = |res: io::Result<()>| match res { - Err(err) if err.kind() == io::ErrorKind::AlreadyExists => Ok(()), - _ => res, - }; - if !is_emscripten { - // 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. - discard_already_exists_error(fs_err::os::unix::fs::symlink( - format!("python{}.{}", self.key.major, self.key.minor), - extracted.join("bin").join("python"), - ))?; + // 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"), + ) { + Ok(()) => {} + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {} + Err(err) => return Err(err.into()), } } @@ -919,16 +934,6 @@ impl ManagedPythonDownload { fs_err::tokio::remove_dir_all(&path).await?; } - if is_emscripten { - fs_err::create_dir(&path)?; - extracted.push("pyodide-root/dist"); - path.push("bin"); - fs_err::copy( - extracted.join("python"), - extracted.join(format!("python{}.{}", self.key.major, self.key.minor)), - )?; - } - // Persist it to the target. debug!("Moving {} to {}", extracted.display(), path.user_display()); rename_with_retry(extracted, &path) @@ -938,9 +943,6 @@ impl ManagedPythonDownload { err, })?; - if is_emscripten { - path.pop(); - } Ok(DownloadResult::Fetched(path)) } diff --git a/crates/uv-python/src/lib.rs b/crates/uv-python/src/lib.rs index 5fc7fd2823444..09b568a234358 100644 --- a/crates/uv-python/src/lib.rs +++ b/crates/uv-python/src/lib.rs @@ -363,6 +363,9 @@ mod tests { "/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", diff --git a/crates/uv-python/src/managed.rs b/crates/uv-python/src/managed.rs index 800e080543ddd..d863f8a9a1c5f 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 support by current platform `{platform}`"); + return false; + } + true + }); Ok(iter) } diff --git a/crates/uv/tests/it/python_install.rs b/crates/uv/tests/it/python_install.rs index 670bd738ba7da..517752ab38212 100644 --- a/crates/uv/tests/it/python_install.rs +++ b/crates/uv/tests/it/python_install.rs @@ -2987,3 +2987,94 @@ 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").arg("-vv"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + DEBUG uv [VERSION] ([COMMIT] DATE) + DEBUG Using Python request `cpython-3.13.2-[PLATFORM]` from explicit request + DEBUG Searching for cpython-3.13.2-[PLATFORM] in virtual environments, managed installations, or search path + DEBUG Searching for managed installations at `managed` + TRACE Libc `none` is not compatible with `musl` + DEBUG Skipping managed installation `cpython-3.13.2-[PLATFORM]`: not support by current platform `macos-aarch64-none` + TRACE Searching PATH for executables: cpython3.13.2, cpython3.13, cpython3, cpython, python3.13.2, python3.13, python3, python + TRACE Error trace: No interpreter found for cpython-3.13.2-[PLATFORM] in virtual environments, managed installations, or search path + error: No interpreter found for cpython-3.13.2-[PLATFORM] in virtual environments, managed installations, or search path + "); + + // 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 ----- + "); +} From a4c961d86b1ba4e25a0fe7fd33369b53d8f7bd63 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Wed, 13 Aug 2025 10:21:15 -0500 Subject: [PATCH 13/15] Fix libc filtering --- crates/uv-platform/src/lib.rs | 2 +- crates/uv-python/src/discovery.rs | 4 ++-- crates/uv-python/src/managed.rs | 2 +- crates/uv/tests/it/python_install.rs | 16 ++++------------ 4 files changed, 8 insertions(+), 16 deletions(-) diff --git a/crates/uv-platform/src/lib.rs b/crates/uv-platform/src/lib.rs index 27925a90a011e..014bfa3f9188c 100644 --- a/crates/uv-platform/src/lib.rs +++ b/crates/uv-platform/src/lib.rs @@ -78,7 +78,7 @@ impl Platform { } // 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()) { + if self.libc != other.libc && !(other.os.is_emscripten() || self.os.is_emscripten()) { trace!( "Libc `{}` is not compatible with `{}`", self.libc, other.libc diff --git a/crates/uv-python/src/discovery.rs b/crates/uv-python/src/discovery.rs index f0337adf45022..534e48a67afd7 100644 --- a/crates/uv-python/src/discovery.rs +++ b/crates/uv-python/src/discovery.rs @@ -342,7 +342,7 @@ 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 satisfies the request to avoid unnecessary interpreter queries later Ok(installations .into_iter() .filter(move |installation| { @@ -351,7 +351,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 diff --git a/crates/uv-python/src/managed.rs b/crates/uv-python/src/managed.rs index d863f8a9a1c5f..c20dd2b7eb073 100644 --- a/crates/uv-python/src/managed.rs +++ b/crates/uv-python/src/managed.rs @@ -265,7 +265,7 @@ impl ManagedPythonInstallations { .find_all()? .filter(move |installation| { if !platform.supports(installation.platform()) { - debug!("Skipping managed installation `{installation}`: not support by current platform `{platform}`"); + debug!("Skipping managed installation `{installation}`: not supported by current platform `{platform}`"); return false; } true diff --git a/crates/uv/tests/it/python_install.rs b/crates/uv/tests/it/python_install.rs index 517752ab38212..d8275d2243578 100644 --- a/crates/uv/tests/it/python_install.rs +++ b/crates/uv/tests/it/python_install.rs @@ -3039,21 +3039,13 @@ fn python_install_pyodide() { "###); // We should be able to find the Pyodide interpreter - uv_snapshot!(context.filters(), context.python_find().arg("cpython-3.13.2-emscripten-wasm32-musl").arg("-vv"), @r" - success: false - exit_code: 2 + 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 ----- - DEBUG uv [VERSION] ([COMMIT] DATE) - DEBUG Using Python request `cpython-3.13.2-[PLATFORM]` from explicit request - DEBUG Searching for cpython-3.13.2-[PLATFORM] in virtual environments, managed installations, or search path - DEBUG Searching for managed installations at `managed` - TRACE Libc `none` is not compatible with `musl` - DEBUG Skipping managed installation `cpython-3.13.2-[PLATFORM]`: not support by current platform `macos-aarch64-none` - TRACE Searching PATH for executables: cpython3.13.2, cpython3.13, cpython3, cpython, python3.13.2, python3.13, python3, python - TRACE Error trace: No interpreter found for cpython-3.13.2-[PLATFORM] in virtual environments, managed installations, or search path - error: No interpreter found for cpython-3.13.2-[PLATFORM] in virtual environments, managed installations, or search path "); // We should be able to create a virtual environment with it From 30eccaa2073ad23759ef522a359937161fc21fd7 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Wed, 13 Aug 2025 10:22:59 -0500 Subject: [PATCH 14/15] Revert change to comment --- crates/uv-python/src/discovery.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/uv-python/src/discovery.rs b/crates/uv-python/src/discovery.rs index 534e48a67afd7..f50d310205138 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 satisfies 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| { From 38e2d11aa2ecae75eb111bdf722c18cf76fec456 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Wed, 13 Aug 2025 10:42:23 -0500 Subject: [PATCH 15/15] Fixups --- crates/uv-platform/src/lib.rs | 2 +- crates/uv-python/src/downloads.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/uv-platform/src/lib.rs b/crates/uv-platform/src/lib.rs index 014bfa3f9188c..3fba0493a76c9 100644 --- a/crates/uv-platform/src/lib.rs +++ b/crates/uv-platform/src/lib.rs @@ -110,7 +110,7 @@ impl Platform { // 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 - if self.arch.family() == other.arch.family() { + if self.arch.family() != other.arch.family() { trace!( "Architecture `{}` is not compatible with `{}`", self.arch, other.arch diff --git a/crates/uv-python/src/downloads.rs b/crates/uv-python/src/downloads.rs index 8cd98b5f6639f..9664a17f0607d 100644 --- a/crates/uv-python/src/downloads.rs +++ b/crates/uv-python/src/downloads.rs @@ -903,7 +903,7 @@ impl ManagedPythonDownload { // 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::create_dir_all(extracted.join("bin"))?; fs_err::os::unix::fs::symlink( "../python", extracted