#!/usr/bin/env python3 """Build pinned LibreMetaverse metadata twice and emit a deterministic API catalog.""" from __future__ import annotations import argparse import filecmp import shutil import subprocess import tempfile from pathlib import Path UPSTREAM_COMMIT = "2aa70bb68513b39795da5d13c88f31b86e85a3ba" TARGET_FRAMEWORK = "net10.0" PROJECTS = ( "LibreMetaverse.Types/LibreMetaverse.Types.csproj", "LibreMetaverse.StructuredData/LibreMetaverse.StructuredData.csproj", "LibreMetaverse.Imaging.Abstractions/LibreMetaverse.Imaging.Abstractions.csproj", "LibreMetaverse.Imaging.Skia/LibreMetaverse.Imaging.Skia.csproj", "PrimMesher/LibreMetaverse.PrimMesher.csproj", "LibreMetaverse/LibreMetaverse.csproj", "LibreMetaverse.Rendering.Simple/LibreMetaverse.Rendering.Simple.csproj", "LibreMetaverse.Rendering.MeshFoundry/LibreMetaverse.Rendering.MeshFoundry.csproj", "LibreMetaverse.LslTools/LibreMetaverse.LslTools.csproj", "LibreMetaverse.RLV/LibreMetaverse.RLV.csproj", "LibreMetaverse.Utilities/LibreMetaverse.Utilities.csproj", "LibreMetaverse.Voice.Vivox/LibreMetaverse.Voice.Vivox.csproj", "LibreMetaverse.Voice.WebRTC/LibreMetaverse.Voice.WebRTC.csproj", ) ASSEMBLIES = tuple(Path(project).stem for project in PROJECTS) def run(*command: str, cwd: Path | None = None) -> None: subprocess.run(command, cwd=cwd, check=True) def output(command: list[str], cwd: Path) -> str: return subprocess.run(command, cwd=cwd, check=True, text=True, stdout=subprocess.PIPE).stdout.strip() def validate_upstream(upstream: Path) -> None: actual = output(["git", "rev-parse", "HEAD"], upstream) if actual != UPSTREAM_COMMIT: raise RuntimeError(f"expected upstream {UPSTREAM_COMMIT}, found {actual}") status = output(["git", "status", "--porcelain"], upstream) if status: raise RuntimeError("the pinned upstream checkout must be clean") def build_tool(root: Path, temporary: Path) -> Path: artifacts = temporary / "tool-artifacts" run( "dotnet", "build", str(root / "tools/api-catalog/ApiCatalog.csproj"), "-c", "Release", "--artifacts-path", str(artifacts), ) candidates = sorted((artifacts / "bin").rglob("ApiCatalog.dll")) if len(candidates) != 1: raise RuntimeError(f"expected one extractor assembly, found {candidates}") run("dotnet", str(candidates[0]), "--self-test") return candidates[0] def build_upstream(upstream: Path, temporary: Path) -> tuple[list[Path], Path]: artifacts = temporary / "upstream-artifacts" binaries = temporary / "bin" generated = temporary / "generated" for project in PROJECTS: command = [ "dotnet", "build", str(upstream / project), "-c", "Release", "-f", TARGET_FRAMEWORK, "--artifacts-path", str(artifacts), "-p:ContinuousIntegrationBuild=true", f"-p:OutputPath={binaries}/", "-p:CopyLocalLockFileAssemblies=true", ] if project == "LibreMetaverse/LibreMetaverse.csproj": command.extend([ "--no-incremental", "-p:EmitCompilerGeneratedFiles=true", f"-p:CompilerGeneratedFilesOutputPath={generated}", ]) run(*command) paths = [] for name in ASSEMBLIES: path = binaries / f"{name}.dll" if not path.is_file(): raise RuntimeError(f"missing built assembly {path}") paths.append(path) return paths, generated def extract(tool: Path, assemblies: list[Path], generated: Path, destination: Path) -> None: run( "dotnet", str(tool), "--output", str(destination), "--upstream-commit", UPSTREAM_COMMIT, "--target-framework", TARGET_FRAMEWORK, "--generated-root", str(generated), *(str(path) for path in assemblies), ) def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--upstream", type=Path, default=Path("../libremetaverse")) parser.add_argument("--output", type=Path, default=Path("api/public-api.json")) args = parser.parse_args() root = Path(__file__).resolve().parent.parent upstream = args.upstream.resolve() destination = args.output.resolve() validate_upstream(upstream) with tempfile.TemporaryDirectory(prefix="metacrate-api-catalog-") as directory: temporary = Path(directory) tool = build_tool(root, temporary) assemblies, generated = build_upstream(upstream, temporary / "upstream") outputs = [] for pass_number in (1, 2): catalog = temporary / f"public-api-{pass_number}.json" extract(tool, assemblies, generated, catalog) outputs.append(catalog) if not filecmp.cmp(outputs[0], outputs[1], shallow=False): raise RuntimeError("two clean extraction passes produced different bytes") validate_upstream(upstream) destination.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(outputs[0], destination) print(f"wrote deterministic API catalog to {destination}") if __name__ == "__main__": main()