feat(jobs): runtime Python embebido + cadena de fallback (issue 0033 fase B)
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. <exe_dir>/runtime/python/{python.exe|bin/python3} (embedded)
2. $FN_PYTHON (env)
3. <registry_root>/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) <noreply@anthropic.com>
This commit is contained in:
+22
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: `<exe_dir>/runtime/python/{python.exe|bin/python3}` →
|
||||
`$FN_PYTHON` → `<registry_root>/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 `<hash-fase-B>`. 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)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <sys/stat.h>
|
||||
#include <mutex>
|
||||
#include <queue>
|
||||
#include <sstream>
|
||||
@@ -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. <exe_dir>/runtime/python/{python.exe|bin/python3} -> kind=embedded
|
||||
// 2. $FN_PYTHON -> kind=env
|
||||
// 3. <registry_root>/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<milliseconds>(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 <root> -- python3 <run.py> (legacy)
|
||||
// - "bash": wsl.exe --cd <root> -- bash <run.sh>
|
||||
// - "python": embedded (Windows nativo) si existe runtime/, si
|
||||
// no fallback a wsl.exe + venv del registry.
|
||||
// - "bash": wsl.exe --cd <root> -- bash <run.sh> (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<wchar_t> 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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Executable
+167
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env bash
|
||||
# freeze_python_runtime.sh — genera <app_dir>/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 <app_dir> <platform>
|
||||
# <platform> = linux | windows
|
||||
#
|
||||
# Lee `python_runtime_deps` del frontmatter de <app_dir>/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):
|
||||
# <app_dir>/runtime/python/python.exe
|
||||
# <app_dir>/runtime/python/Lib/site-packages/<deps>/...
|
||||
#
|
||||
# Salida (Linux):
|
||||
# <app_dir>/runtime/python/bin/python3
|
||||
# <app_dir>/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)"
|
||||
Reference in New Issue
Block a user