taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

archive.py (5585B)


      1 #!/usr/bin/env python3
      2 
      3 # This file has been placed in the public domain.
      4 
      5 """Create a source archive containing this repository and its submodules."""
      6 
      7 from __future__ import annotations
      8 
      9 import argparse
     10 import io
     11 import json
     12 import os
     13 from pathlib import Path
     14 import subprocess
     15 import sys
     16 import tarfile
     17 
     18 
     19 def run_git(repo: Path, *args: str, input_data: bytes | None = None) -> bytes:
     20     try:
     21         return subprocess.run(
     22             ["git", *args],
     23             cwd=repo,
     24             input=input_data,
     25             check=True,
     26             stdout=subprocess.PIPE,
     27         ).stdout
     28     except subprocess.CalledProcessError as exc:
     29         command = " ".join(("git", *args))
     30         raise RuntimeError(f"'{command}' failed in {repo}") from exc
     31 
     32 
     33 def tracked_paths(repo: Path) -> list[Path]:
     34     output = run_git(repo, "ls-files", "-z", "--cached")
     35     return [
     36         Path(os.fsdecode(item)) for item in output.rstrip(b"\0").split(b"\0") if item
     37     ]
     38 
     39 
     40 def submodule_paths(repo: Path) -> set[Path]:
     41     gitmodules = repo / ".gitmodules"
     42     if not gitmodules.exists():
     43         return set()
     44     result = subprocess.run(
     45         [
     46             "git",
     47             "config",
     48             "-f",
     49             ".gitmodules",
     50             "--get-regexp",
     51             r"^submodule\..*\.path$",
     52         ],
     53         cwd=repo,
     54         check=False,
     55         stdout=subprocess.PIPE,
     56         text=True,
     57     )
     58     if result.returncode not in (0, 1):
     59         raise RuntimeError(f"could not read submodules from {gitmodules}")
     60     return {Path(line.split(maxsplit=1)[1]) for line in result.stdout.splitlines()}
     61 
     62 
     63 def export_ignored(repo: Path, paths: list[Path]) -> set[Path]:
     64     if not paths:
     65         return set()
     66     query = b"\0".join(os.fsencode(path) for path in paths) + b"\0"
     67     output = run_git(
     68         repo, "check-attr", "-z", "--stdin", "export-ignore", input_data=query
     69     )
     70     fields = output.rstrip(b"\0").split(b"\0")
     71     ignored: set[Path] = set()
     72     for index in range(0, len(fields), 3):
     73         if fields[index + 2] == b"set":
     74             ignored.add(Path(os.fsdecode(fields[index])))
     75     return ignored
     76 
     77 
     78 def repository_entries(repo: Path) -> list[tuple[Path, Path]]:
     79     """Return (filesystem path, archive-relative path) pairs for one repository."""
     80     submodules = submodule_paths(repo)
     81     candidates: list[tuple[Path, Path]] = []
     82 
     83     for relative in tracked_paths(repo):
     84         if relative not in submodules:
     85             candidates.append((repo / relative, relative))
     86 
     87     for submodule in sorted(submodules):
     88         submodule_dir = repo / submodule
     89         if not (submodule_dir / ".git").exists():
     90             raise RuntimeError(
     91                 f"submodule '{submodule}' is not initialized; run './bootstrap' first"
     92             )
     93         for source, relative in repository_entries(submodule_dir):
     94             candidates.append((source, submodule / relative))
     95 
     96     ignored = export_ignored(repo, [relative for _, relative in candidates])
     97     return [
     98         (source, relative) for source, relative in candidates if relative not in ignored
     99     ]
    100 
    101 
    102 def archive_prefix(output: Path) -> Path:
    103     name = output.name
    104     for suffix in (".tar.gz", ".tgz"):
    105         if name.endswith(suffix):
    106             prefix = name[: -len(suffix)]
    107             if prefix:
    108                 return Path(prefix)
    109             break
    110     raise ValueError("output file must end in .tar.gz or .tgz")
    111 
    112 
    113 def parse_args() -> argparse.Namespace:
    114     parser = argparse.ArgumentParser(description=__doc__)
    115     parser.add_argument(
    116         "--include",
    117         action="append",
    118         default=[],
    119         metavar="FILE",
    120         help="include an untracked or export-ignored file (may be repeated)",
    121     )
    122     parser.add_argument("output", type=Path, help="output .tar.gz file")
    123     return parser.parse_args()
    124 
    125 
    126 def main() -> int:
    127     args = parse_args()
    128     root = Path(run_git(Path.cwd(), "rev-parse", "--show-toplevel").decode().strip())
    129     output = args.output.resolve()
    130     prefix = archive_prefix(output)
    131     entries = repository_entries(root)
    132     build_info = subprocess.run(
    133         ["node", str(root / "build-system/version-cli.mjs"), "--json", str(root)],
    134         check=True,
    135         stdout=subprocess.PIPE,
    136     ).stdout
    137     version_metadata = json.loads(build_info)
    138 
    139     included: list[tuple[Path, Path]] = []
    140     for name in args.include:
    141         relative = Path(name)
    142         if relative.is_absolute() or ".." in relative.parts:
    143             raise ValueError(
    144                 f"included path must be relative to the repository: {name}"
    145             )
    146         source = (root / relative).absolute()
    147         if not source.is_file():
    148             raise FileNotFoundError(f"included file does not exist: {name}")
    149         included.append((source, relative))
    150 
    151     with tarfile.open(output, "w:gz") as archive:
    152         for source, relative in [*included, *entries]:
    153             if relative == Path("build-info.json"):
    154                 continue
    155             archive.add(source, arcname=prefix / relative, recursive=False)
    156         metadata = tarfile.TarInfo(str(prefix / "build-info.json"))
    157         metadata.size = len(build_info)
    158         metadata.mode = 0o644
    159         metadata.mtime = int(
    160             run_git(root, "show", "-s", "--format=%ct", version_metadata["gitHash"])
    161         )
    162         archive.addfile(metadata, io.BytesIO(build_info))
    163 
    164     print(f"created {output}")
    165     return 0
    166 
    167 
    168 if __name__ == "__main__":
    169     try:
    170         sys.exit(main())
    171     except (OSError, RuntimeError, ValueError, subprocess.CalledProcessError) as exc:
    172         print(f"archive: {exc}", file=sys.stderr)
    173         sys.exit(1)