331 lines
9.4 KiB
Python
Executable File
331 lines
9.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
spr - Soleprint component manager
|
|
|
|
Manages distributable components from the spr repo into target folders.
|
|
Runs from spr/. Consuming projects have no awareness of spr — they just
|
|
commit whatever lands in the target folder.
|
|
|
|
Usage:
|
|
python ctrl/spr.py list
|
|
python ctrl/spr.py sync soleprint-ui ~/wdir/unt/ui/framework
|
|
python ctrl/spr.py watch soleprint-ui ~/wdir/unt/ui/framework # ctrl+c to stop
|
|
python ctrl/spr.py publish soleprint-ui ~/wdir/mpr/ui/framework
|
|
python ctrl/spr.py diff soleprint-ui ~/wdir/mpr/ui/framework
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import logging
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
log = logging.getLogger("spr")
|
|
|
|
SPR_HOME = Path(__file__).resolve().parent.parent
|
|
REGISTRY_PATH = SPR_HOME / "registry.json"
|
|
|
|
EXCLUDE = {
|
|
"node_modules",
|
|
"__pycache__",
|
|
".venv",
|
|
"dist",
|
|
".git",
|
|
"*.egg-info",
|
|
".spr",
|
|
"pnpm-lock.yaml",
|
|
}
|
|
|
|
|
|
def load_registry():
|
|
if not REGISTRY_PATH.exists():
|
|
log.error("registry not found: %s", REGISTRY_PATH)
|
|
sys.exit(1)
|
|
return json.loads(REGISTRY_PATH.read_text())
|
|
|
|
|
|
def resolve_component(registry, name):
|
|
if name not in registry:
|
|
log.error("unknown component: %s", name)
|
|
log.info("available: %s", ", ".join(registry.keys()))
|
|
sys.exit(1)
|
|
|
|
entry = registry[name]
|
|
source = SPR_HOME / entry["path"]
|
|
if not source.is_dir():
|
|
log.error("source not found: %s", source)
|
|
sys.exit(1)
|
|
|
|
return entry["type"], source
|
|
|
|
|
|
def get_version(comp_type, source):
|
|
try:
|
|
if comp_type == "npm":
|
|
pkg = json.loads((source / "package.json").read_text())
|
|
return pkg.get("version", "?")
|
|
elif comp_type == "pip":
|
|
for line in (source / "pyproject.toml").read_text().splitlines():
|
|
if line.strip().startswith("version"):
|
|
return line.split('"')[1]
|
|
except FileNotFoundError:
|
|
pass
|
|
return "?"
|
|
|
|
|
|
def get_sha():
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "rev-parse", "--short", "HEAD"],
|
|
cwd=SPR_HOME,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
return result.stdout.strip() if result.returncode == 0 else "unknown"
|
|
except FileNotFoundError:
|
|
return "unknown"
|
|
|
|
|
|
def should_exclude(path, base):
|
|
"""Check if a path should be excluded from sync."""
|
|
rel = path.relative_to(base)
|
|
for part in rel.parts:
|
|
if part in EXCLUDE:
|
|
return True
|
|
for pattern in EXCLUDE:
|
|
if "*" in pattern and part.endswith(pattern.replace("*", "")):
|
|
return True
|
|
return False
|
|
|
|
|
|
def copy_tree(src, dst):
|
|
"""Copy src to dst, excluding build artifacts. Returns count of files copied."""
|
|
count = 0
|
|
dst.mkdir(parents=True, exist_ok=True)
|
|
|
|
for item in src.iterdir():
|
|
if should_exclude(item, src.parent):
|
|
continue
|
|
|
|
target = dst / item.name
|
|
if item.is_dir():
|
|
count += copy_tree(item, target)
|
|
else:
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(item, target)
|
|
count += 1
|
|
|
|
return count
|
|
|
|
|
|
def sync_tree(src, dst):
|
|
"""Bidirectional sync — newest file wins. Returns (fwd, rev) counts."""
|
|
fwd = _sync_one_way(src, dst)
|
|
rev = _sync_one_way(dst, src)
|
|
return fwd, rev
|
|
|
|
|
|
def _sync_one_way(src, dst):
|
|
"""Copy files from src to dst only if src is newer."""
|
|
count = 0
|
|
if not src.is_dir():
|
|
return count
|
|
|
|
dst.mkdir(parents=True, exist_ok=True)
|
|
|
|
for item in src.iterdir():
|
|
if should_exclude(item, src.parent):
|
|
continue
|
|
|
|
target = dst / item.name
|
|
if item.is_dir():
|
|
count += _sync_one_way(item, target)
|
|
else:
|
|
if not target.exists() or item.stat().st_mtime > target.stat().st_mtime:
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(item, target)
|
|
count += 1
|
|
|
|
# Remove files in dst that don't exist in src
|
|
if dst.is_dir():
|
|
for item in dst.iterdir():
|
|
if should_exclude(item, dst.parent):
|
|
continue
|
|
counterpart = src / item.name
|
|
if not counterpart.exists():
|
|
if item.is_dir():
|
|
shutil.rmtree(item)
|
|
else:
|
|
item.unlink()
|
|
count += 1
|
|
|
|
return count
|
|
|
|
|
|
def write_stamp(dest, name, comp_type, source, mode):
|
|
version = get_version(comp_type, source)
|
|
sha = get_sha()
|
|
stamp = dest / ".spr"
|
|
stamp.write_text(
|
|
f"name={name}\n"
|
|
f"version={version}\n"
|
|
f"type={comp_type}\n"
|
|
f"sha={sha}\n"
|
|
f"mode={mode}\n"
|
|
f"updated={datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}\n"
|
|
f"source={source}\n"
|
|
)
|
|
|
|
|
|
# ── commands ─────────────────────────────────────────────────
|
|
|
|
|
|
def cmd_list(args):
|
|
registry = load_registry()
|
|
log.info("Components (%s):\n", REGISTRY_PATH)
|
|
for name, entry in registry.items():
|
|
comp_type = entry["type"]
|
|
source = SPR_HOME / entry["path"]
|
|
version = get_version(comp_type, source)
|
|
log.info(" %s %s v%-10s %s", f"{name:<25}", f"{comp_type:<5}", version, entry["path"])
|
|
|
|
|
|
def cmd_publish(args):
|
|
registry = load_registry()
|
|
comp_type, source = resolve_component(registry, args.component)
|
|
dest = Path(args.dest).resolve()
|
|
|
|
if dest.exists():
|
|
shutil.rmtree(dest)
|
|
|
|
count = copy_tree(source, dest)
|
|
write_stamp(dest, args.component, comp_type, source, "published")
|
|
|
|
version = get_version(comp_type, source)
|
|
sha = get_sha()
|
|
log.info("%s v%s (%s) -> %s (%d files)", args.component, version, sha, dest, count)
|
|
|
|
|
|
def cmd_sync(args):
|
|
registry = load_registry()
|
|
comp_type, source = resolve_component(registry, args.component)
|
|
dest = Path(args.dest).resolve()
|
|
dest.mkdir(parents=True, exist_ok=True)
|
|
|
|
fwd, rev = sync_tree(source, dest)
|
|
write_stamp(dest, args.component, comp_type, source, "synced")
|
|
|
|
log.info("%s synced (%d fwd, %d rev)", args.component, fwd, rev)
|
|
|
|
|
|
def cmd_watch(args):
|
|
registry = load_registry()
|
|
comp_type, source = resolve_component(registry, args.component)
|
|
dest = Path(args.dest).resolve()
|
|
dest.mkdir(parents=True, exist_ok=True)
|
|
interval = args.interval
|
|
|
|
# Initial sync
|
|
fwd, rev = sync_tree(source, dest)
|
|
write_stamp(dest, args.component, comp_type, source, "watching")
|
|
log.info("%s initial sync (%d fwd, %d rev)", args.component, fwd, rev)
|
|
log.info(" source: %s", source)
|
|
log.info(" dest: %s", dest)
|
|
log.info(" watching every %ds — ctrl+c to stop", interval)
|
|
|
|
try:
|
|
while True:
|
|
time.sleep(interval)
|
|
fwd, rev = sync_tree(source, dest)
|
|
if fwd or rev:
|
|
log.info("synced (%d fwd, %d rev)", fwd, rev)
|
|
except KeyboardInterrupt:
|
|
# Final sync
|
|
fwd, rev = sync_tree(source, dest)
|
|
if fwd or rev:
|
|
log.info("final sync (%d fwd, %d rev)", fwd, rev)
|
|
log.info("stopped")
|
|
|
|
|
|
def cmd_diff(args):
|
|
registry = load_registry()
|
|
_, source = resolve_component(registry, args.component)
|
|
dest = Path(args.dest).resolve()
|
|
|
|
if not dest.exists():
|
|
log.error("%s not found at %s", args.component, dest)
|
|
sys.exit(1)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
"diff",
|
|
"-rq",
|
|
"--exclude=node_modules",
|
|
"--exclude=__pycache__",
|
|
"--exclude=.venv",
|
|
"--exclude=dist",
|
|
"--exclude=*.egg-info",
|
|
"--exclude=.git",
|
|
"--exclude=.spr",
|
|
"--exclude=pnpm-lock.yaml",
|
|
str(source),
|
|
str(dest),
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if result.stdout:
|
|
log.info("\n%s", result.stdout.rstrip())
|
|
else:
|
|
log.info("%s is in sync", args.component)
|
|
|
|
|
|
# ── main ─────────────────────────────────────────────────────
|
|
|
|
|
|
def main():
|
|
logging.basicConfig(
|
|
level=logging.DEBUG if "--debug" in sys.argv else logging.INFO,
|
|
format="%(levelname)s %(message)s" if "--debug" in sys.argv else ":: %(message)s",
|
|
)
|
|
sys.argv = [a for a in sys.argv if a != "--debug"]
|
|
|
|
parser = argparse.ArgumentParser(
|
|
prog="spr",
|
|
description="Soleprint component manager",
|
|
)
|
|
sub = parser.add_subparsers(dest="command")
|
|
|
|
sub.add_parser("list", help="show available components")
|
|
|
|
for cmd in ("publish", "sync", "diff"):
|
|
p = sub.add_parser(cmd)
|
|
p.add_argument("component", help="component name")
|
|
p.add_argument("dest", help="target folder path")
|
|
|
|
p = sub.add_parser("watch", help="continuous two-way sync (foreground, ctrl+c to stop)")
|
|
p.add_argument("component", help="component name")
|
|
p.add_argument("dest", help="target folder path")
|
|
p.add_argument("-i", "--interval", type=int, default=2, help="poll interval in seconds (default: 2)")
|
|
|
|
args = parser.parse_args()
|
|
if not args.command:
|
|
parser.print_help()
|
|
sys.exit(0)
|
|
|
|
{
|
|
"list": cmd_list,
|
|
"publish": cmd_publish,
|
|
"sync": cmd_sync,
|
|
"watch": cmd_watch,
|
|
"diff": cmd_diff,
|
|
}[args.command](args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|