1use std::path::PathBuf;
7
8use async_trait::async_trait;
9use serde::{Deserialize, Serialize};
10
11use crate::backend::{Backend, Ctx, InstallCtx};
12use crate::error::{Error, Result};
13use crate::http;
14use crate::pipeline::{self, ArchiveKind, Checksum, HashAlgo, InstallPlan, PipelineCtx};
15use crate::platform::Os;
16use crate::source::Source;
17use crate::version::{select_version, ToolRequest, ToolVersion, VersionInfo};
18
19pub struct PythonBackend;
20
21pub fn select_installed(spec: &str, installed: &[String]) -> Option<String> {
22 super::python_catalog::select_installed(spec, installed)
23}
24
25pub fn is_prerelease(version: &str) -> bool {
26 super::python_catalog::is_prerelease(version)
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31struct Asset {
32 name: String,
33 sha256: String,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38struct Catalog {
39 tag: String,
40 assets: Vec<Asset>,
41}
42
43impl PythonBackend {
44 fn asset_matches(name: &str, py_version: &str, triple: &str) -> bool {
48 name.starts_with(&format!("cpython-{py_version}+"))
49 && name.contains(&format!("-{triple}-install_only"))
50 && name.contains("install_only")
51 && !name.contains("freethreaded")
53 && (name.ends_with(".tar.gz") || name.ends_with(".tar.zst"))
54 }
55}
56
57#[async_trait]
58impl Backend for PythonBackend {
59 fn id(&self) -> &str {
60 "python"
61 }
62
63 fn aliases(&self) -> &[&str] {
64 &["py", "cpython"]
65 }
66
67 fn default_sources(&self) -> Vec<Source> {
68 vec![
69 Source::official(
70 "astral",
71 "https://releases.astral.sh/github/python-build-standalone/releases/download",
72 )
73 .with_index("https://releases.astral.sh"),
74 Source::mirror(
75 "ghproxy",
76 "https://gh-proxy.com/https://github.com/astral-sh/python-build-standalone/releases/download",
77 10,
78 )
79 .with_index("https://gh-proxy.com"),
80 Source::mirror(
81 "github",
82 "https://github.com/astral-sh/python-build-standalone/releases/download",
83 20,
84 )
85 .with_index("https://github.com"),
86 ]
87 }
88
89 fn probe_url(&self, _ctx: &Ctx, source: &Source) -> Option<String> {
90 let tag = super::python_releases::RELEASES.last()?.1;
91 Some(http::join_url(
92 &http::join_url(&source.download_url, tag),
93 "SHA256SUMS",
94 ))
95 }
96
97 async fn list_remote_versions(&self, ctx: &Ctx) -> Result<Vec<VersionInfo>> {
98 let triple = ctx.platform.llvm_triple();
99 use std::collections::BTreeSet;
100 let mut versions: BTreeSet<String> = BTreeSet::new();
101
102 for (version, _) in super::python_releases::RELEASES {
104 if version_available_on_platform(version, &triple) {
105 versions.insert((*version).to_string());
106 }
107 }
108
109 let mut out: Vec<VersionInfo> = versions
110 .into_iter()
111 .map(|v| VersionInfo {
112 version: v,
113 stable: true,
114 lts: None,
115 })
116 .collect();
117 out.sort_by(|a, b| cmp_versions(&a.version, &b.version));
118 Ok(out)
119 }
120
121 async fn resolve_version(&self, ctx: &Ctx, request: &ToolRequest) -> Result<ToolVersion> {
122 if request
123 .options
124 .contains_key(pipeline::LOCKED_ARTIFACT_URL_OPTION)
125 {
126 if let crate::version::VersionSpec::Exact(identity) = &request.spec {
127 let mut resolved = ToolVersion::new(self.id(), identity);
128 resolved.options = request.options.clone();
129 return Ok(resolved);
130 }
131 }
132 let parsed = super::python_catalog::PythonRequest::parse(request)?;
133 if parsed.implementation == "cpython"
134 && parsed.variant == "default"
135 && !parsed.explicit_prerelease
136 && ctx.config.settings.python.catalog_url.is_none()
137 && !matches!(
138 ctx.config.settings.prerelease,
139 crate::config::PrereleasePolicy::Allow
140 )
141 {
142 let versions = self.list_remote_versions(ctx).await?;
143 let chosen =
144 select_version(&parsed.spec, &versions).ok_or_else(|| Error::VersionResolve {
145 tool: self.id().into(),
146 spec: parsed.spec.to_string(),
147 hint: Some("no matching stable CPython version found".into()),
148 })?;
149 if super::python_catalog::is_prerelease(&chosen.version)
150 && matches!(
151 ctx.config.settings.prerelease,
152 crate::config::PrereleasePolicy::Never
153 )
154 {
155 return Err(Error::VersionResolve {
156 tool: self.id().into(),
157 spec: parsed.spec.to_string(),
158 hint: Some("pre-release Python versions are disabled".into()),
159 });
160 }
161 let mut resolved = ToolVersion::new(self.id(), &chosen.version);
162 resolved.options = super::python_catalog::resolved_options(&parsed, &chosen.version);
163 resolved.options.extend(request.options.clone());
164 return Ok(resolved);
165 }
166
167 let catalog = super::python_catalog::load(ctx).await?;
168 let (mut resolved, _) = super::python_catalog::resolve_catalog(&catalog, &parsed, ctx)?;
169 resolved.options.extend(request.options.clone());
170 Ok(resolved)
171 }
172
173 async fn install(&self, ictx: &InstallCtx<'_>, tv: &ToolVersion) -> Result<()> {
174 let ctx = ictx.ctx;
175 if let Some(plan) = pipeline::locked_install_plan(self.id(), tv, true)? {
176 let pctx = PipelineCtx {
177 client: &ctx.client,
178 dirs: &ctx.dirs,
179 cas: &ctx.cas,
180 link_mode: ctx.config.settings.link_mode,
181 show_progress: ctx.show_progress,
182 offline: ctx.config.settings.offline,
183 require_checksums: ctx.config.settings.require_checksums,
184 };
185 pipeline::run(&plan, &pctx).await?;
186 if let Err(error) = ensure_python_aliases(ctx, tv) {
187 let _ = std::fs::remove_dir_all(ctx.dirs.install_path(self.id(), &tv.version));
188 return Err(error);
189 }
190 return Ok(());
191 }
192 let implementation = tv
193 .options
194 .get("implementation")
195 .map(String::as_str)
196 .unwrap_or("cpython");
197 let variant = tv
198 .options
199 .get("variant")
200 .map(String::as_str)
201 .unwrap_or("default");
202 let python_version = tv
203 .options
204 .get("python-version")
205 .cloned()
206 .unwrap_or_else(|| tv.version.clone());
207 if implementation != "cpython"
208 || variant != "default"
209 || super::python_catalog::is_prerelease(&python_version)
210 || tv.options.get("catalog").map(String::as_str) == Some("true")
211 {
212 let request = ToolRequest {
213 backend: self.id().into(),
214 spec: crate::version::VersionSpec::Exact(format!(
215 "{implementation}-{python_version}+{variant}"
216 )),
217 options: tv.options.clone(),
218 };
219 let parsed = super::python_catalog::PythonRequest::parse(&request)?;
220 let catalog = super::python_catalog::load(ctx).await?;
221 let (_, entry) = super::python_catalog::resolve_catalog(&catalog, &parsed, ctx)?;
222 let entry = entry.ok_or_else(|| Error::other("python catalog entry missing"))?;
223 let plan = super::python_catalog::install_plan(&tv.version, &entry)?;
224 let pctx = PipelineCtx {
225 client: &ctx.client,
226 dirs: &ctx.dirs,
227 cas: &ctx.cas,
228 link_mode: ctx.config.settings.link_mode,
229 show_progress: ctx.show_progress,
230 offline: ctx.config.settings.offline,
231 require_checksums: true,
232 };
233 pipeline::run(&plan, &pctx).await?;
234 if let Err(error) = ensure_python_aliases(ctx, tv) {
235 let _ = std::fs::remove_dir_all(ctx.dirs.install_path(self.id(), &tv.version));
236 return Err(error);
237 }
238 return Ok(());
239 }
240 let sources = crate::source::select::ranked_source_list(ctx, self).await?;
241 let triple = ctx.platform.llvm_triple();
242 let tag = match tv.options.get("tag") {
246 Some(tag) => tag.as_str(),
247 None => super::python_releases::tag_for(&tv.version).ok_or_else(|| {
248 Error::VersionResolve {
249 tool: self.id().to_string(),
250 spec: tv.version.clone(),
251 hint: Some(
252 "version is not in the built-in PBS release index; use -o tag=YYYYMMDD"
253 .into(),
254 ),
255 }
256 })?,
257 };
258 let catalog = self.fetch_catalog_for_tag(ctx, tag).await?;
259
260 let asset = catalog
262 .assets
263 .iter()
264 .filter(|a| Self::asset_matches(&a.name, &tv.version, &triple))
265 .max_by_key(|a| a.name.contains("install_only_stripped"))
266 .ok_or_else(|| Error::VersionResolve {
267 tool: self.id().to_string(),
268 spec: tv.version.clone(),
269 hint: Some(format!("no install_only asset for {triple}")),
270 })?;
271
272 let urls = sources
273 .iter()
274 .map(|source| {
275 let release = http::join_url(&source.download_url, &catalog.tag);
276 http::join_url(&release, &asset.name)
277 })
278 .collect();
279
280 let kind = ArchiveKind::from_name(&asset.name)?;
281 let checksum = if asset.sha256.len() == 64 {
283 Some(Checksum {
284 algo: HashAlgo::Sha256,
285 hex: asset.sha256.clone(),
286 })
287 } else {
288 None
289 };
290
291 let plan = InstallPlan {
292 tool: self.id().to_string(),
293 version: tv.version.clone(),
294 urls,
295 file_name: asset.name.clone(),
296 kind,
297 checksum,
298 strip_root: true, subdir: None,
300 };
301 let pctx = PipelineCtx {
302 client: &ctx.client,
303 dirs: &ctx.dirs,
304 cas: &ctx.cas,
305 link_mode: ctx.config.settings.link_mode,
306 show_progress: ctx.show_progress,
307 offline: ctx.config.settings.offline,
308 require_checksums: ctx.config.settings.require_checksums,
309 };
310 pipeline::run(&plan, &pctx).await?;
311 Ok(())
312 }
313
314 fn bin_paths(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<Vec<PathBuf>> {
315 let root = ctx.dirs.install_path(self.id(), &tv.version);
316 if tv.version.starts_with("pyodide-") {
317 return Ok(vec![root]);
318 }
319 let dir = match ctx.platform.os {
321 Os::Windows => root,
322 _ => root.join("bin"),
323 };
324 Ok(vec![dir])
325 }
326
327 fn bin_names(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<Vec<String>> {
328 let paths = self.bin_paths(ctx, tv)?;
329 let discovered = crate::backend::bin_names_in_dirs(&paths);
330 if discovered.is_empty() {
331 if tv.version.starts_with("pypy-") {
332 Ok(vec!["pypy".into(), "pypy3".into(), "python".into()])
333 } else if tv.version.starts_with("graalpy-") {
334 Ok(vec!["graalpy".into(), "python".into(), "python3".into()])
335 } else if tv.version.starts_with("pyodide-") {
336 Ok(vec!["python".into()])
337 } else if tv.version.contains("+freethreaded") {
338 Ok(vec!["pythont".into(), "python3t".into()])
339 } else {
340 Ok(vec![
341 "python".into(),
342 "python3".into(),
343 "pip".into(),
344 "pip3".into(),
345 ])
346 }
347 } else {
348 Ok(discovered)
349 }
350 }
351
352 fn idiomatic_files(&self) -> &[&str] {
353 &[".python-version"]
354 }
355}
356
357impl PythonBackend {
358 async fn fetch_catalog_for_tag(&self, ctx: &Ctx, tag: &str) -> Result<Catalog> {
361 let cache_file = ctx
362 .dirs
363 .remote_cache()
364 .join(format!("python-{tag}-catalog.json"));
365 if let Some(catalog) = read_catalog(&cache_file) {
366 return Ok(catalog);
367 }
368 let sources = crate::source::select::ranked_source_list(ctx, self).await?;
369 let mut last_err: Option<Error> = None;
370 for source in &sources {
371 let prefix = http::join_url(&source.download_url, tag);
372 let url = http::join_url(&prefix, "SHA256SUMS");
373 match http::get_cached_text(ctx, &url).await {
374 Ok(body) => {
375 let assets = parse_sha256sums(&body);
376 if !assets.is_empty() {
377 let catalog = Catalog {
378 tag: tag.to_string(),
379 assets,
380 };
381 if let Some(parent) = cache_file.parent() {
382 let _ = std::fs::create_dir_all(parent);
383 }
384 if let Ok(bytes) = serde_json::to_vec_pretty(&catalog) {
385 let _ = std::fs::write(&cache_file, bytes);
386 }
387 return Ok(catalog);
388 }
389 last_err = Some(Error::other("empty SHA256SUMS"));
390 }
391 Err(e) => last_err = Some(e),
392 }
393 }
394 Err(last_err.unwrap_or_else(|| Error::other(format!("no SHA256SUMS for tag {tag}"))))
395 }
396}
397
398fn ensure_python_aliases(ctx: &Ctx, tv: &ToolVersion) -> Result<()> {
399 let paths = PythonBackend.bin_paths(ctx, tv)?;
400 let Some(directory) = paths.first() else {
401 return Ok(());
402 };
403 let candidates: &[&str] = if tv.version.starts_with("pypy-") {
404 &["pypy3", "pypy"]
405 } else if tv.version.starts_with("graalpy-") {
406 &["graalpy"]
407 } else if tv.version.starts_with("pyodide-") {
408 &["python"]
409 } else {
410 return Ok(());
411 };
412 let Some(source) = candidates
413 .iter()
414 .map(|name| directory.join(format!("{name}{}", ctx.platform.os.exe_suffix())))
415 .find(|path| path.is_file())
416 else {
417 return Err(Error::other(format!(
418 "installed Python identity {} has no executable in {}",
419 tv.version,
420 directory.display()
421 )));
422 };
423 for name in ["python", "python3"] {
424 let destination = directory.join(format!("{name}{}", ctx.platform.os.exe_suffix()));
425 if destination.exists() {
426 continue;
427 }
428 #[cfg(unix)]
429 {
430 use std::os::unix::fs::symlink;
431 let target = source
432 .file_name()
433 .ok_or_else(|| Error::other("python alias source has no filename"))?;
434 symlink(target, &destination).map_err(|error| Error::io(&destination, error))?;
435 }
436 #[cfg(windows)]
437 {
438 std::fs::copy(&source, &destination).map_err(|error| Error::io(&destination, error))?;
439 }
440 }
441 Ok(())
442}
443
444fn version_available_on_platform(version: &str, triple: &str) -> bool {
445 match triple {
446 "x86_64-unknown-linux-gnu" | "x86_64-apple-darwin" | "aarch64-apple-darwin" => true,
447 "aarch64-unknown-linux-gnu" => version != "3.8.12",
448 "x86_64-pc-windows-msvc" => minimum_for_minor(
449 version,
450 &[
451 ("3.8", "3.8.19"),
452 ("3.9", "3.9.19"),
453 ("3.10", "3.10.14"),
454 ("3.11", "3.11.9"),
455 ("3.12", "3.12.3"),
456 ("3.13", "3.13.0"),
457 ("3.14", "3.14.0"),
458 ],
459 ),
460 "x86_64-unknown-linux-musl" => minimum_for_minor(
461 version,
462 &[
463 ("3.9", "3.9.21"),
464 ("3.10", "3.10.16"),
465 ("3.11", "3.11.11"),
466 ("3.12", "3.12.9"),
467 ("3.13", "3.13.2"),
468 ("3.14", "3.14.0"),
469 ],
470 ),
471 "aarch64-unknown-linux-musl" => minimum_for_minor(
472 version,
473 &[
474 ("3.9", "3.9.23"),
475 ("3.10", "3.10.18"),
476 ("3.11", "3.11.13"),
477 ("3.12", "3.12.11"),
478 ("3.13", "3.13.7"),
479 ("3.14", "3.14.0"),
480 ],
481 ),
482 "aarch64-pc-windows-msvc" => minimum_for_minor(
483 version,
484 &[
485 ("3.11", "3.11.13"),
486 ("3.12", "3.12.11"),
487 ("3.13", "3.13.5"),
488 ("3.14", "3.14.0"),
489 ],
490 ),
491 _ => false,
492 }
493}
494
495fn minimum_for_minor(version: &str, minimums: &[(&str, &str)]) -> bool {
496 minimums.iter().any(|(minor, minimum)| {
497 (version == *minor || version.starts_with(&format!("{minor}.")))
498 && cmp_versions(version, minimum) != std::cmp::Ordering::Less
499 })
500}
501
502fn parse_sha256sums(body: &str) -> Vec<Asset> {
504 let mut out = Vec::new();
505 for line in body.lines() {
506 let line = line.trim();
507 if line.is_empty() {
508 continue;
509 }
510 let mut it = line.split_whitespace();
511 let hash = match it.next() {
512 Some(h) => h,
513 None => continue,
514 };
515 let name = match it.next() {
516 Some(n) => n.trim_start_matches('*'),
517 None => continue,
518 };
519 if hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit()) {
520 out.push(Asset {
521 name: name.to_string(),
522 sha256: hash.to_string(),
523 });
524 }
525 }
526 out
527}
528
529fn read_catalog(path: &std::path::Path) -> Option<Catalog> {
530 let bytes = std::fs::read(path).ok()?;
531 let cat: Catalog = serde_json::from_slice(&bytes).ok()?;
532 if cat.assets.is_empty() {
533 None
534 } else {
535 Some(cat)
536 }
537}
538
539pub fn cmp_versions(a: &str, b: &str) -> std::cmp::Ordering {
541 let pa: Vec<u64> = a.split('.').filter_map(|s| s.parse().ok()).collect();
542 let pb: Vec<u64> = b.split('.').filter_map(|s| s.parse().ok()).collect();
543 pa.cmp(&pb)
544}
545
546#[cfg(test)]
547mod tests {
548 use std::sync::Arc;
549
550 use super::*;
551 use crate::config::{Config, PrereleasePolicy, PythonSettings, Settings, SourcesConfig};
552 use crate::dirs::Dirs;
553 use crate::platform::{Arch, Libc, Platform};
554 use crate::store::Cas;
555
556 #[test]
557 fn asset_matching() {
558 let name = "cpython-3.12.7+20241016-x86_64-unknown-linux-gnu-install_only.tar.gz";
559 assert!(PythonBackend::asset_matches(
560 name,
561 "3.12.7",
562 "x86_64-unknown-linux-gnu"
563 ));
564 assert!(!PythonBackend::asset_matches(
565 name,
566 "3.12.7",
567 "aarch64-apple-darwin"
568 ));
569 let stripped =
570 "cpython-3.12.7+20241016-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz";
571 assert!(PythonBackend::asset_matches(
572 stripped,
573 "3.12.7",
574 "x86_64-unknown-linux-gnu"
575 ));
576 let ft =
578 "cpython-3.13.1+20241016-x86_64-unknown-linux-gnu-freethreaded-install_only.tar.gz";
579 assert!(!PythonBackend::asset_matches(
580 ft,
581 "3.13.1",
582 "x86_64-unknown-linux-gnu"
583 ));
584 }
585
586 #[test]
587 fn parse_sums_extracts_assets() {
588 let body = "\
589391e2bbe4da892fd7dd9f773f42ad8eae82f33d3d4fc8f0025af80b4dfa134b3 cpython-3.10.21+20260814-x86_64-unknown-linux-gnu-install_only.tar.gz
5903297691ae34f75fed81ac424e040145fccb0bafe8e581cd5cadbddfa1c0766c0 cpython-3.12.14+20260814-x86_64-unknown-linux-gnu-install_only.tar.gz
591not-a-hash garbage-line
592";
593 let assets = parse_sha256sums(body);
594 assert_eq!(assets.len(), 2);
595 assert_eq!(assets[0].sha256.len(), 64);
596 assert!(assets[1].name.contains("3.12.14"));
597 }
598
599 #[test]
600 fn version_ordering() {
601 assert_eq!(cmp_versions("3.9.1", "3.12.0"), std::cmp::Ordering::Less);
602 assert_eq!(cmp_versions("3.12.7", "3.12.7"), std::cmp::Ordering::Equal);
603 }
604
605 #[test]
606 fn generated_release_index_covers_recent_python() {
607 assert_eq!(
608 super::super::python_releases::tag_for("3.12.14"),
609 Some("20260814")
610 );
611 assert!(version_available_on_platform(
612 "3.12.14",
613 "aarch64-pc-windows-msvc"
614 ));
615 assert!(!version_available_on_platform(
616 "3.10.21",
617 "aarch64-pc-windows-msvc"
618 ));
619 assert!(!version_available_on_platform(
620 "3.12.8",
621 "x86_64-unknown-linux-musl"
622 ));
623 assert!(version_available_on_platform(
624 "3.12.9",
625 "x86_64-unknown-linux-musl"
626 ));
627 }
628
629 fn fixture_archive(subdir: Option<&str>, executable: &str) -> Vec<u8> {
630 let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
631 let mut archive = tar::Builder::new(encoder);
632 let archive_path = match subdir {
633 Some(subdir) => format!("root/{subdir}/{executable}"),
634 None => format!("root/{executable}"),
635 };
636 let contents = b"#!/bin/sh\nexit 0\n";
637 let mut header = tar::Header::new_gnu();
638 header.set_size(contents.len() as u64);
639 header.set_mode(0o755);
640 header.set_cksum();
641 archive
642 .append_data(&mut header, archive_path, &contents[..])
643 .unwrap();
644 archive.finish().unwrap();
645 archive.into_inner().unwrap().finish().unwrap()
646 }
647
648 fn fixture_ctx(root: &std::path::Path, catalog_path: &std::path::Path, digest: &str) -> Ctx {
649 let dirs = Dirs::resolve_from(|key| match key {
650 "OSDK_DATA_DIR" => Some(root.join("data").display().to_string()),
651 "OSDK_CACHE_DIR" => Some(root.join("cache").display().to_string()),
652 "OSDK_CONFIG_DIR" => Some(root.join("config").display().to_string()),
653 "OSDK_STORE_DIR" => Some(root.join("store").display().to_string()),
654 "OSDK_INSTALL_DIR" => Some(root.join("installs").display().to_string()),
655 _ => None,
656 })
657 .unwrap();
658 dirs.ensure().unwrap();
659 Ctx {
660 cas: Arc::new(Cas::new(dirs.store.clone())),
661 dirs,
662 platform: Platform {
663 os: Os::Linux,
664 arch: Arch::X64,
665 libc: Libc::Glibc,
666 },
667 config: Config {
668 settings: Settings {
669 offline: true,
670 prerelease: PrereleasePolicy::Allow,
671 python: PythonSettings {
672 catalog_url: Some(catalog_path.display().to_string()),
673 catalog_sha256: Some(digest.into()),
674 },
675 ..Default::default()
676 },
677 sources: SourcesConfig::default(),
678 tools: Default::default(),
679 tool_configs: Default::default(),
680 global_tools: Default::default(),
681 global_tool_configs: Default::default(),
682 tool_origins: Default::default(),
683 aliases: Default::default(),
684 project_config_path: None,
685 },
686 client: reqwest::Client::new(),
687 show_progress: false,
688 }
689 }
690
691 #[tokio::test]
692 async fn catalog_implementations_and_variants_install_offline_and_coexist() {
693 let temp = tempfile::tempdir().unwrap();
694 let fixtures = [
695 ("cpython", "3.14.7", "default", None, "bin/python3"),
696 ("cpython", "3.14.7", "freethreaded", None, "bin/python3t"),
697 ("pypy", "3.11.15", "default", None, "bin/pypy3"),
698 ("graalpy", "3.12.0", "default", None, "bin/graalpy"),
699 (
700 "pyodide",
701 "3.14.2",
702 "default",
703 Some("pyodide-root/dist"),
704 "python",
705 ),
706 ];
707 let mut entries = Vec::new();
708 let mut archives = std::collections::BTreeMap::new();
709 for (index, (implementation, version, variant, subdir, executable)) in
710 fixtures.iter().enumerate()
711 {
712 let file_name = format!("{implementation}-{index}.tar.gz");
713 let archive = temp.path().join(&file_name);
714 let archive_bytes = fixture_archive(*subdir, executable);
715 let sha256 = pipeline::verify::hash_bytes(&archive_bytes, HashAlgo::Sha256);
716 archives.insert(file_name.clone(), archive_bytes);
717 let url = reqwest::Url::from_file_path(&archive).unwrap().to_string();
718 entries.push(serde_json::json!({
719 "implementation": implementation,
720 "version": version,
721 "variant": variant,
722 "os": if *implementation == "pyodide" { "emscripten" } else { "linux" },
723 "arch": if *implementation == "pyodide" { "wasm32" } else { "x86_64" },
724 "libc": if *implementation == "pyodide" { "musl" } else { "gnu" },
725 "url": url,
726 "sha256": sha256,
727 "subdir": subdir,
728 }));
729 }
730 let catalog = serde_json::json!({
731 "schema": 1,
732 "source": "offline fixture",
733 "source_sha256": "fixture",
734 "entries": entries,
735 });
736 let catalog_bytes = serde_json::to_vec_pretty(&catalog).unwrap();
737 let catalog_path = temp.path().join("catalog.json");
738 std::fs::write(&catalog_path, &catalog_bytes).unwrap();
739 let digest = pipeline::verify::hash_bytes(&catalog_bytes, HashAlgo::Sha256);
740 let ctx = fixture_ctx(temp.path(), &catalog_path, &digest);
741
742 for (implementation, version, variant, _, _) in fixtures {
743 let request_value = if implementation == "cpython" {
744 if variant == "default" {
745 format!("python@{version}")
746 } else {
747 format!("python@cpython-{version}+{variant}")
748 }
749 } else {
750 format!("python@{implementation}-{version}")
751 };
752 let request = ToolRequest::parse(&request_value).unwrap();
753 let resolved = PythonBackend.resolve_version(&ctx, &request).await.unwrap();
754 let catalog = super::super::python_catalog::load(&ctx).await.unwrap();
755 let parsed = super::super::python_catalog::PythonRequest::parse(&request).unwrap();
756 let (_, entry) =
757 super::super::python_catalog::resolve_catalog(&catalog, &parsed, &ctx).unwrap();
758 let entry = entry.unwrap();
759 let file_name = super::super::python_catalog::install_plan(&resolved.version, &entry)
760 .unwrap()
761 .file_name;
762 let cached =
763 pipeline::artifact_cache_path(&ctx.dirs, "python", &resolved.version, &file_name)
764 .unwrap();
765 std::fs::create_dir_all(cached.parent().unwrap()).unwrap();
766 std::fs::write(&cached, archives.get(&file_name).unwrap()).unwrap();
767 PythonBackend
768 .install(&InstallCtx { ctx: &ctx }, &resolved)
769 .await
770 .unwrap();
771 assert!(pipeline::is_installed(
772 &ctx.dirs,
773 "python",
774 &resolved.version
775 ));
776 }
777
778 assert!(ctx
779 .dirs
780 .install_path("python", "3.14.7")
781 .join("bin/python3")
782 .is_file());
783 assert!(ctx
784 .dirs
785 .install_path("python", "cpython-3.14.7+freethreaded")
786 .join("bin/python3t")
787 .is_file());
788 assert!(ctx
789 .dirs
790 .install_path("python", "pypy-3.11.15")
791 .join("bin/pypy3")
792 .is_file());
793 assert!(ctx
794 .dirs
795 .install_path("python", "graalpy-3.12.0")
796 .join("bin/graalpy")
797 .is_file());
798 assert!(ctx
799 .dirs
800 .install_path("python", "pyodide-3.14.2")
801 .join("python")
802 .is_file());
803 }
804}