feat: add packaging scripts, build process, and GPU acceleration documentation
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user