#!/usr/bin/env python3 """ Soleprint Build Tool Builds the soleprint instance using modelgen for model generation. Both dev and deploy modes copy files (no symlinks) for Docker compatibility. After editing source files, re-run `python build.py dev` to update gen/. Usage: python build.py dev # Build gen/ from source python build.py dev --cfg amar # Include amar room config python build.py deploy --output /path/ # Build for production python build.py models # Only regenerate models Examples: # Set up dev environment python build.py dev cd gen && .venv/bin/python run.py # With room config python build.py dev --cfg amar # Build for deployment python build.py deploy --output ../deploy/soleprint/ """ import argparse import logging import os import shutil import subprocess import sys from pathlib import Path # SPR root is where this script lives SPR_ROOT = Path(__file__).resolve().parent # Configure logging logging.basicConfig( level=logging.INFO, format="%(message)s", ) log = logging.getLogger(__name__) def ensure_dir(path: Path): """Create directory if it doesn't exist.""" path.mkdir(parents=True, exist_ok=True) def copy_path(source: Path, target: Path): """Copy file or directory, resolving symlinks.""" if target.is_symlink(): target.unlink() elif target.exists(): if target.is_dir(): shutil.rmtree(target) else: target.unlink() if source.is_dir(): shutil.copytree(source, target, symlinks=False) log.info(f" Copied: {target.name}/ ({count_files(target)} files)") else: shutil.copy2(source, target) log.info(f" Copied: {target.name}") def count_files(path: Path) -> int: """Count files in directory recursively.""" return sum(1 for _ in path.rglob("*") if _.is_file()) def generate_models(output_dir: Path): """Generate models using modelgen tool. Args: output_dir: Directory where models/pydantic/__init__.py will be created """ config_path = SPR_ROOT / "cfg" / "soleprint.config.json" if not config_path.exists(): log.warning(f"Config not found at {config_path}") return False # Soleprint-specific: models go in models/pydantic/__init__.py models_file = output_dir / "models" / "pydantic" / "__init__.py" models_file.parent.mkdir(parents=True, exist_ok=True) # Run modelgen as subprocess cmd = [ sys.executable, "-m", "station.tools.modelgen", "from-config", "--config", str(config_path), "--output", str(models_file), "--format", "pydantic", ] result = subprocess.run(cmd, cwd=SPR_ROOT) return result.returncode == 0 def copy_cfg(output_dir: Path, cfg_name: str | None): """Copy configuration files to output directory. Args: output_dir: Target directory cfg_name: Name of room config (e.g., 'amar'), or None for base only """ cfg_dir = output_dir / "cfg" ensure_dir(cfg_dir) # Always copy base config base_config = SPR_ROOT / "cfg" / "soleprint.config.json" if base_config.exists(): copy_path(base_config, cfg_dir / "soleprint.config.json") # Copy room-specific config if specified if cfg_name: room_cfg = SPR_ROOT / "cfg" / cfg_name if room_cfg.exists() and room_cfg.is_dir(): log.info(f"\nCopying {cfg_name} room config...") for item in room_cfg.iterdir(): if item.name == ".env.example": # Copy .env.example to output root as template copy_path(item, output_dir / ".env.example") elif item.is_dir(): copy_path(item, cfg_dir / cfg_name / item.name) else: ensure_dir(cfg_dir / cfg_name) copy_path(item, cfg_dir / cfg_name / item.name) # Copy room-specific databrowse depot if exists room_databrowse = room_cfg / "databrowse" / "depot" if room_databrowse.exists(): log.info(f" Copying {cfg_name} databrowse depot...") target = output_dir / "station" / "monitors" / "databrowse" / "depot" copy_path(room_databrowse, target) else: log.warning(f"Room config '{cfg_name}' not found at {room_cfg}") def build_dev(output_dir: Path, cfg_name: str | None = None): """ Build for development using copies (Docker-compatible). Structure: gen/ ├── main.py ├── run.py ├── index.html ├── requirements.txt ├── Dockerfile ├── dataloader/ ├── artery/ ├── atlas/ ├── station/ ├── data/ ├── cfg/ ├── .env.example # From cfg//.env.example └── models/ # Generated After editing source files, re-run `python build.py dev` to update gen/. """ log.info("\n=== Building DEV environment ===") log.info(f"SPR root: {SPR_ROOT}") log.info(f"Output: {output_dir}") if cfg_name: log.info(f"Room cfg: {cfg_name}") ensure_dir(output_dir) # Soleprint core files log.info("\nCopying soleprint files...") soleprint = SPR_ROOT / "soleprint" copy_path(soleprint / "main.py", output_dir / "main.py") copy_path(soleprint / "run.py", output_dir / "run.py") copy_path(soleprint / "index.html", output_dir / "index.html") copy_path(soleprint / "requirements.txt", output_dir / "requirements.txt") copy_path(soleprint / "dataloader", output_dir / "dataloader") if (soleprint / "Dockerfile").exists(): copy_path(soleprint / "Dockerfile", output_dir / "Dockerfile") # System directories log.info("\nCopying systems...") for system in ["artery", "atlas", "station"]: source = SPR_ROOT / system if source.exists(): copy_path(source, output_dir / system) # Data directory log.info("\nCopying data...") copy_path(SPR_ROOT / "data", output_dir / "data") # Config log.info("\nCopying config...") copy_cfg(output_dir, cfg_name) # Models (generated) log.info("\nGenerating models...") if not generate_models(output_dir): log.warning("Model generation failed, you may need to run it manually") log.info("\n✓ Dev build complete!") log.info(f"\nTo run:") log.info(f" cd {output_dir}") log.info(f" python3 -m venv .venv") log.info(f" .venv/bin/pip install -r requirements.txt") log.info(f" .venv/bin/python run.py # Single-port bare-metal dev") log.info(f"\nAfter editing source, rebuild with: python build.py dev") def build_deploy(output_dir: Path, cfg_name: str | None = None): """ Build for deployment by copying all files (no symlinks). """ log.info("\n=== Building DEPLOY package ===") log.info(f"SPR root: {SPR_ROOT}") log.info(f"Output: {output_dir}") if cfg_name: log.info(f"Room cfg: {cfg_name}") if output_dir.exists(): response = input(f"\nOutput directory exists. Overwrite? [y/N] ") if response.lower() != "y": log.info("Aborted.") return shutil.rmtree(output_dir) ensure_dir(output_dir) # Soleprint core files (copy) log.info("\nCopying soleprint files...") soleprint = SPR_ROOT / "soleprint" copy_path(soleprint / "main.py", output_dir / "main.py") copy_path(soleprint / "run.py", output_dir / "run.py") copy_path(soleprint / "index.html", output_dir / "index.html") copy_path(soleprint / "requirements.txt", output_dir / "requirements.txt") copy_path(soleprint / "dataloader", output_dir / "dataloader") if (soleprint / "Dockerfile").exists(): copy_path(soleprint / "Dockerfile", output_dir / "Dockerfile") # System directories (copy) log.info("\nCopying systems...") for system in ["artery", "atlas", "station"]: source = SPR_ROOT / system if source.exists(): copy_path(source, output_dir / system) # Data directory (copy) log.info("\nCopying data...") copy_path(SPR_ROOT / "data", output_dir / "data") # Config (copy) log.info("\nCopying config...") copy_cfg(output_dir, cfg_name) # Models (generate fresh) - pass output_dir, modelgen adds models/pydantic log.info("\nGenerating models...") if not generate_models(output_dir): # Fallback: copy from gen if exists existing = SPR_ROOT / "gen" / "models" if existing.exists(): log.info(" Using existing models from gen/") copy_path(existing, output_dir / "models") # Copy schema.json for reference log.info("\nCopying schema...") copy_path(SPR_ROOT / "schema.json", output_dir / "schema.json") # Create run script run_script = output_dir / "start.sh" run_script.write_text("""#!/bin/bash # Soleprint runner cd "$(dirname "$0")" if [ ! -d ".venv" ]; then echo "Creating virtual environment..." python3 -m venv .venv .venv/bin/pip install -r requirements.txt fi echo "Starting soleprint on http://localhost:12000" .venv/bin/python main.py """) run_script.chmod(0o755) log.info(" Created: start.sh") total_files = count_files(output_dir) log.info(f"\n✓ Deploy build complete! ({total_files} files)") log.info(f"\nTo run:") log.info(f" cd {output_dir}") log.info(f" ./start.sh") log.info(f"\nOr deploy to server:") log.info(f" rsync -av {output_dir}/ server:/app/soleprint/") log.info(f" ssh server 'cd /app/soleprint && ./start.sh'") def build_models(): """Only regenerate models.""" log.info("\n=== Generating models only ===") output_dir = SPR_ROOT / "gen" ensure_dir(output_dir) if generate_models(output_dir): log.info("\n✓ Models generated!") else: log.error("Model generation failed") sys.exit(1) def main(): parser = argparse.ArgumentParser( description="Soleprint Build Tool", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__, ) subparsers = parser.add_subparsers(dest="command", required=True) # dev command dev_parser = subparsers.add_parser("dev", help="Build for development (copies)") dev_parser.add_argument( "--output", "-o", type=Path, default=SPR_ROOT / "gen", help="Output directory (default: gen/)", ) dev_parser.add_argument( "--cfg", "-c", type=str, default=None, help="Room config to include (e.g., 'amar')", ) # deploy command deploy_parser = subparsers.add_parser( "deploy", help="Build for deployment (copies)" ) deploy_parser.add_argument( "--output", "-o", type=Path, required=True, help="Output directory for deployment package", ) deploy_parser.add_argument( "--cfg", "-c", type=str, default=None, help="Room config to include (e.g., 'amar')", ) # models command subparsers.add_parser("models", help="Only regenerate models") args = parser.parse_args() if args.command == "dev": build_dev(args.output.resolve(), args.cfg) elif args.command == "deploy": build_deploy(args.output.resolve(), args.cfg) elif args.command == "models": build_models() if __name__ == "__main__": main()