diff options
Diffstat (limited to 'scripts/screenshots/shoot.py')
| -rw-r--r-- | scripts/screenshots/shoot.py | 443 |
1 files changed, 443 insertions, 0 deletions
diff --git a/scripts/screenshots/shoot.py b/scripts/screenshots/shoot.py new file mode 100644 index 0000000..b844d64 --- /dev/null +++ b/scripts/screenshots/shoot.py @@ -0,0 +1,443 @@ +#!/usr/bin/env python3 +"""One command for the whole screenshot job. + + py -3 shoot.py --binary <path>\\GpgFrontend.exe # dry run + py -3 shoot.py --binary <path>\\GpgFrontend.exe --apply # write lock and docs + py -3 shoot.py --binary <path>\\GpgFrontend.exe --upload # also upload to the CDN + +Steps, each stopping with a message when it cannot go on: + +1. Creates `.venv` and installs requirements.txt (again when it changes), then + re-runs itself inside the venv. +2. Clones the GpgFrontend repository, or fast-forwards an existing clone. A + clone with local changes, or on another branch, is left alone and the run + stops; `--no-update` uses it as it is. +3. Checks that every font family in the manifest resolves. On Windows, + `--install-fonts` downloads the pinned Noto releases, verifies them and + installs them for the current user (no admin rights needed). +4. Refuses to start while another GpgFrontend is running: its writes to the + per-user app data would fail the footprint check. +5. Runs run.py with the options given here, plus anything after `--`. + +This helper only prepares and calls the pipeline. It is not part of +`inputs_hash`, and it never runs git in the Manual repository. + +`--recover` skips steps 2-4 and runs `run.py --recover`. +""" + +from __future__ import annotations + +import argparse +import hashlib +import os +import shutil +import subprocess +import sys +import urllib.request +import zipfile +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +HERE = Path(__file__).resolve().parent +MANUAL_ROOT = HERE.parent.parent +VENV = HERE / ".venv" +CACHE = HERE / ".screenshots-cache" +REQUIREMENTS = HERE / "requirements.txt" +REQ_MARKER = VENV / ".requirements.sha256" + +REPO_URL = "https://github.com/saturneric/GpgFrontend.git" +DEFAULT_REF = "main" +SOURCE_ENV = "GF_SHOTS_GPGFRONTEND" +BINARY_ENV = "GF_SCREENSHOT_BINARY" +CDN_ENVS = ("GF_MANUAL_CDN_API", "GF_MANUAL_CDN_TOKEN") + +_FONTS_KEY = r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts" + + +class ShootError(Exception): + """A step cannot continue; the message says what to do.""" + + +@dataclass(frozen=True) +class FontPackage: + family: str + url: str + sha256: str + # zip member -> registry value name, as Windows itself would register it + members: dict[str, str] + + +def _noto_sans_members() -> dict[str, str]: + weights = ["Thin", "ExtraLight", "Light", "Regular", "Medium", "SemiBold", "Bold", "ExtraBold", "Black"] + out = {} + for weight in weights: + for italic in (False, True): + if weight == "Regular": + style = "Italic" if italic else "Regular" + else: + style = f"{weight}Italic" if italic else weight + label = {"Regular": "Regular", "Italic": "Italic"}.get(style, style.replace("Italic", " Italic")) + out[f"NotoSans/unhinted/ttf/NotoSans-{style}.ttf"] = f"Noto Sans {label} (TrueType)" + return out + + +FONT_PACKAGES = ( + FontPackage( + "Noto Sans", + "https://github.com/notofonts/latin-greek-cyrillic/releases/download/NotoSans-v2.015/NotoSans-v2.015.zip", + "0c34df072a3fa7efbb7cbf34950e1f971a4447cffe365d3a359e2d4089b958f5", + _noto_sans_members(), + ), + FontPackage( + "Noto Sans CJK SC", + "https://github.com/notofonts/noto-cjk/releases/download/Sans2.004/08_NotoSansCJKsc.zip", + "a927e56f53bd6c3b920bc139c0b94aa36c7d9ad0cf009b159437a1a003581140", + { + "NotoSansCJKsc-Regular.otf": "Noto Sans CJK SC (OpenType)", + "NotoSansCJKsc-Medium.otf": "Noto Sans CJK SC Medium (OpenType)", + "NotoSansCJKsc-Bold.otf": "Noto Sans CJK SC Bold (OpenType)", + }, + ), +) + + +def say(msg: str) -> None: + print(f"[shoot] {msg}", flush=True) + + +# --- arguments ----------------------------------------------------------------- + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + description="Prepare everything and run the manual screenshot pipeline.", + epilog="Arguments after `--` go to run.py unchanged, e.g. -- --accept-baseline main-window", + ) + p.add_argument("--binary", type=Path, help=f"screenshot-mode GpgFrontend binary (else ${BINARY_ENV})") + p.add_argument("--gpgfrontend", type=Path, + help=f"GpgFrontend clone to create or update (else ${SOURCE_ENV}, " + "else GpgFrontend-Src next to the Manual repository)") + p.add_argument("--ref", default=DEFAULT_REF, help="branch to clone and fast-forward (default: main)") + p.add_argument("--repo-url", default=REPO_URL, help="where to clone GpgFrontend from") + p.add_argument("--no-update", action="store_true", help="use the GpgFrontend clone as it is") + p.add_argument("--install-fonts", action="store_true", + help="Windows: download and install missing Noto fonts for this user") + p.add_argument("--ignore-running", action="store_true", + help="start even though another GpgFrontend is running") + p.add_argument("--only", help="comma separated entry ids") + p.add_argument("--force", action="store_true", help="render every entry, stale or not") + p.add_argument("--repeat", type=int, help="render N times and require identical bytes") + mode = p.add_mutually_exclusive_group() + mode.add_argument("--apply", action="store_true", help="write lock, docs and cache (no network)") + mode.add_argument("--upload", action="store_true", help="also upload replacements to the CDN") + mode.add_argument("--recover", action="store_true", help="only finish pending transactions") + return p + + +def split_passthrough(argv: list[str]) -> tuple[list[str], list[str]]: + if "--" in argv: + i = argv.index("--") + return argv[:i], argv[i + 1:] + return argv, [] + + +def default_source() -> Path: + env = os.environ.get(SOURCE_ENV) + return Path(env) if env else MANUAL_ROOT.parent / "GpgFrontend-Src" + + +def run_py_argv(args: argparse.Namespace, source: Optional[Path], binary: Optional[Path], + extra: list[str]) -> list[str]: + if args.recover: + return ["--recover", *extra] + out = ["--gpgfrontend", str(source), "--binary", str(binary)] + if args.only: + out += ["--only", args.only] + if args.force: + out.append("--force") + if args.repeat is not None: + out += ["--repeat", str(args.repeat)] + if args.apply: + out.append("--apply") + if args.upload: + out.append("--upload") + return out + extra + + +# --- 1. venv --------------------------------------------------------------------- + + +def venv_python(venv: Path = VENV) -> Path: + return venv / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + + +def in_venv(venv: Path = VENV) -> bool: + try: + return Path(sys.prefix).resolve() == venv.resolve() + except OSError: + return False + + +def requirements_digest() -> str: + return hashlib.sha256(REQUIREMENTS.read_bytes()).hexdigest() + + +def ensure_venv() -> Path: + py = venv_python() + if not py.exists(): + say(f"creating {VENV}") + subprocess.run([sys.executable, "-m", "venv", str(VENV)], check=True) + digest = requirements_digest() + if not REQ_MARKER.exists() or REQ_MARKER.read_text(encoding="ascii").strip() != digest: + say("installing requirements.txt") + proc = subprocess.run([str(py), "-m", "pip", "install", "--disable-pip-version-check", + "-q", "-r", str(REQUIREMENTS)]) + if proc.returncode != 0: + raise ShootError("pip install failed; check the network and run again") + REQ_MARKER.write_text(digest + "\n", encoding="ascii") + return py + + +# --- 2. GpgFrontend clone ---------------------------------------------------------- + + +def _git(repo: Optional[Path], *args: str, check: bool = True) -> str: + cmd = ["git", "-c", "core.quotepath=off"] + if repo is not None: + cmd += ["-C", str(repo)] + try: + proc = subprocess.run(cmd + list(args), capture_output=True, check=False) + except OSError as exc: + raise ShootError(f"cannot run git: {exc}") from exc + if check and proc.returncode != 0: + detail = proc.stderr.decode("utf-8", "replace").strip() + raise ShootError(f"git {' '.join(args)} failed: {detail}") + return proc.stdout.decode("utf-8", "replace").strip() + + +def sync_source(path: Path, ref: str, url: str, update: bool) -> str: + """Clones or fast-forwards `path` to `ref`; returns HEAD.""" + if not (path / ".git").exists(): + if path.exists() and any(path.iterdir()): + raise ShootError(f"{path} exists, is not empty and is not a git clone; " + "pass --gpgfrontend with another path") + if not update: + raise ShootError(f"{path} is not a GpgFrontend clone, and --no-update forbids cloning") + say(f"cloning {url} ({ref}) into {path}") + # Demo data must stay byte-identical; resource/screenshot/.gitattributes + # also says so, this keeps the rest of the tree as committed too. + _git(None, "clone", "--config", "core.autocrlf=false", "--branch", ref, url, str(path)) + return _git(path, "rev-parse", "HEAD") + + head = _git(path, "rev-parse", "HEAD") + if not update: + say(f"using {path} as it is ({head[:12]})") + return head + changed = _git(path, "status", "--porcelain", "--untracked-files=no") + if changed: + raise ShootError(f"{path} has uncommitted changes; commit or stash them, " + "or pass --no-update to render from it as it is") + branch = _git(path, "symbolic-ref", "--short", "-q", "HEAD", check=False) + if branch != ref: + raise ShootError(f"{path} is on {branch or 'a detached HEAD'}, not {ref}; " + f"check out {ref}, pass --ref {branch or '<branch>'}, or pass --no-update") + say(f"updating {path} ({ref})") + _git(path, "fetch", "--quiet", "origin", ref) + _git(path, "merge", "--ff-only", "--quiet", "FETCH_HEAD") + new = _git(path, "rev-parse", "HEAD") + if new != head: + say(f"{head[:12]} -> {new[:12]}") + return new + + +# --- 3. fonts -------------------------------------------------------------------- + + +def manifest_families() -> list[str]: + from manifest import load_manifest # needs the venv (PyYAML) + + families: list[str] = [] + for entry in load_manifest(HERE / "screenshots.yaml").entries: + if entry.renderable: + for family in entry.fonts: + if family not in families: + families.append(family) + return families + + +def missing_fonts(families: list[str]) -> list[str]: + import fonts + + return [f for f in families if fonts.resolve_font_file(f) is None] + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +def fetch_package(pkg: FontPackage, cache: Path) -> Path: + cache.mkdir(parents=True, exist_ok=True) + target = cache / pkg.url.rsplit("/", 1)[1] + if target.exists() and _sha256_file(target) == pkg.sha256: + return target + say(f"downloading {pkg.url}") + tmp = target.with_suffix(".part") + with urllib.request.urlopen(pkg.url, timeout=120) as resp, tmp.open("wb") as out: # noqa: S310 + shutil.copyfileobj(resp, out) + actual = _sha256_file(tmp) + if actual != pkg.sha256: + tmp.unlink() + raise ShootError(f"{pkg.url}: sha256 {actual} does not match the pinned {pkg.sha256}") + os.replace(tmp, target) + return target + + +def extract_members(archive: Path, pkg: FontPackage, dest: Path) -> list[tuple[Path, str]]: + dest.mkdir(parents=True, exist_ok=True) + out = [] + with zipfile.ZipFile(archive) as zf: + names = set(zf.namelist()) + for member, reg_name in pkg.members.items(): + if member not in names: + raise ShootError(f"{archive.name} has no {member}") + path = dest / Path(member).name + path.write_bytes(zf.read(member)) + out.append((path, reg_name)) + return out + + +def install_user_fonts(files: list[tuple[Path, str]]) -> None: # pragma: no cover - Windows only + import ctypes + import winreg + + dest = Path(os.environ["LOCALAPPDATA"]) / "Microsoft" / "Windows" / "Fonts" + dest.mkdir(parents=True, exist_ok=True) + gdi32 = ctypes.windll.gdi32 + with winreg.CreateKey(winreg.HKEY_CURRENT_USER, _FONTS_KEY) as key: + for src, reg_name in files: + target = dest / src.name + shutil.copyfile(src, target) + winreg.SetValueEx(key, reg_name, 0, winreg.REG_SZ, str(target)) + # Loaded into this session now; the registry entry loads it at sign-in. + if gdi32.AddFontResourceW(str(target)) == 0: + say(f" {src.name}: AddFontResource failed; it loads at the next sign-in") + say(f" installed {src.name}") + hwnd_broadcast, wm_fontchange, smto_abortifhung = 0xFFFF, 0x001D, 0x0002 + ctypes.windll.user32.SendMessageTimeoutW(hwnd_broadcast, wm_fontchange, 0, 0, + smto_abortifhung, 1000, None) + + +def ensure_fonts(install: bool) -> None: + if os.name != "nt": + return # Linux previews: the binary's own font gate reports by name + missing = missing_fonts(manifest_families()) + if not missing: + return + packages = {p.family: p for p in FONT_PACKAGES} + unknown = [f for f in missing if f not in packages] + if unknown: + raise ShootError(f"font(s) not installed and not installable by this helper: {', '.join(unknown)}") + if not install: + raise ShootError(f"font(s) not installed: {', '.join(missing)}. Install them, " + "or pass --install-fonts to install the pinned Noto releases for this user") + for family in missing: + pkg = packages[family] + archive = fetch_package(pkg, CACHE / "fonts") + install_user_fonts(extract_members(archive, pkg, CACHE / "fonts" / "files")) + still = missing_fonts(manifest_families()) + if still: + raise ShootError(f"font(s) still not resolved after installing: {', '.join(still)}") + + +# --- 4. running GpgFrontend -------------------------------------------------------- + + +def running_gpgfrontend() -> list[str]: + """Names of running processes that look like GpgFrontend.""" + found: list[str] = [] + if os.name == "nt": + proc = subprocess.run(["tasklist", "/FO", "CSV", "/NH"], capture_output=True, check=False) + for line in proc.stdout.decode("utf-8", "replace").splitlines(): + name = line.split('","', 1)[0].strip('"') + if name.lower() == "gpgfrontend.exe": + found.append(name) + else: + for comm in Path("/proc").glob("[0-9]*/comm"): + try: + name = comm.read_text().strip() + except OSError: + continue + if name.lower() == "gpgfrontend": + found.append(name) + return found + + +# --- main ------------------------------------------------------------------------ + + +def resolve_binary(args: argparse.Namespace) -> Path: + binary = args.binary or (Path(os.environ[BINARY_ENV]) if os.environ.get(BINARY_ENV) else None) + if binary is None: + raise ShootError(f"--binary or ${BINARY_ENV} is required") + if not binary.is_file(): + raise ShootError(f"binary not found: {binary}") + return binary.resolve() + + +def explain(code: int, args: argparse.Namespace) -> None: + if args.recover: + return + if code == 0 and not (args.apply or args.upload): + say(f"dry run done; previews in {CACHE / 'preview'}") + elif code == 0: + say("done; review the doc and lock changes with git diff before committing") + elif code == 1: + say(f"an entry failed or was refused; details in {CACHE / 'last-run.json'}") + elif code == 3: + say("a transaction needs a decision; run: shoot.py --recover") + + +def main(argv: Optional[list[str]] = None) -> int: + raw = list(sys.argv[1:] if argv is None else argv) + own, extra = split_passthrough(raw) + args = build_parser().parse_args(own) + try: + if not in_venv(): + py = ensure_venv() + return subprocess.run([str(py), str(Path(__file__).resolve()), *raw]).returncode + + sys.path.insert(0, str(HERE)) + if args.recover: + source, binary = None, None + else: + binary = resolve_binary(args) + if args.upload: + unset = [name for name in CDN_ENVS if not os.environ.get(name)] + if unset: + raise ShootError(f"--upload needs {', '.join(unset)} set") + source = (args.gpgfrontend or default_source()).resolve() + sync_source(source, args.ref, args.repo_url, update=not args.no_update) + ensure_fonts(args.install_fonts) + running = running_gpgfrontend() + if running and not args.ignore_running: + raise ShootError("GpgFrontend is running; close it first (its app data writes would " + "fail the footprint check), or pass --ignore-running") + cmd = [sys.executable, str(HERE / "run.py"), *run_py_argv(args, source, binary, extra)] + code = subprocess.run(cmd, cwd=str(HERE)).returncode + explain(code, args) + return code + except ShootError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + except subprocess.CalledProcessError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) |
