#!/usr/bin/env python3 """Create or update a persistent Manual Invoice Python environment.""" from __future__ import annotations import argparse import hashlib import json import shutil import subprocess import sys from pathlib import Path from typing import Optional from runtime_config import PLUGIN_ROOT, runtime_cache_root, venv_python MINIMUM_PYTHON = (3, 10) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Bootstrap Manual Invoice dependencies.") parser.add_argument("--cache-dir", type=Path) parser.add_argument("--python-version", default="3.11") parser.add_argument("--check", action="store_true") parser.add_argument("--force", action="store_true") return parser.parse_args() def requirements_hash(requirements: Path, python_version: str) -> str: digest = hashlib.sha256() digest.update(requirements.read_bytes()) digest.update(f"\npython={python_version}\n".encode("utf-8")) return digest.hexdigest() def command_ok(command: list[str]) -> bool: return subprocess.run(command, capture_output=True, text=True).returncode == 0 def environment_ready(python: Path, expected_hash: str, hash_file: Path) -> bool: if not python.is_file() or not hash_file.is_file(): return False if hash_file.read_text(encoding="utf-8").strip() != expected_hash: return False return command_ok( [ str(python), "-c", "from google.cloud import bigquery; print(bigquery.__name__)", ] ) def python_version(python: Path) -> Optional[tuple[int, int]]: try: result = subprocess.run( [str(python), "-c", "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')"], check=True, capture_output=True, text=True, ) major, minor = result.stdout.strip().split(".", 1) return int(major), int(minor) except (OSError, ValueError, subprocess.CalledProcessError): return None def select_fallback_python() -> Optional[str]: candidates = ("python3.13", "python3.12", "python3.11", "python3.10", "python3") for candidate in candidates: executable = shutil.which(candidate) if not executable: continue version = python_version(Path(executable)) if version and version >= MINIMUM_PYTHON: return executable return None def run(command: list[str]) -> None: subprocess.run(command, check=True) def install_with_uv(uv: str, venv: Path, python: Path, requested_version: str, requirements: Path) -> None: if not python.is_file(): run([uv, "python", "install", requested_version]) run([uv, "venv", "--python", requested_version, str(venv)]) run([uv, "pip", "install", "--python", str(python), "-r", str(requirements)]) def install_with_stdlib(executable: str, venv: Path, python: Path, requirements: Path) -> None: if not python.is_file(): run([executable, "-m", "venv", str(venv)]) run([str(python), "-m", "pip", "install", "--upgrade", "pip"]) run([str(python), "-m", "pip", "install", "-r", str(requirements)]) def main() -> int: args = parse_args() cache_root = (args.cache_dir or runtime_cache_root()).expanduser().resolve() venv = cache_root / "venv" python = venv_python(cache_root) requirements = PLUGIN_ROOT / "requirements.txt" hash_file = cache_root / "requirements.sha256" expected_hash = requirements_hash(requirements, args.python_version) ready = environment_ready(python, expected_hash, hash_file) if args.check: print( json.dumps( { "status": "ready" if ready else "missing", "cache_dir": str(cache_root), "venv": str(venv), "python": str(python), }, ensure_ascii=False, ) ) return 0 if ready else 3 if ready and not args.force: print( json.dumps( { "status": "ready", "installed": False, "cache_dir": str(cache_root), "venv": str(venv), "python": str(python), "requirements_sha256": expected_hash, }, ensure_ascii=False, ) ) return 0 existing_version = python_version(python) if python.exists() else None requested_tuple = tuple(int(part) for part in args.python_version.split(".")[:2]) if args.force or (existing_version and existing_version != requested_tuple): shutil.rmtree(venv, ignore_errors=True) cache_root.mkdir(parents=True, exist_ok=True) uv = shutil.which("uv") if uv: install_with_uv(uv, venv, python, args.python_version, requirements) installer = "uv" else: fallback = select_fallback_python() if not fallback: print( "Python 3.10+ or uv is required to bootstrap Manual Invoice.", file=sys.stderr, ) return 2 install_with_stdlib(fallback, venv, python, requirements) installer = "venv-pip" if not command_ok([str(python), "-c", "from google.cloud import bigquery"]): print("BigQuery dependency verification failed.", file=sys.stderr) return 2 hash_file.write_text(expected_hash + "\n", encoding="utf-8") print( json.dumps( { "status": "ready", "installed": True, "installer": installer, "cache_dir": str(cache_root), "venv": str(venv), "python": str(python), "requirements_sha256": expected_hash, }, ensure_ascii=False, ) ) return 0 if __name__ == "__main__": raise SystemExit(main())