build_env.py
Rama main · réplica de lectura del repositorio original.
"""The SEALED build Python: what you install on this machine can never reach a release.
Angela, 2026-10-21: "everytime I uninstall/install a ... lib like tensorflow, or
jupyter lab Tlamatini must not get [broken]".
Until now every release was frozen by the EVERYDAY Python of the build machine
(C:\\Program Files\\Python312 plus the per-user site-packages). PyInstaller
follows the import graph through whatever happens to be installed there, so
each `pip install` / `pip uninstall` changed what a release contained:
tensorflow and Jupyter rode into the build, a running Jupyter's locked file
killed it after 20 minutes, and the build itself wrote back into that Python
(`pip install --user`, a numpy purge) - so building could break the user's own
projects as well.
Now build.py, build_uninstaller.py and build_installer.py re-run themselves
inside ONE dedicated virtual environment:
* it lives in the application-owned ``Temp/build-env`` directory
(``TLAMATINI_BUILD_ENV_DIR`` overrides). Temp is excluded from source snapshots
and is not removed by the build/dist cleanup;
* it sees NO system and NO user site-packages - only what requirements.txt
installs into it (PyInstaller and its hooks are pinned there too);
* it is named after a fingerprint of requirements.txt and the base Python, so
editing requirements.txt builds a fresh one, and an unchanged one is reused
in seconds;
* it is published only after it installed AND imported cleanly - a half-made
environment is never used;
* environments for different requirement fingerprints coexist. Creating the
Spanish environment must never delete the Python running an English build;
* preparation and the complete build hold an OS lock for their environment.
Another process waits instead of clearing an environment in use.
If the sealed environment cannot be made (for instance, no internet the very
first time), a healthy prior sealed environment can be reused. If none exists,
stop before build cleanup or dependency changes: never silently purge/install
packages in the machine Python. ``TLAMATINI_BUILD_ENV=off`` remains an explicit
legacy opt-out, never an automatic fallback.
Stdlib only; nothing here imports Tlamatini.
"""
from __future__ import annotations
from contextlib import contextmanager
import datetime
import errno
import hashlib
import json
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent
REQUIREMENTS = REPO_ROOT / "requirements.txt"
#: Bump when the way the environment is made changes; it rebuilds every env.
SCHEMA = "1"
#: Set in the re-run child; ``off`` in the caller's environment disables sealing.
INSIDE_VAR = "TLAMATINI_BUILD_ENV"
MARKER_NAME = "tlamatini-build-env.json"
#: Imports that prove the environment can freeze Tlamatini.
HEALTH_IMPORTS = ("PyInstaller", "django", "numpy")
PIP_ATTEMPTS = 3
ENV_LOCK_TIMEOUT_SECONDS = 3600
#: Variables that would let the machine's own Python leak back into the build.
_LEAKY_VARS = (
"PYTHONPATH", "PYTHONHOME", "PYTHONSTARTUP", "PYTHONUSERBASE",
"PYTHONEXECUTABLE", "__PYVENV_LAUNCHER__", "VIRTUAL_ENV", "CONDA_PREFIX",
"PIP_USER", "PIP_TARGET", "PIP_PREFIX", "PIP_REQUIRE_VIRTUALENV",
)
def say(message: str) -> None:
print(f"--- [BUILD-ENV] {message}", flush=True)
def envs_root() -> Path:
override = os.environ.get("TLAMATINI_BUILD_ENV_DIR", "").strip()
if override:
return Path(override)
return REPO_ROOT / "Temp" / "build-env"
def base_python() -> str:
"""The real interpreter under any venv the caller may be in."""
return getattr(sys, "_base_executable", "") or sys.executable
def fingerprint(requirements: Path = REQUIREMENTS, base: str | None = None) -> str:
digest = hashlib.sha256()
digest.update(f"schema={SCHEMA}\n".encode())
digest.update(f"python={sys.version}\n".encode())
digest.update(f"base={os.path.normcase(base or base_python())}\n".encode())
digest.update(Path(requirements).read_bytes())
return digest.hexdigest()
def env_dir_for(fp: str, root: Path | None = None) -> Path:
tag = f"py{sys.version_info[0]}{sys.version_info[1]}"
return (root or envs_root()) / f"{tag}-{fp[:12]}"
def env_python_in(env_dir: Path) -> Path:
if os.name == "nt":
return Path(env_dir) / "Scripts" / "python.exe"
return Path(env_dir) / "bin" / "python"
def inside_sealed_env() -> bool:
return os.environ.get(INSIDE_VAR) == "1" and sys.prefix != sys.base_prefix
def sealed_environ(env_python: Path, base_env=None) -> dict:
"""The environment the build runs in: the sealed Python first, nothing leaking in."""
env = dict(os.environ if base_env is None else base_env)
for name in _LEAKY_VARS:
env.pop(name, None)
env_dir = Path(env_python).parent.parent
env["VIRTUAL_ENV"] = str(env_dir)
env["PATH"] = str(Path(env_python).parent) + os.pathsep + env.get("PATH", "")
env["PYTHONNOUSERSITE"] = "1"
env["PIP_DISABLE_PIP_VERSION_CHECK"] = "1"
env[INSIDE_VAR] = "1"
return env
def _run(command, env, timeout=None) -> subprocess.CompletedProcess:
return subprocess.run([str(part) for part in command], env=env, timeout=timeout)
@contextmanager
def environment_lock(env_dir: Path, timeout: float = ENV_LOCK_TIMEOUT_SECONDS):
"""Lease this cache entry across processes; the OS releases it on a crash.
Keep the lock outside the environment that create() may need to repair.
Never unlink lock files: another process may already hold the same inode.
Nested build scripts inherit INSIDE_VAR and run under their parent's lease.
"""
env_dir = Path(env_dir).resolve()
lock_dir = env_dir.parent / ".locks"
lock_dir.mkdir(parents=True, exist_ok=True)
lock_path = lock_dir / (env_dir.name + ".lock")
with lock_path.open("a+b") as handle:
handle.seek(0, os.SEEK_END)
if handle.tell() == 0:
handle.write(b"0")
handle.flush()
deadline = time.monotonic() + timeout
next_notice = 0.0
acquired = False
try:
while not acquired:
handle.seek(0)
try:
if os.name == "nt":
import msvcrt
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
acquired = True
except OSError as exc:
if exc.errno not in (errno.EACCES, errno.EAGAIN, errno.EDEADLK):
raise
now = time.monotonic()
if now >= deadline:
raise TimeoutError(f"Build Python is still in use: {env_dir}") from exc
if now >= next_notice:
say(f"waiting for another build using {env_dir}")
next_notice = now + 10
time.sleep(min(0.2, max(0, deadline - now)))
yield
finally:
if acquired:
handle.seek(0)
if os.name == "nt":
import msvcrt
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
def healthy(env_dir: Path) -> bool:
python = env_python_in(env_dir)
if (not python.is_file() or not (Path(env_dir) / "pyvenv.cfg").is_file()
or not (Path(env_dir) / MARKER_NAME).is_file()):
return False
probe = "import sys; assert sys.prefix != sys.base_prefix; import " + ", ".join(HEALTH_IMPORTS)
try:
result = subprocess.run(
[str(python), "-c", probe], env=sealed_environ(python),
capture_output=True, text=True, timeout=180,
)
except Exception:
return False
return result.returncode == 0
def _remove(path: Path) -> None:
def _writable(func, target, _exc):
try:
os.chmod(target, 0o700)
func(target)
except Exception:
pass
if sys.version_info >= (3, 12):
shutil.rmtree(path, onexc=_writable)
else:
shutil.rmtree(path, onerror=_writable)
def create(env_dir: Path, requirements: Path = REQUIREMENTS) -> Path:
"""Prepare under environment_lock(); publish only after imports succeed."""
env_dir = Path(env_dir)
marker = env_dir / MARKER_NAME
if env_dir.exists():
_remove(env_dir)
if env_dir.exists():
raise RuntimeError(f"Cannot repair build Python while files remain locked: {env_dir}")
env_dir.parent.mkdir(parents=True, exist_ok=True)
plain = dict(os.environ)
for name in _LEAKY_VARS:
plain.pop(name, None)
plain["PYTHONNOUSERSITE"] = "1"
say(f"creating the sealed build Python (one time per requirements.txt): {env_dir}")
made = _run([base_python(), "-m", "venv", "--clear", env_dir], plain)
if made.returncode != 0:
raise RuntimeError(f"python -m venv failed with exit code {made.returncode}")
python = env_python_in(env_dir)
env = sealed_environ(python)
install = [python, "-m", "pip", "install", "--no-warn-script-location",
"--retries", "5", "--timeout", "60", "-r", requirements]
for attempt in range(1, PIP_ATTEMPTS + 1):
say(f"installing requirements.txt into it (attempt {attempt}/{PIP_ATTEMPTS}) ...")
if _run(install, env).returncode == 0:
break
if attempt == PIP_ATTEMPTS:
raise RuntimeError("pip could not install requirements.txt into the sealed build Python")
marker.write_text(json.dumps({
"fingerprint": fingerprint(requirements),
"base_python": base_python(),
"created": datetime.datetime.now().isoformat(timespec="seconds"),
}, indent=2), encoding="utf-8")
if not healthy(env_dir):
marker.unlink(missing_ok=True)
raise RuntimeError("the sealed build Python was made but cannot import "
+ ", ".join(HEALTH_IMPORTS))
return python
def _previous_healthy(exclude: Path, root: Path | None = None) -> Path | None:
root = root or envs_root()
if not root.is_dir():
return None
candidates = sorted(
(path for path in root.iterdir()
if path.is_dir() and path != exclude and (path / MARKER_NAME).is_file()),
key=lambda path: (path / MARKER_NAME).stat().st_mtime, reverse=True,
)
for path in candidates:
if healthy(path):
return path
return None
def ensure_sealed_env(requirements: Path = REQUIREMENTS) -> Path | None:
"""Prepare one fingerprint without removing any other edition's cache."""
env_dir = env_dir_for(fingerprint(requirements))
with environment_lock(env_dir):
# Recheck only after acquiring the lease: another builder may have just
# finished preparing it, or may still be using its Python and packages.
if healthy(env_dir):
say(f"reusing the sealed build Python: {env_dir}")
return env_python_in(env_dir)
try:
return create(env_dir, requirements)
except Exception as exc:
say(f"WARNING: could not make the sealed build Python: {exc}")
older = _previous_healthy(env_dir)
if older is not None:
say(f"WARNING: building with the PREVIOUS sealed Python {older} "
"(requirements.txt changed since it was made)")
return env_python_in(older)
return None
def run_in_sealed_env(script: str, argv=None) -> None:
"""Re-run ``script`` inside the sealed build Python; returns only when it must not."""
if inside_sealed_env():
say(f"running in the sealed build Python: {sys.executable}")
return
if os.environ.get(INSIDE_VAR, "").strip().lower() in ("off", "0", "false", "no"):
say(f"{INSIDE_VAR}=off - building with this machine's Python: {sys.executable}")
return
python = None
try:
python = ensure_sealed_env()
except TimeoutError as exc:
raise SystemExit(str(exc)) from exc
except Exception as exc:
say(f"WARNING: sealed build Python unavailable: {exc}")
if python is None:
raise SystemExit(
"No usable sealed build Python. Restore package-index connectivity "
"or a matching healthy cache, then retry. The machine Python was "
"not used for build cleanup, numpy removal or dependency installation."
)
args = list(sys.argv[1:] if argv is None else argv)
say(f"re-running {Path(script).name} inside the sealed build Python: {python}")
try:
with environment_lock(python.parent.parent):
# Cover the gap between preparation and launch. The lease stays
# alive until the build and its nested uninstaller have finished.
if not healthy(python.parent.parent):
raise SystemExit(f"Build Python became unavailable before launch: {python}")
code = subprocess.run([str(python), "-s", str(Path(script).resolve()), *args],
env=sealed_environ(python)).returncode
except KeyboardInterrupt:
code = 130
sys.exit(code)