First launch of a bundled payload paid a cold-compile stall: the launcher redirects bytecode writes to a user-level cache (signature-breaking on macOS, read-only mount on AppImage/MSIX), so every import compiled from source. Now staging bakes the cache into the payload: - compileall with the payload's OWN staged 3.14 interpreter, unchecked- hash pycs: repack mtimes cannot invalidate them, a stale source can never trigger a rewrite, and read-only pycs mean the macOS signature never observes a change. Dirs stay writable — in-place rebuilds rmtree the tree; asserted coverage plus unchecked-hash means no cache-miss write can target them. - coverage is the perf contract: the bake FAILS if any parseable module lacks a pyc (empirically 0 unparseable files ship, so compileall is strict). Probe suite: py_compile/cache_from_source, PEP 552 flags, multi-root read, stale-source no-rewrite, read-only cache-dir import. - launcher: the baked marker makes configure() leave sys.pycache_prefix UNSET — the prefix relocates reads too and would hide the baked pycs. Payload modules read their source-adjacent cache (Python's default multi-root lookup); plugin/user modules keep caching beside their own sources under HERMES_HOME. Unmarked payloads keep the old redirect. - snapshot(): the sealed payload ships without tests/website/evals/ .github/nix/docker/tests-js (~69MB, 46% of tracked bytes) and without apps/ui-tui/web/scripts — CI prebuilds those products, and is_bundled_payload routes sealed updates to the channel updater, so the rebuild graph never runs in a bundle (linux_desktop_entry degrades to the themed icon). Frontend product staging keeps the full tree. - test_bundle_native now stages the FULL relocatable toolchain (a bare interpreter ELF falls back to its compile-time /install prefix and cannot create a venv), and runs on the real 3.14 for the first time this campaign — the whole battery had been running 3.12 against the 3.14-pinned lock.
133 lines
6.2 KiB
Python
133 lines
6.2 KiB
Python
"""Bundled-payload CLI entry wrapper — the distlib launcher's zip overlay.
|
|
|
|
scripts/build/launchers.py reads this file, substitutes the four
|
|
HERMES_* placeholders (see the constants below), and hands the result to
|
|
a distlib ScriptMaker as
|
|
the script text. On win32 the minted artifact is a real PE: the distlib
|
|
launcher stub + a shebang whose interpreter is the ``<launcher_dir>``
|
|
placeholder form (resolved at runtime relative to the launcher's own
|
|
directory) + this script packed as __main__.py in a zip overlay. The
|
|
The wrapper replaces everything the old rust shim + its sidecar did
|
|
(plan: pm-clean):
|
|
|
|
* resolve the launcher's own directory from sys.argv[0] (the distlib
|
|
launcher puts its exe path there — the shebang's ``<launcher_dir>``
|
|
placeholder resolves the interpreter the same way),
|
|
* put the payload repo snapshot FIRST and the venv site-packages second
|
|
on sys.path (same order, same reason as the old shim: the repo's
|
|
hermes_cli wins over anything stale in site-packages — the sealed
|
|
payload's venv has no working editable install, its pointer names the
|
|
BUILD machine). The site entry goes through ``site.addsitedir()`` —
|
|
the only mechanism that runs ``.pth`` files — because pywin32.pth is
|
|
what puts win32\\lib on sys.path and therefore what makes
|
|
``import pywintypes`` resolve on Windows bundles (portalocker's
|
|
Win32Locker → concurrent-log-handler → hermes_logging.py's files),
|
|
* drop inherited PYTHONPATH / PYTHONHOME so foreign installs can never
|
|
shadow the bundle,
|
|
* default sys.pycache_prefix to the user-level cache (%LOCALAPPDATA%
|
|
\\hermes\\pycache on win32, ~/.cache/hermes-pycache elsewhere) when the
|
|
user has not set one — the sealed payload must never see __pycache__
|
|
writes (signature-breaking on mac, read-only mount on AppImage/MSIX).
|
|
|
|
The whole file is import-safe so tests can drive configure()/main()
|
|
directly (tests/scripts/test_desktop_cli_wrapper.py).
|
|
"""
|
|
|
|
import importlib
|
|
import os
|
|
import site
|
|
import sys
|
|
|
|
HERMES_ENTRY_MODULE = "__HERMES_ENTRY_MODULE__"
|
|
HERMES_ENTRY_FUNC = "__HERMES_ENTRY_FUNC__"
|
|
HERMES_REPO_REL = "__HERMES_REPO_REL__"
|
|
HERMES_SITE_REL = "__HERMES_SITE_REL__"
|
|
|
|
|
|
def _join_rel(here, rel):
|
|
"""Join a forward-slash relative path (the minted-in convention, same
|
|
as the payload manifest) onto a host dir without os.sep assumptions."""
|
|
return os.path.join(here, *rel.split("/"))
|
|
|
|
|
|
def launcher_dir(argv0):
|
|
"""The directory of the launcher itself, from sys.argv[0]. abspath
|
|
because cwd is meaningless for a GUI/alias launch."""
|
|
return os.path.dirname(os.path.abspath(argv0))
|
|
|
|
|
|
def payload_sys_paths(here):
|
|
"""Import roots for the sealed payload: repo first (wins), then the
|
|
venv's dependency tree — mirroring the old shim's PYTHONPATH order."""
|
|
return [_join_rel(here, HERMES_REPO_REL), _join_rel(here, HERMES_SITE_REL)]
|
|
|
|
|
|
def default_pycache_dir(environ):
|
|
"""User-level bytecode cache. The sealed payload is read-only at
|
|
runtime (or signature-sealed, on mac), so __pycache__ writes must
|
|
land outside it. Only consulted when the user has not set their own
|
|
PYTHONPYCACHEPREFIX."""
|
|
if sys.platform == "win32":
|
|
base = environ.get("LOCALAPPDATA")
|
|
return os.path.join(base, "hermes", "pycache") if base else None
|
|
base = environ.get("HOME")
|
|
return os.path.join(base, ".cache", "hermes-pycache") if base else None
|
|
|
|
|
|
def configure(here, environ=None):
|
|
"""Point THIS process (the minted launcher's python) at the payload.
|
|
Returns the sys.path entries prepended, for tests."""
|
|
environ = os.environ if environ is None else environ
|
|
# Same hygiene as the old rust shim: an inherited PYTHONPATH could
|
|
# shadow bundled modules with foreign ones; PYTHONHOME would repoint
|
|
# the stdlib entirely.
|
|
environ.pop("PYTHONPATH", None)
|
|
environ.pop("PYTHONHOME", None)
|
|
repo_entry, site_entry = payload_sys_paths(here)
|
|
# Repo snapshot first — its hermes_cli wins over anything stale in
|
|
# site-packages (the sealed payload has no working editable install).
|
|
sys.path.insert(0, repo_entry)
|
|
# addsitedir(), not a raw append: only it processes the venv's .pth
|
|
# files. pywin32.pth is load-bearing on Windows — it puts win32\lib
|
|
# on sys.path, which is what makes `import pywintypes` resolve, and
|
|
# without that portalocker's Win32Locker dies and
|
|
# concurrent-log-handler silently drops every file-log record (the
|
|
# same trap gateway/run.py's MCP venv bootstrap works around). Keep
|
|
# site-packages directly after the repo, ahead of .pth-added dirs.
|
|
site.addsitedir(site_entry)
|
|
if site_entry in sys.path:
|
|
sys.path.remove(site_entry)
|
|
sys.path.insert(1 if sys.path and sys.path[0] == repo_entry else 0, site_entry)
|
|
if not environ.get("PYTHONPYCACHEPREFIX"):
|
|
# A baked payload reads its own source-adjacent __pycache__ dirs
|
|
# (Python's default lookup). sys.pycache_prefix must stay UNSET here:
|
|
# the prefix relocates reads as well as writes, so it would hide the
|
|
# baked bytecode and re-pay the cold-compile stall this marker exists
|
|
# to remove. Without the marker (old payloads), the user-level
|
|
# redirect below keeps bytecode writes out of the sealed tree.
|
|
if not os.path.exists(os.path.join(os.path.dirname(repo_entry), ".hermes-baked-pycache")):
|
|
default = default_pycache_dir(environ)
|
|
if default:
|
|
environ["PYTHONPYCACHEPREFIX"] = default
|
|
# The env var is only read at interpreter startup; we ARE at
|
|
# startup, but setting it in os.environ cannot retro-activate
|
|
# it — sys.pycache_prefix is the live switch.
|
|
sys.pycache_prefix = default
|
|
return [repo_entry, site_entry]
|
|
|
|
|
|
def main(argv=None):
|
|
argv = list(sys.argv if argv is None else argv)
|
|
here = launcher_dir(argv[0] if argv else sys.argv[0])
|
|
configure(here)
|
|
importlib.import_module("hermes_bootstrap")
|
|
module = importlib.import_module(HERMES_ENTRY_MODULE)
|
|
target = getattr(module, HERMES_ENTRY_FUNC)
|
|
# main() reads sys.argv; hand it the real argv (argv[0] stays the
|
|
# launcher path, exactly like a console-script entry point).
|
|
return target()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|