pyinstaller_guard.py
Rama main · réplica de lectura del repositorio original.
"""Run PyInstaller so a Tlamatini release build cannot die on the build machine's clutter.
Angela, 2026-10-21: "make building process for public and private:
INVULNERABLE MUST ALWAYS BUILD".
A public build ran for 20 minutes and then died in PyInstaller's COLLECT step:
PermissionError: [Errno 13] Permission denied:
'C:\\Users\\angel\\AppData\\Roaming\\jupyter\\runtime\\jpserver-11572-open.html'
Nothing in Tlamatini uses Jupyter. ``thinc.tests.test_examples`` imports
``nbconvert``, nbconvert imports ``notebook``, and the contributed
``hook-notebook.py`` then bundles every folder
``jupyter_core.paths.jupyter_path()`` names - including the builder's own
``%APPDATA%\\jupyter``: the files of a RUNNING Jupyter server (locked), its
tokens, ``jupyter_cookie_secret`` and ``notebook_secret``. So the build failed
whenever Jupyter was open, and carried the builder's Jupyter secrets whenever
it was not.
build.py now excludes the Jupyter family from the frozen graph. This runner is
the second and third line of defence, for EVERY PyInstaller build of a release
(build.py, build_uninstaller.py, build_installer.py), public and private alike:
1. HERMETIC PROFILE - before PyInstaller is even imported, the Jupyter and
IPython profile variables point at an empty folder under ``build/``, so no
hook can reach the builder's own profile.
2. LOCKED-FILE GUARD - a file another program holds is retried for about 12
seconds. If it is still locked and it does not belong to Tlamatini or to
Python (it lies outside the repository, every interpreter prefix, every
site-packages folder and the Windows folder), it is SKIPPED and named on
the screen: such a file belongs to another program on this machine and is
never part of the app. A locked file that IS part of the build still fails
the build, with its path and what to do - never a silent half-build.
Stdlib only. Importing this module changes nothing; only ``main()`` patches.
"""
from __future__ import annotations
import errno
import os
import shutil
import site
import sys
import time
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent
HERMETIC_PROFILE = REPO_ROOT / "build" / "pyinstaller-hermetic-profile"
#: Folders Jupyter / IPython read for the USER's own profile.
PROFILE_DIR_VARS = ("JUPYTER_DATA_DIR", "JUPYTER_CONFIG_DIR", "JUPYTER_RUNTIME_DIR", "IPYTHONDIR")
#: Search paths that could add more of the user's folders.
PROFILE_PATH_VARS = ("JUPYTER_PATH", "JUPYTER_CONFIG_PATH")
#: Windows errors meaning "another program holds this file":
#: access denied, sharing violation, lock violation.
_LOCK_WINERRORS = frozenset({5, 32, 33})
_LOCK_ERRNOS = frozenset({errno.EACCES, errno.EBUSY, errno.EPERM})
#: 0.25 + 0.5 + 1 + 2 + 4 + 4 seconds of waiting between seven attempts.
LOCK_RETRIES = 7
_ORIGINAL_COPYFILE = shutil.copyfile
_skipped: list[tuple[str, str, str]] = [] # (source, destination, reason)
_trusted: tuple[str, ...] | None = None
def pyinstaller_command() -> list[str]:
"""The argv prefix that replaces ``[sys.executable, '-m', 'PyInstaller']``."""
return [sys.executable, str(Path(__file__).resolve())]
def apply_hermetic_profile(env=None, profile_root=HERMETIC_PROFILE) -> Path:
"""Point every Jupyter / IPython profile variable at an EMPTY folder."""
env = os.environ if env is None else env
root = Path(profile_root)
shutil.rmtree(root, ignore_errors=True)
for var in PROFILE_DIR_VARS:
folder = root / var.lower()
folder.mkdir(parents=True, exist_ok=True)
env[var] = str(folder)
for var in PROFILE_PATH_VARS:
env.pop(var, None)
env["JUPYTER_NO_CONFIG"] = "1"
return root
def _norm(path) -> str:
return os.path.normcase(os.path.abspath(os.fspath(path)))
def trusted_roots() -> tuple[str, ...]:
"""Folders whose files the app may really need: never skipped, only failed."""
global _trusted
if _trusted is None:
roots = {str(REPO_ROOT), sys.prefix, sys.base_prefix, sys.exec_prefix,
os.environ.get("SystemRoot", ""), os.environ.get("WINDIR", "")}
try:
roots.update(site.getsitepackages())
except Exception:
pass
try:
roots.add(site.getuserbase())
roots.add(site.getusersitepackages())
except Exception:
pass
roots.update(entry for entry in sys.path if entry and os.path.isdir(entry))
_trusted = tuple(sorted({_norm(root) for root in roots if root}))
return _trusted
def belongs_to_build(path) -> bool:
"""True when ``path`` lies under the repository, Python, or Windows."""
target = _norm(path)
for root in trusted_roots():
try:
if os.path.commonpath([target, root]) == root:
return True
except ValueError: # another drive
continue
return False
def is_lock_error(exc: BaseException) -> bool:
if not isinstance(exc, OSError):
return False
if isinstance(exc, PermissionError):
return True
return getattr(exc, "winerror", None) in _LOCK_WINERRORS or exc.errno in _LOCK_ERRNOS
def guarded_copyfile(src, dst, *args, **kwargs):
"""``shutil.copyfile`` that waits out a lock, then skips foreign clutter."""
delay = 0.25
failure: OSError | None = None
for attempt in range(1, LOCK_RETRIES + 1):
try:
return _ORIGINAL_COPYFILE(src, dst, *args, **kwargs)
except OSError as exc:
if not is_lock_error(exc):
raise
failure = exc
if attempt < LOCK_RETRIES:
time.sleep(delay)
delay = min(delay * 2, 4.0)
if not belongs_to_build(src):
_skipped.append((os.fspath(src), os.fspath(dst), str(failure)))
print("WARNING [BUILD-GUARD] skipped a locked file that is not part of "
f"Tlamatini or Python:\n {src}\n ({failure})", flush=True)
# An empty stand-in lets PyInstaller finish its own bookkeeping
# (it chmods what it copied); remove_placeholders() deletes it.
try:
Path(dst).parent.mkdir(parents=True, exist_ok=True)
Path(dst).touch()
except OSError:
pass
return dst
raise PermissionError(
f"[BUILD-GUARD] {src} is part of the build, but another program keeps it "
f"locked (tried {LOCK_RETRIES} times over about 12 seconds). Close that "
"program - or a Tlamatini still running from dist\\ - and build again. "
f"Destination: {dst}"
) from failure
def remove_placeholders() -> None:
for _src, dst, _why in _skipped:
try:
if os.path.isfile(dst) and os.path.getsize(dst) == 0:
os.remove(dst)
except OSError:
pass
def report() -> None:
if not _skipped:
return
print("\n" + "=" * 74)
print(f"[BUILD-GUARD] {len(_skipped)} locked file(s) outside Tlamatini and Python "
"were NOT bundled:")
for src, _dst, _why in _skipped:
print(f" - {src}")
print("They belong to other programs on this machine; the build does not need them.")
print("=" * 74, flush=True)
def main(argv=None) -> int:
args = list(sys.argv[1:] if argv is None else argv)
profile = apply_hermetic_profile()
print(f"--- [BUILD-GUARD] Jupyter/IPython profile isolated in {profile}", flush=True)
print("--- [BUILD-GUARD] locked files are retried; a locked file outside "
"Tlamatini and Python is skipped and named", flush=True)
shutil.copyfile = guarded_copyfile
try:
from PyInstaller.__main__ import run
run(args)
except SystemExit as exc: # PyInstaller reports its own errors this way
if exc.code is None:
return 0
if isinstance(exc.code, int):
return exc.code
print(exc.code, file=sys.stderr, flush=True)
return 1
finally:
shutil.copyfile = _ORIGINAL_COPYFILE
remove_placeholders()
report()
return 0
if __name__ == "__main__":
sys.exit(main())