Skip to main content

pite_export/
python_bundle.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Bundling a pinned CPython 3.12 so an exported game runs on a machine with
3//! no Python installed.
4//!
5//! Shape follows the "embedded" distribution: the shared library plus a stdlib
6//! **zip** and nothing else. `pite` is a native module registered from Rust, so
7//! no `site-packages` ship. The archive is cached under the toolchain dir
8//! (`PITE_TOOLCHAIN_DIR`, default `~/.cache/pite/toolchains`) and never vendored
9//! in the repo; a checksum mismatch or an offline machine is a loud error, and
10//! `--no-bundle-python` keeps the older system-Python gate.
11
12use std::io::Write;
13use std::path::{Path, PathBuf};
14
15use anyhow::{bail, Context, Result};
16use sha2::{Digest, Sha256};
17use zip::write::SimpleFileOptions;
18use zip::{CompressionMethod, ZipWriter};
19
20/// Pinned upstream build: astral-sh/python-build-standalone, `install_only`.
21/// Checksums come from the release's `SHA256SUMS`.
22pub const PINNED_BUILD: &str = "20250317";
23pub const PINNED_PYTHON: &str = "3.12.9";
24
25/// Every member path in these archives sits under this directory.
26pub const ARCHIVE_ROOT: &str = "python";
27
28const RELEASE_BASE: &str = "https://github.com/astral-sh/python-build-standalone/releases/download";
29
30/// One platform's pinned artifact.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct BundleSpec {
33    pub platform: &'static str,
34    pub file: &'static str,
35    pub sha256: &'static str,
36    /// Shared library inside the archive, relative to [`ARCHIVE_ROOT`].
37    pub lib: &'static str,
38    /// Stdlib directory inside the archive, relative to [`ARCHIVE_ROOT`].
39    pub stdlib: &'static str,
40    /// Stdlib zip name used by the interpreter.
41    pub zip_name: &'static str,
42    /// Loader config file the interpreter reads from beside [`Self::lib`].
43    /// `None` where `PYTHONPATH` alone is enough to find the stdlib zip.
44    pub pth_name: Option<&'static str>,
45}
46
47/// The `install_only` archives do not agree on layout: Linux and macOS nest the
48/// stdlib under `lib/python3.12/`, Windows puts it straight in `Lib/`. Windows
49/// also ships `python3.dll` next to `python312.dll`, but the first is a 56 KB
50/// forwarder onto the second, so only the versioned library can be staged.
51const LINUX: BundleSpec = BundleSpec {
52    platform: "linux",
53    file: "cpython-3.12.9+20250317-x86_64-unknown-linux-gnu-install_only.tar.gz",
54    sha256: "ef382fb88cbb41a3b0801690bd716b8a1aec07a6c6471010bcc6bd14cd575226",
55    lib: "lib/libpython3.12.so.1.0",
56    stdlib: "lib/python3.12",
57    zip_name: "python312.zip",
58    pth_name: None,
59};
60
61const WINDOWS: BundleSpec = BundleSpec {
62    platform: "windows",
63    file: "cpython-3.12.9+20250317-x86_64-pc-windows-msvc-install_only.tar.gz",
64    sha256: "d15361fd202dd74ae9c3eece1abdab7655f1eba90bf6255cad1d7c53d463ed4d",
65    lib: "python312.dll",
66    stdlib: "Lib",
67    zip_name: "python312.zip",
68    pth_name: Some("python312._pth"),
69};
70
71#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
72const MACOS: BundleSpec = BundleSpec {
73    platform: "macos",
74    file: "cpython-3.12.9+20250317-x86_64-apple-darwin-install_only.tar.gz",
75    sha256: "1ee1b1bb9fbce5c145c4bec9a3c98d7a4fa22543e09a7c1d932bc8599283c2dc",
76    lib: "lib/libpython3.12.dylib",
77    stdlib: "lib/python3.12",
78    zip_name: "python312.zip",
79    pth_name: None,
80};
81
82#[cfg(not(all(target_os = "macos", target_arch = "x86_64")))]
83const MACOS: BundleSpec = BundleSpec {
84    platform: "macos",
85    file: "cpython-3.12.9+20250317-aarch64-apple-darwin-install_only.tar.gz",
86    sha256: "7c7fd9809da0382a601a79287b5d62d61ce0b15f5a5ee836233727a516e85381",
87    lib: "lib/libpython3.12.dylib",
88    stdlib: "lib/python3.12",
89    zip_name: "python312.zip",
90    pth_name: None,
91};
92
93/// The pinned artifact for `platform`, or `None` when we do not ship one.
94pub fn pinned(platform: &str) -> Option<BundleSpec> {
95    match platform {
96        "linux" => Some(LINUX),
97        "windows" => Some(WINDOWS),
98        "macos" => Some(MACOS),
99        _ => None,
100    }
101}
102
103/// Every platform whose bundle artifact is pinned and checksummed.
104pub fn pinned_platforms() -> Vec<&'static str> {
105    vec!["linux", "windows", "macos"]
106}
107
108/// Where downloaded toolchains live. Never inside the project or repo.
109pub fn toolchain_dir() -> PathBuf {
110    if let Some(dir) = std::env::var_os("PITE_TOOLCHAIN_DIR") {
111        return PathBuf::from(dir);
112    }
113    let base = std::env::var_os("XDG_CACHE_HOME")
114        .map(PathBuf::from)
115        .filter(|p| !p.as_os_str().is_empty())
116        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
117        .unwrap_or_else(|| PathBuf::from(".pitetoolchain"));
118    base.join("pite").join("toolchains")
119}
120
121pub fn archive_path(spec: &BundleSpec) -> PathBuf {
122    toolchain_dir()
123        .join(spec.platform)
124        .join(PINNED_PYTHON)
125        .join(&spec.file)
126}
127
128/// Fetch the pinned archive into the toolchain cache and verify it.
129/// A cached file is re-verified every time: a bad cache is never trusted.
130pub fn ensure_archive(spec: &BundleSpec) -> Result<PathBuf> {
131    let path = archive_path(spec);
132    if path.is_file() {
133        verify(spec, &path)?;
134        return Ok(path);
135    }
136    if let Some(parent) = path.parent() {
137        std::fs::create_dir_all(parent)
138            .with_context(|| format!("cannot create {}", parent.display()))?;
139    }
140    eprintln!(
141        "info: downloading pinned Python {} runtime for {} ({})...",
142        PINNED_PYTHON, spec.platform, spec.file
143    );
144    let url = format!("{RELEASE_BASE}/{PINNED_BUILD}/{}", spec.file);
145    download(&url, &path)?;
146    if let Err(e) = verify(spec, &path) {
147        let _ = std::fs::remove_file(&path);
148        return Err(e);
149    }
150    eprintln!("info: verified Python runtime ({})", spec.file);
151    Ok(path)
152}
153
154/// SHA-256 of `bytes`, lowercase hex.
155pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
156    Sha256::digest(bytes)
157        .iter()
158        .map(|b| format!("{b:02x}"))
159        .collect()
160}
161
162/// Checksum gate. Never warns, never repairs: a mismatch is fatal.
163pub fn verify(spec: &BundleSpec, path: &Path) -> Result<()> {
164    let bytes = std::fs::read(path).with_context(|| format!("cannot read {}", path.display()))?;
165    let actual = sha256_hex(&bytes);
166    if actual.eq_ignore_ascii_case(spec.sha256) {
167        return Ok(());
168    }
169    if spec.sha256 == "REPLACE_WINDOWS_SHA256" {
170        bail!(
171            "no pinned checksum recorded for {} (expected placeholder); refusing to use {}",
172            spec.file,
173            path.display()
174        );
175    }
176    bail!(
177        "checksum mismatch for {}: expected {}, got {}. Delete the cached file and retry.",
178        path.display(),
179        spec.sha256,
180        actual
181    )
182}
183
184pub(crate) fn download(url: &str, dest: &Path) -> Result<()> {
185    use std::io::IsTerminal;
186    let tool = downloader()?;
187    let mut cmd = std::process::Command::new(&tool);
188    cmd.args(["--fail", "--location", "--retry", "3", "--retry-delay", "3"]);
189    if std::io::stderr().is_terminal() {
190        cmd.arg("--progress-bar");
191    } else {
192        cmd.args(["--silent", "--show-error"]);
193    }
194    let result = cmd.arg(url).arg("--output").arg(dest).status();
195    match result {
196        Ok(status) if status.success() => Ok(()),
197        Ok(status) => bail!(
198            "download of {url} failed ({}). Export needs network access to fetch the \
199             pinned Python; retry with network, or pass --no-bundle-python to keep the \
200             system-Python requirement.",
201            status
202        ),
203        Err(e) => bail!(
204            "cannot run {tool} to download {url}: {e}. Export needs network access to \
205             fetch the pinned Python; or pass --no-bundle-python to keep the \
206             system-Python requirement."
207        ),
208    }
209}
210
211fn downloader() -> Result<&'static str> {
212    for tool in ["curl", "curl.exe"] {
213        if Command::new(tool)
214            .arg("--version")
215            .output()
216            .is_ok_and(|o| o.status.success())
217        {
218            return Ok(tool);
219        }
220    }
221    bail!(
222        "no downloader found (need curl). Fetch {} manually into {} , or pass \
223         --no-bundle-python to keep the system-Python requirement.",
224        LINUX.file,
225        toolchain_dir().display()
226    )
227}
228
229use std::process::Command;
230
231/// What an exported directory needs to find the bundled interpreter.
232#[derive(Debug, Clone, PartialEq, Eq)]
233pub struct StagedBundle {
234    pub root: PathBuf,
235    pub lib: PathBuf,
236    pub stdlib_zip: PathBuf,
237}
238
239/// Extract the shared library, pack the stdlib into a zip, and lay both out
240/// under `dest` (`<out>/python`). `site-packages` is deliberately dropped.
241pub fn stage(spec: &BundleSpec, archive: &Path, dest: &Path) -> Result<StagedBundle> {
242    let work = dest.join(".staging");
243    let _ = std::fs::remove_dir_all(&work);
244    std::fs::create_dir_all(&work).with_context(|| format!("cannot create {}", work.display()))?;
245    unpack(spec, archive, &work)?;
246
247    let lib_src = work.join(ARCHIVE_ROOT).join(&spec.lib);
248    if !lib_src.is_file() {
249        bail!(
250            "archive {} does not contain {ARCHIVE_ROOT}/{}",
251            archive.display(),
252            spec.lib
253        );
254    }
255    let lib_dir = dest.join("lib");
256    std::fs::create_dir_all(&lib_dir)?;
257    let lib_name = lib_src
258        .file_name()
259        .expect("lib file has a name")
260        .to_string_lossy()
261        .into_owned();
262    let lib = lib_dir.join(&lib_name);
263    std::fs::copy(&lib_src, &lib).with_context(|| format!("cannot copy {}", lib_src.display()))?;
264    strip_debug(&lib);
265
266    let stdlib_dir = work.join(ARCHIVE_ROOT).join(&spec.stdlib);
267    if !stdlib_dir.is_dir() {
268        bail!(
269            "archive {} does not contain {ARCHIVE_ROOT}/{}",
270            archive.display(),
271            spec.stdlib
272        );
273    }
274    let stdlib_zip = dest.join(spec.zip_name);
275    zip_dir(&stdlib_dir, &stdlib_zip)?;
276    let _ = std::fs::remove_dir_all(&work);
277    Ok(StagedBundle {
278        root: dest.to_path_buf(),
279        lib,
280        stdlib_zip,
281    })
282}
283
284/// Drop debug symbols from a staged file best-effort. Upstream ships the
285/// Linux shared library unstripped (~205 MB → ~36 MB after), and the engine
286/// binary carries its symbol table too (~34 MB → ~25 MB). Non-ELF files
287/// (e.g. the already-stripped Windows DLL/exe) and machines without `strip`
288/// keep the copy as-is: a missed strip only costs bytes, never correctness.
289pub(crate) fn strip_debug(lib: &Path) {
290    let mut magic = [0u8; 4];
291    let is_elf = std::fs::File::open(lib)
292        .and_then(|mut f| std::io::Read::read_exact(&mut f, &mut magic))
293        .is_ok()
294        && magic == [0x7f, b'E', b'L', b'F'];
295    if !is_elf {
296        return;
297    }
298    let _ = std::process::Command::new("strip")
299        .arg("--strip-unneeded")
300        .arg(lib)
301        .output();
302}
303
304/// Directories never shipped. `pite` registers its module from Rust, so an
305/// exported game needs the stdlib and nothing else — no installer, no REPL
306/// tooling, no virtualenv support. `venv` is the bulky one (8 MB of the
307/// Windows stdlib) and is dead weight for an embedded interpreter.
308const EXCLUDED: &[&str] = &[
309    "site-packages",
310    "test",
311    "idlelib",
312    "tkinter",
313    "ensurepip",
314    "__pycache__",
315    "venv",
316    "lib2to3",
317    "pydoc_data",
318    "turtledemo",
319    "msilib",
320];
321
322/// Versionless ABI forwarders. They dispatch *to* the versioned interpreter
323/// rather than being one, so a copy of these cannot satisfy the loader.
324#[cfg(test)]
325const FORWARDERS: &[&str] = &["python3.dll", "libpython3.so", "libpython3.dylib"];
326
327fn unpack(_spec: &BundleSpec, archive: &Path, work: &Path) -> Result<()> {
328    let tar = if cfg!(windows) { "tar.exe" } else { "tar" };
329    let out = std::process::Command::new(tar)
330        .arg("-xf")
331        .arg(archive)
332        .arg("-C")
333        .arg(work)
334        .output();
335    match out {
336        Ok(o) if o.status.success() => Ok(()),
337        Ok(o) => bail!(
338            "cannot unpack {} with tar ({}); install tar or pass --no-bundle-python",
339            archive.display(),
340            o.status
341        ),
342        Err(e) => bail!(
343            "cannot run {tar} to unpack {}: {e}; or pass --no-bundle-python",
344            archive.display()
345        ),
346    }
347}
348
349/// Deflated entries in sorted order, no directory members: the shape the
350/// interpreter imports. Compression roughly thirds the stdlib (~9 MB raw)
351/// and `zipimport` reads deflated entries, so nothing else changes.
352fn zip_dir(dir: &Path, zip_path: &Path) -> Result<()> {
353    let mut entries: Vec<(String, PathBuf)> = Vec::new();
354    collect(dir, dir, &mut entries)?;
355    entries.sort_by(|a, b| a.0.cmp(&b.0));
356    let file = std::fs::File::create(zip_path)
357        .with_context(|| format!("cannot write {}", zip_path.display()))?;
358    let mut zip = ZipWriter::new(file);
359    let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
360    for (name, path) in &entries {
361        let data =
362            std::fs::read(path).with_context(|| format!("cannot read {}", path.display()))?;
363        zip.start_file(name, options)?;
364        zip.write_all(&data)?;
365    }
366    zip.finish().context("cannot finish the stdlib zip")?;
367    Ok(())
368}
369
370fn collect(base: &Path, dir: &Path, out: &mut Vec<(String, PathBuf)>) -> Result<()> {
371    for entry in std::fs::read_dir(dir).with_context(|| format!("cannot read {}", dir.display()))? {
372        let entry = entry?;
373        let name = entry.file_name().to_string_lossy().into_owned();
374        if EXCLUDED.contains(&name.as_str()) {
375            continue;
376        }
377        let path = entry.path();
378        if path.is_dir() {
379            collect(base, &path, out)?;
380        } else if path.is_file() {
381            let rel = path
382                .strip_prefix(base)
383                .expect("entry under base")
384                .to_string_lossy()
385                .replace('\\', "/");
386            out.push((rel, path));
387        }
388    }
389    Ok(())
390}
391
392/// Env the launcher must set for a staged bundle. Proved on Linux and macOS:
393/// the process runs with no Python on `PATH`.
394pub fn launcher_env(bundle: &StagedBundle) -> Vec<(String, String)> {
395    vec![
396        (
397            "LD_LIBRARY_PATH".to_string(),
398            bundle
399                .lib
400                .parent()
401                .unwrap_or(&bundle.root)
402                .to_string_lossy()
403                .into_owned(),
404        ),
405        (
406            "DYLD_LIBRARY_PATH".to_string(),
407            bundle
408                .lib
409                .parent()
410                .unwrap_or(&bundle.root)
411                .to_string_lossy()
412                .into_owned(),
413        ),
414        (
415            "PYTHONPATH".to_string(),
416            bundle.stdlib_zip.to_string_lossy().into_owned(),
417        ),
418    ]
419}
420
421#[cfg(test)]
422mod tests {
423    use std::io::Read;
424
425    use super::*;
426
427    fn tmpdir(tag: &str) -> PathBuf {
428        let n = std::time::SystemTime::now()
429            .duration_since(std::time::UNIX_EPOCH)
430            .map(|d| d.subsec_nanos())
431            .unwrap_or(0);
432        let dir =
433            std::env::temp_dir().join(format!("pite-bundle-{tag}-{}-{n}", std::process::id()));
434        let _ = std::fs::remove_dir_all(&dir);
435        std::fs::create_dir_all(&dir).unwrap();
436        dir
437    }
438
439    #[test]
440    fn pinned_specs_exist_for_supported_platforms() {
441        for platform in pinned_platforms() {
442            let spec = pinned(platform).expect("pinned");
443            assert_eq!(spec.platform, platform);
444            assert!(
445                !spec.sha256.starts_with("REPLACE"),
446                "{platform} must ship a real checksum"
447            );
448            assert!(spec.file.starts_with("cpython-3.12"));
449        }
450        assert!(pinned("ps5").is_none());
451    }
452
453    #[test]
454    fn sha256_matches_published_vectors() {
455        assert_eq!(
456            sha256_hex(b""),
457            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
458        );
459        assert_eq!(
460            sha256_hex(b"abc"),
461            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
462        );
463        assert_eq!(
464            sha256_hex(&b"a".repeat(1_000_000)),
465            "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0"
466        );
467    }
468
469    #[test]
470    fn checksum_mismatch_is_loud_and_leaves_nothing_usable() {
471        let dir = tmpdir("sum");
472        let archive = dir.join("fake.tar.gz");
473        std::fs::write(&archive, b"not the pinned archive").unwrap();
474        let err = verify(&LINUX, &archive).unwrap_err().to_string();
475        assert!(err.contains("checksum mismatch"), "got: {err}");
476        assert!(err.contains(LINUX.sha256), "got: {err}");
477        std::fs::remove_dir_all(&dir).ok();
478    }
479
480    #[test]
481    fn offline_download_is_loud_and_suggests_the_opt_out() {
482        let _guard = crate::SERIAL.lock().unwrap();
483        let mut spec = LINUX;
484        spec.file = "cpython-offline-probe.tar.gz";
485        let dir = tmpdir("offline");
486        std::env::set_var("PITE_TOOLCHAIN_DIR", &dir);
487        let err = ensure_archive(&spec).unwrap_err().to_string();
488        assert!(err.contains("--no-bundle-python"), "got: {err}");
489        std::env::remove_var("PITE_TOOLCHAIN_DIR");
490        std::fs::remove_dir_all(&dir).ok();
491    }
492
493    #[test]
494    fn stdlib_zip_is_deflated_and_sorted_with_spec_crcs() {
495        let dir = tmpdir("stored");
496        let stdlib = dir.join("stdlib");
497        std::fs::create_dir_all(stdlib.join("sub")).unwrap();
498        std::fs::write(stdlib.join("b.py"), b"b = 1\n").unwrap();
499        std::fs::write(stdlib.join("sub").join("a.py"), b"a = 2\n").unwrap();
500        let zip = dir.join("python312.zip");
501        zip_dir(&stdlib, &zip).unwrap();
502        let mut archive = zip::ZipArchive::new(std::fs::File::open(&zip).unwrap()).unwrap();
503        let names: Vec<String> = (0..archive.len())
504            .map(|i| archive.by_index(i).unwrap().name().to_string())
505            .collect();
506        assert_eq!(names, ["b.py", "sub/a.py"]);
507        for i in 0..archive.len() {
508            let mut entry = archive.by_index(i).unwrap();
509            assert_eq!(entry.compression(), CompressionMethod::Deflated);
510            let mut data = Vec::new();
511            entry.read_to_end(&mut data).unwrap();
512            assert_eq!(entry.crc32(), crc32fast::hash(&data));
513        }
514        assert_eq!(crc32fast::hash(b"123456789"), 0xCBF43926);
515        std::fs::remove_dir_all(&dir).ok();
516    }
517
518    #[test]
519    fn stdlib_zip_excludes_site_packages() {
520        let dir = tmpdir("zip");
521        let stdlib = dir.join("stdlib");
522        std::fs::create_dir_all(stdlib.join("site-packages")).unwrap();
523        std::fs::create_dir_all(stdlib.join("__pycache__")).unwrap();
524        std::fs::write(stdlib.join("os.py"), b"import sys\n").unwrap();
525        std::fs::write(stdlib.join("site-packages").join("junk.py"), b"x = 1\n").unwrap();
526        std::fs::write(stdlib.join("__pycache__").join("os.pyc"), b"junk").unwrap();
527        let zip = dir.join("python312.zip");
528        zip_dir(&stdlib, &zip).unwrap();
529        let out = std::process::Command::new("python3")
530            .arg("-c")
531            .arg("import zipfile,sys; print(sorted(zipfile.ZipFile(sys.argv[1]).namelist()))")
532            .arg(&zip)
533            .output()
534            .expect("python3 available to list the zip");
535        let listing = String::from_utf8_lossy(&out.stdout).into_owned();
536        assert!(listing.contains("os.py"), "got: {listing}");
537        assert!(!listing.contains("site-packages"), "got: {listing}");
538        assert!(!listing.contains("__pycache__"), "got: {listing}");
539        std::fs::remove_dir_all(&dir).ok();
540    }
541
542    /// The archive layout is an assumption `stage()` bails on halfway through a
543    /// user's export, and the Windows one was wrong: `install_only` puts the
544    /// stdlib at `Lib/` there, not `lib/python3.12/`. Read the real archive when
545    /// it is cached so a layout change fails here instead of in the wild.
546    #[test]
547    fn pinned_layouts_match_the_cached_real_archives() {
548        for platform in pinned_platforms() {
549            let spec = pinned(platform).expect("pinned");
550            let archive = archive_path(&spec);
551            if !archive.is_file() {
552                eprintln!(
553                    "skipped {platform}: nothing cached at {} (run an export once to populate it)",
554                    archive.display()
555                );
556                continue;
557            }
558            let out = std::process::Command::new("tar")
559                .arg("-tf")
560                .arg(&archive)
561                .output()
562                .expect("tar runs");
563            assert!(out.status.success(), "cannot list {}", archive.display());
564            let listing = String::from_utf8_lossy(&out.stdout);
565            let lib = format!("{ARCHIVE_ROOT}/{}", spec.lib);
566            let stdlib = format!("{ARCHIVE_ROOT}/{}", spec.stdlib);
567            let has = |needle: &str| {
568                listing
569                    .lines()
570                    .map(|l| l.trim_end_matches('/'))
571                    .any(|l| l == needle || l.starts_with(&format!("{needle}/")))
572            };
573            assert!(has(&lib), "{platform}: archive has no interpreter at {lib}");
574            assert!(
575                has(&stdlib),
576                "{platform}: archive has no stdlib at {stdlib}"
577            );
578            let stem = spec.lib.rsplit('/').next().unwrap_or(spec.lib);
579            assert!(
580                !FORWARDERS.contains(&stem),
581                "{platform}: {} is an ABI forwarder, not the interpreter \
582                 (it dispatches to the versioned library instead of being one)",
583                spec.lib
584            );
585        }
586    }
587
588    #[test]
589    fn launcher_env_points_at_lib_and_stdlib() {
590        let bundle = StagedBundle {
591            root: PathBuf::from("/game"),
592            lib: PathBuf::from("/game/python/lib/libpython3.12.so.1.0"),
593            stdlib_zip: PathBuf::from("/game/python/python312.zip"),
594        };
595        let env = launcher_env(&bundle);
596        assert!(env.contains(&("LD_LIBRARY_PATH".into(), "/game/python/lib".into())));
597        assert!(env.contains(&("PYTHONPATH".into(), "/game/python/python312.zip".into())));
598    }
599}