Ctrl K
ComunidadGuías…

build_env.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""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)