217 lines
7.3 KiB
Python
217 lines
7.3 KiB
Python
"""Assemble a portable LM-Gambit archive.
|
|
|
|
python packaging/build.py # for the current platform
|
|
python packaging/build.py --platform linux # or windows / macos
|
|
python packaging/build.py --skip-web # reuse an existing web/dist
|
|
|
|
Produces ``dist/LM-Gambit-<version>-<platform>.{zip,tar.gz}`` containing the
|
|
application source, the prebuilt web interface, and a launcher that creates a
|
|
virtual environment on first run.
|
|
|
|
**Why not a frozen binary.** ``llama-cpp-python`` compiles its backend in at
|
|
install time, so a bundled interpreter would freeze one choice — whatever the
|
|
build machine had, which for a CI runner means CPU-only — for every user
|
|
forever. Installing on the target machine lets pip pick the wheel that matches
|
|
that machine's hardware. The cost is a Python requirement; the alternative is
|
|
shipping something that cannot use anybody's GPU.
|
|
|
|
Only files git tracks are copied, so the archive cannot pick up local models,
|
|
results, settings or virtual environments.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import platform
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import tarfile
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
PACKAGING = ROOT / "packaging"
|
|
DIST = ROOT / "dist"
|
|
|
|
#: Tracked paths that have no business in a distribution.
|
|
EXCLUDE_PREFIXES = (
|
|
".github/",
|
|
"tests_py/",
|
|
"packaging/",
|
|
"web/src/",
|
|
"web/public/",
|
|
"web/package.json",
|
|
"web/package-lock.json",
|
|
"web/tsconfig",
|
|
"web/vite.config.ts",
|
|
".gitignore",
|
|
".python-version",
|
|
"requirements-ci.txt",
|
|
)
|
|
|
|
PLATFORMS = {
|
|
"windows": {"launcher": "LM-Gambit.bat", "archive": "zip"},
|
|
"linux": {"launcher": "LM-Gambit.sh", "archive": "tar.gz"},
|
|
"macos": {"launcher": "LM-Gambit.sh", "archive": "tar.gz"},
|
|
}
|
|
|
|
|
|
def current_platform() -> str:
|
|
system = platform.system().lower()
|
|
if system == "windows":
|
|
return "windows"
|
|
if system == "darwin":
|
|
return "macos"
|
|
return "linux"
|
|
|
|
|
|
def read_version() -> str:
|
|
text = (ROOT / "server" / "__init__.py").read_text(encoding="utf-8")
|
|
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', text)
|
|
if not match:
|
|
raise SystemExit("Could not read __version__ from server/__init__.py")
|
|
return match.group(1)
|
|
|
|
|
|
def tracked_files() -> list[str]:
|
|
"""Files git tracks, so nothing local leaks into the archive."""
|
|
result = subprocess.run(
|
|
["git", "ls-files"], cwd=ROOT, capture_output=True, text=True, check=True
|
|
)
|
|
return [line for line in result.stdout.splitlines() if line.strip()]
|
|
|
|
|
|
def included(path: str) -> bool:
|
|
return not any(path.startswith(prefix) for prefix in EXCLUDE_PREFIXES)
|
|
|
|
|
|
def build_web() -> None:
|
|
npm = shutil.which("npm") or shutil.which("npm.cmd")
|
|
if npm is None:
|
|
raise SystemExit("npm not found. Install Node 20+, or pass --skip-web.")
|
|
print("Building the web interface...")
|
|
for args in (["ci"], ["run", "build"]):
|
|
subprocess.run([npm, *args], cwd=ROOT / "web", check=True, shell=False)
|
|
|
|
|
|
def stage(target: str, staging: Path) -> None:
|
|
"""Copy the application into a clean staging directory."""
|
|
if staging.exists():
|
|
shutil.rmtree(staging)
|
|
staging.mkdir(parents=True)
|
|
|
|
copied = 0
|
|
for relative in tracked_files():
|
|
if not included(relative):
|
|
continue
|
|
source = ROOT / relative
|
|
if not source.is_file():
|
|
continue
|
|
destination = staging / relative
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(source, destination)
|
|
copied += 1
|
|
|
|
# web/dist is gitignored but is the whole point of shipping a build.
|
|
dist_source = ROOT / "web" / "dist"
|
|
if not (dist_source / "index.html").is_file():
|
|
raise SystemExit(
|
|
"web/dist is missing or incomplete. Run without --skip-web, or "
|
|
"build it with: cd web && npm ci && npm run build"
|
|
)
|
|
shutil.copytree(dist_source, staging / "web" / "dist")
|
|
|
|
launcher = PLATFORMS[target]["launcher"]
|
|
_copy_launcher(PACKAGING / launcher, staging / launcher)
|
|
shutil.copy2(PACKAGING / "GPU-ACCELERATION.md", staging / "GPU-ACCELERATION.md")
|
|
|
|
print(f"Staged {copied} tracked files plus the web build.")
|
|
|
|
|
|
def _copy_launcher(source: Path, destination: Path) -> None:
|
|
"""Copy a launcher with the line endings its interpreter requires.
|
|
|
|
``.gitattributes`` pins these too, but the archive must be correct no
|
|
matter how the tree was checked out: a CRLF ``.sh`` fails on Linux with
|
|
"bad interpreter: ...^M", which is a baffling error for someone who just
|
|
downloaded a release. Normalising here means a stale clone or an unusual
|
|
``core.autocrlf`` cannot ship a broken launcher.
|
|
"""
|
|
text = source.read_bytes().replace(b"\r\n", b"\n")
|
|
if destination.suffix == ".bat":
|
|
text = text.replace(b"\n", b"\r\n")
|
|
destination.write_bytes(text)
|
|
if destination.suffix == ".sh":
|
|
os.chmod(destination, 0o755)
|
|
|
|
|
|
def archive(target: str, staging: Path, version: str) -> Path:
|
|
DIST.mkdir(exist_ok=True)
|
|
stem = f"LM-Gambit-{version}-{target}"
|
|
|
|
if PLATFORMS[target]["archive"] == "zip":
|
|
out = DIST / f"{stem}.zip"
|
|
out.unlink(missing_ok=True)
|
|
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
for path in sorted(staging.rglob("*")):
|
|
if path.is_file():
|
|
zf.write(path, Path(stem) / path.relative_to(staging))
|
|
return out
|
|
|
|
out = DIST / f"{stem}.tar.gz"
|
|
out.unlink(missing_ok=True)
|
|
with tarfile.open(out, "w:gz") as tf:
|
|
# arcname roots everything under a single directory so extracting does
|
|
# not scatter files into the user's current folder.
|
|
tf.add(staging, arcname=stem, filter=_normalize)
|
|
return out
|
|
|
|
|
|
def _normalize(info: tarfile.TarInfo) -> tarfile.TarInfo:
|
|
"""Force sane POSIX metadata regardless of the build machine.
|
|
|
|
Windows has no execute bit, so ``os.chmod(0o755)`` on the staged launcher
|
|
is a no-op there and the shell script arrives non-executable — a Linux user
|
|
extracts it and gets "permission denied". Setting the mode on the archive
|
|
member instead makes a tarball built on Windows identical to one built on
|
|
Linux. Ownership is zeroed for the same reason: whoever built it is not who
|
|
should own the extracted files.
|
|
"""
|
|
info.uid = info.gid = 0
|
|
info.uname = info.gname = "root"
|
|
if info.isdir():
|
|
info.mode = 0o755
|
|
elif info.name.endswith(".sh"):
|
|
info.mode = 0o755
|
|
else:
|
|
info.mode = 0o644
|
|
return info
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--platform", choices=sorted(PLATFORMS), default=current_platform())
|
|
parser.add_argument("--skip-web", action="store_true", help="Reuse an existing web/dist")
|
|
args = parser.parse_args()
|
|
|
|
version = read_version()
|
|
print(f"LM-Gambit {version} -> {args.platform}")
|
|
|
|
if not args.skip_web:
|
|
build_web()
|
|
|
|
staging = DIST / f"_staging-{args.platform}"
|
|
stage(args.platform, staging)
|
|
out = archive(args.platform, staging, version)
|
|
shutil.rmtree(staging, ignore_errors=True)
|
|
|
|
size_mb = out.stat().st_size / (1024 * 1024)
|
|
print(f"\n {out.relative_to(ROOT)} ({size_mb:.1f} MB)")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|