# ═══════════════════════════════════════════════════════════════════ # ✦ T L A M A T I N I ✦ — "one who knows" # # Created by Angela López Mendoza · @angelahack1 # Developer · Architect · Creator of Tlamatini # # Every line of this file was written by Angela López Mendoza. # ═══════════════════════════════════════════════════════════════════ # Tlamatini Author Banner — do not remove (releases scrub the name automatically) # build.py — Tlamatini Build Script import json import os import re import stat import subprocess import sys import time from pathlib import Path import importlib.util import shutil import zipfile from build_runtime_assets import ( MAX_RELEASE_ZIP_BYTES, capture_source_payload, copy_root_sources, ignore_runtime_state, validate_source_assets, verify_collected_static, verify_package, write_runtime_manifest, ) # Versioning: SemVer 2.0.0 with git-tag-derived version. See VERSIONING.md. from build_env import run_in_sealed_env from pyinstaller_guard import pyinstaller_command from versioning import ( emit_build_artifacts, extract_cli_version, extract_cli_release_type, resolve_build_version, ) # ── pip's "A new release of pip is available" nag: OFF for the whole build ── # It is pure noise in a long build log, and it is NOT fixable the obvious way: # the build Python is normally the SYSTEM one under Program Files, whose pip # lives in a READ-ONLY prefix, so `python -m pip install --upgrade pip` cannot # write there without admin — and upgrading a DIFFERENT interpreter's pip (e.g. # the carried /python, which is writable) does nothing for it. Even a # successful upgrade only buys silence until pip's next release. So the CHECK # itself is disabled: set here in the environment so EVERY child pip inherits # it (including nested ones we do not spawn directly), while each pip command # below ALSO passes --disable-pip-version-check explicitly — belt-and-braces, # so the silence survives a refactor that rebuilds env from scratch. # Pinned by Tlamatini/agent/test_build_pip_quiet.py. os.environ["PIP_DISABLE_PIP_VERSION_CHECK"] = "1" def find_package_data_paths(pypi_name, import_name): """Finds paths for a package's code and metadata.""" paths_to_add = [] try: spec = importlib.util.find_spec(import_name) if not spec or not spec.origin: print(f"WARNING: Could not find code for '{import_name}'.") return [] code_path = Path(os.path.dirname(spec.origin)) paths_to_add.append(f'--add-data={code_path};{import_name}') print(f"Found '{import_name}' code at: {code_path}") dist_info_name_base = pypi_name.replace('-', '_') dist_info_path = None for path in sys.path: if Path(path).is_dir() and 'site-packages' in str(path): for item in Path(path).iterdir(): if item.is_dir() and item.name.startswith(dist_info_name_base) and item.name.endswith('.dist-info'): dist_info_path = item break if dist_info_path: break if dist_info_path: paths_to_add.append(f'--add-data={dist_info_path};{dist_info_path.name}') print(f"Found '{pypi_name}' metadata at: {dist_info_path}") else: print(f"WARNING: Could not find .dist-info for '{pypi_name}'.") return paths_to_add except Exception as e: print(f"Error finding package {pypi_name}/{import_name}: {e}") return [] def find_package_code_path(package_name): """Finds the full path to an installed package's code directory.""" try: spec = importlib.util.find_spec(package_name) return Path(os.path.dirname(spec.origin)) if spec and spec.origin else None except Exception: return None def _gather_search_dirs(): """Build an ordered, deduplicated list of directories to search for DLLs. Resolution order (first match wins): 1. sys.base_prefix / sys.prefix / executable dir — the running Python 2. PYTHON_HOME env var entries 3. PATH env var entries that contain a python*.dll 4. DLLs sub-folders (standard + MS Store layout) 5. C:/Windows/System32 (VC runtime fallback) """ dirs: list[Path] = [] # ── 1) The Python that is actually executing this script ────────── dirs.append(Path(sys.base_prefix)) dirs.append(Path(sys.prefix)) dirs.append(Path(sys.executable).parent) # ── 2) DLLs sub-folders (standard installer + MS Store on Win11) ── dirs.append(Path(sys.base_prefix) / "DLLs") dirs.append(Path(sys.executable).parent / "DLLs") # ── 3) Windows 10/11 SDK UCRT Redistributables ───────────────────── # This prevents PyInstaller from crashing on fresh Windows endpoints # without requiring contributors to have a specific hardcoded SDK path. sdk_base = Path("C:/Program Files (x86)/Windows Kits/10/Redist") if sdk_base.is_dir(): dirs.append(sdk_base / "ucrt/DLLs/x64") for ver_dir in sdk_base.iterdir(): if ver_dir.is_dir(): dirs.append(ver_dir / "ucrt/DLLs/x64") # ── 4) System32 as last-resort for VC runtimes ──────────────────── dirs.append(Path("C:/Windows/System32")) # Deduplicate while preserving order seen: set[Path] = set() unique: list[Path] = [] for d in dirs: try: resolved = d.resolve() except OSError: continue if resolved not in seen: seen.add(resolved) unique.append(resolved) return unique def _find_first_dll(name: str, search_dirs: list[Path]) -> Path | None: """Return the first existing path for *name* across *search_dirs*.""" for d in search_dirs: candidate = d / name if candidate.exists(): return candidate return None def collect_python_dll_binaries(): """Find **all** DLLs required by the embedded Python so the bootloader can load ``python3XX.dll`` without errors. In addition to the versioned DLL itself, the following are bundled: - ``python3.dll`` – stable ABI DLL the bootloader may need - ``vcruntime140.dll`` – VC runtime - ``vcruntime140_1.dll`` – VC runtime (additional) - ``ucrtbase.dll`` – Universal CRT base - ``api-ms-win-crt-*.dll`` – Universal CRT API-set forwarders Without these, Windows will report "The specified module could not be found" even though the main DLL is present, because *its* transitive dependencies are missing from the temporary extraction directory. Returns a list of ``--add-binary=;`` arguments. """ binaries: list[str] = [] ver = sys.version_info dll_name = f"python{ver.major}{ver.minor}.dll" search_dirs = _gather_search_dirs() print(f"Python executable : {sys.executable}") print(f"Python version : {ver.major}.{ver.minor}.{ver.micro}") print(f"Looking for : {dll_name}") print(f"Search directories: {len(search_dirs)}") # ── 1) python3XX.dll (versioned) ───────────────────────────────── found = _find_first_dll(dll_name, search_dirs) if found: binaries.append(f"--add-binary={found};.") print(f"Bundling Python DLL: {found}") else: print(f"WARNING: Could not locate {dll_name} — the exe may fail to start.") # ── 2) python3.dll (stable ABI – bootloader may require it) ────── found = _find_first_dll("python3.dll", search_dirs) if found: binaries.append(f"--add-binary={found};.") print(f"Bundling stable ABI DLL: {found}") else: print("WARNING: Could not locate python3.dll") # ── 3) VC runtime DLLs ─────────────────────────────────────────── for vc_name in ["vcruntime140.dll", "vcruntime140_1.dll"]: found = _find_first_dll(vc_name, search_dirs) if found: binaries.append(f"--add-binary={found};.") print(f"Bundling VC runtime: {found}") else: print(f"WARNING: Could not locate {vc_name}") # ── 4) Universal CRT (ucrtbase + api-ms-win-crt forwarders) ────── ucrt_found = _find_first_dll("ucrtbase.dll", search_dirs) if ucrt_found: binaries.append(f"--add-binary={ucrt_found};.") print(f"Bundling UCRT base: {ucrt_found}") ucrt_forwarders_bundled = 0 seen_names: set[str] = set() for d in search_dirs: if not d.is_dir(): continue for f in d.iterdir(): lname = f.name.lower() if lname.startswith("api-ms-win-crt-") and lname.endswith(".dll"): if lname not in seen_names: seen_names.add(lname) binaries.append(f"--add-binary={f};.") ucrt_forwarders_bundled += 1 if ucrt_forwarders_bundled: print(f"Bundling {ucrt_forwarders_bundled} UCRT forwarder DLLs") else: print("WARNING: Could not locate any api-ms-win-crt-*.dll forwarders") return binaries _NUMPY_DIST_INFO_RE = re.compile(r'^numpy[-_].+\.(dist-info|egg-info)$', re.IGNORECASE) _NUMPY_WHEEL_RE = re.compile(r'^numpy-.+\.whl$', re.IGNORECASE) def _purge_numpy_environment(python_exe): """Wipe every numpy trace from a target Python's site-packages. Repeated or partial numpy installs leave multiple ``numpy-*.dist-info`` directories side-by-side. When that happens, ``importlib.metadata`` returns whichever dist-info sorts first (often the stale older one), PyInstaller's numpy hook branches on that wrong version number, and ``collect_dynamic_libs("numpy")`` walks a file list that no longer matches what's on disk — returning zero binaries and letting PyInstaller pick up ``numpy/core/`` duplicate ``.pyd`` files via the module graph instead, which trips numpy 2.x's one-init-per-process guard at runtime. To get a clean slate, uninstall numpy repeatedly (pip removes one install at a time when duplicates are present) and then sweep away any orphan ``numpy/`` tree, ``numpy.libs/`` tree, ``numpy-*.dist-info`` directory, or ``numpy-*.whl`` wheel file left behind across every site-packages directory the target Python knows about. The subsequent ``pip install -r requirements.txt`` reinstalls numpy fresh against the pinned version. """ print(f"\n--- Purging numpy environment for: {python_exe} ---") # Enumerate every site-packages directory (system + user) for this Python. probe = ( "import site, json, sys; " "dirs = list(site.getsitepackages()); " "u = site.getusersitepackages(); " "dirs.append(u) if u else None; " "print(json.dumps(dirs))" ) try: raw = subprocess.check_output([python_exe, "-c", probe], text=True).strip() site_dirs = [Path(p) for p in json.loads(raw)] except Exception as e: print(f"WARNING: Could not enumerate site-packages for {python_exe}: {e}") site_dirs = [] # Repeatedly ``pip uninstall`` — each call removes one dist-info, so we # loop until pip reports nothing left. for _ in range(5): result = subprocess.run( [python_exe, "-m", "pip", "--disable-pip-version-check", "uninstall", "-y", "numpy"], capture_output=True, text=True, ) combined = (result.stdout or "") + (result.stderr or "") if result.returncode != 0 or "not installed" in combined.lower(): break # Sweep residual files. pip uninstall only removes what its manifest # tracks; orphan ``.whl`` files and dist-info dirs from aborted installs # must be removed manually. removed = 0 for sp in site_dirs: if not sp.is_dir(): continue for item in sp.iterdir(): name = item.name should_remove = False if name.lower() in ('numpy', 'numpy.libs'): should_remove = True elif _NUMPY_DIST_INFO_RE.match(name): should_remove = True elif _NUMPY_WHEEL_RE.match(name): should_remove = True if not should_remove: continue try: if item.is_dir(): shutil.rmtree(item, onerror=_on_rmtree_error) else: item.unlink() print(f"Removed numpy residual: {item}") removed += 1 except Exception as e: print(f"WARNING: Could not remove {item}: {e}") if removed == 0: print("No numpy residuals found.") else: print(f"Cleared {removed} numpy residual entries.") def run_step(label, func, *args, **kwargs): """Execute a build step with consistent logging and error handling.""" print(f"\n--- {label} ---") try: return func(*args, **kwargs) except Exception as e: print(f"ERROR during '{label}': {e}") raise def _on_rmtree_error(func, path, exc_info): """Handle Windows file-locking / read-only errors during shutil.rmtree.""" try: os.chmod(path, stat.S_IWUSR | stat.S_IREAD) func(path) except Exception: print(f"WARNING: Could not remove locked file: {path}") def clean_directory(path): """Remove a directory tree if it exists (handles locked files on Windows).""" p = Path(path) if p.exists(): shutil.rmtree(p, onerror=_on_rmtree_error) print(f"Removed: {p}") # The ONLY Python version Tlamatini ships to run its pool agents. The carried # interpreter MUST match this exactly — see _probe_carried_python below. CARRIED_PYTHON_VERSION = (3, 12, 10) # Release contract: pkg.zip and the final installer archive stay below 1.99 GB. # Use decimal bytes because release hosts and Windows file properties report the # user-facing "GB" size in decimal units. The guard runs before build cleanup. MAX_PKG_ZIP_BYTES = MAX_RELEASE_ZIP_BYTES # A few representative third-party deps the pool agents import. The carried # interpreter must be able to import all of them, or the agents would fail at # runtime on a clean machine — exactly the bug this whole feature fixes. _CARRIED_PYTHON_REQUIRED_IMPORTS = ("yaml", "langgraph", "langchain", "requests", "numpy", "cv2") # The COMPLETE set of third-party modules the pool agents + the STM32 MCP server # import. The carried Python must import EVERY one of these IN ISOLATION (no build- # machine user-site, no PYTHON* env) or a frozen agent crashes at runtime. Single # source of truth: referenced by the per-interpreter pip-verify (step 1b) AND the # isolated carried-tree verify at the end of bundle_carried_python. _AGENT_RUNTIME_IMPORTS = ( "numpy", "cv2", # numeric core (all media agents) + OpenCV (Camcorder / VideoPlayer) "mcp", "serial", # STM32 MCP server (STM32er) "PyPDF2", "pypdf", "fitz", "odf", # PDF / ODF file backends "markdown", "xhtml2pdf", "reportlab", "PIL", # PDFer document composer (md->html->pdf + images) "ebooklib", "openpyxl", "xlrd", "striprtf", "docx", "pptx", # file-format backends "bs4", "requests", "py7zr", "yaml", # crawler / http / archive / config "psutil", # process/PID liveness in the SHARED pool-agent boilerplate (every pool agent) + OOB kill-tree "pyautogui", "playwright", "telethon", # desktop / browser / telegram agents "pymongo", "pyodbc", "win32gui", "win32con", # db / windows agents (pywin32) "win32cred", "win32crypt", # Torrenter account-scoped credentials in the carried agent "sounddevice", # microphone capture (Recorder) — native PortAudio "soundfile", # audio playback (AudioPlayer) — native libsndfile "ffpyplayer", # video+audio playback (VideoPlayer) — bundled ffmpeg+SDL "torch", "snac", # text-to-speech (Talker) — Orpheus token -> 24 kHz audio vocoder "faster_whisper", "ctranslate2", "av", # Whisperer / Video-Analyzer audio tracks, GPU auto / CPU fallback "yt_dlp", # Video-Analyzer web links (python -m yt_dlp); private self-updating copy as backup ) # These packages execute only in pool/chat-voice subprocesses under the carried # Python. They must remain there; collecting them again in Django's interpreter # adds another browser driver and hundreds of MiB of unused media binaries. _FROZEN_POOL_ONLY_MODULES = ( "cv2", "ffpyplayer", "av", "playwright", "faster_whisper", "ctranslate2", "onnxruntime", ) # Jupyter never runs in Tlamatini. `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 # live %APPDATA%\jupyter: a RUNNING server's files (locked), its tokens and # jupyter_cookie_secret. A public build died on one of those locked files after # 20 minutes (2026-10-21). Excluding the family removes the hook itself; # pyinstaller_guard.py also isolates the profile and survives locked clutter. _FROZEN_JUPYTER_MODULES = ( "notebook", "notebook_shim", "nbclassic", "nbconvert", "jupyter_server", "jupyter_server_terminals", "jupyterlab", "jupyterlab_server", ) def verify_frozen_pool_libraries_absent(dist_root): """Prove that the frozen interpreter respects the pool runtime boundary.""" names = _read_pyz_module_names(dist_root) if names is None: raise RuntimeError("Cannot inspect frozen pool-library exclusions") unexpected = sorted(name for name in names if name.split(".", 1)[0] in _FROZEN_POOL_ONLY_MODULES) internal = Path(dist_root) / "_internal" for path in internal.iterdir(): stem = path.name.split(".", 1)[0].split("-", 1)[0] if stem in _FROZEN_POOL_ONLY_MODULES: unexpected.append(path.name) if unexpected: raise RuntimeError("Pool-only libraries leaked into the frozen interpreter: " + ", ".join(unexpected[:20])) print(" Frozen pool-library boundary passed; carried agent dependencies are retained.") def prune_regenerable_bytecode(dist_root): """Remove build-created caches only when their Python source is present. Never remove source-less bytecode, the frozen PYZ or standard-library ZIPs. Run after all executable checks and before creating the final receipt. """ root = Path(dist_root).resolve() removed = size = 0 for tree in (root / "python", root / "_internal"): if not tree.is_dir() or tree.is_symlink() or tree.is_junction(): continue for directory, dirs, files in os.walk(tree, topdown=False, followlinks=False): parent = Path(directory) if not parent.resolve().is_relative_to(root): continue for name in files: path = parent / name if path.suffix not in {".pyc", ".pyo"} or path.is_symlink(): continue if parent.name == "__pycache__": try: source = Path(importlib.util.source_from_cache(str(path))) except ValueError: continue else: source = path.with_suffix(".py") if source.is_file(): size += path.stat().st_size path.unlink() removed += 1 if parent.name == "__pycache__" and not any(parent.iterdir()): parent.rmdir() print(f" Removed {removed} regenerable bytecode files ({size:,} bytes); source retained.") return {"files": removed, "bytes": size} # ── FROZEN-BUNDLE CARRIAGE PROOF (Angela review, 2026-08-16) ───────────────── # _AGENT_RUNTIME_IMPORTS above covers the CARRIED Python (the pool agents). It # says NOTHING about what landed inside the PyInstaller bundle, and these # modules run INSIDE the frozen Django app, not in the pool. # # WHY A PROOF AND NOT JUST A --hidden-import: every one of these is imported # fail-open (`try: from . import runtime_provisioner / except ImportError: # runtime_provisioner = None`). A fail-open import cannot report its own # absence — the app boots perfectly and simply loses the capability. So the # review question "confirm the frozen build REALLY ships it" cannot be answered # by reading build.py at all; it has to be answered by looking in the archive # the build just produced. That is what verify_frozen_agent_modules() does. # PDF context runs in the frozen web process, independently of the carried # Python used by pool agents. Keep collection and archive checks in sync. _FROZEN_PDF_MODULES = ( "agent.pdf_context", "agent.pdf_context_jobs", "agent.pdf_context_views", "agent.pdf_image_analysis", "agent.agents.image_interpreter.image_interpreter", "pymupdf", ) # Prompt Flow Panel runs execute in the frozen web process, not in a pool agent. _FROZEN_PROMPT_FLOW_PANEL_MODULES = ( "agent.flow_file_open", "agent.flow_file_views", "agent.prompt_flow_panel_consumer", "agent.prompt_flow_panel_runtime", "agent.services.prompt_flow_panel", "agent.management.commands.check_prompt_flow_panel", ) # Terminater's hub, WebSocket consumer and loopback API run in the frozen web process. Its engine, # ConPTY and text modules are LOOSE files in the agents tree (loaded by path), not imports. _FROZEN_TERMINATER_MODULES = ( "agent.terminal_hub", "agent.terminal_consumer", "agent.terminal_views", "agent.terminal_guard", # live-tutorial mode, ledger and claim audit (mcp_agent imports it) "agent.terminater_tool", # the typed tool; tools.py imports it FAIL-OPEN, so name it "agent.management.commands.check_terminater", ) _FROZEN_TORRENTER_MODULES = ( "agent.management.commands.enroll_torrenter", "agent.torrenter_tool", "agent.torrenter_views", "agent.torrenter_bridge", "agent.agents.torrenter.client", "agent.agents.torrenter.contract", "agent.agents.torrenter.sources", "agent.agents.torrenter.activity", "agent.agents.torrenter.torrenter", "agent.migrations.0221_torrenter", "agent.migrations.0222_replace_mistral_large3", "agent.rag.directory_listing", ) # Catalog texts are imported dynamically by the native account handler. Name # every dependency explicitly and verify it in the frozen archive as well. _FROZEN_ACCOUNT_WIZARD_MODULES = ( "agent.user_creation_wizard", "agent.management.commands.createuserconsole", "agent.migrations.0185_standardize_prompt_params_batch3", "agent.migrations.0219_user_wizard_graphical_notifications", "agent.migrations.0223_create_superuser_console_handoff", ) # PyInstaller ships these Windows extensions as loose .pyd files outside the PYZ. _FROZEN_PYWIN32_EXTENSIONS = ("win32cred", "win32crypt") if os.name == "nt" else () _FROZEN_REQUIRED_AGENT_MODULES = ( "agent.interactor", "agent.interactor.context", "agent.interactor.protocol", "agent.interaction_settings", "agent.interaction_narration", "agent.interactor.hub", "agent.interactor.consumer", "agent.interactor.files", "agent.interactor.tool", "agent.canvas_file_views", "agent.native_dialogs", # native canvas Save/Save As "agent.usage_tracking", "agent.usage_provider", "agent.usage_views", "agent.chat_voice_consumer", "agent.chat_voice_runtime", "agent.chat_voice_settings", "agent.agents.model_settings", # compiled registry, independent of data paths "agent.services.flow_knowledge", # new/existing pool helper refresh "agent.management.commands.check_agent_runtimes", # executed release gate "build_runtime_assets", # updater's frozen integrity checker (stdlib only) "agent.runtime_provisioner", # private node/npm/npx/pnpm/uv/uvx provisioning "agent.external_mcp_defaults", # ships + seeds `memory` / `sequential-thinking` "agent.external_mcp_manager", # the universal External-MCP client "agent.agent_verdict", # the deterministic Exec-Report verdict engine "agent.context_governor", # context measurement + the chat gauge sink "agent.context_baseline", # the gauge AT REST: next request + Ollama's real count "agent.context_sidecar_timeout", # time limit on the Files-Search/System-Metrics Ollama calls "agent.model_brain", # per-model settings from formal sources (fail-open import) "agent.model_brain_chat", # keeps/returns a thinking model's reasoning (fail-open import) "agent.model_brain_views", # the Auto-tuning dialog's endpoints "agent.skills.validation", # ONE skill validator; Diagnostics imports it fail-open "agent.skills.redaction", # secret redaction at the skill boundary (harness) "agent.win_shim", # Windows .cmd/.exe resolution (fail-open import) "agent.path_guard", # /Temp + /Templates policy "agent.self_update", # About ▸ Check for updates "agent.sqlite_copy", # WAL-safe DB backup + post-update restoration "agent._version", # SemVer resolver *_FROZEN_PDF_MODULES, # PDF extraction + optional Image-Interpreter *_FROZEN_PROMPT_FLOW_PANEL_MODULES, *_FROZEN_TERMINATER_MODULES, *_FROZEN_TORRENTER_MODULES, *_FROZEN_ACCOUNT_WIZARD_MODULES, ) def _read_pyz_module_names(dist_root): """Return the set of module names inside the build's PYZ archive. ⚠️ WHERE THE PYZ ACTUALLY LIVES (measured, not assumed): under PyInstaller 6 in onedir mode there is NO loose ``_internal/PYZ-00.pyz``. The PYZ is an entry named ``PYZ.pyz`` INSIDE the executable's CArchive — verified against the shipped C:/Tlamatini/Tlamatini.exe, whose CArchive holds 21 entries and whose PYZ holds 15,061 modules. A glob for ``PYZ-*.pyz`` finds nothing and would have reported "cannot verify" forever, i.e. a check that never fails and never proves anything — the same silence this whole item is about. Returns None when the archive cannot be read. The release gate then stops: an uninspectable payload is not proof that required modules were packaged. """ root = Path(dist_root) names = set() # 1) The normal path: the PYZ embedded in the frozen executable. try: from PyInstaller.archive.readers import CArchiveReader # noqa: PLC0415 for exe in sorted(root.glob("*.exe")): try: carchive = CArchiveReader(str(exe)) except Exception: continue # not a PyInstaller exe (installer stub, ...) toc = getattr(carchive, "toc", None) or {} for entry in toc: if not str(entry).upper().startswith("PYZ"): continue try: inner = carchive.open_embedded_archive(entry) except Exception: continue inner_toc = getattr(inner, "toc", None) if inner_toc: names.update( inner_toc.keys() if hasattr(inner_toc, "keys") else inner_toc ) except Exception: pass # 2) Legacy / onefile layouts that DO write a loose PYZ next to the app. if not names: try: from PyInstaller.archive.readers import ZlibArchiveReader # noqa: PLC0415 for pyz in sorted(root.rglob("PYZ*.pyz")): toc = getattr(ZlibArchiveReader(str(pyz)), "toc", None) if toc: names.update(toc.keys() if hasattr(toc, "keys") else toc) except Exception: pass return names or None def verify_frozen_agent_modules(dist_root): """Verify Python modules in the PYZ and pywin32 extensions in the frozen runtime. Aborts for both absent modules and an unreadable archive. Update the reader for incompatible PyInstaller versions instead of publishing unproven output. """ print("\n--- Post-build: verifying frozen agent modules ---") names = _read_pyz_module_names(dist_root) if names is None: raise RuntimeError("Cannot inspect the frozen PYZ archive; runtime carriage is not proven") missing = [m for m in _FROZEN_REQUIRED_AGENT_MODULES if m not in names] internal = Path(dist_root) / "_internal" for module in _FROZEN_PYWIN32_EXTENSIONS: filename = f"{module}.pyd" if not any((path / filename).is_file() for path in (internal / "win32", internal)): missing.append(filename) if missing: print("ERROR: the frozen bundle is MISSING required runtime modules: " + ", ".join(missing)) print(" Missing modules can break startup or disable a runtime capability. " "Check dependency installation and hidden imports, then rebuild. " "Aborting build.") sys.exit(1) print(f" OK - all {len(_FROZEN_REQUIRED_AGENT_MODULES)} required runtime " f"module(s) present in the PYZ ({len(names)} modules total).") if _FROZEN_PYWIN32_EXTENSIONS: print(f" OK - frozen native extensions present: {', '.join(_FROZEN_PYWIN32_EXTENSIONS)}.") return True def verify_frozen_torch_absent(dist_root): """Fail the build if PyInstaller emitted Torch into the frozen web app. Pool agents run under ``/python`` and Talker legitimately needs the CPU-only Torch carried there. The frozen Django process has no Torch import and must not inherit a build user's multi-gigabyte CUDA installation. """ internal_dir = Path(dist_root) / "_internal" torch_dir = internal_dir / "torch" cuda_dll_prefixes = ( "torch", "c10", "cudart", "cudnn", "cublas", "cufft", "cusparse", "cusolver", "curand", "nvjitlink", "nvrtc", ) orphaned_dlls = [] if internal_dir.exists(): orphaned_dlls = [ path for path in internal_dir.rglob("*.dll") if path.name.lower().startswith(cuda_dll_prefixes) ] print("\n--- Post-build: verifying frozen Torch is absent ---") if torch_dir.exists() or orphaned_dlls: offending_files = { path for path in orphaned_dlls if path.is_file() } if torch_dir.exists(): offending_files.update( path for path in torch_dir.rglob("*") if path.is_file() ) size_bytes = sum(path.stat().st_size for path in offending_files) samples = "\n".join(f" - {path}" for path in sorted(offending_files)[:8]) print( "ERROR: PyInstaller bundled Torch/CUDA binaries into the frozen Django process:\n" f" Size: {size_bytes / 1_000_000_000:.3f} GB\n" f" Files:\n{samples}\n" " Torch belongs only in /python for the Talker pool agent.\n" " Keep the no-op pyinstaller_hooks/hook-torch.py and frozen ML\n" " exclusions enabled. Aborting build." ) sys.exit(1) print(" OK - frozen Torch/CUDA binaries are absent; carried CPU Torch remains separate.") return True def enforce_pkg_zip_size(pkg_zip_path, max_bytes=MAX_PKG_ZIP_BYTES): """Fail when ``pkg.zip`` exceeds the public-release size budget.""" path = Path(pkg_zip_path) actual_bytes = path.stat().st_size if actual_bytes > max_bytes: print( "ERROR: pkg.zip exceeds the release-size budget:\n" f" File: {path}\n" f" Actual: {actual_bytes / 1_000_000_000:.3f} GB " f"({actual_bytes:,} bytes)\n" f" Limit : {max_bytes / 1_000_000_000:.3f} GB " f"({max_bytes:,} bytes)\n" " The build output is being retained for inspection. Aborting build." ) sys.exit(1) print( f" Size guard passed: {actual_bytes / 1_000_000_000:.3f} GB " f"<= {max_bytes / 1_000_000_000:.3f} GB." ) return actual_bytes def _probe_carried_python(python_exe): """Run *python_exe* and return (version_tuple, prefix, is_venv, import_error). ``import_error`` is None when all required imports succeed, else a message. Returns None if the interpreter could not be probed at all. """ code = ( "import sys, json\n" "info = {'v': list(sys.version_info[:3]), 'prefix': sys.prefix, " "'venv': sys.prefix != sys.base_prefix}\n" "missing = []\n" "for m in %r:\n" " try: __import__(m)\n" " except Exception as e: missing.append(m + ': ' + str(e)[:80])\n" "info['missing'] = missing\n" "print(json.dumps(info))\n" ) % (list(_CARRIED_PYTHON_REQUIRED_IMPORTS),) try: out = subprocess.check_output([python_exe, "-c", code], text=True, timeout=120) info = json.loads(out.strip().splitlines()[-1]) except Exception as e: print(f"ERROR: could not probe carried Python '{python_exe}': {e}") return None ver = tuple(info.get("v", [])) import_error = "; ".join(info.get("missing", [])) or None return ver, info.get("prefix", ""), bool(info.get("venv", False)), import_error def bundle_carried_python(dist_manage, frozen_python, build_python): """Copy a self-contained, deps-complete Python 3.12.10 into ``/python``. This is the interpreter that the FROZEN pool agents resolve via ``get_user_python_home`` / ``get_python_command`` / ``_resolve_python_executable`` (they ALWAYS prefer ``/python``). Without it, agents cannot run on a machine that has no system Python. The build ABORTS LOUDLY unless the source interpreter is EXACTLY Python %s, is a full standalone install (NOT a venv, so it is portable), and can import the pool agents' third-party dependencies. """ % ".".join(map(str, CARRIED_PYTHON_VERSION)) source_exe = frozen_python or build_python print(f"\n--- Bundling carried Python (for pool agents) from: {source_exe} ---") probe = _probe_carried_python(source_exe) if probe is None: raise RuntimeError(f"Carried-Python source '{source_exe}' is not runnable.") ver, prefix, is_venv, import_error = probe if ver != CARRIED_PYTHON_VERSION: raise RuntimeError( f"Carried Python MUST be exactly {'.'.join(map(str, CARRIED_PYTHON_VERSION))}, " f"but '{source_exe}' is {'.'.join(map(str, ver)) or '?'}. " "Set PYTHON_HOME to a full Python " f"{'.'.join(map(str, CARRIED_PYTHON_VERSION))} install (with requirements.txt " "installed into it) before building." ) if is_venv: raise RuntimeError( f"Carried Python '{source_exe}' is a VIRTUALENV (sys.prefix != base_prefix). " "A venv is not portable — point PYTHON_HOME at a FULL standalone Python " f"{'.'.join(map(str, CARRIED_PYTHON_VERSION))} installation (with deps) so the " "shipped interpreter runs on a machine with no Python." ) if import_error: raise RuntimeError( f"Carried Python '{source_exe}' is missing pool-agent dependencies: {import_error}. " "Run `pip install -r requirements.txt` into it (or set PYTHON_HOME to one that has them)." ) src_prefix = Path(prefix) if not (src_prefix / ("python.exe" if os.name == "nt" else "bin/python3")).exists(): raise RuntimeError(f"Carried Python prefix '{src_prefix}' has no python executable at its root.") dst = Path(dist_manage) / "python" if dst.exists(): shutil.rmtree(dst) # ── SIZE LOCK: prune ML libs the POOL AGENTS never load ────────────── # The carried interpreter exists ONLY for the pool agents (executer, # gitter, stm32er, playwrighter, camcorder, ...). A developer's PYTHON_HOME # accumulates the *CUDA* build of torch (~4 GB on its own), and the old # wholesale copytree dragged all of it in, ballooning the release to ~4 GB. # The Talker pool agent (agent/agents/talker/talker.py) DOES import torch + # snac to decode Orpheus audio tokens into a 24 kHz waveform, so the carried # Python MUST keep torch (the CPU wheel from requirements.txt, ~250 MB — well # under GitHub's 2 GB release-asset limit). We still drop the heavy stuff # Talker does NOT need: the CUDA runtime (nvidia* prefix), torchvision / # torchaudio, triton, transformers, mxnet. KEEP: torch (Talker/snac), cv2, # ffpyplayer, numpy, pillow, yaml, langchain, langgraph, requests, scipy. # Override with TLAMATINI_BUNDLE_FULL_PYTHON=1 to carry the interpreter # verbatim (no ML prune at all). prune_full = os.environ.get("TLAMATINI_BUNDLE_FULL_PYTHON", "").strip() in ("1", "true", "True") # Exact site-packages directory names (and their -.dist-info / # .egg-info / .libs siblings) to drop. _PRUNE_PKG_STEMS = ( "torchvision", "torchaudio", "triton", "transformers", "mxnet", ) # Namespace-package prefixes to drop wholesale (NVIDIA CUDA runtime wheels). _PRUNE_PKG_PREFIXES = ("nvidia",) _pruned = [] def _is_pruned_sp_entry(name): low = name.lower() if low.startswith(_PRUNE_PKG_PREFIXES): return True # exact package dir if low in _PRUNE_PKG_STEMS: return True # -.dist-info / .egg-info and .libs for stem in _PRUNE_PKG_STEMS: if low == f"{stem}.libs": return True if (low.startswith(stem + "-") or low.startswith(stem + "_")) and \ (low.endswith(".dist-info") or low.endswith(".egg-info")): return True return False src_norm = os.path.normpath(str(src_prefix)) doc_html = os.path.normpath(os.path.join(src_norm, "Doc", "html")) def _ignore(directory, names): # Skip caches and pip's own download/temp caches to keep the payload lean. skipped = {n for n in names if n in ("__pycache__", ".cache") or n.endswith((".pyc", ".pyo"))} if prune_full: return list(skipped) # CPython's bundled HTML docs (~55 MB) — never needed at runtime. if os.path.normpath(directory) == doc_html: skipped.update(names) return list(skipped) # Drop the heavy unused packages only at a site-packages level. if os.path.basename(os.path.normpath(directory)).lower() == "site-packages": for n in names: if _is_pruned_sp_entry(n): skipped.add(n) _pruned.append(n) return list(skipped) print(f" Copying {src_prefix} -> {dst} (this is large; please wait)...") if prune_full: print(" TLAMATINI_BUNDLE_FULL_PYTHON=1 — carrying the interpreter verbatim (no ML prune).") shutil.copytree(src_prefix, dst, ignore=_ignore, dirs_exist_ok=False) file_total = sum(1 for p in dst.rglob("*") if p.is_file()) size_mb = sum(p.stat().st_size for p in dst.rglob("*") if p.is_file()) / (1024 * 1024) carried_exe = dst / ("python.exe" if os.name == "nt" else "bin/python3") if _pruned: print(f" Pruned {len(_pruned)} unused ML entries from site-packages: " f"{', '.join(sorted(set(_pruned)))}") print(f" Carried Python {'.'.join(map(str, ver))} bundled: {file_total} files, {size_mb:.0f} MB.") print(f" Pool agents will run via: {carried_exe}") # ── HARDEN: verify the carried tree imports every agent lib IN ISOLATION ── # The carried tree is copytreed from the source-tree build Python (/python), # whose own prefix has all deps installed (ensure_local_build_python). Re-verify # against the COPIED python in ISOLATED mode (`-I` == ignore PYTHON* env, no # user-site, no cwd on sys.path) — an exact dry-run of a clean target machine, so # ONLY the carried tree's own site-packages are visible. Abort LOUDLY if any agent # lib is missing — far better than silently shipping pool agents that crash on # import at runtime. verify_src = "\n".join([ "import importlib", "mods = " + repr(list(_AGENT_RUNTIME_IMPORTS)), "miss = []", "for _m in mods:", " try:", " importlib.import_module(_m)", " except Exception as _e:", " miss.append(_m + ' (' + type(_e).__name__ + ': ' + str(_e)[:90] + ')')", "try:", " import torch as _torch", " _cuda = getattr(getattr(_torch, 'version', None), 'cuda', None)", " if _cuda is not None:", " miss.append('torch (expected CPU-only build, found CUDA ' + str(_cuda) + ')')", "except Exception:", " pass", # the import loop above already records a broken/missing torch "print('MISSING: ' + '; '.join(miss) if miss else 'CARRIED_LIBS_OK')", "raise SystemExit(3 if miss else 0)", ]) print(f" -> Verifying agent libs import in the CARRIED tree (isolated): {carried_exe} ...") try: chk = subprocess.run( [str(carried_exe), "-I", "-B", "-c", verify_src], capture_output=True, text=True, timeout=300, ) except Exception as e: raise RuntimeError(f"Could not run the isolated carried-Python import verify: {e}") print(" " + ((chk.stdout or "") + (chk.stderr or "")).strip()) if chk.returncode != 0: raise RuntimeError( "Carried Python is INCOMPLETE — one or more agent libraries do NOT import " "in isolation (-I), so frozen pool agents would crash at runtime.\n" " The carried tree is copied from /python, whose deps are installed by " "ensure_local_build_python(). A failure here means that install did not fully " "succeed (or a native wheel is broken). Fix: check the pip output above, delete " "/python to force a clean re-provision, correct requirements.txt if needed, " "then rebuild. Aborting build." ) def _probe_cpu_torch(python_exe): """Return ``(is_cpu_only, detail)`` for an isolated interpreter.""" code = ( "import torch\n" "cuda = getattr(torch.version, 'cuda', None)\n" "print(str(torch.__version__) + '|cuda=' + str(cuda))\n" "raise SystemExit(0 if cuda is None else 3)\n" ) env = dict(os.environ) env["PYTHONNOUSERSITE"] = "1" try: result = subprocess.run( [str(python_exe), "-I", "-c", code], capture_output=True, text=True, timeout=120, env=env, ) except Exception as exc: return False, str(exc) detail = ((result.stdout or "") + (result.stderr or "")).strip() return result.returncode == 0, detail def ensure_local_build_python(): """Provision a standalone, WRITABLE Python kept UNDER the source tree at ``/python`` and install the agent deps into its OWN prefix (``--no-user``), returning its ``python.exe``. This is the CARRIED-Python SOURCE: ``bundle_carried_python`` copytrees it into ``/python``, so the carried interpreter ships COMPLETE — without the build ever WRITING to a read-only system Python (e.g. ``C:\\Program Files\\Python312``; that one is only READ, once, to seed the copy). Reused across builds (deps are re-checked, not re-downloaded). Gitignored + excluded from the self-modify snapshot. Raises (aborts the build) on any failure — never returns an incomplete interpreter. """ local_dir = Path(__file__).resolve().parent / "python" local_exe = local_dir / ("python.exe" if os.name == "nt" else "bin/python3") # 1) Seed it ONCE from the current interpreter's standalone install. if not local_exe.exists(): # Seed from the REAL interpreter, also when build.py runs in the sealed venv. seed_python = getattr(sys, "_base_executable", "") or sys.executable probe = _probe_carried_python(seed_python) if probe is None: raise RuntimeError( f"Cannot probe the build Python '{sys.executable}' to seed /python.") ver, prefix, is_venv, _imp = probe want = ".".join(map(str, CARRIED_PYTHON_VERSION)) if ver != CARRIED_PYTHON_VERSION: raise RuntimeError( f"/python must be Python {want}, but the build interpreter " f"'{sys.executable}' is {'.'.join(map(str, ver)) or '?'}. Run build.py with a " f"Python {want}, or place a standalone {want} at /python yourself.") if is_venv: raise RuntimeError( f"The build interpreter '{sys.executable}' is a VIRTUALENV — not portable. " f"Seed /python from a FULL standalone Python {want} (run build.py with one).") src_prefix = Path(prefix) print(f"\n--- Provisioning source-tree build Python (one-time): {src_prefix} -> {local_dir} ---") def _seed_ignore(directory, names): return {n for n in names if n in ("__pycache__", ".cache") or n.endswith((".pyc", ".pyo"))} if local_dir.exists(): shutil.rmtree(local_dir) # remove a partial/corrupt prior seed first shutil.copytree(src_prefix, local_dir, ignore=_seed_ignore, dirs_exist_ok=False) # 2) Install the agent deps into /python's OWN prefix (it is writable). # PYTHONNOUSERSITE=1 → pip ignores the build user's user-site and targets THIS # prefix, so the tree is self-contained. Force a CPU wheel whenever this # isolated tree is missing Torch or contains CUDA Torch; a plain `pip install # torch` incorrectly treats an existing +cu build as already satisfied. req_file = Path(__file__).with_name("requirements.txt") env = dict(os.environ) env["PYTHONNOUSERSITE"] = "1" print(f"--- Installing agent deps into the source-tree build Python: {local_exe} ---") cpu_torch_ok, cpu_torch_detail = _probe_cpu_torch(local_exe) if not cpu_torch_ok: print(f" -> Replacing missing/CUDA Torch with CPU-only Torch ({cpu_torch_detail or 'not importable'}) ...") torch_result = subprocess.run( [str(local_exe), "-m", "pip", "--disable-pip-version-check", "install", "--no-warn-script-location", "--upgrade", "--force-reinstall", "--no-deps", "torch", "--index-url", "https://download.pytorch.org/whl/cpu"], env=env, check=False, ) if torch_result.returncode != 0: raise RuntimeError( f"Failed to install CPU-only Torch into carried Python ({local_exe}).") if req_file.exists(): rc = subprocess.run( [str(local_exe), "-m", "pip", "--disable-pip-version-check", "install", "--no-warn-script-location", "-r", str(req_file)], env=env, check=False, ) if rc.returncode != 0: raise RuntimeError( f"Failed to install requirements into the source-tree build Python " f"({local_exe}). Delete /python and rebuild. Aborting build.") cpu_torch_ok, cpu_torch_detail = _probe_cpu_torch(local_exe) if not cpu_torch_ok: raise RuntimeError( "Carried Python must contain CPU-only Torch for Talker, but the isolated " f"post-install probe failed: {cpu_torch_detail or 'unknown error'}" ) print(f" -> Verified carried CPU-only Torch: {cpu_torch_detail}") return str(local_exe) def _active_playwright_revisions(manifest_path=None): """Directory-name tokens for the Chromium builds the INSTALLED Playwright actually pins — read from ``playwright/driver/package/browsers.json``. Used to drop STALE chromium revisions that earlier Playwright upgrades leave behind in the ``%LOCALAPPDATA%/ms-playwright`` cache (e.g. a ``chromium-1228`` sitting next to the ``chromium-1169`` the current Playwright pins). Copying them verbatim bloats the release by ~0.7 GB and pushed the zip past GitHub's 2 GiB asset limit. Returns a set like ``{'chromium-1169', 'chromium_headless_shell-1169', 'ffmpeg-1011'}``; a failure aborts packaging rather than accepting arbitrary cached revisions.""" keep: set[str] = set() try: import json as _json if manifest_path is None: import playwright as _pw bj = Path(_pw.__file__).parent / "driver" / "package" / "browsers.json" else: bj = Path(manifest_path) data = _json.loads(bj.read_text(encoding="utf-8")) for b in data.get("browsers", []): if not b.get("installByDefault"): continue name, rev = b.get("name", ""), str(b.get("revision", "")) if not rev: continue if name == "chromium": keep.add(f"chromium-{rev}") elif name == "chromium-headless-shell": keep.add(f"chromium_headless_shell-{rev}") # cache dir uses underscores elif name == "ffmpeg": keep.add(f"ffmpeg-{rev}") except Exception as exc: raise RuntimeError("Cannot resolve the required Playwright browser revisions") from exc if not any(name.startswith("chromium-") for name in keep): raise RuntimeError("Playwright browser manifest contains no default Chromium revision") return keep def bundle_playwright_browsers(dist_manage): """Carry the Playwright browser binaries into ``/ms-playwright``. Playwright stores its browsers OUTSIDE site-packages (in ``%LOCALAPPDATA%/ms-playwright``), so bundle_carried_python does NOT pick them up. Without this, Playwrighter and the Googler tool fail on a clean machine ("browser not found"). At runtime manage.py points ``PLAYWRIGHT_BROWSERS_PATH`` at ``/ms-playwright`` — inherited by every spawned pool agent via ``os.environ.copy()``. Googler, Playwrighter and Whatsapper use the carried Python's browser driver. Mandatory: missing browser binaries abort the release instead of disabling Playwrighter/Googler on the user's clean computer. DETERMINISTIC PAYLOAD (size lock): Playwrighter and the Googler tool only ever drive **Chromium**, but a developer who runs the bare ``playwright install`` (default = ALL engines) ends up with Firefox (~257 MB) and WebKit (~160 MB) sitting in the SAME cache. The old code copied the cache verbatim, so the release size silently tracked whatever happened to be installed on the build machine — that is exactly how the lean ~1.3 GB v1.17.0 zip grew by ~0.4 GB on a later "patch-only" build. We now copy ONLY the engines Tlamatini actually uses (chromium + its headless shell + ffmpeg + the tiny resolution metadata) and SKIP firefox/webkit, so every future build is byte-stable regardless of the dev's browser cache. Override with ``TLAMATINI_BUNDLE_ALL_BROWSERS=1`` to carry the full set. """ src = os.path.join(os.environ.get("LOCALAPPDATA", ""), "ms-playwright") dst = Path(dist_manage) / "ms-playwright" if not os.path.isdir(src): raise RuntimeError( f"Playwright browsers not found at '{src}'. " "Run `python -m playwright install chromium` and rebuild." ) if dst.exists(): shutil.rmtree(dst) bundle_all = os.environ.get("TLAMATINI_BUNDLE_ALL_BROWSERS", "").strip() in ("1", "true", "True") # Engines Tlamatini never launches — excluded by default to keep the payload # deterministic and ~0.4 GB smaller. Matched as a case-insensitive prefix on # the top-level cache entry name (e.g. ``firefox-1482``, ``webkit-2158``). _UNUSED_ENGINE_PREFIXES = ("firefox", "webkit") # Keep ONLY the chromium / headless-shell revisions the installed Playwright # pins (see _active_playwright_revisions); every stale revision left in the # cache by an earlier upgrade is skipped. Even --all requires active builds. active_revs = _active_playwright_revisions( Path(dist_manage) / "python" / "Lib" / "site-packages" / "playwright" / "driver" / "package" / "browsers.json" ) for revision in active_revs: if not (Path(src) / revision).is_dir(): raise RuntimeError(f"Missing required Playwright browser revision: {revision}") if active_revs: print(f" Size lock: keeping only active Playwright revisions " f"{', '.join(sorted(active_revs))} (stale chromium builds skipped).") def _ignore(directory, names): skipped = {n for n in names if n == "__pycache__"} if not bundle_all and os.path.normpath(directory) == os.path.normpath(src): for n in names: ln = n.lower() if ln.startswith(_UNUSED_ENGINE_PREFIXES): skipped.add(n) elif active_revs and (ln.startswith("chromium-") or ln.startswith("chromium_headless_shell-") or ln.startswith("ffmpeg-")): if n not in active_revs: skipped.add(n) return list(skipped) print(f"\n--- Bundling Playwright browsers: {src} -> {dst} ---") if bundle_all: print(" TLAMATINI_BUNDLE_ALL_BROWSERS=1 — carrying ALL engines (firefox/webkit included).") else: print(" Carrying Chromium-only (firefox/webkit skipped). " "Set TLAMATINI_BUNDLE_ALL_BROWSERS=1 to include them.") shutil.copytree(src, dst, ignore=_ignore) size_mb = sum(p.stat().st_size for p in dst.rglob("*") if p.is_file()) / (1024 * 1024) print(f" Playwright browsers bundled ({size_mb:.0f} MB). Runtime path: " "/ms-playwright (PLAYWRIGHT_BROWSERS_PATH).") def _java_home_for_bundle(): """Resolve a JDK/JRE root to carry: $JAVA_HOME first, else `which java`.""" jh = os.environ.get("JAVA_HOME", "").strip() leaf = "java.exe" if os.name == "nt" else "java" if jh and (Path(jh) / "bin" / leaf).exists(): return Path(jh) exe = shutil.which("java") if exe: return Path(exe).resolve().parent.parent return None def _git_install_root_for_bundle(): """Resolve the Git-for-Windows install root to carry.""" exe = shutil.which("git") if not exe: return None p = Path(exe).resolve() # C:\Program Files\Git\cmd\git.exe or ...\mingw64\bin\git.exe -> the root # holds both `cmd` and `mingw64`. for cand in (p.parent.parent, p.parent.parent.parent, p.parent): if (cand / "cmd").is_dir() and (cand / "mingw64").is_dir(): return cand return p.parent.parent def bundle_java_runtime(dist_manage): """Carry a Java runtime into ``/jre`` so J-Decompiler works offline. jd-cli (the J-Decompiler payload) needs Java. Without a carried runtime it depends on a system JDK — which a clean machine will not have. At runtime manage.py sets ``JAVA_HOME=/jre`` and prepends ``jre/bin`` to PATH (inherited by every agent), and ``jd-cli.bat`` also resolves the bundled JRE relative to itself. Missing Java aborts packaging. """ src = _java_home_for_bundle() dst = Path(dist_manage) / "jre" if src is None or not src.exists(): raise RuntimeError("No Java (JAVA_HOME / `java` on PATH) found on the build " "machine — J-Decompiler will NOT work on the target. Install a JDK " "and rebuild to carry it.") if dst.exists(): shutil.rmtree(dst) print(f"\n--- Bundling Java runtime: {src} -> {dst} ---") # SIZE LOCK (2026-07-15): jd-cli only needs bin/ (java + runtime DLLs) and # lib/ (the modules image). ``jmods/`` (~80 MB, jlink source modules), # ``lib/src.zip`` (~43 MB, JDK source), and demo/sample/man are NEVER used at # runtime — dropping them trims ~125 MB from the release with zero functional # impact on the J-Decompiler. shutil.copytree(src, dst, ignore=shutil.ignore_patterns( "__pycache__", "jmods", "src.zip", "demo", "sample", "man")) size_mb = sum(p.stat().st_size for p in dst.rglob("*") if p.is_file()) / (1024 * 1024) print(f" Java bundled ({size_mb:.0f} MB). Runtime: JAVA_HOME=/jre.") def bundle_git(dist_manage): """Carry portable Git into ``/git`` so Gitter + the STM32er MCP clone work on a machine with no system Git. Gitter shells out to a bare ``git`` and the STM32er zero-config bootstrap does a ``git clone``. At runtime manage.py prepends ``git/cmd`` (+ the mingw64/usr bin dirs) to PATH, inherited by every agent. Missing Git aborts packaging instead of disabling the agents on a clean target machine. """ src = _git_install_root_for_bundle() dst = Path(dist_manage) / "git" if src is None or not src.exists(): raise RuntimeError("No Git found on the build machine — Gitter and the STM32er " "MCP git-clone bootstrap will NOT work on the target. Install " "Git for Windows and rebuild to carry it.") if dst.exists(): shutil.rmtree(dst) print(f"\n--- Bundling Git: {src} -> {dst} ---") def ignore_git_installer_state(directory, names): skipped = {name for name in names if name == "__pycache__"} if Path(directory).resolve() == src.resolve(): # These describe the build machine's Git installation. The carried # runtime is owned by Tlamatini's installer, not Git's old uninstaller. skipped.update(name for name in names if re.fullmatch(r"unins\d+\.(exe|dat|msg)", name, re.IGNORECASE)) return skipped shutil.copytree(src, dst, ignore=ignore_git_installer_state) size_mb = sum(p.stat().st_size for p in dst.rglob("*") if p.is_file()) / (1024 * 1024) print(f" Git bundled ({size_mb:.0f} MB). Runtime: /git/cmd on PATH.") def main(): """Runs the PyInstaller command with correctly resolved paths.""" build_start = time.time() print("=" * 60) print(" Tlamatini Build Script") print("=" * 60) # Resolve inputs relative to this script even when invoked from elsewhere. repo_root = Path(__file__).resolve().parent os.chdir(repo_root) run_step("Checking required runtime source assets", validate_source_assets, repo_root) # ── Resolve and emit version artefacts FIRST ───────────────────── # Precedence: --version CLI flag > $TLAMATINI_VERSION > git describe. # See VERSIONING.md for the full contract. release_type = extract_cli_release_type(sys.argv) cli_version = extract_cli_version(sys.argv) tlamatini_version = resolve_build_version(cli_version) version_file_path = emit_build_artifacts( tlamatini_version, product_name="Tlamatini", original_filename="Tlamatini.exe", release_type=release_type, ) print(f"Tlamatini version : {tlamatini_version}") print(f"Release type : {release_type.upper()}") print(f"VERSIONINFO file : {version_file_path}") # ── Self-modify packaging flag ─────────────────────────────────── # When --self-modify is passed, the build ships Tlamatini's own source tree # (Tlamatini/agent/TlamatiniSourceCode) next to the executable, making the # running app a "self-able-modify" version that can read and modify its own # code. WITHOUT the flag the directory is omitted from the package entirely # (a "not-self-able-modify" build), AND so is Tlamatini.md — her source and # her self-knowledge ship together, or not at all. See Tlamatini.md §9 / # prompt.pmt. `--no-self-modify` is the EXPLICIT form of the default and # always WINS over --self-modify, so a wrapper script can force it off. self_modify = "--self-modify" in sys.argv and "--no-self-modify" not in sys.argv print( "Self-modify build : " + ("YES — bundling TlamatiniSourceCode" if self_modify else "no — source tree omitted") ) separator = ';' dist_manage = Path("dist") / "manage" # ── 0) Clean previous build artifacts ──────────────────────────── run_step("Cleaning previous build artifacts", lambda: [ clean_directory("build"), clean_directory("dist"), ]) # Remove old pkg.zip old_zip = Path("pkg.zip") if old_zip.exists(): old_zip.unlink() print(f"Removed old: {old_zip}") # ── 1) Install dependencies ────────────────────────────────────── build_python = sys.executable # GUARD: build.py must NOT be launched WITH the carried Python (/python). # build_python (= the interpreter running this script) gets a numpy-purge + # `--user` reinstall + import-verify (step 1b). The carried Python is a SEPARATE # interpreter, provisioned cleanly into its OWN prefix by ensure_local_build_python(). # If they are the same, the purge wipes the carried prefix's numpy and the verify # then fails with the misleading "Successfully installed numpy ... MISSING: numpy". # Abort EARLY with a clear fix instead of failing ~14 min later. _carried_dir = (Path(__file__).resolve().parent / "python").resolve() try: _build_py_under_carried = ( _carried_dir == Path(build_python).resolve().parent or _carried_dir in Path(build_python).resolve().parents ) except Exception: _build_py_under_carried = False if _build_py_under_carried: print( "ERROR: build.py was launched WITH the carried Python " f"({build_python}).\n" " Run it with a SEPARATE system Python 3.12.10 instead, e.g.:\n" ' & "C:\\Program Files\\Python312\\python.exe" .\\build.py --self-modify\n' " (The carried interpreter under \\python is provisioned by the\n" " build itself and must not be the one running build.py.) Aborting build." ) sys.exit(1) # The CARRIED Python (the interpreter EVERY frozen pool agent runs on) is sourced # from a standalone, WRITABLE Python kept UNDER the source tree at /python, # auto-provisioned by ensure_local_build_python(). Its deps install into its OWN # prefix (--no-user) so the carried copy is COMPLETE — and the build NEVER writes # to a read-only system Python (e.g. C:\Program Files\Python312). bundle_carried_python # copytrees it into /python. (PYTHON_HOME is no longer used for this — the # source-tree Python is the single, predictable carried source.) if os.environ.get("PYTHON_HOME", "").strip(): print("NOTE: PYTHON_HOME is set but is IGNORED for the carried Python — it now always " "comes from /python (auto-provisioned, writable).") frozen_python = ensure_local_build_python() # PyInstaller (run by build_python) bundles the FROZEN _internal (the Django side); # its deps go in with --user so a read-only build Python (Program Files) still works. # The carried Python is handled above (its own writable prefix), so it is NOT in # this install loop. install_pythons = [build_python] # Inside the sealed build Python (build_env.py) pip installs into that venv, # which `--user` would refuse; it is also fresh, so the numpy purge and the # unpinned Torch step only serve the old everyday-Python fallback. in_sealed_env = sys.prefix != sys.base_prefix pip_scope = [] if in_sealed_env else ["--user"] req_file = Path(__file__).with_name('requirements.txt') for target_python in install_pythons: print(f"\n--- Installing dependencies into: {target_python} ---") # 1a-pre) Clean numpy residuals so pip reinstalls numpy fresh. # Prevents stale dist-info/wheel fragments from tripping PyInstaller's # numpy hook (wrong version branch, empty collect_dynamic_libs, and the # downstream "cannot load module more than once per process" crash). if not in_sealed_env: run_step( f"Purging numpy residuals in {target_python}", _purge_numpy_environment, target_python, ) # 1a) Install torch CPU-only FIRST to avoid CUDA DLL issues (WinError 1114). # Use --user so this works even when the build Python lives in a READ-ONLY # location (e.g. C:\Program Files\Python312): its deps go to the per-user site # so PyInstaller (run by THIS Python) can still bundle them into the frozen # _internal. NOTHING is written to the build Python's prefix / Program Files. # The CARRIED Python (the one frozen pool agents run on) is populated SEPARATELY # and DIRECTLY inside dist/ — see bundle_carried_python — so its completeness # does NOT depend on this Python's prefix being writable or pre-populated. if not in_sealed_env: print(f" -> Installing torch (CPU-only) for {target_python} ...") torch_cmd = [ target_python, "-m", "pip", "--disable-pip-version-check", "install", "--user", "torch", "--index-url", "https://download.pytorch.org/whl/cpu", ] torch_result = subprocess.run(torch_cmd) if torch_result.returncode != 0: print(f"WARNING: torch CPU install failed for {target_python}. Continuing anyway.") # 1b) Install remaining dependencies from requirements.txt if req_file.exists(): pip_cmd = [target_python, "-m", "pip", "--disable-pip-version-check", "install", *pip_scope, "-r", str(req_file)] pip_result = subprocess.run(pip_cmd) if pip_result.returncode != 0: print(f"ERROR: pip install -r requirements.txt failed for {target_python}. Aborting build.") sys.exit(1) else: raise FileNotFoundError("requirements.txt is required for a complete frozen release") # 1b-post) VERIFY the agent / MCP-server third-party libs actually IMPORT in # this target Python — fail the build loudly if any is missing. # Frozen-mode pool agents (shoter/playwrighter/windower/sqler/...) AND the # STM32 Template Project MCP server that STM32er spawns run UNDER this # interpreter (via get_python_command / PYTHON_HOME), NOT inside the # PyInstaller bundle — so every library they import must be present HERE or # the frozen assets are incomplete and the agents crash at runtime. Pinning # them in requirements.txt is not enough on its own; this asserts the install # truly took (catches a broken wheel / missing native dep too). _agent_libs = list(_AGENT_RUNTIME_IMPORTS) verify_src = "\n".join([ "import importlib", "mods = " + repr(_agent_libs), "miss = []", "for _m in mods:", " try:", " importlib.import_module(_m)", " except Exception as _e:", " miss.append(_m + ' (' + type(_e).__name__ + ')')", "print('MISSING:' + '; '.join(miss) if miss else 'ALL_AGENT_LIBS_OK')", "raise SystemExit(3 if miss else 0)", ]) print(f" -> Verifying agent/MCP libs import in {target_python} ...") verify = subprocess.run([target_python, "-c", verify_src], capture_output=True, text=True) print(" " + ((verify.stdout or "") + (verify.stderr or "")).strip()) if verify.returncode != 0: print(f"ERROR: required agent/MCP libraries are missing/broken in {target_python}. " f"Add them to requirements.txt so the frozen assets are complete. Aborting build.") sys.exit(1) # 1b-post-2) VERIFY Ruff is runnable via `-m ruff` in this target Python. # Pythonxer's STRICT correctness gate shells out to # ` -m ruff check