aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsaturneric <[email protected]>2026-10-24 17:55:27 +0200
committersaturneric <[email protected]>2026-10-24 17:55:27 +0200
commit944a8fbe4b7eaca4524b344f4e10f74a0103e6d3 (patch)
tree8d0b44abc8e2ff8da1cf15df72120876db921569
parentfix(screenshots): validate captured window dimensions (diff)
downloadmanual-944a8fbe4b7eaca4524b344f4e10f74a0103e6d3.tar.gz
manual-944a8fbe4b7eaca4524b344f4e10f74a0103e6d3.zip
feat(screenshots): add one-command capture setup
- add shoot.py to manage the virtual environment and dependencies - clone or fast-forward a clean GpgFrontend repository - validate and optionally install pinned Windows fonts - block runs while another GpgFrontend process is active - support passthrough pipeline options and recovery mode - document the helper workflow and command-line options - exclude shoot.py from pipeline input hashes - add tests for cloning, arguments, fonts, and setup behavior
-rw-r--r--scripts/screenshots/README.md47
-rw-r--r--scripts/screenshots/manifest.py9
-rw-r--r--scripts/screenshots/shoot.py443
-rw-r--r--scripts/screenshots/tests/test_shoot.py172
4 files changed, 661 insertions, 10 deletions
diff --git a/scripts/screenshots/README.md b/scripts/screenshots/README.md
index 88a5dc6..9fca8d2 100644
--- a/scripts/screenshots/README.md
+++ b/scripts/screenshots/README.md
@@ -5,8 +5,39 @@ mode. They render each image, post-process it, compare it with the last one,
upload it to the image CDN and rewrite the `[img-N]: url` definition in the
page. Everything runs locally. Nothing is uploaded unless you pass `--upload`.
-The scripts never run git in this repository. They only read the GpgFrontend
-repository, with `rev-parse`, `cat-file`, `merge-base`, `diff` and `status`.
+The scripts never run git in this repository. The pipeline only reads the
+GpgFrontend repository, with `rev-parse`, `cat-file`, `merge-base`, `diff` and
+`status`. Only the `shoot.py` helper writes to it: it clones it, or
+fast-forwards a clean clone.
+
+## Quick start
+
+`shoot.py` does the whole job in one command. It creates the venv, clones or
+updates GpgFrontend, checks the fonts, then runs `run.py`:
+
+```powershell
+cd scripts\screenshots
+py -3 shoot.py --binary <path>\GpgFrontend.exe --install-fonts # dry run
+py -3 shoot.py --binary <path>\GpgFrontend.exe --upload # render, upload, rewrite docs
+```
+
+| Option | Meaning |
+| ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
+| `--binary` | screenshot-mode binary (else `GF_SCREENSHOT_BINARY`) |
+| `--gpgfrontend` | GpgFrontend clone (else `GF_SHOTS_GPGFRONTEND`, else `GpgFrontend-Src` next to this repository); cloned when missing |
+| `--ref` | branch to clone and fast-forward, default `main` |
+| `--no-update` | use the clone as it is |
+| `--install-fonts` | Windows: download the pinned Noto Sans 2.015 and Noto Sans CJK SC 2.004 releases, check their sha256, install them for the current user |
+| `--ignore-running` | start although another GpgFrontend is running |
+| `--only`, `--force`, `--repeat`, `--apply`, `--upload` | passed to `run.py` |
+| `--recover` | only `run.py --recover` |
+| `-- <args>` | anything else for `run.py`, e.g. `-- --accept-baseline main-window` |
+
+The clone is only updated when it is on `--ref` and has no uncommitted changes
+to tracked files; otherwise the run stops and says why. The run also stops
+while another GpgFrontend is running, because its writes to the per-user app
+data would fail the footprint check. `shoot.py` is not part of `inputs_hash`.
+The exit code is `run.py`'s; 2 also covers a setup step that failed.
## Setup
@@ -40,6 +71,7 @@ Pass it with `--binary`, or set `GF_SCREENSHOT_BINARY`.
| `screenshots.yaml` | the manifest: defaults, global sources, entries |
| `screenshots.lock.json` | committed record per entry: url, hashes, commit, env |
| `.screenshots-cache/` | ignored: previews, baselines, transactions, `last-run.json` |
+| `shoot.py` | one-command helper: venv, GpgFrontend clone, fonts, `run.py` |
| `check.py` | status report and inventory |
| `run.py` | select, render, post-process, diff, upload, apply, recover |
| `render.py` | runs the binary in a throwaway environment (also a debug CLI) |
@@ -249,18 +281,17 @@ risk small.
the same folder as its `BUILD-INFO.json`, so the freshness check finds the
commit. CI only builds this binary and checks nothing: the steps below,
on this machine, are the first time it runs.
-2. Install Noto Sans and Noto Sans CJK SC if they are not installed.
-3. Run a dry run, in light and dark (set `theme` in the manifest, or add
- entries):
+2. Close any running GpgFrontend. Run a dry run; `--install-fonts` installs
+ Noto Sans and Noto Sans CJK SC if they are missing:
```powershell
- .venv\Scripts\python run.py --gpgfrontend <GpgFrontend repo> --binary <path>\GpgFrontend.exe `
- --only main-window,key-generate-dialog,text-decrypt-verify-result,main-window-menu-popup --repeat 2
+ py -3 shoot.py --binary <path>\GpgFrontend.exe --install-fonts --force --repeat 2
```
Any gate failure is reported by name.
-4. Review the previews in `.screenshots-cache\preview\`.
+3. Review the previews in `.screenshots-cache\preview\`.
+4. When they are right, run it again with `--upload` to replace the images.
5. If anything fails, send `.screenshots-cache\last-run.json` and the env
records in `.screenshots-cache\preview\*.json`. They contain no `del` links.
diff --git a/scripts/screenshots/manifest.py b/scripts/screenshots/manifest.py
index 2fbe6cc..e0f4d6a 100644
--- a/scripts/screenshots/manifest.py
+++ b/scripts/screenshots/manifest.py
@@ -321,13 +321,18 @@ def load_manifest(path: Path) -> Manifest:
return parse_manifest(data, path)
+# Top-level scripts that prepare a run but never touch what is rendered.
+NOT_PIPELINE = frozenset({"shoot.py"})
+
+
def pipeline_files(pipeline_dir: Path = SCRIPT_DIR) -> dict[str, str]:
"""sha256 of every pipeline file: top-level *.py plus requirements.txt.
- Tests live under tests/ and are not part of the pipeline.
+ Tests live under tests/ and are not part of the pipeline, nor is
+ anything in NOT_PIPELINE.
"""
pipeline_dir = Path(pipeline_dir)
- files = sorted(pipeline_dir.glob("*.py"))
+ files = sorted(f for f in pipeline_dir.glob("*.py") if f.name not in NOT_PIPELINE)
req = pipeline_dir / "requirements.txt"
if req.exists():
files.append(req)
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())
diff --git a/scripts/screenshots/tests/test_shoot.py b/scripts/screenshots/tests/test_shoot.py
new file mode 100644
index 0000000..8a23870
--- /dev/null
+++ b/scripts/screenshots/tests/test_shoot.py
@@ -0,0 +1,172 @@
+from __future__ import annotations
+
+import hashlib
+import io
+import shutil
+import unittest
+import zipfile
+from pathlib import Path
+from unittest import mock
+
+from helpers import commit_change, git, make_gf_repo, short_tempdir, write
+
+import fonts
+import manifest
+import shoot
+
+
+class SyncSourceTest(unittest.TestCase):
+ def setUp(self) -> None:
+ self.tmp = short_tempdir("gfsh")
+ self.addCleanup(shutil.rmtree, self.tmp, True)
+ self.upstream = make_gf_repo(self.tmp / "upstream")
+ quiet = mock.patch.object(shoot, "say")
+ quiet.start()
+ self.addCleanup(quiet.stop)
+ self.clone = self.tmp / "clone"
+
+ def test_clones_when_missing(self) -> None:
+ head = shoot.sync_source(self.clone, "main", str(self.upstream), update=True)
+ self.assertEqual(head, git(self.upstream, "rev-parse", "HEAD"))
+ self.assertEqual(git(self.clone, "config", "core.autocrlf"), "false")
+
+ def test_fast_forwards_an_existing_clone(self) -> None:
+ shoot.sync_source(self.clone, "main", str(self.upstream), update=True)
+ new = commit_change(self.upstream, "src/ui/main/MainWindow.cpp")
+ self.assertEqual(shoot.sync_source(self.clone, "main", str(self.upstream), update=True), new)
+
+ def test_no_update_leaves_the_clone_alone(self) -> None:
+ old = shoot.sync_source(self.clone, "main", str(self.upstream), update=True)
+ commit_change(self.upstream, "src/ui/main/MainWindow.cpp")
+ self.assertEqual(shoot.sync_source(self.clone, "main", str(self.upstream), update=False), old)
+
+ def test_refuses_local_changes(self) -> None:
+ shoot.sync_source(self.clone, "main", str(self.upstream), update=True)
+ write(self.clone / "README", "edited\n")
+ with self.assertRaisesRegex(shoot.ShootError, "uncommitted changes"):
+ shoot.sync_source(self.clone, "main", str(self.upstream), update=True)
+
+ def test_untracked_files_do_not_block(self) -> None:
+ shoot.sync_source(self.clone, "main", str(self.upstream), update=True)
+ write(self.clone / "build" / "out.txt", "x\n")
+ shoot.sync_source(self.clone, "main", str(self.upstream), update=True)
+
+ def test_refuses_another_branch(self) -> None:
+ shoot.sync_source(self.clone, "main", str(self.upstream), update=True)
+ git(self.clone, "checkout", "-q", "-b", "feature")
+ with self.assertRaisesRegex(shoot.ShootError, "on feature, not main"):
+ shoot.sync_source(self.clone, "main", str(self.upstream), update=True)
+
+ def test_refuses_a_non_empty_non_clone(self) -> None:
+ write(self.clone / "stuff.txt", "x\n")
+ with self.assertRaisesRegex(shoot.ShootError, "not a git clone"):
+ shoot.sync_source(self.clone, "main", str(self.upstream), update=True)
+
+ def test_no_update_never_clones(self) -> None:
+ with self.assertRaisesRegex(shoot.ShootError, "--no-update"):
+ shoot.sync_source(self.clone, "main", str(self.upstream), update=False)
+
+
+class ArgvTest(unittest.TestCase):
+ def parse(self, *argv: str):
+ own, extra = shoot.split_passthrough(list(argv))
+ return shoot.build_parser().parse_args(own), extra
+
+ def test_dry_run(self) -> None:
+ args, extra = self.parse("--binary", "gf.exe")
+ self.assertEqual(shoot.run_py_argv(args, Path("src"), Path("gf.exe"), extra),
+ ["--gpgfrontend", "src", "--binary", "gf.exe"])
+
+ def test_options_and_passthrough(self) -> None:
+ args, extra = self.parse("--only", "a,b", "--force", "--repeat", "2", "--upload",
+ "--", "--accept-baseline", "a")
+ self.assertEqual(shoot.run_py_argv(args, Path("src"), Path("gf"), extra),
+ ["--gpgfrontend", "src", "--binary", "gf", "--only", "a,b", "--force",
+ "--repeat", "2", "--upload", "--accept-baseline", "a"])
+
+ def test_recover(self) -> None:
+ args, extra = self.parse("--recover", "--", "--resolve", "t1", "--retry-upload")
+ self.assertEqual(shoot.run_py_argv(args, None, None, extra),
+ ["--recover", "--resolve", "t1", "--retry-upload"])
+
+ def test_modes_are_exclusive(self) -> None:
+ with self.assertRaises(SystemExit), mock.patch("sys.stderr", io.StringIO()):
+ shoot.build_parser().parse_args(["--apply", "--upload"])
+
+
+class FontPackageTest(unittest.TestCase):
+ def setUp(self) -> None:
+ self.tmp = short_tempdir("gfsf")
+ self.addCleanup(shutil.rmtree, self.tmp, True)
+
+ def test_registry_names_resolve_the_manifest_families(self) -> None:
+ values = [(name, f"C:\\fonts\\{Path(member).name}")
+ for pkg in shoot.FONT_PACKAGES for member, name in pkg.members.items()]
+ self.assertEqual(fonts.match_registry("Noto Sans", values), "C:\\fonts\\NotoSans-Regular.ttf")
+ self.assertEqual(fonts.match_registry("Noto Sans CJK SC", values),
+ "C:\\fonts\\NotoSansCJKsc-Regular.otf")
+
+ def test_noto_sans_names(self) -> None:
+ names = set(shoot._noto_sans_members().values())
+ self.assertEqual(len(names), 18)
+ for expected in ("Noto Sans Regular (TrueType)", "Noto Sans Italic (TrueType)",
+ "Noto Sans Bold Italic (TrueType)", "Noto Sans SemiBold (TrueType)"):
+ self.assertIn(expected, names)
+
+ def make_zip(self) -> tuple[Path, shoot.FontPackage]:
+ archive = self.tmp / "pkg.zip"
+ with zipfile.ZipFile(archive, "w") as zf:
+ zf.writestr("dir/A-Regular.ttf", b"regular")
+ zf.writestr("dir/A-Bold.ttf", b"bold")
+ digest = hashlib.sha256(archive.read_bytes()).hexdigest()
+ pkg = shoot.FontPackage("A", archive.as_uri(), digest,
+ {"dir/A-Regular.ttf": "A Regular (TrueType)"})
+ return archive, pkg
+
+ def test_fetch_verifies_and_extracts(self) -> None:
+ _, pkg = self.make_zip()
+ with mock.patch("sys.stdout", io.StringIO()):
+ got = shoot.fetch_package(pkg, self.tmp / "cache")
+ files = shoot.extract_members(got, pkg, self.tmp / "files")
+ self.assertEqual([(p.name, n) for p, n in files], [("A-Regular.ttf", "A Regular (TrueType)")])
+ self.assertEqual(files[0][0].read_bytes(), b"regular")
+
+ def test_fetch_rejects_a_wrong_hash(self) -> None:
+ _, pkg = self.make_zip()
+ bad = shoot.FontPackage(pkg.family, pkg.url, "0" * 64, pkg.members)
+ with mock.patch("sys.stdout", io.StringIO()), self.assertRaisesRegex(shoot.ShootError, "sha256"):
+ shoot.fetch_package(bad, self.tmp / "cache")
+ self.assertFalse((self.tmp / "cache" / "pkg.zip").exists())
+
+
+class PipelineFilesTest(unittest.TestCase):
+ def test_helper_is_not_part_of_inputs_hash(self) -> None:
+ tmp = short_tempdir("gfsp")
+ self.addCleanup(shutil.rmtree, tmp, True)
+ write(tmp / "run.py", "x\n")
+ write(tmp / "shoot.py", "y\n")
+ self.assertEqual(set(manifest.pipeline_files(tmp)), {"run.py"})
+
+
+class MainTest(unittest.TestCase):
+ def test_missing_binary_is_reported(self) -> None:
+ err = io.StringIO()
+ with mock.patch.object(shoot, "in_venv", return_value=True), \
+ mock.patch.dict("os.environ", {shoot.BINARY_ENV: ""}), mock.patch("sys.stderr", err):
+ self.assertEqual(shoot.main([]), 2)
+ self.assertIn("--binary", err.getvalue())
+
+ def test_upload_needs_credentials(self) -> None:
+ tmp = short_tempdir("gfsm")
+ self.addCleanup(shutil.rmtree, tmp, True)
+ binary = write(tmp / "gf.exe", "")
+ err = io.StringIO()
+ env = {name: "" for name in shoot.CDN_ENVS}
+ with mock.patch.object(shoot, "in_venv", return_value=True), \
+ mock.patch.dict("os.environ", env), mock.patch("sys.stderr", err):
+ self.assertEqual(shoot.main(["--binary", str(binary), "--upload"]), 2)
+ self.assertIn("GF_MANUAL_CDN_API", err.getvalue())
+
+
+if __name__ == "__main__":
+ unittest.main()