Skip to main content

osdk_core/backend/
python.rs

1//! Python backend: installs prebuilt CPython from astral-sh/python-build-
2//! standalone (the same source uv/mise use). Discovery uses a generated
3//! version-to-release-tag index, while immutable per-release `SHA256SUMS`
4//! documents select and verify platform assets. No GitHub API is required.
5
6use 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/// A single asset parsed from `SHA256SUMS`: filename + sha256.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31struct Asset {
32    name: String,
33    sha256: String,
34}
35
36/// The cached catalog for one release tag.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38struct Catalog {
39    tag: String,
40    assets: Vec<Asset>,
41}
42
43impl PythonBackend {
44    /// Match an `install_only` asset for a given python version + host triple.
45    /// Asset names look like:
46    ///   cpython-3.12.7+20241016-x86_64-unknown-linux-gnu-install_only.tar.gz
47    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            // exclude the free-threaded ("freethreaded") variants by default
52            && !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        // Merge the generated historical index without any remote API calls.
103        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        // Resolve a catalog (latest, or an older historical tag) that has this
243        // version for the host triple. An explicit `-o tag=YYYYMMDD` pins the
244        // PBS release tag (deterministic, no GitHub API needed).
245        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        // Find the asset matching this exact python version for the host triple.
261        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        // Checksum comes straight from SHA256SUMS — no extra request.
282        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, // archives wrap in a `python/` dir
299            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        // PBS layout after stripping `python/`: bin/ on unix, root on windows.
320        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    /// Fetch the catalog for a specific historical tag by reading its
359    /// SHA256SUMS (each dated release has its own).
360    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
502/// Parse a `SHA256SUMS` body: each line is `<hex>  <filename>`.
503fn 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
539/// Compare two dotted numeric versions ascending.
540pub 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        // free-threaded variant excluded
577        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}