#!/usr/bin/env python3 """build_archive.py Assembles ``llamacppctl-installable.tar.gz`` directly on the local machine from the llamacppctl project files, and verifies that all required files and declared dependencies are present and consistent before/after packaging. This script is intentionally self-contained (standard library only, plus an optional import of ``tomllib``/``tomli`` purely for dependency verification) so it can be run directly, without first installing the llamacppctl package itself. Usage ----- python3 build_archive.py python3 build_archive.py --project-dir /path/to/llamacppctl --output /path/to/llamacppctl-installable.tar.gz python3 build_archive.py --skip-tests # skip running pytest before packaging python3 build_archive.py --skip-pip-check # skip the pip dependency dry-run Exit codes ---------- 0 archive built and verified successfully 1 a required file was missing, a test failed, or verification failed """ from __future__ import annotations import argparse import subprocess import sys import tarfile import venv from pathlib import Path # -------------------------------------------------------------------------- # Required file manifest. # # Every path here is relative to the project directory and MUST exist before # packaging. This is the "verifies that all dependencies are correctly # included" contract: the archive is only built if every one of these is # present, and re-opened afterwards to confirm every one of them made it # into the tarball unmodified. # -------------------------------------------------------------------------- REQUIRED_FILES = [ "pyproject.toml", "README.md", "LICENSE", "CHANGELOG.md", "requirements.txt", "requirements-dev.txt", "llama.cpp.config.example", "docs/SECURITY_AND_OPERATIONS.md", "man/llamacppctl.1", "src/llamacppctl/__init__.py", "src/llamacppctl/py.typed", "src/llamacppctl/cli.py", "src/llamacppctl/schema.py", "src/llamacppctl/config.py", "src/llamacppctl/prompt_io.py", "src/llamacppctl/docker_ops.py", "src/llamacppctl/http_ops.py", "src/llamacppctl/lock_ops.py", "src/llamacppctl/actions.py", "src/llamacppctl/main.py", "tests/conftest.py", "tests/test_prompt_sources.py", "tests/test_prompt_files.py", "tests/test_prompt_urls.py", "tests/test_config.py", "tests/test_cli.py", "tests/test_docker_ops.py", "tests/test_http_ops.py", "tests/test_lock_ops.py", "tests/test_actions.py", "scripts/smoke.sh", "BEDIENUNGSANLEITUNG.md", "INSTALL_FROM_ARCHIVE.md", ] # Top-level directories to include wholesale (in addition to REQUIRED_FILES), # so that any file added later without updating REQUIRED_FILES is still # packaged. REQUIRED_FILES remains the source of truth for *verification*. INCLUDE_DIRS = ["src", "tests", "docs", "man", "scripts"] INCLUDE_FILES = [ "pyproject.toml", "README.md", "LICENSE", "CHANGELOG.md", "requirements.txt", "requirements-dev.txt", "llama.cpp.config.example", "build_archive.py", "BEDIENUNGSANLEITUNG.md", "INSTALL_FROM_ARCHIVE.md", ] EXCLUDE_DIR_NAMES = {"__pycache__", ".pytest_cache", ".venv", "venv", ".git", "*.egg-info"} EXCLUDE_SUFFIXES = {".pyc", ".pyo"} ARCHIVE_ROOT_NAME = "llamacppctl" # top-level directory name inside the tarball class BuildError(RuntimeError): pass def log(msg: str) -> None: print(f"[build_archive] {msg}") def verify_required_files(project_dir: Path) -> None: log("Verifying required files are present...") missing = [] for rel in REQUIRED_FILES: p = project_dir / rel if not p.is_file(): missing.append(rel) if missing: raise BuildError( "Missing required files, refusing to build archive:\n " + "\n ".join(missing) ) log(f" All {len(REQUIRED_FILES)} required files present.") def verify_dependencies_declared(project_dir: Path) -> list: """Parses pyproject.toml and cross-checks it against requirements.txt. Returns the list of runtime dependency specifiers declared in pyproject.toml. Raises BuildError on any mismatch. """ log("Verifying declared dependencies are consistent...") pyproject_path = project_dir / "pyproject.toml" requirements_path = project_dir / "requirements.txt" try: import tomllib # Python 3.11+ except ModuleNotFoundError: try: import tomli as tomllib # type: ignore except ModuleNotFoundError: tomllib = None if tomllib is not None: with open(pyproject_path, "rb") as f: data = tomllib.load(f) deps = data.get("project", {}).get("dependencies", []) else: # Fallback: minimal manual extraction, good enough for verification # purposes when neither tomllib nor tomli is available. log(" (tomllib/tomli unavailable, falling back to manual parse)") deps = [] in_deps = False for line in pyproject_path.read_text(encoding="utf-8").splitlines(): stripped = line.strip() if stripped.startswith("dependencies"): in_deps = True if "[" in stripped and "]" in stripped: inner = stripped.split("[", 1)[1].rsplit("]", 1)[0] deps = [d.strip().strip('"').strip("'") for d in inner.split(",") if d.strip()] in_deps = False continue if in_deps: if "]" in stripped: in_deps = False stripped = stripped.split("]", 1)[0] cleaned = stripped.strip().rstrip(",").strip('"').strip("'") if cleaned: deps.append(cleaned) if not deps: raise BuildError("No runtime dependencies found in pyproject.toml [project.dependencies]") req_text = requirements_path.read_text(encoding="utf-8") req_names = set() for line in req_text.splitlines(): line = line.strip() if not line or line.startswith("#"): continue # crude package-name extraction, e.g. "requests>=2.31,<3" -> "requests" name = line for sep in (">=", "<=", "==", "!=", ">", "<", "~="): if sep in name: name = name.split(sep, 1)[0] req_names.add(name.strip().lower()) missing_from_requirements = [] for dep in deps: name = dep for sep in (">=", "<=", "==", "!=", ">", "<", "~="): if sep in name: name = name.split(sep, 1)[0] name = name.strip().lower() if name not in req_names: missing_from_requirements.append(dep) if missing_from_requirements: raise BuildError( "pyproject.toml declares dependencies not mirrored in requirements.txt:\n " + "\n ".join(missing_from_requirements) ) log(f" pyproject.toml dependencies: {deps}") log(" requirements.txt is consistent with pyproject.toml.") return deps def run_pip_dependency_check(project_dir: Path, deps: list) -> None: """Creates a throwaway virtualenv and does a real `pip install` of just the declared dependencies (not the project itself) to confirm they are resolvable from PyPI. This is a stronger guarantee than a --dry-run against an environment that might already happen to have them cached.""" log("Verifying dependencies are installable (throwaway virtualenv)...") import tempfile with tempfile.TemporaryDirectory(prefix="llamacppctl-depcheck-") as tmp: venv_dir = Path(tmp) / "venv" venv.EnvBuilder(with_pip=True, clear=True).create(venv_dir) pip_bin = venv_dir / "bin" / "pip" if not pip_bin.exists(): pip_bin = venv_dir / "Scripts" / "pip.exe" # Windows fallback cmd = [str(pip_bin), "install", "--quiet", *deps] result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: raise BuildError( "Dependency installability check failed:\n" f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" ) log(" All declared dependencies installed successfully in an isolated environment.") def run_tests(project_dir: Path) -> None: log("Running test suite (pytest) before packaging...") result = subprocess.run( [sys.executable, "-m", "pytest", "-q"], cwd=str(project_dir), capture_output=True, text=True, ) print(result.stdout) if result.returncode != 0: print(result.stderr, file=sys.stderr) raise BuildError(f"Test suite failed (exit code {result.returncode}); refusing to package.") log(" Test suite passed.") def _tar_filter(tarinfo: tarfile.TarInfo) -> tarfile.TarInfo | None: parts = Path(tarinfo.name).parts for part in parts: if part in EXCLUDE_DIR_NAMES or part.endswith(".egg-info"): return None if Path(tarinfo.name).suffix in EXCLUDE_SUFFIXES: return None # Normalize metadata for reproducibility. tarinfo.uid = 0 tarinfo.gid = 0 tarinfo.uname = "" tarinfo.gname = "" return tarinfo def build_tarball(project_dir: Path, output_path: Path) -> None: log(f"Building archive: {output_path}") output_path.parent.mkdir(parents=True, exist_ok=True) with tarfile.open(output_path, "w:gz") as tar: for rel in INCLUDE_FILES: src = project_dir / rel if src.is_file(): tar.add(src, arcname=f"{ARCHIVE_ROOT_NAME}/{rel}", filter=_tar_filter) for dirname in INCLUDE_DIRS: src_dir = project_dir / dirname if src_dir.is_dir(): tar.add(src_dir, arcname=f"{ARCHIVE_ROOT_NAME}/{dirname}", filter=_tar_filter) log(f" Archive written: {output_path} ({output_path.stat().st_size:,} bytes)") def verify_tarball(output_path: Path) -> None: log("Re-opening archive to verify contents...") with tarfile.open(output_path, "r:gz") as tar: names = set(tar.getnames()) missing = [] for rel in REQUIRED_FILES: expected = f"{ARCHIVE_ROOT_NAME}/{rel}" if expected not in names: missing.append(expected) if missing: raise BuildError( "Archive verification failed, required files missing from tarball:\n " + "\n ".join(missing) ) log(f" Verified {len(REQUIRED_FILES)} required files are present in the archive.") log(f" Total archive members: {len(names)}") def smoke_test_install(output_path: Path) -> None: """Extracts the tarball into a temp dir, installs it in a throwaway virtualenv, and confirms the `llamacppctl` console script works via --print-effective-config and --dry-run (no Docker daemon required for either).""" log("Smoke-testing installation from the built archive...") import tempfile with tempfile.TemporaryDirectory(prefix="llamacppctl-smoketest-") as tmp: tmp_path = Path(tmp) with tarfile.open(output_path, "r:gz") as tar: tar.extractall(tmp_path) extracted_project = tmp_path / ARCHIVE_ROOT_NAME venv_dir = tmp_path / "venv" venv.EnvBuilder(with_pip=True, clear=True).create(venv_dir) pip_bin = venv_dir / "bin" / "pip" py_bin = venv_dir / "bin" / "python" llamacppctl_bin = venv_dir / "bin" / "llamacppctl" result = subprocess.run( [str(pip_bin), "install", "--quiet", str(extracted_project)], capture_output=True, text=True, ) if result.returncode != 0: raise BuildError( "Smoke-test install failed:\n" f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" ) log(" Package installed successfully from the extracted archive.") config_example = extracted_project / "llama.cpp.config.example" result = subprocess.run( [ str(llamacppctl_bin), "--print-effective-config", "--config", str(config_example), "--profile", "qwen35b", "--start", "--dry-run", ], capture_output=True, text=True, ) print(result.stdout) docker_unavailable = "docker is not available on PATH" in (result.stdout + result.stderr) if result.returncode != 0 and not docker_unavailable: print(result.stderr, file=sys.stderr) raise BuildError( f"Smoke test invocation failed unexpectedly (exit code {result.returncode})." ) if docker_unavailable: log( " Console script and argument/config resolution work end-to-end; " "exited cleanly at the expected Docker-unavailable check " "(no Docker daemon in this environment -- this is not a packaging defect)." ) else: log(" --print-effective-config / --dry-run smoke test succeeded.") def main(argv: list | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--project-dir", default=str(Path(__file__).resolve().parent), help="Path to the llamacppctl project directory (default: this script's directory)", ) parser.add_argument( "--output", default=None, help="Output path for the tar.gz (default: /../llamacppctl-installable.tar.gz)", ) parser.add_argument("--skip-tests", action="store_true", help="Skip running pytest before packaging") parser.add_argument( "--skip-pip-check", action="store_true", help="Skip the throwaway-virtualenv dependency installability check", ) parser.add_argument( "--skip-smoke-test", action="store_true", help="Skip extracting + installing the built archive as a final smoke test", ) args = parser.parse_args(argv) project_dir = Path(args.project_dir).resolve() output_path = ( Path(args.output).resolve() if args.output else project_dir.parent / "llamacppctl-installable.tar.gz" ) try: verify_required_files(project_dir) deps = verify_dependencies_declared(project_dir) if not args.skip_pip_check: run_pip_dependency_check(project_dir, deps) else: log("Skipping pip dependency installability check (--skip-pip-check).") if not args.skip_tests: run_tests(project_dir) else: log("Skipping test suite (--skip-tests).") build_tarball(project_dir, output_path) verify_tarball(output_path) if not args.skip_smoke_test: smoke_test_install(output_path) else: log("Skipping install smoke test (--skip-smoke-test).") except BuildError as exc: print(f"\n[build_archive] BUILD FAILED: {exc}", file=sys.stderr) return 1 log("") log("Build and verification complete.") log(f"Archive: {output_path}") log(f"Size: {output_path.stat().st_size:,} bytes") log("") log("To install on the target machine:") log(f" tar xzf {output_path.name}") log(f" cd {ARCHIVE_ROOT_NAME}") log(" python3 -m venv .venv && source .venv/bin/activate") log(" pip install .") return 0 if __name__ == "__main__": sys.exit(main())