From 30f6f3758fec227359b1bd416b0016b0bb788943 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 2 May 2026 16:51:02 +0200 Subject: [PATCH] feat(jobs): runtime Python embebido + cadena de fallback (issue 0033 fase B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permite distribuir graph_explorer.exe Windows sin dependencia de WSL ni del .venv del registry. Tambien funciona en Linux como bundle autocontenido portable. Cambios: 1. tools/freeze_python_runtime.sh - Linux: copia python-build-standalone (uv) ~87 MB, elimina marker EXTERNALLY-MANAGED, instala wheels. - Windows: descarga python-3.12.7-embed-amd64.zip oficial (~12 MB), habilita site-packages, instala wheels via pip install --target --platform win_amd64. - Idempotente via runtime/.lock con SHA256 del estado. - Lee python_runtime_deps del frontmatter de app.md. 2. jobs.cpp::cached_python_runtime() — resolver con cadena: 1. /runtime/python/{python.exe|bin/python3} (embedded) 2. $FN_PYTHON (env) 3. /python/.venv/bin/python3 (registry_venv) 4. python3 del PATH (system) Loggea procedencia al iniciar jobs_init. 3. POSIX run_subprocess: usa el runtime resuelto en lugar del path hardcodeado. 4. Windows run_subprocess: ramifica por needs_wsl. Si embedded o env, lanza Python Windows nativo via CreateProcessW directamente (run_path tambien Windows nativo). Solo el legacy registry_venv sigue por wsl.exe. 5. app.md: nuevos campos python_runtime: true y python_runtime_deps: [requests, certifi, urllib3]. 6. .gitignore extendido con runtime/, projects/, _vendored/, .vendor.lock, binarios Go de enrichers. Tests: 26/26 verde — 16 originales + 6 dispatcher fase A + 4 nuevos del resolver fase B (con/sin embed, FN_PYTHON, idempotencia del freeze script). Smoke E2E manual: runtime/python/bin/python3 ejecuta web_search con cwd /tmp y registry_root pasado en ctx, sin tocar el .venv del registry. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 22 +++ app.md | 5 + ...33-multilang-dispatcher-embedded-python.md | 41 ++++- jobs.cpp | 172 ++++++++++++++++-- tests/test_python_runtime_resolver.py | 141 ++++++++++++++ tools/freeze_python_runtime.sh | 167 +++++++++++++++++ 6 files changed, 521 insertions(+), 27 deletions(-) create mode 100644 tests/test_python_runtime_resolver.py create mode 100755 tools/freeze_python_runtime.sh diff --git a/.gitignore b/.gitignore index 9a9995d..c212239 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,25 @@ build/ *.exe *.o *.obj + +# pytest / python caches +.pytest_cache/ +__pycache__/ +*.pyc + +# Runtime Python embebido (issue 0033 fase B) — generado por +# tools/freeze_python_runtime.sh. Cada PC lo regenera. +runtime/ + +# Carpetas de proyectos (cada proyecto tiene su propia operations.db) +projects/ +*.ini + +# Vendoring de funciones Python (issue 0033b) — generado por +# tools/vendor_enricher_python.sh. +enrichers/*/_vendored/ +enrichers/*/.vendor.lock + +# Binarios de enrichers Go (issue 0034) — generados por su build.sh. +enrichers/*/run +enrichers/*/run.exe diff --git a/app.md b/app.md index e69f2b2..0fd230b 100644 --- a/app.md +++ b/app.md @@ -25,6 +25,11 @@ framework: "imgui" entry_point: "main.cpp" dir_path: "projects/osint_graph/apps/graph_explorer" repo_url: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/dataforge/graph_explorer" +python_runtime: true +python_runtime_deps: + - requests + - certifi + - urllib3 --- ## Arquitectura diff --git a/issues/0033-multilang-dispatcher-embedded-python.md b/issues/0033-multilang-dispatcher-embedded-python.md index 26c487d..51cbf39 100644 --- a/issues/0033-multilang-dispatcher-embedded-python.md +++ b/issues/0033-multilang-dispatcher-embedded-python.md @@ -207,17 +207,38 @@ y `jobs.cpp`. Implementado en commit `fce3f97` (rama `issue/0033a-multilang-dispatcher`). -### Fase B — runtime Python embebido (1-2 sesiones) +### Fase B — runtime Python embebido (COMPLETADA) -1. Escribir `tools/freeze_python_runtime.sh` (Linux + Windows). -2. Anadir hook al `/compile` skill: tras compilar el `.exe`, ejecutar - el freeze si `python_runtime: true`. -3. `python_runtime_path()` en `jobs.cpp`: busca `runtime/python/` - junto al exe; fallback a env var → registry venv → PATH. -4. Test: arranque limpio de `graph_explorer.exe` con runtime/ junto al - exe — invocar enricher Python sin WSL ni venv del registry. -5. Documentar en `app.md` y en `cpp_apps.md` la nueva regla: - apps con enrichers Python necesitan `python_runtime: true`. +1. ✅ `tools/freeze_python_runtime.sh` con dos backends: + - Linux: copia `python-build-standalone` (uv) ~87 MB, autocontenido. + - Windows: descarga `python-3.X.Y-embed-amd64.zip` oficial, + habilita site-packages, instala wheels via `pip install + --target` cross-platform. + Idempotente via `runtime/.lock` con SHA256 de (PY_VERSION, deps, + platform). Lee `python_runtime_deps` del frontmatter de `app.md` + (override: env `PY_DEPS=...`). +2. ⏳ Hook al `/compile` skill — pendiente, parte del issue 0033e. +3. ✅ `cached_python_runtime()` en `jobs.cpp` con cadena de + fallback: `/runtime/python/{python.exe|bin/python3}` → + `$FN_PYTHON` → `/python/.venv/bin/python3` → + `python3` del PATH. Loggea procedencia (`kind=embedded|env| + registry_venv|system`) al iniciar jobs_init. +4. ✅ Tests: + - `tests/test_python_runtime_resolver.py` — 4 tests verifican + fallback con/sin embed, override via FN_PYTHON e idempotencia + del freeze script. + - Smoke E2E manual: `runtime/python/bin/python3` ejecuta + `web_search` en cwd arbitrario, sin pasar por el venv del + registry. +5. ✅ `app.md` actualizado con `python_runtime: true` y + `python_runtime_deps: [requests, certifi, urllib3]`. +6. ✅ `.gitignore` creado: ignora `runtime/`, `_vendored/`, + `.vendor.lock`, binarios Go de enrichers. + +Implementado en commits ``. Para Windows nativo, el +spawn ahora bifurca: si el resolver es `embedded`/`env`/`system`, +lanza Python Windows nativo via CreateProcessW; si es `registry_venv` +(legacy), sigue el camino `wsl.exe` previo. ### Fase C — UI hints (0.5 sesion) diff --git a/jobs.cpp b/jobs.cpp index 87add73..c531f40 100644 --- a/jobs.cpp +++ b/jobs.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -74,6 +75,116 @@ struct State { State* g_state = nullptr; +// ============================================================================ +// Python runtime resolver (issue 0033 fase B) +// ============================================================================ + +// Resultado de resolver el Python runtime: path absoluto + procedencia +// + flag indicando si el path apunta a un Python dentro de WSL (solo +// Windows usa este flag para decidir si lanzar via wsl.exe). +struct PyRuntime { + std::string path; // path al ejecutable Python + std::string kind; // "embedded" | "env" | "registry_venv" | "system" | "" + bool needs_wsl = false; +}; + +// Determina el directorio del ejecutable actual (junto al cual se +// busca runtime/python/). En POSIX usa /proc/self/exe; en Windows +// usa GetModuleFileNameW. +std::string get_exe_dir() { +#ifdef _WIN32 + wchar_t buf[MAX_PATH * 2]; + DWORD n = GetModuleFileNameW(nullptr, buf, (DWORD)(sizeof(buf)/sizeof(buf[0]))); + if (n == 0 || n >= sizeof(buf)/sizeof(buf[0])) return ""; + int u8n = WideCharToMultiByte(CP_UTF8, 0, buf, (int)n, nullptr, 0, nullptr, nullptr); + std::string out(u8n, 0); + WideCharToMultiByte(CP_UTF8, 0, buf, (int)n, out.data(), u8n, nullptr, nullptr); + size_t slash = out.find_last_of("/\\"); + return (slash == std::string::npos) ? "" : out.substr(0, slash); +#else + char buf[4096]; + ssize_t n = readlink("/proc/self/exe", buf, sizeof(buf) - 1); + if (n <= 0) return ""; + buf[n] = 0; + std::string out(buf); + size_t slash = out.find_last_of('/'); + return (slash == std::string::npos) ? "" : out.substr(0, slash); +#endif +} + +bool file_exists(const std::string& p) { + if (p.empty()) return false; + struct stat st{}; + return stat(p.c_str(), &st) == 0 && !S_ISDIR(st.st_mode); +} + +// Cadena de fallback (logged una sola vez al primer uso): +// 1. /runtime/python/{python.exe|bin/python3} -> kind=embedded +// 2. $FN_PYTHON -> kind=env +// 3. /python/.venv/bin/python3 -> kind=registry_venv +// 4. python3 del PATH -> kind=system +PyRuntime resolve_python_runtime() { + PyRuntime r; + std::string exe = get_exe_dir(); + +#ifdef _WIN32 + if (!exe.empty()) { + std::string p = exe + "\\runtime\\python\\python.exe"; + if (file_exists(p)) { r.path = p; r.kind = "embedded"; return r; } + } +#else + if (!exe.empty()) { + std::string p = exe + "/runtime/python/bin/python3"; + if (file_exists(p)) { r.path = p; r.kind = "embedded"; return r; } + } +#endif + + if (const char* env = std::getenv("FN_PYTHON"); env && *env) { + if (file_exists(env)) { r.path = env; r.kind = "env"; return r; } + } + + // Legacy: el venv del registry. En Windows requiere wsl.exe + // porque ese .venv vive en el sistema de archivos Linux. + if (!g_state->registry_root.empty()) { + std::string p = g_state->registry_root + "/python/.venv/bin/python3"; +#ifdef _WIN32 + // En Windows el path es WSL-form; no podemos statearlo desde + // Windows directamente, asumimos que existe si registry_root + // se resolvio. needs_wsl=true marca que jobs.cpp debe seguir + // el camino legacy con wsl.exe. + r.path = p; + r.kind = "registry_venv"; + r.needs_wsl = true; + return r; +#else + if (file_exists(p)) { r.path = p; r.kind = "registry_venv"; return r; } +#endif + } + +#ifdef _WIN32 + r.path = "python.exe"; +#else + r.path = "python3"; +#endif + r.kind = "system"; + return r; +} + +// Cache estatico — log una vez la procedencia para que el usuario +// vea en stdout que runtime se eligio. +const PyRuntime& cached_python_runtime() { + static bool inited = false; + static PyRuntime r; + if (!inited) { + r = resolve_python_runtime(); + std::fprintf(stdout, + "[jobs] python runtime: kind=%s path=%s wsl=%d\n", + r.kind.c_str(), r.path.c_str(), r.needs_wsl ? 1 : 0); + inited = true; + } + return r; +} + long long now_ms() { using namespace std::chrono; return duration_cast(system_clock::now().time_since_epoch()).count(); @@ -436,8 +547,9 @@ ProcResult run_subprocess(const std::string& job_id, // Construir cmdline segun lang (issue 0033). // - "go": ejecutar el .exe nativo directamente, sin wsl.exe. - // - "python": wsl.exe --cd -- python3 (legacy) - // - "bash": wsl.exe --cd -- bash + // - "python": embedded (Windows nativo) si existe runtime/, si + // no fallback a wsl.exe + venv del registry. + // - "bash": wsl.exe --cd -- bash (siempre) std::wstring cmdline; if (lang == "go") { // run_path es el .exe Windows nativo. CreateProcessW lo lanza @@ -445,21 +557,35 @@ ProcResult run_subprocess(const std::string& job_id, cmdline = L"\""; cmdline += utf8_to_wide(run_path); cmdline += L"\""; - } else { + } else if (lang == "bash") { std::string run_wsl = to_wsl_path(run_path); std::string root_wsl = to_wsl_path(g_state->registry_root); - std::string interp; - if (lang == "bash") { - interp = "/bin/bash"; - } else { - interp = root_wsl + "/python/.venv/bin/python3"; - } cmdline = L"wsl.exe --cd "; cmdline += utf8_to_wide(root_wsl); - cmdline += L" -- "; - cmdline += utf8_to_wide(interp); - cmdline += L" "; + cmdline += L" -- /bin/bash "; cmdline += utf8_to_wide(run_wsl); + } else { + // python — fase B: usar embedded si esta disponible. + const PyRuntime& rt = cached_python_runtime(); + if (rt.needs_wsl) { + // Legacy: registry venv vive en WSL. + std::string run_wsl = to_wsl_path(run_path); + std::string root_wsl = to_wsl_path(g_state->registry_root); + cmdline = L"wsl.exe --cd "; + cmdline += utf8_to_wide(root_wsl); + cmdline += L" -- "; + cmdline += utf8_to_wide(rt.path); + cmdline += L" "; + cmdline += utf8_to_wide(run_wsl); + } else { + // Embedded / FN_PYTHON / system — Python nativo Windows. + // run_path es Windows nativo, no necesita conversion. + cmdline = L"\""; + cmdline += utf8_to_wide(rt.path); + cmdline += L"\" \""; + cmdline += utf8_to_wide(run_path); + cmdline += L"\""; + } } std::vector cmdbuf(cmdline.begin(), cmdline.end()); @@ -648,11 +774,18 @@ ProcResult run_subprocess(const std::string& job_id, std::fprintf(stderr, "execv bash failed\n"); _exit(127); } - // Default: python. - std::string py = g_state->registry_root + "/python/.venv/bin/python3"; - const char* argv[] = { py.c_str(), run_path.c_str(), nullptr }; - execv(py.c_str(), (char* const*)argv); - std::fprintf(stderr, "execv failed: %s\n", py.c_str()); + // Default: python — usa la cadena de fallback de fase B + // (embedded > FN_PYTHON > registry venv > system PATH). + const PyRuntime& rt = cached_python_runtime(); + if (rt.kind == "system") { + // Lookup en PATH via execvp. + const char* argv[] = { rt.path.c_str(), run_path.c_str(), nullptr }; + execvp(rt.path.c_str(), (char* const*)argv); + } else { + const char* argv[] = { rt.path.c_str(), run_path.c_str(), nullptr }; + execv(rt.path.c_str(), (char* const*)argv); + } + std::fprintf(stderr, "execv failed: %s\n", rt.path.c_str()); _exit(127); } @@ -980,6 +1113,11 @@ bool jobs_init(const char* app_db_path, } } + // Forzar resolucion del Python runtime al iniciar — asi el log + // sale en stdout una sola vez con la procedencia (embedded / + // env / registry_venv / system) y el usuario ve que se elegira. + (void)cached_python_runtime(); + for (int i = 0; i < n_workers; ++i) { g_state->workers.emplace_back(worker_loop); } diff --git a/tests/test_python_runtime_resolver.py b/tests/test_python_runtime_resolver.py new file mode 100644 index 0000000..897fc32 --- /dev/null +++ b/tests/test_python_runtime_resolver.py @@ -0,0 +1,141 @@ +"""Tests del resolver de Python runtime (issue 0033 fase B). + +El resolver vive en C++ (jobs.cpp::cached_python_runtime). Como +pytest no puede llamarlo directo, los tests verifican el +comportamiento via efectos observables: + + 1. La estructura del freeze script y su lock. + 2. Que el binario, al arrancar, loggea la procedencia del runtime + (kind=embedded|registry_venv|env|system). + +El test del binario solo corre si el .exe esta compilado para Linux +en el path esperado — si no, se skipea. +""" +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +from conftest import REGISTRY_ROOT, APP_DIR_SRC + + +GRAPH_EXPLORER_BIN = ( + REGISTRY_ROOT / "cpp" / "build" / "linux" / "apps" + / "graph_explorer" / "graph_explorer" +) + + +def _runtime_log_line(stdout: str) -> str | None: + for line in stdout.splitlines(): + if line.startswith("[jobs] python runtime:"): + return line + return None + + +@pytest.mark.skipif(not GRAPH_EXPLORER_BIN.exists(), + reason="binario graph_explorer no compilado") +def test_resolver_falls_back_to_registry_venv_when_no_embed(tmp_path): + """Sin runtime/ junto al exe, debe caer a registry_venv.""" + # Copiamos el binario a tmp_path (sin runtime/) y lo arrancamos + # desde el repo root (donde existe projects/default). + bin_copy = tmp_path / "graph_explorer" + shutil.copy(GRAPH_EXPLORER_BIN, bin_copy) + bin_copy.chmod(0o755) + + proc = subprocess.run( + [str(bin_copy)], + cwd=str(REGISTRY_ROOT / "cpp"), + capture_output=True, text=True, timeout=4, + env={**os.environ, "DISPLAY": ""}, # forzamos GLFW fail rapido + ) + line = _runtime_log_line(proc.stdout) + assert line is not None, f"no se logueo runtime: {proc.stdout[-500:]}" + assert "kind=registry_venv" in line, line + assert "wsl=0" in line, line + + +@pytest.mark.skipif(not GRAPH_EXPLORER_BIN.exists(), + reason="binario graph_explorer no compilado") +def test_resolver_picks_embedded_when_runtime_present(tmp_path): + """Con runtime/ junto al exe, debe elegir embedded.""" + bin_copy = tmp_path / "graph_explorer" + shutil.copy(GRAPH_EXPLORER_BIN, bin_copy) + bin_copy.chmod(0o755) + + # Faux runtime: solo necesita un ejecutable en bin/python3. + rt = tmp_path / "runtime" / "python" / "bin" + rt.mkdir(parents=True) + fake_py = rt / "python3" + fake_py.write_text("#!/bin/bash\necho fake\n") + fake_py.chmod(0o755) + + proc = subprocess.run( + [str(bin_copy)], + cwd=str(REGISTRY_ROOT / "cpp"), + capture_output=True, text=True, timeout=4, + env={**os.environ, "DISPLAY": ""}, + ) + line = _runtime_log_line(proc.stdout) + assert line is not None, f"no se logueo runtime: {proc.stdout[-500:]}" + assert "kind=embedded" in line, line + assert str(fake_py) in line, line + + +@pytest.mark.skipif(not GRAPH_EXPLORER_BIN.exists(), + reason="binario graph_explorer no compilado") +def test_resolver_uses_fn_python_env_var(tmp_path): + """FN_PYTHON tiene prioridad sobre el venv del registry.""" + bin_copy = tmp_path / "graph_explorer" + shutil.copy(GRAPH_EXPLORER_BIN, bin_copy) + bin_copy.chmod(0o755) + + fake = tmp_path / "my_python" + fake.write_text("#!/bin/bash\necho fake\n") + fake.chmod(0o755) + + proc = subprocess.run( + [str(bin_copy)], + cwd=str(REGISTRY_ROOT / "cpp"), + capture_output=True, text=True, timeout=4, + env={**os.environ, "DISPLAY": "", "FN_PYTHON": str(fake)}, + ) + line = _runtime_log_line(proc.stdout) + assert line is not None, f"no se logueo runtime: {proc.stdout[-500:]}" + assert "kind=env" in line, line + assert str(fake) in line, line + + +def test_freeze_script_is_idempotent(tmp_path): + """Llamadas consecutivas con mismas deps no rehacen el runtime.""" + fake_app = tmp_path / "app" + fake_app.mkdir() + (fake_app / "app.md").write_text( + "---\nname: x\npython_runtime: true\n" + "python_runtime_deps:\n - requests\n---\n", + encoding="utf-8") + + script = APP_DIR_SRC / "tools" / "freeze_python_runtime.sh" + + # 1ra ejecucion: deberia hacer todo el trabajo. + proc1 = subprocess.run( + [str(script), str(fake_app), "linux"], + capture_output=True, text=True, timeout=120, + ) + if proc1.returncode != 0: + pytest.skip(f"freeze fallo (sin uv?): {proc1.stderr[:200]}") + assert (fake_app / "runtime" / ".lock").exists() + lock1 = (fake_app / "runtime" / ".lock").read_text() + + # 2da: con el .lock ya escrito, debe reportar "sin cambios". + proc2 = subprocess.run( + [str(script), str(fake_app), "linux"], + capture_output=True, text=True, timeout=10, + ) + assert proc2.returncode == 0, proc2.stderr + assert "sin cambios" in proc2.stdout, proc2.stdout + lock2 = (fake_app / "runtime" / ".lock").read_text() + assert lock1 == lock2 diff --git a/tools/freeze_python_runtime.sh b/tools/freeze_python_runtime.sh new file mode 100755 index 0000000..9c78772 --- /dev/null +++ b/tools/freeze_python_runtime.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +# freeze_python_runtime.sh — genera /runtime/python/ embebido +# para distribuir graph_explorer (u otra app) sin dependencia de WSL +# ni del .venv del registry. +# +# Issue 0033 fase B. +# +# Uso: +# tools/freeze_python_runtime.sh +# = linux | windows +# +# Lee `python_runtime_deps` del frontmatter de /app.md +# (lista YAML inline). Tambien acepta override via env var: +# PY_DEPS="requests certifi urllib3" tools/freeze_python_runtime.sh ... +# +# Idempotente — calcula un hash de (PY_VERSION + deps + platform) y +# lo guarda en runtime/.lock. Si coincide con el actual, no rehace. +# +# Salida (Windows): +# /runtime/python/python.exe +# /runtime/python/Lib/site-packages//... +# +# Salida (Linux): +# /runtime/python/bin/python3 +# /runtime/python/lib/... + +set -euo pipefail + +PY_VERSION="${PY_VERSION:-3.12.7}" +APP_DIR="${1:?app_dir requerido}" +PLATFORM="${2:?platform requerido (linux|windows)}" + +if [[ ! -d "$APP_DIR" ]]; then + echo "ERROR: $APP_DIR no es un directorio" >&2 + exit 1 +fi + +RUNTIME_DIR="$APP_DIR/runtime/python" +APP_MD="$APP_DIR/app.md" + +# ---------------------------------------------------------------------------- +# Resolver dependencias: PY_DEPS (env) > frontmatter de app.md > vacio. +# ---------------------------------------------------------------------------- +deps_from_env="${PY_DEPS:-}" +deps_from_md="" + +if [[ -z "$deps_from_env" && -f "$APP_MD" ]]; then + # Parser ad-hoc del frontmatter (entre el primer y segundo `---`). + # Busca lineas que empiezan por ` - ` despues de una linea + # `python_runtime_deps:`. Suficiente para el formato YAML simple + # que usamos en los app.md del registry. Si necesitamos algo mas + # complejo (anidados, comentarios), portarlo a Python o usar yq. + deps_from_md=$(awk ' + /^---$/ { fm = !fm; next } + !fm { next } + /^python_runtime_deps:[[:space:]]*$/ { collecting = 1; next } + collecting && /^[[:space:]]*-[[:space:]]+/ { + sub(/^[[:space:]]*-[[:space:]]+/, "") + sub(/[[:space:]]*#.*$/, "") + gsub(/[\047"]/, "") + print + next + } + collecting && /^[^[:space:]-]/ { collecting = 0 } + ' "$APP_MD" | xargs) +fi + +DEPS="${deps_from_env:-$deps_from_md}" + +# ---------------------------------------------------------------------------- +# Hash del estado: si coincide con runtime/.lock no rehacemos nada. +# ---------------------------------------------------------------------------- +state_hash=$(echo -n "$PY_VERSION|$PLATFORM|$DEPS" | sha256sum | cut -d' ' -f1) +lock_file="$APP_DIR/runtime/.lock" +if [[ -f "$lock_file" ]]; then + cur=$(cat "$lock_file" 2>/dev/null || echo "") + if [[ "$cur" == "$state_hash" ]]; then + echo "freeze: sin cambios (.lock = $state_hash)" + exit 0 + fi +fi + +# ---------------------------------------------------------------------------- +# Limpieza y creacion. +# ---------------------------------------------------------------------------- +echo "freeze: $PLATFORM, deps=[$DEPS], py=$PY_VERSION" +rm -rf "$RUNTIME_DIR" +mkdir -p "$RUNTIME_DIR" + +if [[ "$PLATFORM" == "windows" ]]; then + # Windows embedded distribution (zip oficial python.org). + zip="python-${PY_VERSION}-embed-amd64.zip" + cache="${TMPDIR:-/tmp}/$zip" + if [[ ! -f "$cache" ]]; then + echo "freeze: descargando $zip..." + curl -sSL --fail \ + "https://www.python.org/ftp/python/$PY_VERSION/$zip" \ + -o "$cache.tmp" + mv "$cache.tmp" "$cache" + fi + unzip -oq "$cache" -d "$RUNTIME_DIR" + + # Habilitar site-packages (el embedded viene con ._pth restrictivo). + short=$(echo "$PY_VERSION" | awk -F. '{print $1$2}') + pth="$RUNTIME_DIR/python${short}._pth" + if [[ -f "$pth" ]]; then + sed -i 's|^#import site|import site|' "$pth" + fi + + # Instalar deps con pip "host" usando el embedded como target. + if [[ -n "$DEPS" ]]; then + echo "freeze: pip install $DEPS (target=$RUNTIME_DIR/Lib/site-packages)" + # `--platform win_amd64 --only-binary=:all:` fuerza wheels + # binarios para Windows aunque pip corra en Linux. + python3 -m pip install --quiet \ + --target "$RUNTIME_DIR/Lib/site-packages" \ + --platform win_amd64 --only-binary=:all: \ + --python-version "$PY_VERSION" \ + $DEPS + fi + +elif [[ "$PLATFORM" == "linux" ]]; then + # En Linux preferimos `uv` si esta disponible — descarga un + # Python standalone (de python-build-standalone) que no depende + # del Python del sistema y empaqueta todo. Si no, fallback a + # `python3 -m venv` (requiere python3-venv del sistema). + if command -v uv >/dev/null 2>&1; then + # uv mantiene un cache global de Pythons standalone (de + # python-build-standalone) en ~/.local/share/uv/python/. Para + # un runtime distribuible copiamos ese arbol completo (~82 MB) + # y luego instalamos deps con su pip propio. Sin esto el venv + # quedaria con symlinks al cache de uv que se rompen al + # mover la carpeta a otra maquina. + uv python install "$PY_VERSION" >/dev/null 2>&1 || true + py_root=$(uv python find "$PY_VERSION" 2>/dev/null | xargs -I{} dirname {} | xargs dirname) + if [[ -z "$py_root" || ! -d "$py_root" ]]; then + echo "ERROR: no se localizo Python $PY_VERSION via uv" >&2 + exit 4 + fi + cp -r "$py_root/." "$RUNTIME_DIR/" + # python-build-standalone deja un marker EXTERNALLY-MANAGED + # (PEP 668) que bloquea pip install. Es nuestro runtime, no + # gestion del sistema — lo eliminamos. + find "$RUNTIME_DIR" -name "EXTERNALLY-MANAGED" -delete 2>/dev/null || true + py_bin="$RUNTIME_DIR/bin/python3" + if [[ -n "$DEPS" ]]; then + echo "freeze: pip install $DEPS" + "$py_bin" -m pip install --quiet --no-warn-script-location $DEPS + fi + else + python3 -m venv "$RUNTIME_DIR" --copies >/dev/null + if [[ ! -x "$RUNTIME_DIR/bin/pip" ]]; then + echo "ERROR: pip no disponible. Instala uv o python3-venv del sistema." >&2 + exit 3 + fi + if [[ -n "$DEPS" ]]; then + echo "freeze: pip install $DEPS" + "$RUNTIME_DIR/bin/pip" install --quiet $DEPS + fi + fi +else + echo "ERROR: platform desconocida: $PLATFORM (esperado linux|windows)" >&2 + exit 2 +fi + +echo "$state_hash" > "$lock_file" +echo "freeze: OK ($RUNTIME_DIR)"