diff options
| author | saturneric <[email protected]> | 2026-10-24 17:55:27 +0200 |
|---|---|---|
| committer | saturneric <[email protected]> | 2026-10-24 17:55:27 +0200 |
| commit | 944a8fbe4b7eaca4524b344f4e10f74a0103e6d3 (patch) | |
| tree | 8d0b44abc8e2ff8da1cf15df72120876db921569 | |
| parent | fix(screenshots): validate captured window dimensions (diff) | |
| download | manual-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.md | 47 | ||||
| -rw-r--r-- | scripts/screenshots/manifest.py | 9 | ||||
| -rw-r--r-- | scripts/screenshots/shoot.py | 443 | ||||
| -rw-r--r-- | scripts/screenshots/tests/test_shoot.py | 172 |
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() |
