Skip to content

Commit 340507a

Browse files
committed
Add support for installing pyodide Pythons
WIP
1 parent afcbcc7 commit 340507a

File tree

5 files changed

+268
-50
lines changed

5 files changed

+268
-50
lines changed

crates/uv-python/download-metadata.json

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8847,6 +8847,22 @@
88478847
"sha256": "e5a904ecfb4061389773dd655d3b5665447c80cbf2948fcb1c07e92716eed955",
88488848
"variant": null
88498849
},
8850+
"cpython-3.13.2-emscripten-wasm32-musl": {
8851+
"name": "cpython",
8852+
"arch": {
8853+
"family": "wasm32",
8854+
"variant": null
8855+
},
8856+
"os": "emscripten",
8857+
"libc": "musl",
8858+
"major": 3,
8859+
"minor": 13,
8860+
"patch": 2,
8861+
"prerelease": "",
8862+
"url": "https://github.com/pyodide/pyodide/releases/download/0.28.0/xbuildenv-0.28.0.tar.bz2",
8863+
"sha256": null,
8864+
"variant": null
8865+
},
88508866
"cpython-3.13.2-linux-aarch64-gnu": {
88518867
"name": "cpython",
88528868
"arch": {
@@ -13391,6 +13407,22 @@
1339113407
"sha256": "848405b92bda20fad1f9bba99234c7d3f11e0b31e46f89835d1cb3d735e932aa",
1339213408
"variant": null
1339313409
},
13410+
"cpython-3.12.7-emscripten-wasm32-musl": {
13411+
"name": "cpython",
13412+
"arch": {
13413+
"family": "wasm32",
13414+
"variant": null
13415+
},
13416+
"os": "emscripten",
13417+
"libc": "musl",
13418+
"major": 3,
13419+
"minor": 12,
13420+
"patch": 7,
13421+
"prerelease": "",
13422+
"url": "https://github.com/pyodide/pyodide/releases/download/0.27.7/xbuildenv-0.27.7.tar.bz2",
13423+
"sha256": null,
13424+
"variant": null
13425+
},
1339413426
"cpython-3.12.7-linux-aarch64-gnu": {
1339513427
"name": "cpython",
1339613428
"arch": {
@@ -15439,6 +15471,22 @@
1543915471
"sha256": "eca96158c1568dedd9a0b3425375637a83764d1fa74446438293089a8bfac1f8",
1544015472
"variant": null
1544115473
},
15474+
"cpython-3.12.1-emscripten-wasm32-musl": {
15475+
"name": "cpython",
15476+
"arch": {
15477+
"family": "wasm32",
15478+
"variant": null
15479+
},
15480+
"os": "emscripten",
15481+
"libc": "musl",
15482+
"major": 3,
15483+
"minor": 12,
15484+
"patch": 1,
15485+
"prerelease": "",
15486+
"url": "https://github.com/pyodide/pyodide/releases/download/0.26.4/xbuildenv-0.26.4.tar.bz2",
15487+
"sha256": null,
15488+
"variant": null
15489+
},
1544215490
"cpython-3.12.1-linux-aarch64-gnu": {
1544315491
"name": "cpython",
1544415492
"arch": {
@@ -19775,6 +19823,22 @@
1977519823
"sha256": "f710b8d60621308149c100d5175fec39274ed0b9c99645484fd93d1716ef4310",
1977619824
"variant": null
1977719825
},
19826+
"cpython-3.11.3-emscripten-wasm32-musl": {
19827+
"name": "cpython",
19828+
"arch": {
19829+
"family": "wasm32",
19830+
"variant": null
19831+
},
19832+
"os": "emscripten",
19833+
"libc": "musl",
19834+
"major": 3,
19835+
"minor": 11,
19836+
"patch": 3,
19837+
"prerelease": "",
19838+
"url": "https://github.com/pyodide/pyodide/releases/download/0.25.1/xbuildenv-0.25.1.tar.bz2",
19839+
"sha256": null,
19840+
"variant": null
19841+
},
1977819842
"cpython-3.11.3-linux-aarch64-gnu": {
1977919843
"name": "cpython",
1978019844
"arch": {

crates/uv-python/fetch-download-metadata.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,92 @@ async def _fetch_checksums(self, downloads: list[PythonDownload]) -> None:
541541
download.sha256 = checksums.get(download.filename)
542542

543543

544+
class PyodideFinder(Finder):
545+
implementation = ImplementationName.CPYTHON
546+
547+
RELEASE_URL = "https://api.github.com/repos/pyodide/pyodide/releases"
548+
METADATA_URL = (
549+
"https://pyodide.github.io/pyodide/api/pyodide-cross-build-environments.json"
550+
)
551+
552+
TRIPLE = PlatformTriple(
553+
platform="emscripten",
554+
arch=Arch("wasm32"),
555+
libc="musl",
556+
)
557+
558+
def __init__(self, client: httpx.AsyncClient):
559+
self.client = client
560+
561+
async def find(self) -> list[PythonDownload]:
562+
downloads = await self._fetch_downloads()
563+
await self._fetch_checksums(downloads, n=10)
564+
return downloads
565+
566+
async def _fetch_downloads(self) -> list[PythonDownload]:
567+
# This will only download the first page, i.e., ~30 releases
568+
[release_resp, meta_resp] = await asyncio.gather(
569+
self.client.get(self.RELEASE_URL), self.client.get(self.METADATA_URL)
570+
)
571+
release_resp.raise_for_status()
572+
meta_resp.raise_for_status()
573+
releases = release_resp.json()
574+
metadata = meta_resp.json()["releases"]
575+
576+
maj_minor_seen = set()
577+
results = []
578+
for release in releases:
579+
pyodide_version = release["tag_name"]
580+
meta = metadata.get(pyodide_version, None)
581+
if meta is None:
582+
continue
583+
584+
maj_min = pyodide_version.rpartition(".")[0]
585+
# Only keep latest
586+
if maj_min in maj_minor_seen:
587+
continue
588+
maj_minor_seen.add(maj_min)
589+
590+
python_version = Version.from_str(meta["python_version"])
591+
# Find xbuildenv asset
592+
for asset in release["assets"]:
593+
if asset["name"].startswith("xbuildenv"):
594+
break
595+
596+
url = asset["browser_download_url"]
597+
results.append(
598+
PythonDownload(
599+
release=0,
600+
version=python_version,
601+
triple=self.TRIPLE,
602+
flavor=pyodide_version,
603+
implementation=self.implementation,
604+
filename=asset["name"],
605+
url=url,
606+
)
607+
)
608+
609+
return results
610+
611+
async def _fetch_checksums(self, downloads: list[PythonDownload], n: int) -> None:
612+
for idx, batch in enumerate(batched(downloads, n)):
613+
logging.info("Fetching Pyodide checksums: %d/%d", idx * n, len(downloads))
614+
checksum_requests = []
615+
for download in batch:
616+
url = download.url + ".sha256"
617+
checksum_requests.append(self.client.get(url))
618+
for download, resp in zip(
619+
batch, await asyncio.gather(*checksum_requests), strict=False
620+
):
621+
try:
622+
resp.raise_for_status()
623+
except httpx.HTTPStatusError as e:
624+
if e.response.status_code == 404:
625+
continue
626+
raise
627+
download.sha256 = resp.text.strip()
628+
629+
544630
class GraalPyFinder(Finder):
545631
implementation = ImplementationName.GRAALPY
546632

@@ -737,6 +823,7 @@ async def find() -> None:
737823
CPythonFinder(client),
738824
PyPyFinder(client),
739825
GraalPyFinder(client),
826+
PyodideFinder(client),
740827
]
741828
downloads = []
742829

crates/uv-python/src/downloads.rs

Lines changed: 92 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -379,21 +379,31 @@ impl PythonDownloadRequest {
379379

380380
/// Whether this request is satisfied by an installation key.
381381
pub fn satisfied_by_key(&self, key: &PythonInstallationKey) -> bool {
382-
if let Some(os) = &self.os {
383-
if key.os != *os {
384-
return false;
385-
}
382+
let key_is_emscripten = *key.os() == Os(target_lexicon::OperatingSystem::Emscripten);
383+
let target_is_windows = self.os == Some(Os(target_lexicon::OperatingSystem::Windows));
384+
// Emscripten is not compatible with windows
385+
if key_is_emscripten && target_is_windows {
386+
return false;
386387
}
388+
// Emscripten is compatible with all other platforms so skip platform
389+
// check in this case.
390+
if !key_is_emscripten {
391+
if let Some(os) = &self.os {
392+
if key.os != *os {
393+
return false;
394+
}
395+
}
387396

388-
if let Some(arch) = &self.arch {
389-
if !arch.satisfied_by(key.arch) {
390-
return false;
397+
if let Some(arch) = &self.arch {
398+
if !arch.satisfied_by(key.arch) {
399+
return false;
400+
}
391401
}
392-
}
393402

394-
if let Some(libc) = &self.libc {
395-
if key.libc != *libc {
396-
return false;
403+
if let Some(libc) = &self.libc {
404+
if key.libc != *libc {
405+
return false;
406+
}
397407
}
398408
}
399409
if let Some(implementation) = &self.implementation {
@@ -453,24 +463,6 @@ impl PythonDownloadRequest {
453463
return false;
454464
}
455465
}
456-
if let Some(os) = self.os() {
457-
let interpreter_os = Os::from(interpreter.platform().os());
458-
if &interpreter_os != os {
459-
debug!(
460-
"Skipping interpreter at `{executable}`: operating system `{interpreter_os}` does not match request `{os}`"
461-
);
462-
return false;
463-
}
464-
}
465-
if let Some(arch) = self.arch() {
466-
let interpreter_arch = Arch::from(&interpreter.platform().arch());
467-
if !arch.satisfied_by(interpreter_arch) {
468-
debug!(
469-
"Skipping interpreter at `{executable}`: architecture `{interpreter_arch}` does not match request `{arch}`"
470-
);
471-
return false;
472-
}
473-
}
474466
if let Some(implementation) = self.implementation() {
475467
let interpreter_implementation = interpreter.implementation_name();
476468
if LenientImplementationName::from(interpreter_implementation)
@@ -482,13 +474,41 @@ impl PythonDownloadRequest {
482474
return false;
483475
}
484476
}
485-
if let Some(libc) = self.libc() {
486-
let interpreter_libc = Libc::from(interpreter.platform().os());
487-
if &interpreter_libc != libc {
488-
debug!(
489-
"Skipping interpreter at `{executable}`: libc `{interpreter_libc}` does not match request `{libc}`"
490-
);
491-
return false;
477+
let interpreter_os = Os::from(interpreter.platform().os());
478+
let interp_is_emscripten =
479+
interpreter_os == Os(target_lexicon::OperatingSystem::Emscripten);
480+
let target_is_windows = self.os() == Some(&Os(target_lexicon::OperatingSystem::Windows));
481+
// Emscripten does not work on windows
482+
if interp_is_emscripten && target_is_windows {
483+
return false;
484+
}
485+
// Emscripten works on all other platforms
486+
if !interp_is_emscripten {
487+
if let Some(os) = self.os() {
488+
if &interpreter_os != os {
489+
debug!(
490+
"Skipping interpreter at `{executable}`: operating system `{interpreter_os}` does not match request `{os}`"
491+
);
492+
return false;
493+
}
494+
}
495+
if let Some(arch) = self.arch() {
496+
let interpreter_arch = Arch::from(&interpreter.platform().arch());
497+
if !arch.satisfied_by(interpreter_arch) {
498+
debug!(
499+
"Skipping interpreter at `{executable}`: architecture `{interpreter_arch}` does not match request `{arch}`"
500+
);
501+
return false;
502+
}
503+
}
504+
if let Some(libc) = self.libc() {
505+
let interpreter_libc = Libc::from(interpreter.platform().os());
506+
if &interpreter_libc != libc {
507+
debug!(
508+
"Skipping interpreter at `{executable}`: libc `{interpreter_libc}` does not match request `{libc}`"
509+
);
510+
return false;
511+
}
492512
}
493513
}
494514
true
@@ -790,7 +810,7 @@ impl ManagedPythonDownload {
790810
reporter: Option<&dyn Reporter>,
791811
) -> Result<DownloadResult, Error> {
792812
let url = self.download_url(python_install_mirror, pypy_install_mirror)?;
793-
let path = installation_dir.join(self.key().to_string());
813+
let mut path = installation_dir.join(self.key().to_string());
794814

795815
// If it is not a reinstall and the dir already exists, return it.
796816
if !reinstall && path.is_dir() {
@@ -916,18 +936,30 @@ impl ManagedPythonDownload {
916936
extracted = extracted.join("install");
917937
}
918938

919-
// If the distribution is missing a `python`-to-`pythonX.Y` symlink, add it. PEP 394 permits
920-
// it, and python-build-standalone releases after `20240726` include it, but releases prior
921-
// to that date do not.
939+
let is_emscripten: bool = *self.os() == Os(target_lexicon::OperatingSystem::Emscripten);
940+
922941
#[cfg(unix)]
923942
{
924-
match fs_err::os::unix::fs::symlink(
925-
format!("python{}.{}", self.key.major, self.key.minor),
926-
extracted.join("bin").join("python"),
927-
) {
928-
Ok(()) => {}
929-
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {}
930-
Err(err) => return Err(err.into()),
943+
let discard_already_exists_error = |res: io::Result<()>| match res {
944+
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => Ok(()),
945+
_ => res,
946+
};
947+
if is_emscripten {
948+
// Emscripten has a "python" file but no pythonX.Y file.
949+
discard_already_exists_error(fs_err::os::unix::fs::symlink(
950+
"python",
951+
extracted
952+
.join("pyodide-root/dist")
953+
.join(format!("python{}.{}", self.key.major, self.key.minor)),
954+
))?;
955+
} else {
956+
// If the distribution is missing a `python`-to-`pythonX.Y` symlink, add it. PEP 394 permits
957+
// it, and python-build-standalone releases after `20240726` include it, but releases prior
958+
// to that date do not.
959+
discard_already_exists_error(fs_err::os::unix::fs::symlink(
960+
format!("python{}.{}", self.key.major, self.key.minor),
961+
extracted.join("bin").join("python"),
962+
))?;
931963
}
932964
}
933965

@@ -937,6 +969,16 @@ impl ManagedPythonDownload {
937969
fs_err::tokio::remove_dir_all(&path).await?;
938970
}
939971

972+
if is_emscripten {
973+
fs_err::create_dir(&path)?;
974+
extracted.push("pyodide-root/dist");
975+
path.push("bin");
976+
fs_err::copy(
977+
extracted.join("python"),
978+
extracted.join(format!("python{}.{}", self.key.major, self.key.minor)),
979+
)?;
980+
}
981+
940982
// Persist it to the target.
941983
debug!("Moving {} to {}", extracted.display(), path.user_display());
942984
rename_with_retry(extracted, &path)
@@ -946,6 +988,9 @@ impl ManagedPythonDownload {
946988
err,
947989
})?;
948990

991+
if is_emscripten {
992+
path.pop();
993+
}
949994
Ok(DownloadResult::Fetched(path))
950995
}
951996

0 commit comments

Comments
 (0)