206 lines
7.2 KiB
Python
206 lines
7.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Copy one CASAN production component through the canonical runtime allowlist."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
from pathlib import Path
|
|
import shutil
|
|
import sys
|
|
|
|
|
|
IGNORED_NAMES = {
|
|
"__pycache__", ".DS_Store", "node_modules", "dist", "build", "coverage",
|
|
}
|
|
IGNORED_SUFFIXES = {".pyc", ".pyo", ".log", ".tmp"}
|
|
|
|
|
|
class LayoutError(RuntimeError):
|
|
pass
|
|
|
|
|
|
def load_layout(source_root: Path) -> dict:
|
|
path = source_root / "packaging" / "runtime-layout.json"
|
|
try:
|
|
layout = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError) as error:
|
|
raise LayoutError(f"cannot load {path}: {error}") from error
|
|
if layout.get("schema_version") != 1:
|
|
raise LayoutError("runtime layout schema_version must be 1")
|
|
return layout
|
|
|
|
|
|
def is_excluded(relative: Path, exclusions: set[str]) -> bool:
|
|
normalized = relative.as_posix()
|
|
if any(part in IGNORED_NAMES for part in relative.parts):
|
|
return True
|
|
if relative.suffix in IGNORED_SUFFIXES:
|
|
return True
|
|
return any(
|
|
normalized == excluded or normalized.startswith(excluded.rstrip("/") + "/")
|
|
for excluded in exclusions
|
|
)
|
|
|
|
|
|
def transformed_content(path: Path, transform: str) -> bytes:
|
|
if transform != "empty_project_registry":
|
|
raise LayoutError(f"unknown runtime transform: {transform}")
|
|
try:
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError) as error:
|
|
raise LayoutError(f"cannot transform project registry: {error}") from error
|
|
payload["projects"] = []
|
|
return (json.dumps(payload, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
|
|
|
|
|
|
def copy_component(
|
|
source_root: Path,
|
|
destination_root: Path,
|
|
name: str,
|
|
preserved: set[str] | None = None,
|
|
clean: bool = False) -> dict:
|
|
layout = load_layout(source_root)
|
|
component = layout.get("components", {}).get(name)
|
|
if not isinstance(component, dict):
|
|
raise LayoutError(f"unknown runtime component: {name}")
|
|
|
|
relative_source = Path(str(component.get("source", "")))
|
|
source = (source_root / relative_source).resolve()
|
|
destination = destination_root / relative_source
|
|
if not source.is_dir():
|
|
raise LayoutError(f"component source is missing: {source}")
|
|
if source_root not in source.parents:
|
|
raise LayoutError(f"component source escapes source root: {source}")
|
|
|
|
exclusions = {
|
|
str(path).replace("\\", "/").strip("/")
|
|
for path in component.get("exclude", [])
|
|
if isinstance(path, str) and path.strip("/")
|
|
}
|
|
unsafe = [
|
|
path for path in exclusions
|
|
if Path(path).is_absolute() or ".." in Path(path).parts
|
|
]
|
|
if unsafe:
|
|
raise LayoutError(
|
|
f"runtime component {name} has unsafe exclusions: {', '.join(unsafe)}")
|
|
transforms = component.get("transforms", {})
|
|
if not isinstance(transforms, dict):
|
|
raise LayoutError(f"runtime component {name} transforms must be an object")
|
|
removed = 0
|
|
if clean and destination.exists():
|
|
for excluded in sorted(exclusions, reverse=True):
|
|
candidate = destination / excluded
|
|
if candidate.is_dir() and not candidate.is_symlink():
|
|
shutil.rmtree(candidate)
|
|
removed += 1
|
|
elif candidate.exists() or candidate.is_symlink():
|
|
candidate.unlink()
|
|
removed += 1
|
|
for candidate in sorted(destination.rglob("*"), reverse=True):
|
|
if (candidate.name in IGNORED_NAMES or
|
|
(candidate.is_file() and candidate.suffix in IGNORED_SUFFIXES)):
|
|
if candidate.is_dir() and not candidate.is_symlink():
|
|
shutil.rmtree(candidate)
|
|
else:
|
|
candidate.unlink()
|
|
removed += 1
|
|
copied = 0
|
|
skipped = 0
|
|
preserved = preserved or set()
|
|
for path in sorted(source.rglob("*")):
|
|
relative = path.relative_to(source)
|
|
if is_excluded(relative, exclusions):
|
|
skipped += path.is_file()
|
|
continue
|
|
target = destination / relative
|
|
if path.is_dir():
|
|
continue
|
|
if not path.is_file():
|
|
continue
|
|
target_relative = (relative_source / relative).as_posix()
|
|
if target_relative in preserved and target.exists():
|
|
skipped += 1
|
|
continue
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
transform = transforms.get(relative.as_posix())
|
|
if transform:
|
|
target.write_bytes(transformed_content(path, str(transform)))
|
|
shutil.copymode(path, target)
|
|
else:
|
|
shutil.copy2(path, target)
|
|
copied += 1
|
|
|
|
if clean and destination.exists():
|
|
for candidate in sorted(destination.rglob("*"), reverse=True):
|
|
if candidate.is_dir() and not candidate.is_symlink():
|
|
try:
|
|
candidate.rmdir()
|
|
except OSError:
|
|
pass
|
|
|
|
missing = [
|
|
required for required in component.get("required", [])
|
|
if not (destination / required).is_file()
|
|
]
|
|
if missing:
|
|
raise LayoutError(
|
|
f"runtime component {name} is incomplete; missing: {', '.join(missing)}")
|
|
return {
|
|
"component": name,
|
|
"source": str(source),
|
|
"destination": str(destination),
|
|
"copied_files": copied,
|
|
"excluded_files": skipped,
|
|
"removed_paths": removed,
|
|
}
|
|
|
|
|
|
def parser() -> argparse.ArgumentParser:
|
|
value = argparse.ArgumentParser(description=__doc__)
|
|
value.add_argument("--source-root", required=True)
|
|
value.add_argument("--destination-root", required=True)
|
|
value.add_argument("--component", action="append", required=True)
|
|
value.add_argument(
|
|
"--preserve", action="append", default=[],
|
|
help="destination-root-relative file to retain when it already exists")
|
|
value.add_argument(
|
|
"--clean", action="store_true",
|
|
help="remove excluded source-only paths left by an older installation")
|
|
value.add_argument("--json", action="store_true")
|
|
return value
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
args = parser().parse_args(argv)
|
|
source_root = Path(args.source_root).expanduser().resolve()
|
|
destination_root = Path(args.destination_root).expanduser().resolve()
|
|
destination_root.mkdir(parents=True, exist_ok=True)
|
|
preserved = {
|
|
str(path).replace("\\", "/").strip("/")
|
|
for path in args.preserve
|
|
if str(path).strip("/")
|
|
}
|
|
try:
|
|
results = [
|
|
copy_component(
|
|
source_root, destination_root, component, preserved, args.clean)
|
|
for component in args.component
|
|
]
|
|
except LayoutError as error:
|
|
sys.stderr.write(f"copy-runtime: {error}\n")
|
|
return 1
|
|
if args.json:
|
|
print(json.dumps({"status": "ok", "components": results}, indent=2))
|
|
else:
|
|
for result in results:
|
|
print(
|
|
"copy-runtime: {component} copied={copied_files} "
|
|
"excluded={excluded_files} removed={removed_paths}".format(**result))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|