"""Generate and apply a pack's distribution pair: `.json` + `.diff`. A converted pack is not deployed as a folder. Its checked-in review artifact is the DIFFERENCE between the pristine pack and its `v2/` tree, in two files that together are sufficient to re-create `v2/` byte-for-byte on top of any copy of the original. Deployment wraps exactly those two files in one zip. .json the manifest — for every file of the v2 tree, what to do and how to prove it was done right: copy v2 file is byte-identical to the pack's own file convert v2 file derives from it: SEED with the pack's bytes, then apply this file's hunks from the .diff add v2 file has no counterpart: its hunks in the .diff are a creation (a/dev/null) delete a pack file deliberately absent from v2/ Every entry carries sha256 of what it expects and of what it must produce, so application is verified, not hoped. `v2/` is a COMPLETE pack, so a node module sits beside every sibling it imports and `__file__`-relative resources resolve exactly as they did upstream. The duplication costs nothing in the artifact: `copy` is one manifest line, not a file body. .diff ordinary unified diff, human-readable — the REVIEW artifact. Because `convert` seeds from the original, its hunks are the conversion and nothing else; a reviewer reads the boundary changes, not two interleaved copies of the pack. Why a pair instead of one big diff: a plain tree diff must carry every unchanged file as a full addition (fonts, vendored JS, untouched sources), which buries the conversion and bloats the artifact by the size of the pack. A binary file may be `copy` or `delete` only. A binary that genuinely changes in conversion has no reviewable diff, which is a smell worth refusing until a real case argues otherwise. """ from __future__ import annotations import difflib import hashlib import io import json import re import shutil import stat import tempfile import zipfile from pathlib import Path, PurePosixPath, PureWindowsPath FORMAT = "comfy-pack-patch/1" OPS = {"copy", "convert", "add", "delete"} EMPTY_SHA256 = hashlib.sha256(b"").hexdigest() SHA256_RE = re.compile(r"[0-9a-f]{64}") HUNK_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*)?$") MAX_BUNDLE_BYTES = 96 * 1024 * 1024 MAX_BUNDLE_MEMBER_BYTES = 64 * 1024 * 1024 #: Kept out of both trees entirely: derived or environment litter, never pack #: content. `.git` appears when a pack folder is a checkout; caches when #: anything imported it in place. IGNORE_DIRS = {".git", "__pycache__", ".pytest_cache", "node_modules"} IGNORE_FILES = {".DS_Store"} class PackPatchError(Exception): pass def _files( root: Path, *, installed_manifest_paths: set[str] | None = None, ) -> dict[str, Path]: out: dict[str, Path] = {} for p in sorted(root.rglob("*")): rel = p.relative_to(root) rel_text = rel.as_posix() if ( installed_manifest_paths is not None and rel_text not in installed_manifest_paths ): continue if p.is_symlink(): raise PackPatchError(f"symbolic link is not pack-patch content: {p}") if not p.is_file(): continue if rel.parts[0] == "v2" and root.name != "v2": continue if any(part in IGNORE_DIRS for part in rel.parts): continue if rel.name in IGNORE_FILES: continue out[rel_text] = p return out def _sha(p: Path) -> str: return hashlib.sha256(p.read_bytes()).hexdigest() def _is_text(p: Path) -> bool: data = p.read_bytes() if b"\0" in data[:8192]: return False try: data.decode("utf-8") except UnicodeDecodeError: return False return True def _lines(p: Path) -> list[str]: try: return p.read_bytes().decode("utf-8").splitlines(keepends=True) except UnicodeDecodeError as exc: raise PackPatchError(f"text file is not UTF-8: {p}") from exc def _mode(p: Path) -> int: return stat.S_IMODE(p.stat().st_mode) & 0o777 def _unified(a_lines, b_lines, a_label: str, b_label: str) -> str: """difflib's unified diff, with git's no-newline convention restored. difflib yields a file's final line verbatim — WITHOUT a newline if the file has none — and never emits git's ``\\ No newline at end of file`` marker. Concatenating such parts glues the next file's ``---`` header onto the unterminated line. Every unterminated line therefore gets a newline plus the marker, which the applier strips back out, keeping the round trip byte-exact for files that do not end in a newline. """ out = [] for line in difflib.unified_diff( a_lines, b_lines, fromfile=a_label, tofile=b_label, n=3 ): out.append(line) if not line.endswith("\n"): out.append("\n\\ No newline at end of file\n") return "".join(out) def pack_key(pack: Path) -> str: """The pack's identity: ``x`` + short git commit sha of the snapshot. In the db layout the key IS the snapshot folder's name — ``//`` — so identity is read off the path and the tree itself stays exactly upstream's bytes. ``packs.json`` beside the pack folders records provenance (upstream URL, full commit), not identity. """ name = Path(pack).resolve().name if re.fullmatch(r"x[0-9a-f]{7,12}", name): return name raise PackPatchError( f"pack folder {name!r} is not a snapshot key — expected the db " f"layout /x/" ) def _pack_folder(snapshot: Path) -> Path: pack_dirs = [ directory for directory in sorted(Path(snapshot).iterdir()) if directory.is_dir() and not directory.name.startswith(".") ] if len(pack_dirs) != 1: raise PackPatchError( f"{snapshot}: a snapshot holds exactly one pack folder, " f"found {[directory.name for directory in pack_dirs]}" ) return pack_dirs[0] def generate(pack: Path) -> tuple[dict, str]: """Build the (manifest, diff-text) pair for ``pack``. The v2 tree is authoritative for what ships; the original tree is authoritative for what it is a change AGAINST. """ snapshot = Path(pack) pack_dir = _pack_folder(snapshot) v2 = pack_dir / "v2" if not v2.is_dir(): raise PackPatchError(f"{pack_dir} has no v2/ tree") # Paths are relative to the pack folder on both sides. `_files` skips the # `v2` child, so the pristine side is the pack as upstream shipped it. orig = _files(pack_dir) conv = _files(v2) entries: list[dict] = [] diff_parts: list[str] = [] for rel in sorted(set(orig) | set(conv)): o, c = orig.get(rel), conv.get(rel) if c is None: entries.append({"path": rel, "op": "delete", "base_sha256": _sha(o)}) continue if o is None: if not _is_text(c): raise PackPatchError( f"binary file added in v2 with no original: {rel} — " f"a patch pair cannot review this; ship it in the base " f"pack or argue the case" ) entries.append( {"path": rel, "op": "add", "v2_sha256": _sha(c), "mode": _mode(c)} ) diff_parts.append(_unified([], _lines(c), "/dev/null", f"b/v2/{rel}")) continue base_sha, v2_sha = _sha(o), _sha(c) if base_sha == v2_sha: entries.append( {"path": rel, "op": "copy", "base_sha256": base_sha, "mode": _mode(c)} ) continue if not (_is_text(o) and _is_text(c)): raise PackPatchError(f"binary file differs between trees: {rel}") entries.append( { "path": rel, "op": "convert", "base_sha256": base_sha, "v2_sha256": v2_sha, "mode": _mode(c), } ) diff_parts.append(_unified(_lines(o), _lines(c), f"a/{rel}", f"b/v2/{rel}")) manifest = { "format": FORMAT, "pack": Path(pack).resolve().parent.name, "key": pack_key(pack), "files": entries, "counts": { op: sum(1 for e in entries if e["op"] == op) for op in ("copy", "convert", "add", "delete") }, } return manifest, "".join(diff_parts) def _safe_relpath(value) -> str: if not isinstance(value, str) or not value or "\\" in value or "\0" in value: raise PackPatchError(f"unsafe manifest path {value!r}") path = PurePosixPath(value) if ( path.is_absolute() or PureWindowsPath(value).drive or path.as_posix() != value or ".." in path.parts or path.parts[0] == "v2" ): raise PackPatchError(f"unsafe manifest path {value!r}") return value def _validate_manifest( snapshot: Path, pack: Path, manifest, *, allow_runtime_products: bool = False, ) -> tuple[list[dict], dict[str, Path]]: if not isinstance(manifest, dict): raise PackPatchError("manifest must be an object") if manifest.get("format") != FORMAT: raise PackPatchError(f"unknown manifest format {manifest.get('format')!r}") expected_pack = snapshot.resolve().parent.name if manifest.get("pack") != expected_pack: raise PackPatchError( f"manifest pack {manifest.get('pack')!r} does not match {expected_pack!r}" ) expected_key = pack_key(snapshot) if manifest.get("key") != expected_key: raise PackPatchError( f"manifest key {manifest.get('key')!r} does not match {expected_key!r}" ) entries = manifest.get("files") if not isinstance(entries, list): raise PackPatchError("manifest files must be a list") seen: set[str] = set() seen_casefolded: set[str] = set() for entry in entries: if not isinstance(entry, dict): raise PackPatchError("manifest file entries must be objects") rel = _safe_relpath(entry.get("path")) folded = rel.casefold() if rel in seen or folded in seen_casefolded: raise PackPatchError(f"duplicate manifest path {rel!r}") seen.add(rel) seen_casefolded.add(folded) op = entry.get("op") if op not in OPS: raise PackPatchError(f"unknown op {op!r} for {rel}") required = {"path", "op"} if op in {"copy", "convert", "delete"}: required.add("base_sha256") if op in {"add", "convert"}: required.add("v2_sha256") if op != "delete": required.add("mode") if set(entry) != required: raise PackPatchError( f"manifest fields for {rel} are {sorted(entry)}, expected {sorted(required)}" ) for field in ("base_sha256", "v2_sha256"): if field in entry and not ( isinstance(entry[field], str) and SHA256_RE.fullmatch(entry[field]) ): raise PackPatchError(f"invalid {field} for {rel}") if "mode" in entry and ( isinstance(entry["mode"], bool) or not isinstance(entry["mode"], int) or not 0 <= entry["mode"] <= 0o777 ): raise PackPatchError(f"invalid mode for {rel}") declared_base = { entry["path"] for entry in entries if entry["op"] in {"copy", "convert", "delete"} } base_files = _files( pack, installed_manifest_paths=(declared_base if allow_runtime_products else None), ) actual_base = set(base_files) if declared_base != actual_base: missing = sorted(actual_base - declared_base) extra = sorted(declared_base - actual_base) raise PackPatchError( f"manifest base file set differs: missing={missing}, extra={extra}" ) expected_counts = { op: sum(entry["op"] == op for entry in entries) for op in sorted(OPS) } if manifest.get("counts") != expected_counts: raise PackPatchError( f"manifest counts {manifest.get('counts')!r} do not match {expected_counts!r}" ) return entries, base_files def _parse_diff(diff_text: str) -> dict[str, tuple[str, list[str]]]: """Split one concatenated unified diff into per-target hunk blocks, keyed by the pack-relative path (the ``b/v2/`` prefix stripped). Framing counts hunk spans from the ``@@`` headers rather than pattern- matching ``---``/``+++`` anywhere: a removed content line beginning ``-- x`` renders as ``--- x`` and would otherwise read as a file boundary in the middle of a hunk. The header declares exactly how many body lines follow; trusting it is both simpler and unspoofable. """ lines = diff_text.splitlines(keepends=True) blocks: dict[str, tuple[str, list[str]]] = {} i = 0 while i < len(lines): if not lines[i].startswith("--- "): raise PackPatchError(f"expected a file header, got {lines[i]!r}") source = lines[i][4:].rstrip("\r\n") if i + 1 >= len(lines) or not lines[i + 1].startswith("+++ "): raise PackPatchError(f"file header without target at line {i + 1}") target = lines[i + 1][4:].rstrip("\r\n") if not target.startswith("b/v2/"): raise PackPatchError(f"unexpected diff target {target!r}") rel = _safe_relpath(target[len("b/v2/") :]) if rel in blocks: raise PackPatchError(f"duplicate diff target {rel!r}") block: list[str] = [] i += 2 while i < len(lines) and lines[i].startswith("@@"): header = lines[i] match = HUNK_RE.fullmatch(header.rstrip("\r\n")) if match is None: raise PackPatchError(f"{rel}: malformed hunk header {header!r}") old_len = int(match.group(2)) if match.group(2) is not None else 1 new_len = int(match.group(4)) if match.group(4) is not None else 1 block.append(header) i += 1 remaining_old, remaining_new = old_len, new_len while (remaining_old > 0 or remaining_new > 0) and i < len(lines): body = lines[i] tag = body[0] if body else " " if tag == " ": remaining_old -= 1 remaining_new -= 1 elif tag == "-": remaining_old -= 1 elif tag == "+": remaining_new -= 1 elif tag == "\\": pass else: raise PackPatchError(f"{target}: malformed hunk body {body!r}") block.append(body) i += 1 if remaining_old != 0 or remaining_new != 0: raise PackPatchError(f"{rel}: truncated hunk body") if i < len(lines) and lines[i].startswith("\\"): block.append(lines[i]) i += 1 if not block: raise PackPatchError(f"{rel}: diff header has no hunks") blocks[rel] = (source, block) return blocks def _apply_hunks(base_lines: list[str], hunks: list[str], rel: str) -> list[str]: # Git's no-newline convention: a "\ No newline at end of file" marker means # the line BEFORE it has no terminator — the newline on that line exists # only for the diff's own framing. Resolve markers up front so the loop # below never sees them and body lines compare byte-exactly. resolved: list[str] = [] for h in hunks: if h.startswith("\\"): if not resolved: raise PackPatchError(f"{rel}: dangling no-newline marker") resolved[-1] = resolved[-1].rstrip("\n") else: resolved.append(h) hunks = resolved out: list[str] = [] pos = 0 i = 0 while i < len(hunks): line = hunks[i] if not line.startswith("@@"): raise PackPatchError(f"{rel}: malformed hunk header {line!r}") header = line.split("@@")[1].strip() old_span = header.split(" ")[0] start = int(old_span.lstrip("-").split(",")[0]) # A zero-length old span addresses the line AFTER which to insert. old_len = int(old_span.split(",")[1]) if "," in old_span else 1 anchor = start - 1 if old_len else start if anchor < pos: raise PackPatchError(f"{rel}: overlapping hunks") out.extend(base_lines[pos:anchor]) pos = anchor i += 1 while i < len(hunks) and not hunks[i].startswith("@@"): h = hunks[i] tag, body = h[0], h[1:] if tag == " ": if pos >= len(base_lines) or base_lines[pos] != body: raise PackPatchError(f"{rel}: context mismatch at line {pos + 1}") out.append(body) pos += 1 elif tag == "-": if pos >= len(base_lines) or base_lines[pos] != body: raise PackPatchError(f"{rel}: removal mismatch at line {pos + 1}") pos += 1 elif tag == "+": out.append(body) else: raise PackPatchError(f"{rel}: malformed hunk line {h!r}") i += 1 out.extend(base_lines[pos:]) return out def apply(pack: Path, manifest: dict, diff_text: str) -> None: """Re-create ``pack/v2`` from a pristine pack plus the pair. Refuses to run against a base that does not match the manifest's hashes — a patch applied to the wrong snapshot must fail before it writes anything, not produce a plausible near-miss. """ snapshot = Path(pack) pack = _pack_folder(snapshot) entries, base_files = _validate_manifest(snapshot, pack, manifest) hunks_by_path = _parse_diff(diff_text) expected_diff_paths = { entry["path"] for entry in entries if entry["op"] == "convert" or (entry["op"] == "add" and entry["v2_sha256"] != EMPTY_SHA256) } if set(hunks_by_path) != expected_diff_paths: missing = sorted(expected_diff_paths - set(hunks_by_path)) extra = sorted(set(hunks_by_path) - expected_diff_paths) raise PackPatchError( f"diff target set differs: missing={missing}, extra={extra}" ) by_path = {entry["path"]: entry for entry in entries} for rel, (source, _) in hunks_by_path.items(): expected_source = "/dev/null" if by_path[rel]["op"] == "add" else f"a/{rel}" if source != expected_source: raise PackPatchError( f"diff source for {rel} is {source!r}, expected {expected_source!r}" ) # Verify the whole base first; write nothing until it all checks out. for e in entries: if e["op"] in ("copy", "convert", "delete"): base = base_files[e["path"]] if _sha(base) != e["base_sha256"]: raise PackPatchError( f"base file does not match the manifest: {e['path']} — " f"this pair was generated against a different snapshot" ) v2 = pack / "v2" if v2.exists() or v2.is_symlink(): raise PackPatchError(f"refusing to replace existing conversion: {v2}") staging = Path(tempfile.mkdtemp(prefix=".v2-build-", dir=snapshot)) try: for e in entries: rel, op = e["path"], e["op"] target = staging / rel if op == "delete": continue target.parent.mkdir(parents=True, exist_ok=True) if op == "copy": shutil.copy2(base_files[rel], target) elif op == "add": hunks = hunks_by_path.get(rel, ("", []))[1] produced = "".join(_apply_hunks([], hunks, rel)) target.write_bytes(produced.encode("utf-8")) elif op == "convert": base_lines = _lines(base_files[rel]) produced = "".join(_apply_hunks(base_lines, hunks_by_path[rel][1], rel)) target.write_bytes(produced.encode("utf-8")) else: raise PackPatchError(f"unknown op {op!r} for {rel}") target.chmod(e["mode"]) if "v2_sha256" in e and _sha(target) != e["v2_sha256"]: raise PackPatchError( f"applied result does not match the manifest: {rel}" ) staging.replace(v2) except BaseException: shutil.rmtree(staging, ignore_errors=True) raise def _artifact_stem(manifest: dict) -> str: pack = manifest.get("pack") if isinstance(manifest, dict) else None key = manifest.get("key") if isinstance(manifest, dict) else None if ( not isinstance(pack, str) or not pack or pack in {".", ".."} or "/" in pack or "\\" in pack ): raise PackPatchError(f"invalid artifact pack name {pack!r}") if not isinstance(key, str) or re.fullmatch(r"x[0-9a-f]{7,12}", key) is None: raise PackPatchError(f"invalid artifact key {key!r}") return f"{pack}-{key}" def _zip_member(name: str, data: bytes) -> tuple[zipfile.ZipInfo, bytes]: info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) info.compress_type = zipfile.ZIP_DEFLATED info.create_system = 3 info.external_attr = (stat.S_IFREG | 0o644) << 16 return info, data def bundle(manifest: dict, diff_text: str) -> bytes: """Build the deterministic deployment zip containing only the pair.""" if not isinstance(diff_text, str): raise PackPatchError("diff must be text") stem = _artifact_stem(manifest) manifest_bytes = (json.dumps(manifest, indent=1) + "\n").encode("utf-8") diff_bytes = diff_text.encode("utf-8") if max(len(manifest_bytes), len(diff_bytes)) > MAX_BUNDLE_MEMBER_BYTES: raise PackPatchError("patch-pair member exceeds the deployment size limit") out = io.BytesIO() with zipfile.ZipFile(out, "w") as archive: for info, data in ( _zip_member(f"{stem}.json", manifest_bytes), _zip_member(f"{stem}.diff", diff_bytes), ): archive.writestr(info, data) return out.getvalue() def _read_bundle(artifact: bytes) -> tuple[dict, str]: if not isinstance(artifact, bytes): raise PackPatchError("deployment artifact must be bytes") if len(artifact) > MAX_BUNDLE_BYTES: raise PackPatchError("deployment artifact exceeds the size limit") try: with zipfile.ZipFile(io.BytesIO(artifact)) as archive: infos = archive.infolist() if len(infos) != 2 or len({info.filename for info in infos}) != 2: raise PackPatchError("deployment zip must contain exactly two files") for info in infos: if ( info.is_dir() or "/" in info.filename or "\\" in info.filename or info.flag_bits & 1 ): raise PackPatchError( f"invalid deployment zip member {info.filename!r}" ) if ( info.file_size > MAX_BUNDLE_MEMBER_BYTES or info.compress_size > MAX_BUNDLE_MEMBER_BYTES ): raise PackPatchError( f"deployment zip member is too large: {info.filename}" ) names = {info.filename for info in infos} json_names = [name for name in names if name.endswith(".json")] diff_names = [name for name in names if name.endswith(".diff")] if len(json_names) != 1 or len(diff_names) != 1: raise PackPatchError( "deployment zip must contain one .json and one .diff" ) manifest = json.loads(archive.read(json_names[0]).decode("utf-8")) expected_stem = _artifact_stem(manifest) expected_names = {f"{expected_stem}.json", f"{expected_stem}.diff"} if names != expected_names: raise PackPatchError( f"deployment zip names {sorted(names)} do not match the manifest" ) diff_text = archive.read(diff_names[0]).decode("utf-8") except PackPatchError: raise except ( json.JSONDecodeError, UnicodeDecodeError, zipfile.BadZipFile, RuntimeError, ) as exc: raise PackPatchError(f"invalid deployment zip: {exc}") from exc return manifest, diff_text def apply_bundle(pack: Path, artifact: bytes | Path) -> None: """Verify and apply a deployment zip to a pristine pack snapshot.""" data = Path(artifact).read_bytes() if isinstance(artifact, Path) else artifact manifest, diff_text = _read_bundle(data) apply(pack, manifest, diff_text) def validate_bundle(pack: Path, artifact: bytes | Path) -> None: """Verify that an existing materialized conversion matches its bundle.""" data = Path(artifact).read_bytes() if isinstance(artifact, Path) else artifact manifest, diff_text = _read_bundle(data) snapshot = Path(pack) pack_root = _pack_folder(snapshot) entries, base_files = _validate_manifest( snapshot, pack_root, manifest, allow_runtime_products=True, ) hunks_by_path = _parse_diff(diff_text) expected_diff_paths = { entry["path"] for entry in entries if entry["op"] == "convert" or (entry["op"] == "add" and entry["v2_sha256"] != EMPTY_SHA256) } if set(hunks_by_path) != expected_diff_paths: missing = sorted(expected_diff_paths - set(hunks_by_path)) extra = sorted(set(hunks_by_path) - expected_diff_paths) raise PackPatchError( f"diff target set differs: missing={missing}, extra={extra}" ) by_path = {entry["path"]: entry for entry in entries} for rel, (source, _) in hunks_by_path.items(): expected_source = "/dev/null" if by_path[rel]["op"] == "add" else f"a/{rel}" if source != expected_source: raise PackPatchError( f"diff source for {rel} is {source!r}, expected {expected_source!r}" ) for entry in entries: if entry["op"] in {"copy", "convert", "delete"}: if _sha(base_files[entry["path"]]) != entry["base_sha256"]: raise PackPatchError( f"base file does not match the manifest: {entry['path']}" ) v2 = pack_root / "v2" if not v2.is_dir() or v2.is_symlink(): raise PackPatchError(f"materialized pack has no regular v2 tree: {v2}") converted = _files(v2) expected_paths = {entry["path"] for entry in entries if entry["op"] != "delete"} if set(converted) != expected_paths: missing = sorted(expected_paths - set(converted)) extra = sorted(set(converted) - expected_paths) raise PackPatchError( f"materialized file set differs: missing={missing}, extra={extra}" ) for entry in entries: rel = entry["path"] if entry["op"] == "delete": continue target = converted[rel] expected_sha = entry.get("v2_sha256", entry.get("base_sha256")) if _sha(target) != expected_sha or _mode(target) != entry["mode"]: raise PackPatchError(f"materialized file differs from manifest: {rel}") if entry["op"] not in {"add", "convert"}: continue hunks = hunks_by_path.get(rel, ("", []))[1] base_lines = [] if entry["op"] == "add" else _lines(base_files[rel]) produced = "".join(_apply_hunks(base_lines, hunks, rel)).encode("utf-8") if produced != target.read_bytes(): raise PackPatchError(f"diff does not reproduce materialized file: {rel}") def validate_tree(actual: Path | str, expected: Path | str) -> None: """Require two pack trees to have identical files, bytes, and modes.""" actual_root = Path(actual).resolve() expected_root = Path(expected).resolve() if not actual_root.is_dir() or not expected_root.is_dir(): raise PackPatchError("validation diff requires two pack directories") actual_files = _files(actual_root) expected_files = _files(expected_root) actual_paths = set(actual_files) expected_paths = set(expected_files) missing = sorted(expected_paths - actual_paths) extra = sorted(actual_paths - expected_paths) shared = sorted(actual_paths & expected_paths) changed = [ path for path in shared if _sha(actual_files[path]) != _sha(expected_files[path]) ] modes = [ path for path in shared if _mode(actual_files[path]) != _mode(expected_files[path]) ] if missing or extra or changed or modes: raise PackPatchError( "materialized conversion differs from debug reference: " f"missing={missing}, extra={extra}, changed={changed}, modes={modes}" ) def main(argv: list[str]) -> int: import argparse ap = argparse.ArgumentParser( description="Generate or apply a pack's .json/.diff pair" ) sub = ap.add_subparsers(dest="cmd", required=True) g = sub.add_parser("generate") g.add_argument("pack", type=Path) g.add_argument("out_dir", type=Path) a = sub.add_parser("apply") a.add_argument("pack", type=Path) a.add_argument( "pair_prefix", type=Path, help="path up to the extension: /" ) b = sub.add_parser("bundle") b.add_argument( "pair_prefix", type=Path, help="path up to the extension: /" ) b.add_argument("output", type=Path, nargs="?") z = sub.add_parser("apply-zip") z.add_argument("pack", type=Path) z.add_argument("artifact", type=Path) args = ap.parse_args(argv) if args.cmd == "generate": manifest, diff_text = generate(args.pack) args.out_dir.mkdir(parents=True, exist_ok=True) base = args.out_dir / f"{manifest['pack']}-{manifest['key']}" base.with_suffix(".json").write_bytes( (json.dumps(manifest, indent=1) + "\n").encode("utf-8") ) base.with_suffix(".diff").write_bytes(diff_text.encode("utf-8")) c = manifest["counts"] print( f"{manifest['pack']} {manifest['key']}: " f"{c['convert']} converted, {c['add']} added, " f"{c['copy']} copied, {c['delete']} deleted -> " f"{base.with_suffix('.json').name}, {base.with_suffix('.diff').name}" ) return 0 if args.cmd == "bundle": manifest = json.loads( args.pair_prefix.with_suffix(".json").read_bytes().decode("utf-8") ) diff_text = args.pair_prefix.with_suffix(".diff").read_bytes().decode("utf-8") output = args.output or args.pair_prefix.with_suffix(".zip") output.write_bytes(bundle(manifest, diff_text)) print(f"wrote {output}") return 0 if args.cmd == "apply-zip": apply_bundle(args.pack, args.artifact) print(f"re-created {_pack_folder(args.pack) / 'v2'} from {args.artifact}") return 0 manifest = json.loads( args.pair_prefix.with_suffix(".json").read_bytes().decode("utf-8") ) diff_text = args.pair_prefix.with_suffix(".diff").read_bytes().decode("utf-8") apply(args.pack, manifest, diff_text) print(f"re-created {_pack_folder(args.pack) / 'v2'} from the pair") return 0 if __name__ == "__main__": import sys raise SystemExit(main(sys.argv[1:]))