mirror of
https://github.com/Niko1221/Strata.git
synced 2026-11-07 06:15:58 -10:00
573 lines
29 KiB
Python
573 lines
29 KiB
Python
#!/usr/bin/env python3
|
|
"""Build the self-contained Strata Terminal distribution for Windows x64, Linux x86_64, Linux aarch64 (the DGX Spark) or macOS arm64.
|
|
|
|
The requirement this exists for (checklist P04, and the owner's rule that starting or serving never fetches Python
|
|
packages from the internet): a user installs the terminal without Desktop, and needs no development checkout, no
|
|
system Python, no pip run, no compiler. So the archive carries its own Python runtime and its own dependencies, and
|
|
the `strata` launchers prefer them.
|
|
|
|
What goes in
|
|
------------
|
|
* a pinned **standalone CPython 3.12.15** (python-build-standalone 20261009, `install_only`), downloaded at BUILD
|
|
time and SHA-256-verified against the pin below (the digests are the ones in that release's SHA256SUMS, the Mac one
|
|
is also what `setup.sh` pins), so the terminal and the installer cannot drift apart;
|
|
* the runtime wheels from the target's lock, `packaging/locks/runtime-<target>-cp312.txt` (made by
|
|
`packaging/lock_wheels.py`), installed into that interpreter with
|
|
`pip install --require-hashes --no-deps --only-binary=:all:` at BUILD time only: every package, direct or
|
|
transitive, is named with an exact version and the SHA-256 of the wheel(s) accepted, so nothing else can land in
|
|
the bundle. pip itself is the one the interpreter shipped with (not upgraded);
|
|
* the application: `strata.py`, `strata_control`, `strata_cli`, `serve`, a `VERSION` file and the docs the CLI
|
|
references;
|
|
* the read-only tooling under `tools/` that the terminal paths import. Everything else under `tools/` is engine
|
|
installation and preparation work and is deliberately excluded: the serving import graph must not pull it in
|
|
(tests/terminal/test_no_acquisition.py checks that);
|
|
* launchers: `bin/strata.cmd` and `bin/strata.ps1` (Windows), `bin/strata` (Linux, macOS).
|
|
|
|
What does NOT go in
|
|
-------------------
|
|
* the engine binary. That is a separate, independently versioned component (`packaging/README.md`);
|
|
* model weights or prepared data;
|
|
* `node_modules` or the UI. The terminal does not need a browser.
|
|
|
|
Usage
|
|
-----
|
|
python packaging/package_terminal.py --out release/artifacts # the host's target
|
|
python packaging/package_terminal.py --out /tmp/x --target linux-x86_64 # on a Linux x86_64 host
|
|
python packaging/package_terminal.py --out /tmp/x --skip-deps # layout check only
|
|
|
|
An archive for a target must be built on that target: the lock is installed with the bundled interpreter, which runs
|
|
only there. `packaging/smoke_terminal.py <archive>` extracts one into a clean environment and runs it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import platform
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
import urllib.request
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
LOCKS = ROOT / "packaging" / "locks"
|
|
|
|
PBS_RELEASE = "20261009"
|
|
PYTHON_VERSION = "3.12.15"
|
|
PBS_BASE = f"https://github.com/astral-sh/python-build-standalone/releases/download/{PBS_RELEASE}/"
|
|
|
|
#: The standalone CPython pins, one per target, from the release's SHA256SUMS. `darwin-arm64` is also what
|
|
#: `setup.sh` pins: a terminal built from one and an installer built from the other are the same interpreter.
|
|
PYTHON_PINS = {
|
|
"windows-x86_64": {
|
|
"file": f"cpython-{PYTHON_VERSION}+{PBS_RELEASE}-x86_64-pc-windows-msvc-install_only.tar.gz",
|
|
"sha256": "194c8f0d9e0de6f1c4576e5dfa67819a48c764be87608b74b8ad43387a57b93b",
|
|
"python": "python/python.exe", "archive": "zip",
|
|
},
|
|
"linux-x86_64": {
|
|
"file": f"cpython-{PYTHON_VERSION}+{PBS_RELEASE}-x86_64-unknown-linux-gnu-install_only.tar.gz",
|
|
"sha256": "5864001bf969db329fadc2b654c4878c6027d170c6bd77e28f88666260bcaf63",
|
|
"python": "python/bin/python3", "archive": "tar.gz",
|
|
},
|
|
"linux-aarch64": {
|
|
"file": f"cpython-{PYTHON_VERSION}+{PBS_RELEASE}-aarch64-unknown-linux-gnu-install_only.tar.gz",
|
|
"sha256": "b342ccfb577fda349d0237c98b24848234ca767c10f034628a5ff9fc858d662f",
|
|
"python": "python/bin/python3", "archive": "tar.gz",
|
|
},
|
|
"darwin-arm64": {
|
|
"file": f"cpython-{PYTHON_VERSION}+{PBS_RELEASE}-aarch64-apple-darwin-install_only.tar.gz",
|
|
"sha256": "83dcb04777c798bd95f3687acf9ae84b09860272ee6bdfb05be8bcf9447f75b8",
|
|
"python": "python/bin/python3", "archive": "tar.gz",
|
|
},
|
|
}
|
|
for _pin in PYTHON_PINS.values():
|
|
_pin["url"] = PBS_BASE + _pin["file"].replace("+", "%2B")
|
|
|
|
#: Kept for callers of the Mac-only script: the macOS pin.
|
|
PYTHON_PIN = {**PYTHON_PINS["darwin-arm64"], "target": "darwin-arm64"}
|
|
|
|
#: The application, as paths relative to ROOT (copied to the same place in the archive).
|
|
PAYLOAD = (
|
|
"strata.py",
|
|
"strata_control",
|
|
"strata_cli",
|
|
"serve",
|
|
"requirements.txt",
|
|
"requirements-monitor.txt",
|
|
"docs/MANAGEMENT_PROTOCOL.md",
|
|
"docs/TERMINAL_LAUNCH.md", # the README below points at it (`strata launch`); it used to be missing from the archive
|
|
"data/gguf_fingerprints.json", # `strata models import --plan` names a known file set from this table
|
|
"bench/prompts/standard-v1", # `strata bench run` (the standard benchmark) sends these frozen requests; hashed byte for byte
|
|
)
|
|
|
|
#: `tools/` modules the terminal paths import. Adding one here is a deliberate act: this list is what keeps
|
|
#: installation/preparation code out of the serving import graph.
|
|
TOOLS_SUBSET = (
|
|
"strata_tokenizer.py",
|
|
"gguf_reader.py",
|
|
"dflash2_pack.py", # the DFlash2 drafter: strata_control/drafting_fetch.py packs the download with it (numpy only)
|
|
)
|
|
|
|
#: What `strata launch` and the desktop app's installer need on top of the read-only terminal: the model catalog and GPU probe
|
|
#: (tools/strata_mcp.py reads setup.py's tables) and the install job, which downloads the model the user chose, prepares it with
|
|
#: the bundled tools and writes a run config naming "component:strata-engine". setup.py runs in its CONTROL mode there
|
|
#: (STRATA_CONTROL_INSTALL=1: no pip, no engine fetch, no compiler). Entry points; their local imports under tools/ come with them.
|
|
#: The same list the app's Control component stages (apps/desktop/scripts/stage-control.py uses these names).
|
|
INSTALLER_FILES = ("setup.py", "ref/load.py") # ref/load.py: strata_pack.py (the Q2_0 AVX-512 repack) imports it
|
|
#: calibrate: setup.py imports it after a tune (calibrate_config, and the earlier tuning that a rerun applies); it was missing from this
|
|
#: list in 0.1.47, so every launch after a tune failed (#3613, #3467). setup_tool_imports() below also reads setup.py itself, so a tools/
|
|
#: module it starts to import is never forgotten again; tests/terminal/test_bundle_imports.py checks the staged bundle.
|
|
INSTALLER_TOOLS = ("strata_mcp", "iq_pack", "strata_pack", "pack_index", "mtp_fetch", "mtp_pack", "mtp_rt", "draft_vocab",
|
|
"strata_tokenizer", "gguf_reader", "calibrate")
|
|
#: tools run BY PATH (`python tools/.../x.py`) by setup.py, strata_cli or strata_control: no import names them, so setup_tool_imports() cannot find
|
|
#: them. DeepSeek's setup and launch run dsv41/pack_gguf.py (the model folder), dsv41/smoke.py (the engine's first answer) and dsv41/tune.py
|
|
#: (the short tune); setup.py's other by-path tools (nvfp4_local_pack, strata_bench, strata_inspect) were missing from the bundle the same way.
|
|
#: tests/terminal/test_bundle_tools_paths.py reads the by-path references out of the source and fails when one is missing from the bundle.
|
|
INSTALLER_SCRIPTS = ("dsv41/pack_gguf", "dsv41/smoke", "dsv41/tune", "nvfp4_local_pack", "strata_bench", "strata_inspect")
|
|
INSTALLER_DATA = "data" # draft vocabularies, expert profiles: 2 MB
|
|
#: the Mac install's tools (setup.py's mac_control_setup runs them by path): Unsloth's many-shard sets become ONE GGUF for the Metal engine (stdlib only, 15 KB)
|
|
INSTALLER_METAL_TOOLS = ("metal_repack", "stream_experts")
|
|
|
|
#: packages the bundle must import after the install (the API server's and the CLI's).
|
|
PROBE = "import jinja2, yaml, regex, psutil, requests, numpy, PIL, tqdm; print('deps ok')"
|
|
|
|
|
|
def host_target() -> str:
|
|
machine = platform.machine().lower()
|
|
if sys.platform == "win32" and machine in ("amd64", "x86_64"):
|
|
return "windows-x86_64"
|
|
if sys.platform.startswith("linux") and machine in ("x86_64", "amd64"):
|
|
return "linux-x86_64"
|
|
if sys.platform.startswith("linux") and machine in ("aarch64", "arm64"):
|
|
return "linux-aarch64"
|
|
if sys.platform == "darwin" and machine in ("arm64", "aarch64"):
|
|
return "darwin-arm64"
|
|
raise SystemExit(f"no terminal target for {sys.platform}/{machine}")
|
|
|
|
|
|
def sha256(path: Path) -> str:
|
|
h = hashlib.sha256()
|
|
with path.open("rb") as fh:
|
|
for block in iter(lambda: fh.read(1 << 20), b""):
|
|
h.update(block)
|
|
return h.hexdigest()
|
|
|
|
|
|
def fetch_python(target: str, cache: Path) -> Path:
|
|
"""Download the pinned interpreter once (build time), verifying the digest before it is used."""
|
|
pin = PYTHON_PINS[target]
|
|
archive = cache / pin["file"].replace("+", "_")
|
|
if not archive.exists():
|
|
print(f"fetching the pinned CPython from {pin['url']}")
|
|
part = archive.with_suffix(archive.suffix + ".part")
|
|
urllib.request.urlretrieve(pin["url"], part)
|
|
part.replace(archive)
|
|
got = sha256(archive)
|
|
if got != pin["sha256"]:
|
|
archive.unlink(missing_ok=True)
|
|
raise SystemExit(
|
|
f"the CPython download does not match its pin\n expected {pin['sha256']}\n"
|
|
f" got {got}\nRefusing to package an unverified interpreter.")
|
|
return archive
|
|
|
|
|
|
def ignore(_directory: str, names: list[str]) -> set[str]:
|
|
return {n for n in names if n in ("__pycache__", ".DS_Store") or n.endswith(".pyc")}
|
|
|
|
|
|
def lock_for(target: str) -> Path:
|
|
path = LOCKS / f"runtime-{target}-cp312.txt"
|
|
if not path.is_file():
|
|
raise SystemExit(f"no lock for {target}: {path} (run packaging/lock_wheels.py --target {target})")
|
|
return path
|
|
|
|
|
|
def lock_names(lock: Path) -> dict[str, str]:
|
|
out = {}
|
|
for line in lock.read_text(encoding="utf-8").splitlines():
|
|
m = re.match(r"^([A-Za-z0-9_.-]+)==([^\s\\]+)", line)
|
|
if m:
|
|
out[re.sub(r"[-_.]+", "-", m.group(1)).lower()] = m.group(2)
|
|
return out
|
|
|
|
|
|
def install_locked(py: Path, lock: Path, root: Path, wheelhouse: Path | None = None) -> dict[str, str]:
|
|
"""pip install --require-hashes into the bundled interpreter, then check what is there is exactly the lock.
|
|
With a wheelhouse (lock_wheels.py --download) nothing is fetched at all: pip reads the wheels from the folder and
|
|
still checks every hash against the lock."""
|
|
env = {**os.environ, "PIP_NO_INPUT": "1", "PIP_DISABLE_PIP_VERSION_CHECK": "1", "PYTHONNOUSERSITE": "1",
|
|
"PIP_CONFIG_FILE": os.devnull}
|
|
for name in ("PYTHONHOME", "PYTHONPATH", "PIP_REQUIRE_VIRTUALENV", "PIP_INDEX_URL", "PIP_EXTRA_INDEX_URL"):
|
|
env.pop(name, None)
|
|
source = ["--no-index", "--find-links", str(wheelhouse)] if wheelhouse else []
|
|
subprocess.run([str(py), "-m", "pip", "install", "--isolated", "--no-cache-dir", "--no-warn-script-location",
|
|
"--require-hashes", "--no-deps", "--only-binary=:all:", *source, "-r", str(lock)],
|
|
check=True, cwd=str(root), env=env)
|
|
subprocess.run([str(py), "-m", "pip", "check"], check=True, cwd=str(root), env=env)
|
|
listed = subprocess.run([str(py), "-m", "pip", "list", "--format=json", "--isolated"], check=True,
|
|
cwd=str(root), env=env, capture_output=True, text=True).stdout
|
|
have = {re.sub(r"[-_.]+", "-", d["name"]).lower(): d["version"] for d in json.loads(listed)}
|
|
want = lock_names(lock)
|
|
extra = {k: v for k, v in have.items() if k not in want and k not in ("pip", "setuptools", "wheel")}
|
|
wrong = {k: (want[k], have.get(k)) for k in want if have.get(k) != want[k]}
|
|
if extra or wrong:
|
|
raise SystemExit(f"the bundle does not match its lock: extra {extra}, wrong/missing {wrong}")
|
|
return have
|
|
|
|
|
|
def drop_build_path_launchers(root: Path, staging: Path, target: str) -> list[str]:
|
|
"""pip writes console-script launchers that name the build-time interpreter by absolute path: they break the
|
|
moment the archive is extracted elsewhere. `python -m <module>` and the `strata` launchers replace them."""
|
|
gone = []
|
|
if target.startswith("windows"):
|
|
scripts = root / "python" / "Scripts"
|
|
if scripts.is_dir():
|
|
for f in sorted(scripts.iterdir()):
|
|
gone.append(f.name)
|
|
shutil.rmtree(scripts)
|
|
return gone
|
|
needle = str(staging).encode()
|
|
bindir = root / "python" / "bin"
|
|
for f in sorted(bindir.iterdir()):
|
|
if f.is_symlink() or not f.is_file():
|
|
continue
|
|
with f.open("rb") as fh:
|
|
head = fh.read(512)
|
|
if head.startswith(b"#!") and needle in head.split(b"\n", 1)[0]:
|
|
gone.append(f.name)
|
|
f.unlink()
|
|
return gone
|
|
|
|
|
|
def local_imports(folder: Path, entry: tuple[str, ...]) -> list[str]:
|
|
"""The tools/ modules `entry` imports, transitively (static: import and from-import statements)."""
|
|
import ast
|
|
seen: set[str] = set()
|
|
todo = list(entry)
|
|
while todo:
|
|
name = todo.pop()
|
|
if name in seen:
|
|
continue
|
|
seen.add(name)
|
|
source = folder / "tools" / f"{name}.py"
|
|
if not source.exists():
|
|
raise SystemExit(f"installer tool missing: tools/{name}.py")
|
|
sub = name.rsplit("/", 1)[0] + "/" if "/" in name else "" # (tools/dsv41/tune.py imports its neighbour smoke.py)
|
|
for node in ast.walk(ast.parse(source.read_text(encoding="utf-8-sig"))):
|
|
names = [a.name.split(".")[0] for a in node.names] if isinstance(node, ast.Import) else [node.module.split(".")[0]] if isinstance(node, ast.ImportFrom) and node.module and node.level == 0 else []
|
|
for m in names:
|
|
for rel in ((sub + m,) if sub else ()) + (m,):
|
|
if (folder / "tools" / f"{rel}.py").exists():
|
|
if rel not in seen:
|
|
todo.append(rel)
|
|
break
|
|
return sorted(seen)
|
|
|
|
|
|
def setup_tool_imports(source: Path = ROOT) -> list[str]:
|
|
"""The `tools/<name>.py` modules that the installer files import (any import statement, at any depth: setup.py imports most of them
|
|
inside functions after putting tools/ on sys.path). tools/hip and tools/image hold engine-install code that Control's mode never runs."""
|
|
import ast
|
|
found: set[str] = set()
|
|
for name in INSTALLER_FILES:
|
|
for node in ast.walk(ast.parse((source / name).read_text(encoding="utf-8-sig"))):
|
|
names = [a.name.split(".")[0] for a in node.names] if isinstance(node, ast.Import) else \
|
|
[node.module.split(".")[0]] if isinstance(node, ast.ImportFrom) and node.module and node.level == 0 else []
|
|
found.update(m for m in names if (source / "tools" / f"{m}.py").exists())
|
|
return sorted(found)
|
|
|
|
|
|
def add_installer(folder: Path, source: Path = ROOT) -> dict:
|
|
"""Copy the installer's files from the source tree into a staged terminal folder (after the terminal payload is in place)."""
|
|
tools = local_imports(source, tuple(sorted(set(INSTALLER_TOOLS) | set(INSTALLER_SCRIPTS) | set(setup_tool_imports(source)))))
|
|
copied = []
|
|
for name in INSTALLER_FILES:
|
|
(folder / name).parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(source / name, folder / name)
|
|
copied.append(name)
|
|
(folder / "tools").mkdir(exist_ok=True)
|
|
for name in tools:
|
|
(folder / "tools" / f"{name}.py").parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(source / "tools" / f"{name}.py", folder / "tools" / f"{name}.py")
|
|
copied.append(f"tools/{name}.py")
|
|
(folder / "metal" / "tools").mkdir(parents=True, exist_ok=True)
|
|
for name in INSTALLER_METAL_TOOLS:
|
|
shutil.copy2(source / "metal" / "tools" / f"{name}.py", folder / "metal" / "tools" / f"{name}.py")
|
|
copied.append(f"metal/tools/{name}.py")
|
|
if (folder / INSTALLER_DATA).exists():
|
|
shutil.rmtree(folder / INSTALLER_DATA)
|
|
shutil.copytree(source / INSTALLER_DATA, folder / INSTALLER_DATA, ignore=shutil.ignore_patterns("__pycache__"))
|
|
return {"files": copied, "data": INSTALLER_DATA, "setup_sha256": sha256(folder / "setup.py"),
|
|
"mode": "STRATA_CONTROL_INSTALL=1: downloads the chosen model, prepares it, installs no package and no engine"}
|
|
|
|
|
|
def write_archive(root: Path, name: str, out_dir: Path, kind: str) -> Path:
|
|
if kind == "zip":
|
|
path = out_dir / f"{name}.zip"
|
|
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as zf:
|
|
for p in sorted(root.rglob("*")):
|
|
if p.is_dir():
|
|
continue
|
|
zf.write(p, Path(name, p.relative_to(root)).as_posix())
|
|
return path
|
|
path = out_dir / f"{name}.tar.gz"
|
|
with tarfile.open(path, "w:gz") as tf:
|
|
tf.add(root, arcname=name)
|
|
return path
|
|
|
|
|
|
def build_commit(explicit: str | None = None) -> str | None:
|
|
"""The full commit this archive is built from: --commit, the STRATA_COMMIT variable, STRATA_BUILD_COMMIT (a Docker build
|
|
copies the files without .git and without git), or `git rev-parse HEAD` of the source tree. None when none of them gives
|
|
40 hex digits (a tree copy without .git)."""
|
|
for candidate in (explicit, os.environ.get("STRATA_COMMIT"), os.environ.get("STRATA_BUILD_COMMIT")):
|
|
if candidate and re.fullmatch(r"[0-9a-fA-F]{40}", candidate.strip()):
|
|
return candidate.strip().lower()
|
|
try:
|
|
out = subprocess.run(["git", "-C", str(ROOT), "rev-parse", "HEAD"], capture_output=True, text=True).stdout.strip().lower()
|
|
except OSError: # no git installed
|
|
return None
|
|
return out if re.fullmatch(r"[0-9a-f]{40}", out) else None
|
|
|
|
|
|
def source_commit() -> str:
|
|
"""`build_commit()` without an explicit value, as a string (empty when unknown)."""
|
|
return build_commit() or ""
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("--out", required=True, help="directory to write the archive into")
|
|
ap.add_argument("--target", choices=sorted(PYTHON_PINS), default=None,
|
|
help="windows-x86_64, linux-x86_64, linux-aarch64 or darwin-arm64 (default: this machine's)")
|
|
ap.add_argument("--version", default=None, help="version label (default: read from the source)")
|
|
ap.add_argument("--skip-deps", action="store_true",
|
|
help="skip the locked install (layout checks only; the result is NOT distributable)")
|
|
ap.add_argument("--commit", default=None, metavar="SHA",
|
|
help="the full commit the sources come from, for a tree copy that has no .git (or set STRATA_COMMIT); "
|
|
"`strata bench run` records it, and a standard benchmark without it is nonconforming")
|
|
ap.add_argument("--keep-staging", action="store_true")
|
|
ap.add_argument("--no-installer", action="store_true",
|
|
help="leave out setup.py, tools/strata_mcp.py and data/ (the read-only terminal of earlier releases; "
|
|
"`strata launch` then cannot list or install models)")
|
|
ap.add_argument("--cache", default=None, help="where the pinned interpreter archive is cached")
|
|
ap.add_argument("--wheelhouse", default=None,
|
|
help="install the locked wheels from this folder (lock_wheels.py --download) and fetch nothing")
|
|
args = ap.parse_args()
|
|
|
|
target = args.target or host_target()
|
|
if not args.skip_deps and target != host_target():
|
|
raise SystemExit(f"{target} must be built on {target} (this is {host_target()}): the locked install runs the "
|
|
"bundled interpreter. Use --skip-deps for a layout check.")
|
|
commit = build_commit(args.commit)
|
|
if commit is None:
|
|
message = ("the commit of these sources is unknown (no .git here, no --commit, no STRATA_COMMIT): the archive would carry "
|
|
"no COMMIT file, and every `strata bench run` on it would be nonconforming")
|
|
if not args.skip_deps:
|
|
raise SystemExit(message)
|
|
print("warning: " + message)
|
|
pin = PYTHON_PINS[target]
|
|
version = args.version or _source_version()
|
|
out_dir = Path(args.out).resolve()
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
name = f"strata-terminal-{version}-{target}"
|
|
|
|
cache = Path(args.cache) if args.cache else Path(tempfile.gettempdir()) / "strata-packaging-cache"
|
|
cache.mkdir(parents=True, exist_ok=True)
|
|
archive = fetch_python(target, cache)
|
|
lock = lock_for(target)
|
|
|
|
staging = Path(tempfile.mkdtemp(prefix="strata-terminal-build-"))
|
|
root = staging / name
|
|
root.mkdir(parents=True)
|
|
try:
|
|
print("unpacking the interpreter")
|
|
with tarfile.open(archive) as tf:
|
|
tf.extractall(root)
|
|
py = root / pin["python"]
|
|
if not py.exists():
|
|
raise SystemExit(f"the archive did not produce {py}")
|
|
|
|
print("copying the application")
|
|
for item in PAYLOAD:
|
|
src = ROOT / item
|
|
dst = root / item
|
|
if not src.exists():
|
|
raise SystemExit(f"missing payload item: {item}")
|
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
if src.is_dir():
|
|
shutil.copytree(src, dst, ignore=ignore)
|
|
else:
|
|
shutil.copy2(src, dst)
|
|
tools_dir = root / "tools"
|
|
tools_dir.mkdir(exist_ok=True)
|
|
for item in TOOLS_SUBSET:
|
|
src = ROOT / "tools" / item
|
|
if not src.exists():
|
|
raise SystemExit(f"missing tools module: {item}")
|
|
shutil.copy2(src, tools_dir / item)
|
|
installer = None if args.no_installer else add_installer(root)
|
|
(root / "VERSION").write_text(version + "\n", encoding="utf-8")
|
|
# the commit the archive was built from: `strata bench run` records it (an archive has no .git to ask)
|
|
if commit:
|
|
(root / "COMMIT").write_text(commit + "\n", encoding="utf-8")
|
|
|
|
bin_dir = root / "bin"
|
|
bin_dir.mkdir(exist_ok=True)
|
|
launchers = ["strata.cmd", "strata.ps1"] if target.startswith("windows") else ["strata"]
|
|
for launcher in launchers:
|
|
shutil.copy2(ROOT / "bin" / launcher, bin_dir / launcher)
|
|
if launcher == "strata":
|
|
(bin_dir / launcher).chmod(0o755)
|
|
|
|
installed: dict[str, str] = {}
|
|
removed: list[str] = []
|
|
if not args.skip_deps:
|
|
print("installing the locked runtime wheels into the bundled interpreter (build time only)")
|
|
installed = install_locked(py, lock, root, Path(args.wheelhouse).resolve() if args.wheelhouse else None)
|
|
removed = drop_build_path_launchers(root, staging, target)
|
|
# nothing the bundle writes while it is built may stay: compiled files are rebuilt on first use
|
|
for cache_dir in list(root.rglob("__pycache__")):
|
|
shutil.rmtree(cache_dir, ignore_errors=True)
|
|
|
|
env = {**os.environ, "PYTHONNOUSERSITE": "1"}
|
|
for k in ("PYTHONHOME", "PYTHONPATH"):
|
|
env.pop(k, None)
|
|
probe = subprocess.run([str(py), "-c", PROBE], capture_output=True, text=True, cwd=str(root), env=env)
|
|
print(" " + (probe.stdout.strip() or probe.stderr.strip()[:200]))
|
|
if not args.skip_deps and probe.returncode != 0:
|
|
raise SystemExit("the bundled interpreter cannot import the runtime packages")
|
|
|
|
(root / "README.txt").write_text(_readme(version, target), encoding="utf-8")
|
|
|
|
archive_path = write_archive(root, name, out_dir, pin["archive"])
|
|
digest = sha256(archive_path)
|
|
size = archive_path.stat().st_size
|
|
print(f"\n{archive_path}")
|
|
print(f" bytes {size}")
|
|
print(f" sha256 {digest}")
|
|
|
|
manifest = out_dir / f"{name}.manifest.json"
|
|
manifest.write_text(json.dumps({
|
|
"name": name,
|
|
"version": version,
|
|
"target": target,
|
|
"archive": archive_path.name,
|
|
"bytes": size,
|
|
"sha256": digest,
|
|
"python": {"version": PYTHON_VERSION, "build": PBS_RELEASE, "url": pin["url"], "sha256": pin["sha256"],
|
|
"target": target},
|
|
"lock": {"file": lock.relative_to(ROOT).as_posix(), "sha256": sha256(lock),
|
|
"install": "pip install --require-hashes --no-deps --only-binary=:all: (build time only)"},
|
|
"installed": installed,
|
|
"launchers_removed": removed,
|
|
"payload": list(PAYLOAD),
|
|
"tools_subset": list(TOOLS_SUBSET),
|
|
"installer": installer,
|
|
"excludes": ["engine binary", "model weights", "prepared data", "UI", "node_modules"],
|
|
"deps_installed": not args.skip_deps,
|
|
}, indent=2) + "\n", encoding="utf-8")
|
|
print(f" manifest {manifest}")
|
|
return 0
|
|
finally:
|
|
if args.keep_staging:
|
|
print(f"staging kept at {staging}")
|
|
else:
|
|
shutil.rmtree(staging, ignore_errors=True)
|
|
|
|
|
|
def _source_version() -> str:
|
|
"""The version the source reports, so the artifact name cannot disagree with the code."""
|
|
text = (ROOT / "CMakeLists.txt").read_text(encoding="utf-8", errors="replace")
|
|
# `project(strata VERSION 0.1.44.1 LANGUAGES C CXX ...)` - the version is a dotted run of numbers and must be
|
|
# read as one. Taking "everything after VERSION" yields "0.1.44.1.LANGUAGES.CXX".
|
|
m = re.search(r"^\s*project\([^)]*?VERSION\s+([0-9]+(?:\.[0-9]+)+)", text, re.MULTILINE)
|
|
return m.group(1) if m else "0.0.0"
|
|
|
|
|
|
def _readme(version: str, target: str) -> str:
|
|
win = target.startswith("windows")
|
|
ext = "zip" if win else "tar.gz"
|
|
stem = f"strata-terminal-{version}-{target}"
|
|
if win:
|
|
install = f""" Expand-Archive {stem}.zip .
|
|
cd {stem}
|
|
.\\bin\\strata.cmd --help
|
|
|
|
Put `bin\\` on your PATH to get the `strata` command everywhere (cmd.exe and PowerShell):
|
|
|
|
setx PATH "%PATH%;%CD%\\bin"
|
|
"""
|
|
else:
|
|
install = f""" tar xzf {stem}.tar.gz
|
|
cd {stem}
|
|
./bin/strata --help
|
|
|
|
Put `bin/` on your PATH to get the `strata` command everywhere:
|
|
|
|
echo "export PATH=\\"$PWD/bin:$PATH\\"" >> ~/.profile
|
|
"""
|
|
ram = (" Task Manager > Performance > Memory > Available, or in PowerShell:\n"
|
|
" [math]::Round((Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory / 1MB, 1) # GB free" if win else
|
|
" free -g" if not target.startswith("darwin") else
|
|
" Activity Monitor > Memory, or: vm_stat (free + inactive pages x 16 KB)")
|
|
return f"""Strata Terminal {version} ({target})
|
|
{'=' * (len(version) + len(target) + 18)}
|
|
|
|
This is the terminal distribution. It carries its own Python {PYTHON_VERSION} and its own packages, so you do not
|
|
need Python, pip, Node, a compiler or a development checkout. Serving and watching (`strata serve`, `strata runtime`,
|
|
`strata monitor`) never install or download anything; if something is missing, the error names the command that
|
|
installs it. Only two commands fetch files, and only when you run them: `strata launch` (the engine and the model you
|
|
choose, after it tells you the size) and `strata update` (the engine and the Strata components).
|
|
|
|
Install
|
|
-------
|
|
{install}
|
|
Before you start a big model
|
|
----------------------------
|
|
A model of tens of GB needs free memory. Check how much is free first:
|
|
|
|
{ram}
|
|
|
|
Close other programs (browsers use a lot) if it is less than the model needs; `strata launch` shows what each model needs.
|
|
|
|
What it does NOT include
|
|
------------------------
|
|
The **engine** is a separate, independently versioned component and is not bundled: the right engine package
|
|
depends on your OS, CPU and GPU. `strata launch` (or `strata update`) fetches the one for your card from the signed
|
|
release feed; or point Strata at one with STRATA_ENGINE. Model weights are likewise not included: `strata launch`
|
|
downloads the one you choose (the installer files setup.py and tools/ are in this archive for that).
|
|
|
|
Useful commands
|
|
---------------
|
|
strata launch choose a model, tune it, serve it and watch it (guided screens; docs/TERMINAL_LAUNCH.md)
|
|
strata connect opencode the setup for a coding app on the running server (also codex, claude-code, omp, pi, hermes, openclaw)
|
|
strata --version the version and the interpreter it runs on
|
|
strata doctor read-only component inventory
|
|
strata profile list saved serving profiles
|
|
strata status one read-only telemetry snapshot
|
|
strata monitor the terminal monitor
|
|
strata serve --profile NAME start the inference server in the foreground
|
|
strata serve --engine mock ... a scripted engine for client tests (no GPU, no model)
|
|
strata runtime start|status|stop run a profile as a managed background instance
|
|
|
|
Existing installs are found in place: the run configs (strata-<model>.json) and the data folder remembered in
|
|
settings.json are used where they are; nothing is copied or moved.
|
|
|
|
Verification
|
|
------------
|
|
The manifest next to the archive records the SHA-256 of the archive, of the pinned interpreter and of the package
|
|
lock it was built from. The launcher prefers the bundled interpreter and ignores the user's site-packages.
|
|
"""
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|