262 lines
7.8 KiB
Python
262 lines
7.8 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Prune unused LLVM binaries/libraries from the install tree.
|
|
|
|
Two modes:
|
|
- discover: iteratively delete candidates and rebuild to confirm they are unused,
|
|
then write the deletion list to a manifest JSON file.
|
|
- apply: read the manifest and delete the listed files without rebuilding
|
|
(useful for LTO builds that trust the non-LTO pruning result).
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Iterable, List, Optional
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description="Prune unused LLVM artifacts.")
|
|
parser.add_argument(
|
|
"--action",
|
|
choices=["discover", "apply"],
|
|
default="discover",
|
|
help="discover: probe deletable files and record them; apply: delete using manifest",
|
|
)
|
|
parser.add_argument(
|
|
"--install-dir",
|
|
type=Path,
|
|
default=Path(".llvm/build-install/lib"),
|
|
help="Path to the LLVM install lib directory (default: .llvm/build-install/lib)",
|
|
)
|
|
parser.add_argument(
|
|
"--build-dir",
|
|
type=Path,
|
|
default=Path("build"),
|
|
help="CMake build directory used for validation builds (default: build)",
|
|
)
|
|
parser.add_argument(
|
|
"--manifest",
|
|
type=Path,
|
|
default=Path("pruned-libs.json"),
|
|
help="Path to manifest JSON for recording or applying deletions",
|
|
)
|
|
parser.add_argument(
|
|
"--gh-run-id",
|
|
type=str,
|
|
help="GitHub run ID to download manifest from when applying",
|
|
)
|
|
parser.add_argument(
|
|
"--gh-artifact",
|
|
type=str,
|
|
default="llvm-pruned-libs",
|
|
help="Artifact name/pattern that contains the manifest",
|
|
)
|
|
parser.add_argument(
|
|
"--gh-download-dir",
|
|
type=Path,
|
|
default=Path("artifacts"),
|
|
help="Directory to place downloaded artifacts",
|
|
)
|
|
parser.add_argument(
|
|
"--max-attempts",
|
|
type=int,
|
|
default=30,
|
|
help="Maximum attempts when waiting for manifest download",
|
|
)
|
|
parser.add_argument(
|
|
"--sleep-seconds",
|
|
type=int,
|
|
default=60,
|
|
help="Seconds to sleep between manifest download attempts",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def _print_failure_output(
|
|
result: subprocess.CalledProcessError, max_lines: int = 50
|
|
) -> None:
|
|
stdout = getattr(result, "stdout", "") or ""
|
|
stderr = getattr(result, "stderr", "") or ""
|
|
combined = stdout + stderr
|
|
if not combined:
|
|
return
|
|
print("Build output (last lines):")
|
|
lines = combined.splitlines()
|
|
for line in lines[-max_lines:]:
|
|
print(line)
|
|
|
|
|
|
def run_build(build_dir: Path) -> bool:
|
|
cmd = ["cmake", "--build", str(build_dir)]
|
|
print(f"Running: {' '.join(cmd)}")
|
|
try:
|
|
subprocess.run(cmd, check=True, capture_output=True, text=True)
|
|
return True
|
|
except subprocess.CalledProcessError as exc:
|
|
_print_failure_output(exc)
|
|
return False
|
|
|
|
|
|
def candidate_files(install_dir: Path) -> Iterable[Path]:
|
|
if not install_dir.is_dir():
|
|
raise FileNotFoundError(f"lib dir not found: {install_dir}")
|
|
for path in sorted(install_dir.iterdir()):
|
|
if not path.is_file():
|
|
continue
|
|
if path.suffix.lower() in {".a", ".lib"}:
|
|
yield path
|
|
else:
|
|
print(f"Skipping non-static file: {path.name}")
|
|
|
|
|
|
def try_delete(path: Path, build_dir: Path) -> bool:
|
|
backup = path.with_suffix(path.suffix + ".bak")
|
|
print(f"Testing deletion: {path}")
|
|
shutil.move(path, backup)
|
|
success = run_build(build_dir)
|
|
if success:
|
|
backup.unlink(missing_ok=True)
|
|
print(f"Safe to delete: {path.name}")
|
|
return True
|
|
shutil.move(backup, path)
|
|
print(f"Required; restored: {path.name}")
|
|
return False
|
|
|
|
|
|
def discover(install_dir: Path, build_dir: Path) -> List[str]:
|
|
deletable: List[str] = []
|
|
for path in candidate_files(install_dir):
|
|
if try_delete(path, build_dir):
|
|
deletable.append(path.name)
|
|
return deletable
|
|
|
|
|
|
def write_manifest(
|
|
manifest: Path, removed: List[str], install_dir: Path, build_dir: Path
|
|
) -> None:
|
|
data = {
|
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
"install_dir": str(install_dir),
|
|
"build_dir": str(build_dir),
|
|
"removed": removed,
|
|
}
|
|
manifest.write_text(json.dumps(data, indent=2))
|
|
print(f"Wrote manifest with {len(removed)} entries to {manifest}")
|
|
|
|
|
|
def apply_manifest(manifest: Path, install_dir: Path) -> None:
|
|
if not manifest.is_file():
|
|
raise FileNotFoundError(f"Manifest not found: {manifest}")
|
|
data = json.loads(manifest.read_text())
|
|
removed = data.get("removed", [])
|
|
if not isinstance(removed, list):
|
|
raise ValueError("Manifest missing 'removed' list")
|
|
for name in removed:
|
|
target = install_dir / name
|
|
if target.exists():
|
|
print(f"Deleting {target}")
|
|
target.unlink()
|
|
else:
|
|
print(f"Already absent: {target}")
|
|
|
|
|
|
def find_manifest(download_dir: Path, manifest_name: str) -> Optional[Path]:
|
|
for path in download_dir.rglob(manifest_name):
|
|
if path.is_file():
|
|
return path
|
|
return None
|
|
|
|
|
|
def wait_and_download_manifest(
|
|
run_id: str,
|
|
artifact: str,
|
|
download_dir: Path,
|
|
manifest_name: str,
|
|
max_attempts: int,
|
|
sleep_seconds: int,
|
|
) -> Path:
|
|
download_dir.mkdir(parents=True, exist_ok=True)
|
|
for attempt in range(1, max_attempts + 1):
|
|
print(
|
|
f"[{attempt}/{max_attempts}] Downloading manifest via gh run download "
|
|
f"(run={run_id}, artifact={artifact})"
|
|
)
|
|
cmd = [
|
|
"gh",
|
|
"run",
|
|
"download",
|
|
str(run_id),
|
|
"--pattern",
|
|
artifact,
|
|
"--dir",
|
|
str(download_dir),
|
|
]
|
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
if result.returncode == 0:
|
|
found = find_manifest(download_dir, manifest_name)
|
|
if found:
|
|
print(f"Found manifest at {found}")
|
|
return found
|
|
print("Download succeeded but manifest not found; retrying...")
|
|
else:
|
|
print("gh run download failed; stderr follows:")
|
|
print(result.stderr)
|
|
if attempt < max_attempts:
|
|
time.sleep(sleep_seconds)
|
|
raise RuntimeError("Manifest could not be downloaded within the allotted attempts")
|
|
|
|
|
|
def ensure_manifest(
|
|
manifest: Path,
|
|
run_id: Optional[str],
|
|
artifact: str,
|
|
download_dir: Path,
|
|
max_attempts: int,
|
|
sleep_seconds: int,
|
|
) -> Path:
|
|
if manifest.exists():
|
|
return manifest
|
|
if not run_id:
|
|
raise FileNotFoundError(
|
|
f"Manifest {manifest} missing and no gh run ID provided for download"
|
|
)
|
|
downloaded = wait_and_download_manifest(
|
|
run_id=run_id,
|
|
artifact=artifact,
|
|
download_dir=download_dir,
|
|
manifest_name=manifest.name,
|
|
max_attempts=max_attempts,
|
|
sleep_seconds=sleep_seconds,
|
|
)
|
|
if downloaded != manifest:
|
|
shutil.copy(downloaded, manifest)
|
|
return manifest
|
|
|
|
|
|
def main() -> None:
|
|
args = parse_args()
|
|
install_dir = args.install_dir
|
|
build_dir = args.build_dir
|
|
if args.action == "discover":
|
|
deletable = discover(install_dir, build_dir)
|
|
write_manifest(args.manifest, deletable, install_dir, build_dir)
|
|
else:
|
|
manifest = ensure_manifest(
|
|
manifest=args.manifest,
|
|
run_id=args.gh_run_id,
|
|
artifact=args.gh_artifact,
|
|
download_dir=args.gh_download_dir,
|
|
max_attempts=args.max_attempts,
|
|
sleep_seconds=args.sleep_seconds,
|
|
)
|
|
apply_manifest(manifest, install_dir)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|