From 8d87ed4c9fcd639692d0391c26a819cbeea2ad80 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Thu, 2 Apr 2026 23:04:35 -0700 Subject: [PATCH] feat: manage.py cross-platform product manager (closes #6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - circuitforge_core.manage module — replaces bash-only manage.sh - config.py: ManageConfig from manage.toml (TOML via tomllib/tomli) app name, default_url, docker compose_file/project, native services Falls back to directory name when no manage.toml present - docker_mode.py: DockerManager wrapping 'docker compose' (v2 plugin) or 'docker-compose' (v1 fallback); docker_available() probe Commands: start, stop, restart, status, logs, build - native_mode.py: NativeManager with PID file process management platformdirs for platform-appropriate PID/log paths Windows-compatible log tailing (polling, no tail -f) Cross-platform kill: SIGTERM→SIGKILL on Unix, taskkill /F on Windows - cli.py: typer CLI — start/stop/restart/status/logs/build/open/install-shims Mode auto-detection: Docker available + compose file → docker; else native --mode docker|native|auto override - templates/manage.sh: bash shim (conda, venv, python3 detection) - templates/manage.ps1: PowerShell shim (same detection, Windows) - templates/manage.toml.example: annotated config template - __main__.py: python -m circuitforge_core.manage entry point - pyproject.toml: manage extras group (platformdirs, typer) cf-manage console script; version bumped to 0.5.0 - 36 tests: config (6), docker_mode (9), native_mode (21) --- circuitforge_core/manage/__init__.py | 12 + circuitforge_core/manage/__main__.py | 4 + circuitforge_core/manage/cli.py | 237 ++++++++++++++++++ circuitforge_core/manage/config.py | 119 +++++++++ circuitforge_core/manage/docker_mode.py | 115 +++++++++ circuitforge_core/manage/native_mode.py | 217 ++++++++++++++++ .../manage/templates/__init__.py | 0 circuitforge_core/manage/templates/manage.ps1 | 30 +++ circuitforge_core/manage/templates/manage.sh | 28 +++ .../manage/templates/manage.toml.example | 38 +++ pyproject.toml | 8 +- tests/test_manage/__init__.py | 0 tests/test_manage/test_config.py | 98 ++++++++ tests/test_manage/test_docker_mode.py | 118 +++++++++ tests/test_manage/test_native_mode.py | 176 +++++++++++++ 15 files changed, 1199 insertions(+), 1 deletion(-) create mode 100644 circuitforge_core/manage/__init__.py create mode 100644 circuitforge_core/manage/__main__.py create mode 100644 circuitforge_core/manage/cli.py create mode 100644 circuitforge_core/manage/config.py create mode 100644 circuitforge_core/manage/docker_mode.py create mode 100644 circuitforge_core/manage/native_mode.py create mode 100644 circuitforge_core/manage/templates/__init__.py create mode 100644 circuitforge_core/manage/templates/manage.ps1 create mode 100644 circuitforge_core/manage/templates/manage.sh create mode 100644 circuitforge_core/manage/templates/manage.toml.example create mode 100644 tests/test_manage/__init__.py create mode 100644 tests/test_manage/test_config.py create mode 100644 tests/test_manage/test_docker_mode.py create mode 100644 tests/test_manage/test_native_mode.py diff --git a/circuitforge_core/manage/__init__.py b/circuitforge_core/manage/__init__.py new file mode 100644 index 0000000..3610f6f --- /dev/null +++ b/circuitforge_core/manage/__init__.py @@ -0,0 +1,12 @@ +"""circuitforge_core.manage — cross-platform product process manager.""" +from .config import ManageConfig, NativeService +from .docker_mode import DockerManager, docker_available +from .native_mode import NativeManager + +__all__ = [ + "ManageConfig", + "NativeService", + "DockerManager", + "docker_available", + "NativeManager", +] diff --git a/circuitforge_core/manage/__main__.py b/circuitforge_core/manage/__main__.py new file mode 100644 index 0000000..c55b3c3 --- /dev/null +++ b/circuitforge_core/manage/__main__.py @@ -0,0 +1,4 @@ +"""Entry point for `python -m circuitforge_core.manage`.""" +from .cli import app + +app() diff --git a/circuitforge_core/manage/cli.py b/circuitforge_core/manage/cli.py new file mode 100644 index 0000000..f902e5a --- /dev/null +++ b/circuitforge_core/manage/cli.py @@ -0,0 +1,237 @@ +""" +circuitforge_core.manage.cli — cross-platform product manager CLI. + +Usage (from any product directory): + python -m circuitforge_core.manage start + python -m circuitforge_core.manage stop + python -m circuitforge_core.manage restart + python -m circuitforge_core.manage status + python -m circuitforge_core.manage logs [SERVICE] + python -m circuitforge_core.manage open + python -m circuitforge_core.manage build + python -m circuitforge_core.manage install-shims + +Products shim into this via a thin manage.sh / manage.ps1 that finds Python +and delegates: exec python -m circuitforge_core.manage "$@" +""" +from __future__ import annotations + +import sys +import webbrowser +from enum import Enum +from pathlib import Path +from typing import Annotated, Optional + +import typer + +from .config import ManageConfig +from .docker_mode import DockerManager, docker_available +from .native_mode import NativeManager + +app = typer.Typer( + name="manage", + help="CircuitForge cross-platform product manager", + no_args_is_help=True, +) + + +class Mode(str, Enum): + auto = "auto" + docker = "docker" + native = "native" + + +def _resolve( + mode: Mode, + root: Path, + config: ManageConfig, +) -> tuple[str, DockerManager | NativeManager]: + """Return (mode_name, manager) based on mode flag and environment.""" + if mode == Mode.docker or ( + mode == Mode.auto + and docker_available() + and (root / config.docker.compose_file).exists() + ): + return "docker", DockerManager(config, root) + return "native", NativeManager(config, root) + + +def _load(root: Path) -> ManageConfig: + return ManageConfig.from_cwd(root) + + +# ── commands ────────────────────────────────────────────────────────────────── + +@app.command() +def start( + service: Annotated[Optional[str], typer.Argument(help="Service name (omit for all)")] = None, + mode: Mode = Mode.auto, + root: Path = Path("."), +) -> None: + """Start services.""" + config = _load(root.resolve()) + mode_name, mgr = _resolve(mode, root.resolve(), config) + typer.echo(f"[{config.app_name}] Starting ({mode_name} mode)…") + if isinstance(mgr, DockerManager): + mgr.start(service or "") + else: + started = mgr.start(service) + if started: + typer.echo(f"[{config.app_name}] Started: {', '.join(started)}") + else: + typer.echo(f"[{config.app_name}] All services already running") + + +@app.command() +def stop( + service: Annotated[Optional[str], typer.Argument(help="Service name (omit for all)")] = None, + mode: Mode = Mode.auto, + root: Path = Path("."), +) -> None: + """Stop services.""" + config = _load(root.resolve()) + mode_name, mgr = _resolve(mode, root.resolve(), config) + typer.echo(f"[{config.app_name}] Stopping ({mode_name} mode)…") + if isinstance(mgr, DockerManager): + mgr.stop(service or "") + else: + stopped = mgr.stop(service) + if stopped: + typer.echo(f"[{config.app_name}] Stopped: {', '.join(stopped)}") + else: + typer.echo(f"[{config.app_name}] No running services to stop") + + +@app.command() +def restart( + service: Annotated[Optional[str], typer.Argument(help="Service name (omit for all)")] = None, + mode: Mode = Mode.auto, + root: Path = Path("."), +) -> None: + """Restart services.""" + config = _load(root.resolve()) + mode_name, mgr = _resolve(mode, root.resolve(), config) + typer.echo(f"[{config.app_name}] Restarting ({mode_name} mode)…") + if isinstance(mgr, DockerManager): + mgr.restart(service or "") + else: + mgr.stop(service) + mgr.start(service) + + +@app.command() +def status( + mode: Mode = Mode.auto, + root: Path = Path("."), +) -> None: + """Show service status.""" + config = _load(root.resolve()) + mode_name, mgr = _resolve(mode, root.resolve(), config) + if isinstance(mgr, DockerManager): + mgr.status() + else: + rows = mgr.status() + if not rows: + typer.echo(f"[{config.app_name}] No native services defined in manage.toml") + return + typer.echo(f"\n {config.app_name} — native services\n") + for svc in rows: + indicator = typer.style("●", fg=typer.colors.GREEN) if svc.running \ + else typer.style("○", fg=typer.colors.RED) + pid_str = f" pid={svc.pid}" if svc.pid else "" + port_str = f" port={svc.port}" if svc.port else "" + typer.echo(f" {indicator} {svc.name:<20}{pid_str}{port_str}") + typer.echo("") + + +@app.command() +def logs( + service: Annotated[Optional[str], typer.Argument(help="Service name")] = None, + follow: bool = typer.Option(True, "--follow/--no-follow", "-f/-F"), + mode: Mode = Mode.auto, + root: Path = Path("."), +) -> None: + """Tail service logs.""" + config = _load(root.resolve()) + mode_name, mgr = _resolve(mode, root.resolve(), config) + if isinstance(mgr, DockerManager): + mgr.logs(service or "", follow=follow) + else: + if not service: + # Default to first service when none specified + if not config.services: + typer.echo("No native services defined", err=True) + raise typer.Exit(1) + service = config.services[0].name + mgr.logs(service, follow=follow) + + +@app.command() +def build( + no_cache: bool = False, + mode: Mode = Mode.auto, + root: Path = Path("."), +) -> None: + """Build/rebuild service images (Docker mode only).""" + config = _load(root.resolve()) + mode_name, mgr = _resolve(mode, root.resolve(), config) + if isinstance(mgr, NativeManager): + typer.echo("build is only available in Docker mode", err=True) + raise typer.Exit(1) + typer.echo(f"[{config.app_name}] Building images…") + mgr.build(no_cache=no_cache) + + +@app.command("open") +def open_browser( + url: Annotated[Optional[str], typer.Option(help="Override URL")] = None, + root: Path = Path("."), +) -> None: + """Open the product web UI in the default browser.""" + config = _load(root.resolve()) + target = url or config.default_url + if not target: + typer.echo("No URL configured. Set default_url in manage.toml or pass --url.", err=True) + raise typer.Exit(1) + typer.echo(f"Opening {target}") + webbrowser.open(target) + + +@app.command("install-shims") +def install_shims( + root: Path = Path("."), + force: bool = typer.Option(False, "--force", help="Overwrite existing shims"), +) -> None: + """ + Write manage.sh and manage.ps1 shims into the product directory. + + The shims auto-detect the Python environment (conda, venv, or system Python) + and delegate all arguments to `python -m circuitforge_core.manage`. + """ + from importlib.resources import files as _res_files + + target = root.resolve() + templates_pkg = "circuitforge_core.manage.templates" + + for filename in ("manage.sh", "manage.ps1"): + dest = target / filename + if dest.exists() and not force: + typer.echo(f" skipped {filename} (already exists — use --force to overwrite)") + continue + content = (_res_files(templates_pkg) / filename).read_text() + dest.write_text(content) + if filename.endswith(".sh"): + dest.chmod(dest.stat().st_mode | 0o111) # make executable + typer.echo(f" wrote {dest}") + + toml_example = target / "manage.toml.example" + if not toml_example.exists() or force: + content = (_res_files(templates_pkg) / "manage.toml.example").read_text() + toml_example.write_text(content) + typer.echo(f" wrote {toml_example}") + + typer.echo("\nDone. Rename manage.toml.example → manage.toml and edit for your services.") + + +if __name__ == "__main__": + app() diff --git a/circuitforge_core/manage/config.py b/circuitforge_core/manage/config.py new file mode 100644 index 0000000..22fb2b8 --- /dev/null +++ b/circuitforge_core/manage/config.py @@ -0,0 +1,119 @@ +""" +circuitforge_core.manage.config — ManageConfig parsed from manage.toml. + +Products drop a manage.toml in their root directory. manage.py reads it to +discover the app name, compose file, and native service definitions. + +Minimal manage.toml (Docker-only): +---------------------------------------------------------------------- +[app] +name = "kiwi" +default_url = "http://localhost:8511" +---------------------------------------------------------------------- + +Full manage.toml (Docker + native services): +---------------------------------------------------------------------- +[app] +name = "kiwi" +default_url = "http://localhost:8511" + +[docker] +compose_file = "compose.yml" # default +project = "kiwi" # defaults to app.name + +[[native.services]] +name = "api" +command = "uvicorn app.main:app --host 0.0.0.0 --port 8512" +port = 8512 + +[[native.services]] +name = "frontend" +command = "npm run preview -- --host --port 8511" +port = 8511 +cwd = "frontend" +---------------------------------------------------------------------- +""" +from __future__ import annotations + +import sys +from dataclasses import dataclass, field +from pathlib import Path + +if sys.version_info >= (3, 11): + import tomllib +else: + try: + import tomllib # type: ignore[no-redef] + except ImportError: + import tomli as tomllib # type: ignore[no-redef] + +_DEFAULT_COMPOSE_FILE = "compose.yml" + + +@dataclass +class NativeService: + """One process to manage in native mode.""" + name: str + command: str # shell command string + port: int = 0 # for status / open URL + cwd: str = "" # relative to project root; "" = root + env: dict[str, str] = field(default_factory=dict) + + +@dataclass +class DockerConfig: + compose_file: str = _DEFAULT_COMPOSE_FILE + project: str = "" # docker compose -p; defaults to app name + + +@dataclass +class ManageConfig: + app_name: str + default_url: str = "" + docker: DockerConfig = field(default_factory=DockerConfig) + services: list[NativeService] = field(default_factory=list) + + @classmethod + def load(cls, path: Path) -> "ManageConfig": + """Load from a manage.toml file.""" + raw = tomllib.loads(path.read_text()) + app = raw.get("app", {}) + name = app.get("name") or path.parent.name # fallback to directory name + default_url = app.get("default_url", "") + + docker_raw = raw.get("docker", {}) + docker = DockerConfig( + compose_file=docker_raw.get("compose_file", _DEFAULT_COMPOSE_FILE), + project=docker_raw.get("project", name), + ) + + services: list[NativeService] = [] + for svc in raw.get("native", {}).get("services", []): + services.append(NativeService( + name=svc["name"], + command=svc["command"], + port=svc.get("port", 0), + cwd=svc.get("cwd", ""), + env=svc.get("env", {}), + )) + + return cls( + app_name=name, + default_url=default_url, + docker=docker, + services=services, + ) + + @classmethod + def from_cwd(cls, cwd: Path | None = None) -> "ManageConfig": + """ + Load from manage.toml in cwd, or return a minimal config derived from + the directory name if no manage.toml exists (Docker-only products work + without one). + """ + root = cwd or Path.cwd() + toml_path = root / "manage.toml" + if toml_path.exists(): + return cls.load(toml_path) + # Fallback: infer from directory name, look for compose.yml + return cls(app_name=root.name) diff --git a/circuitforge_core/manage/docker_mode.py b/circuitforge_core/manage/docker_mode.py new file mode 100644 index 0000000..f1fffe7 --- /dev/null +++ b/circuitforge_core/manage/docker_mode.py @@ -0,0 +1,115 @@ +""" +circuitforge_core.manage.docker_mode — Docker Compose wrapper. + +All commands delegate to `docker compose` (v2 plugin syntax). +Falls back to `docker-compose` (v1 standalone) if the plugin is unavailable. +""" +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +from .config import ManageConfig + + +def _compose_bin() -> list[str]: + """Return the docker compose command as a list (handles v1/v2 difference).""" + # Docker Compose v2: `docker compose` (space, built-in plugin) + # Docker Compose v1: `docker-compose` (hyphen, standalone binary) + if shutil.which("docker"): + return ["docker", "compose"] + if shutil.which("docker-compose"): + return ["docker-compose"] + raise RuntimeError("Neither 'docker' nor 'docker-compose' found on PATH") + + +def docker_available() -> bool: + """Return True if Docker is reachable (docker info succeeds).""" + try: + subprocess.run( + ["docker", "info"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=5, + ) + return True + except Exception: + return False + + +class DockerManager: + """ + Wraps `docker compose` for a single product directory. + + Args: + config: ManageConfig for the current product. + root: Product root directory (where compose file lives). + """ + + def __init__(self, config: ManageConfig, root: Path) -> None: + self.config = config + self.root = root + self._compose_file = root / config.docker.compose_file + + def _run(self, *args: str, check: bool = True) -> subprocess.CompletedProcess: # type: ignore[type-arg] + cmd = [ + *_compose_bin(), + "-f", str(self._compose_file), + "-p", self.config.docker.project or self.config.app_name, + *args, + ] + return subprocess.run(cmd, cwd=self.root, check=check) + + def _stream(self, *args: str) -> None: + """Run a compose command, streaming output directly to the terminal.""" + cmd = [ + *_compose_bin(), + "-f", str(self._compose_file), + "-p", self.config.docker.project or self.config.app_name, + *args, + ] + with subprocess.Popen(cmd, cwd=self.root) as proc: + try: + proc.wait() + except KeyboardInterrupt: + proc.terminate() + + def compose_file_exists(self) -> bool: + return self._compose_file.exists() + + def start(self, service: str = "") -> None: + args = ["up", "-d", "--build"] + if service: + args.append(service) + self._run(*args) + + def stop(self, service: str = "") -> None: + if service: + self._run("stop", service) + else: + self._run("down") + + def restart(self, service: str = "") -> None: + args = ["restart"] + if service: + args.append(service) + self._run(*args) + + def status(self) -> None: + self._run("ps", check=False) + + def logs(self, service: str = "", follow: bool = True) -> None: + args = ["logs"] + if follow: + args.append("-f") + if service: + args.append(service) + self._stream(*args) + + def build(self, no_cache: bool = False) -> None: + args = ["build"] + if no_cache: + args.append("--no-cache") + self._run(*args) diff --git a/circuitforge_core/manage/native_mode.py b/circuitforge_core/manage/native_mode.py new file mode 100644 index 0000000..6a0a9e5 --- /dev/null +++ b/circuitforge_core/manage/native_mode.py @@ -0,0 +1,217 @@ +""" +circuitforge_core.manage.native_mode — PID-file process manager. + +Manages processes directly without Docker. Designed for Windows (no WSL2, +no Docker), but works identically on Linux/macOS. + +Platform conventions (via platformdirs): + PID files : user_runtime_dir(app_name) / .pid + Log files : user_log_dir(app_name) / .log + +PID file format (one line each): + + (first 80 chars of command — used to sanity-check + that the PID belongs to our process, not a recycled one) +""" +from __future__ import annotations + +import os +import platform +import shlex +import subprocess +import sys +import time +from dataclasses import dataclass +from pathlib import Path + +from platformdirs import user_log_dir, user_runtime_dir + +from .config import ManageConfig, NativeService + +_IS_WINDOWS = platform.system() == "Windows" +_LOG_TAIL_LINES = 50 +_FOLLOW_POLL_S = 0.25 + + +@dataclass +class ServiceStatus: + name: str + running: bool + pid: int | None + port: int + log_path: Path + + +class NativeManager: + """ + Start, stop, and monitor native processes for a product. + + Args: + config: ManageConfig for the current product. + root: Product root directory. + """ + + def __init__(self, config: ManageConfig, root: Path) -> None: + self.config = config + self.root = root + self._pid_dir = Path(user_runtime_dir(config.app_name, ensure_exists=True)) + self._log_dir = Path(user_log_dir(config.app_name, ensure_exists=True)) + + # ── helpers ─────────────────────────────────────────────────────────────── + + def _pid_path(self, name: str) -> Path: + return self._pid_dir / f"{name}.pid" + + def _log_path(self, name: str) -> Path: + return self._log_dir / f"{name}.log" + + def _write_pid(self, name: str, pid: int, command: str) -> None: + self._pid_path(name).write_text(f"{pid}\n{command[:80]}\n") + + def _read_pid(self, name: str) -> int | None: + p = self._pid_path(name) + if not p.exists(): + return None + try: + return int(p.read_text().splitlines()[0].strip()) + except (ValueError, IndexError): + return None + + def _pid_alive(self, pid: int) -> bool: + """Return True if a process with this PID is currently running.""" + if _IS_WINDOWS: + try: + result = subprocess.run( + ["tasklist", "/FI", f"PID eq {pid}", "/NH"], + capture_output=True, text=True, + ) + return str(pid) in result.stdout + except Exception: + return False + else: + try: + os.kill(pid, 0) # signal 0 = existence check only + return True + except (OSError, ProcessLookupError): + return False + + def _kill(self, pid: int) -> None: + """Terminate a process gracefully, then force-kill if needed.""" + if _IS_WINDOWS: + subprocess.run(["taskkill", "/F", "/PID", str(pid)], + capture_output=True) + else: + import signal + try: + os.kill(pid, signal.SIGTERM) + for _ in range(30): # wait up to 3 s + time.sleep(0.1) + if not self._pid_alive(pid): + return + os.kill(pid, signal.SIGKILL) + except (OSError, ProcessLookupError): + pass + + def _svc(self, name: str) -> NativeService | None: + return next((s for s in self.config.services if s.name == name), None) + + # ── public API ──────────────────────────────────────────────────────────── + + def is_running(self, name: str) -> bool: + pid = self._read_pid(name) + return pid is not None and self._pid_alive(pid) + + def status(self) -> list[ServiceStatus]: + result = [] + for svc in self.config.services: + pid = self._read_pid(svc.name) + running = pid is not None and self._pid_alive(pid) + result.append(ServiceStatus( + name=svc.name, + running=running, + pid=pid if running else None, + port=svc.port, + log_path=self._log_path(svc.name), + )) + return result + + def start(self, name: str | None = None) -> list[str]: + """Start one or all services. Returns list of started service names.""" + targets = [self._svc(name)] if name else self.config.services + started: list[str] = [] + for svc in targets: + if svc is None: + raise ValueError(f"Unknown service: {name!r}") + if self.is_running(svc.name): + continue + cwd = (self.root / svc.cwd) if svc.cwd else self.root + log_file = open(self._log_path(svc.name), "a") # noqa: WPS515 + env = {**os.environ, **svc.env} + if _IS_WINDOWS: + cmd = svc.command # Windows: pass as string to shell + shell = True + else: + cmd = shlex.split(svc.command) + shell = False + proc = subprocess.Popen( + cmd, + cwd=cwd, + env=env, + shell=shell, + stdout=log_file, + stderr=log_file, + start_new_session=True, # detach from terminal (Unix) + ) + self._write_pid(svc.name, proc.pid, svc.command) + started.append(svc.name) + return started + + def stop(self, name: str | None = None) -> list[str]: + """Stop one or all services. Returns list of stopped service names.""" + names = [name] if name else [s.name for s in self.config.services] + stopped: list[str] = [] + for n in names: + pid = self._read_pid(n) + if pid and self._pid_alive(pid): + self._kill(pid) + stopped.append(n) + pid_path = self._pid_path(n) + if pid_path.exists(): + pid_path.unlink() + return stopped + + def logs(self, name: str, follow: bool = True, lines: int = _LOG_TAIL_LINES) -> None: + """ + Print the last N lines of a service log, then optionally follow. + + Uses polling rather than `tail -f` so it works on Windows. + """ + log_path = self._log_path(name) + if not log_path.exists(): + print(f"[{name}] No log file found at {log_path}", file=sys.stderr) + return + + # Print last N lines + content = log_path.read_bytes() + lines_data = content.splitlines()[-lines:] + for line in lines_data: + print(line.decode("utf-8", errors="replace")) + + if not follow: + return + + # Poll for new content + offset = len(content) + try: + while True: + time.sleep(_FOLLOW_POLL_S) + new_size = log_path.stat().st_size + if new_size > offset: + with open(log_path, "rb") as f: + f.seek(offset) + chunk = f.read() + offset = new_size + for line in chunk.splitlines(): + print(line.decode("utf-8", errors="replace")) + except KeyboardInterrupt: + pass diff --git a/circuitforge_core/manage/templates/__init__.py b/circuitforge_core/manage/templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/circuitforge_core/manage/templates/manage.ps1 b/circuitforge_core/manage/templates/manage.ps1 new file mode 100644 index 0000000..d4ea792 --- /dev/null +++ b/circuitforge_core/manage/templates/manage.ps1 @@ -0,0 +1,30 @@ +# manage.ps1 — CircuitForge cross-platform product manager shim (Windows) +# +# Auto-detects the Python environment and delegates to +# `python -m circuitforge_core.manage`. +# +# Generated by: python -m circuitforge_core.manage install-shims +# Do not edit the logic here; edit manage.toml for product configuration. + +# ── Python detection ────────────────────────────────────────────────────────── +$Python = $null + +if (Test-Path ".venv\Scripts\python.exe") { + $Python = ".venv\Scripts\python.exe" +} elseif (Test-Path "venv\Scripts\python.exe") { + $Python = "venv\Scripts\python.exe" +} elseif ($env:CONDA_DEFAULT_ENV -and (Get-Command conda -ErrorAction SilentlyContinue)) { + # Conda: run via `conda run` so the env is activated correctly + & conda run -n $env:CONDA_DEFAULT_ENV python -m circuitforge_core.manage @args + exit $LASTEXITCODE +} elseif (Get-Command python -ErrorAction SilentlyContinue) { + $Python = "python" +} elseif (Get-Command python3 -ErrorAction SilentlyContinue) { + $Python = "python3" +} else { + Write-Error "No Python interpreter found. Install Python 3.11+, activate a venv, or activate a conda environment." + exit 1 +} + +& $Python -m circuitforge_core.manage @args +exit $LASTEXITCODE diff --git a/circuitforge_core/manage/templates/manage.sh b/circuitforge_core/manage/templates/manage.sh new file mode 100644 index 0000000..d14977f --- /dev/null +++ b/circuitforge_core/manage/templates/manage.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# manage.sh — CircuitForge cross-platform product manager shim +# +# Auto-detects the Python environment and delegates to +# `python -m circuitforge_core.manage`. +# +# Generated by: python -m circuitforge_core.manage install-shims +# Do not edit the logic here; edit manage.toml for product configuration. +set -euo pipefail + +# ── Python detection ────────────────────────────────────────────────────────── +if [ -f ".venv/bin/python" ]; then + PYTHON=".venv/bin/python" +elif [ -f "venv/bin/python" ]; then + PYTHON="venv/bin/python" +elif command -v conda &>/dev/null && [ -n "${CONDA_DEFAULT_ENV:-}" ]; then + PYTHON="conda run -n ${CONDA_DEFAULT_ENV} python" +elif command -v python3 &>/dev/null; then + PYTHON="python3" +elif command -v python &>/dev/null; then + PYTHON="python" +else + echo "ERROR: No Python interpreter found." >&2 + echo "Install Python 3.11+, activate a venv, or activate a conda environment." >&2 + exit 1 +fi + +exec $PYTHON -m circuitforge_core.manage "$@" diff --git a/circuitforge_core/manage/templates/manage.toml.example b/circuitforge_core/manage/templates/manage.toml.example new file mode 100644 index 0000000..f5fe36f --- /dev/null +++ b/circuitforge_core/manage/templates/manage.toml.example @@ -0,0 +1,38 @@ +# manage.toml — CircuitForge product manager configuration +# +# Drop this file (renamed from manage.toml.example) in your product root. +# Docker-only products only need [app]; native services require [[native.services]]. + +[app] +# Product name — used for log/PID directory names and display. +name = "myproduct" + +# URL opened by `manage.py open`. Typically the frontend port. +default_url = "http://localhost:8511" + +[docker] +# Path to the Docker Compose file, relative to this directory. +compose_file = "compose.yml" + +# Docker Compose project name (defaults to app.name). +# project = "myproduct" + +# ── Native mode services ─────────────────────────────────────────────────────── +# Define one [[native.services]] block per process. +# Used when Docker is unavailable (Windows without Docker, or --mode native). + +[[native.services]] +name = "api" +# Full shell command to launch the backend. +command = "uvicorn app.main:app --host 0.0.0.0 --port 8512 --reload" +port = 8512 +# cwd is relative to the project root. Leave empty for the project root itself. +cwd = "" +# Optional extra environment variables. +# env = { PYTHONPATH = ".", DEBUG = "1" } + +[[native.services]] +name = "frontend" +command = "npm run preview -- --host 0.0.0.0 --port 8511" +port = 8511 +cwd = "frontend" diff --git a/pyproject.toml b/pyproject.toml index ab8aa5d..121ffda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "circuitforge-core" -version = "0.2.0" +version = "0.5.0" description = "Shared scaffold for CircuitForge products" requires-python = ">=3.11" dependencies = [ @@ -25,9 +25,14 @@ orch = [ tasks = [ "httpx>=0.27", ] +manage = [ + "platformdirs>=4.0", + "typer[all]>=0.12", +] dev = [ "circuitforge-core[orch]", "circuitforge-core[tasks]", + "circuitforge-core[manage]", "pytest>=8.0", "pytest-asyncio>=0.23", "httpx>=0.27", @@ -35,6 +40,7 @@ dev = [ [project.scripts] cf-orch = "circuitforge_core.resources.cli:app" +cf-manage = "circuitforge_core.manage.cli:app" [tool.setuptools.packages.find] where = ["."] diff --git a/tests/test_manage/__init__.py b/tests/test_manage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_manage/test_config.py b/tests/test_manage/test_config.py new file mode 100644 index 0000000..49568b3 --- /dev/null +++ b/tests/test_manage/test_config.py @@ -0,0 +1,98 @@ +# tests/test_manage/test_config.py +"""Unit tests for ManageConfig TOML parsing.""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from circuitforge_core.manage.config import DockerConfig, ManageConfig, NativeService + + +def _write_toml(tmp_path: Path, content: str) -> Path: + p = tmp_path / "manage.toml" + p.write_text(content) + return p + + +def test_minimal_config(tmp_path: Path) -> None: + _write_toml(tmp_path, """ +[app] +name = "kiwi" +default_url = "http://localhost:8511" +""") + cfg = ManageConfig.from_cwd(tmp_path) + assert cfg.app_name == "kiwi" + assert cfg.default_url == "http://localhost:8511" + assert cfg.services == [] + assert cfg.docker.compose_file == "compose.yml" + + +def test_docker_section(tmp_path: Path) -> None: + _write_toml(tmp_path, """ +[app] +name = "peregrine" + +[docker] +compose_file = "compose.prod.yml" +project = "prng" +""") + cfg = ManageConfig.from_cwd(tmp_path) + assert cfg.docker.compose_file == "compose.prod.yml" + assert cfg.docker.project == "prng" + + +def test_native_services(tmp_path: Path) -> None: + _write_toml(tmp_path, """ +[app] +name = "snipe" +default_url = "http://localhost:8509" + +[[native.services]] +name = "api" +command = "uvicorn app.main:app --port 8510" +port = 8510 + +[[native.services]] +name = "frontend" +command = "npm run preview" +port = 8509 +cwd = "frontend" +""") + cfg = ManageConfig.from_cwd(tmp_path) + assert len(cfg.services) == 2 + api = cfg.services[0] + assert api.name == "api" + assert api.port == 8510 + assert api.cwd == "" + fe = cfg.services[1] + assert fe.name == "frontend" + assert fe.cwd == "frontend" + + +def test_native_service_env(tmp_path: Path) -> None: + _write_toml(tmp_path, """ +[app] +name = "avocet" + +[[native.services]] +name = "api" +command = "uvicorn app.main:app --port 8503" +port = 8503 +env = { PYTHONPATH = ".", DEBUG = "1" } +""") + cfg = ManageConfig.from_cwd(tmp_path) + assert cfg.services[0].env == {"PYTHONPATH": ".", "DEBUG": "1"} + + +def test_from_cwd_no_toml_falls_back_to_dirname(tmp_path: Path) -> None: + """No manage.toml → app_name inferred from directory name.""" + cfg = ManageConfig.from_cwd(tmp_path) + assert cfg.app_name == tmp_path.name + assert cfg.services == [] + + +def test_docker_project_defaults_to_app_name(tmp_path: Path) -> None: + _write_toml(tmp_path, "[app]\nname = \"osprey\"\n") + cfg = ManageConfig.from_cwd(tmp_path) + assert cfg.docker.project == "osprey" diff --git a/tests/test_manage/test_docker_mode.py b/tests/test_manage/test_docker_mode.py new file mode 100644 index 0000000..73e5d30 --- /dev/null +++ b/tests/test_manage/test_docker_mode.py @@ -0,0 +1,118 @@ +# tests/test_manage/test_docker_mode.py +"""Unit tests for DockerManager — compose wrapper.""" +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, call, patch + +import pytest + +from circuitforge_core.manage.config import DockerConfig, ManageConfig +from circuitforge_core.manage.docker_mode import DockerManager, docker_available + + +def _cfg(compose_file: str = "compose.yml", project: str = "testapp") -> ManageConfig: + return ManageConfig( + app_name="testapp", + docker=DockerConfig(compose_file=compose_file, project=project), + ) + + +@pytest.fixture +def mgr(tmp_path: Path) -> DockerManager: + (tmp_path / "compose.yml").write_text("version: '3'") + return DockerManager(_cfg(), root=tmp_path) + + +# ── docker_available ────────────────────────────────────────────────────────── + +def test_docker_available_true() -> None: + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + assert docker_available() is True + + +def test_docker_available_false_on_exception() -> None: + with patch("subprocess.run", side_effect=FileNotFoundError): + assert docker_available() is False + + +# ── compose_file_exists ─────────────────────────────────────────────────────── + +def test_compose_file_exists(mgr: DockerManager, tmp_path: Path) -> None: + assert mgr.compose_file_exists() is True + + +def test_compose_file_missing(tmp_path: Path) -> None: + m = DockerManager(_cfg(compose_file="nope.yml"), root=tmp_path) + assert m.compose_file_exists() is False + + +# ── start / stop / restart / status ────────────────────────────────────────── + +def test_start_runs_up(mgr: DockerManager) -> None: + with patch("subprocess.run") as mock_run: + mgr.start() + args = mock_run.call_args[0][0] + assert "up" in args + assert "-d" in args + assert "--build" in args + + +def test_start_specific_service(mgr: DockerManager) -> None: + with patch("subprocess.run") as mock_run: + mgr.start("api") + args = mock_run.call_args[0][0] + assert "api" in args + + +def test_stop_all_runs_down(mgr: DockerManager) -> None: + with patch("subprocess.run") as mock_run: + mgr.stop() + args = mock_run.call_args[0][0] + assert "down" in args + + +def test_stop_service_runs_stop(mgr: DockerManager) -> None: + with patch("subprocess.run") as mock_run: + mgr.stop("frontend") + args = mock_run.call_args[0][0] + assert "stop" in args + assert "frontend" in args + + +def test_restart_runs_restart(mgr: DockerManager) -> None: + with patch("subprocess.run") as mock_run: + mgr.restart() + args = mock_run.call_args[0][0] + assert "restart" in args + + +def test_status_runs_ps(mgr: DockerManager) -> None: + with patch("subprocess.run") as mock_run: + mgr.status() + args = mock_run.call_args[0][0] + assert "ps" in args + + +def test_build_no_cache(mgr: DockerManager) -> None: + with patch("subprocess.run") as mock_run: + mgr.build(no_cache=True) + args = mock_run.call_args[0][0] + assert "build" in args + assert "--no-cache" in args + + +def test_compose_project_flag_included(mgr: DockerManager) -> None: + with patch("subprocess.run") as mock_run: + mgr.start() + args = mock_run.call_args[0][0] + assert "-p" in args + assert "testapp" in args + + +def test_compose_file_flag_included(mgr: DockerManager, tmp_path: Path) -> None: + with patch("subprocess.run") as mock_run: + mgr.start() + args = mock_run.call_args[0][0] + assert "-f" in args diff --git a/tests/test_manage/test_native_mode.py b/tests/test_manage/test_native_mode.py new file mode 100644 index 0000000..040326e --- /dev/null +++ b/tests/test_manage/test_native_mode.py @@ -0,0 +1,176 @@ +# tests/test_manage/test_native_mode.py +"""Unit tests for NativeManager — PID file process management.""" +from __future__ import annotations + +import os +import time +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from circuitforge_core.manage.config import ManageConfig, NativeService +from circuitforge_core.manage.native_mode import NativeManager + + +def _cfg(*services: NativeService) -> ManageConfig: + return ManageConfig(app_name="testapp", services=list(services)) + + +def _svc(name: str = "api", port: int = 8000) -> NativeService: + return NativeService(name=name, command="python -c 'import time; time.sleep(999)'", port=port) + + +@pytest.fixture +def mgr(tmp_path: Path) -> NativeManager: + cfg = _cfg(_svc("api", 8000), _svc("frontend", 8001)) + m = NativeManager(cfg, root=tmp_path) + # redirect pid/log dirs into tmp_path for test isolation + m._pid_dir = tmp_path / "pids" + m._log_dir = tmp_path / "logs" + m._pid_dir.mkdir() + m._log_dir.mkdir() + return m + + +# ── PID file helpers ────────────────────────────────────────────────────────── + +def test_write_and_read_pid(mgr: NativeManager, tmp_path: Path) -> None: + mgr._write_pid("api", 12345, "python server.py") + assert mgr._read_pid("api") == 12345 + + +def test_read_pid_missing_returns_none(mgr: NativeManager) -> None: + assert mgr._read_pid("nonexistent") is None + + +def test_read_pid_corrupt_returns_none(mgr: NativeManager, tmp_path: Path) -> None: + mgr._pid_dir.mkdir(exist_ok=True) + (mgr._pid_dir / "api.pid").write_text("notanumber\n") + assert mgr._read_pid("api") is None + + +# ── is_running ──────────────────────────────────────────────────────────────── + +def test_is_running_true_when_pid_alive(mgr: NativeManager) -> None: + mgr._write_pid("api", os.getpid(), "test") # use current PID — guaranteed alive + assert mgr.is_running("api") is True + + +def test_is_running_false_when_no_pid_file(mgr: NativeManager) -> None: + assert mgr.is_running("api") is False + + +def test_is_running_false_when_pid_dead(mgr: NativeManager) -> None: + mgr._write_pid("api", 999999999, "dead") # very unlikely PID + with patch.object(mgr, "_pid_alive", return_value=False): + assert mgr.is_running("api") is False + + +# ── start ───────────────────────────────────────────────────────────────────── + +def test_start_spawns_process(mgr: NativeManager, tmp_path: Path) -> None: + mock_proc = MagicMock() + mock_proc.pid = 42 + + with patch("subprocess.Popen", return_value=mock_proc) as mock_popen: + started = mgr.start("api") + + assert "api" in started + mock_popen.assert_called_once() + assert mgr._read_pid("api") == 42 + + +def test_start_all_spawns_all_services(mgr: NativeManager) -> None: + mock_proc = MagicMock() + mock_proc.pid = 99 + + with patch("subprocess.Popen", return_value=mock_proc): + started = mgr.start() # no name = all services + + assert set(started) == {"api", "frontend"} + + +def test_start_skips_already_running(mgr: NativeManager) -> None: + mgr._write_pid("api", os.getpid(), "test") # current PID = alive + + with patch("subprocess.Popen") as mock_popen: + started = mgr.start("api") + + mock_popen.assert_not_called() + assert "api" not in started + + +def test_start_unknown_service_raises(mgr: NativeManager) -> None: + with pytest.raises(ValueError, match="Unknown service"): + mgr.start("doesnotexist") + + +# ── stop ────────────────────────────────────────────────────────────────────── + +def test_stop_kills_and_removes_pid_file(mgr: NativeManager) -> None: + mgr._write_pid("api", 55555, "test") + + with patch.object(mgr, "_pid_alive", return_value=True), \ + patch.object(mgr, "_kill") as mock_kill: + stopped = mgr.stop("api") + + assert "api" in stopped + mock_kill.assert_called_once_with(55555) + assert not mgr._pid_path("api").exists() + + +def test_stop_all(mgr: NativeManager) -> None: + mgr._write_pid("api", 1001, "test") + mgr._write_pid("frontend", 1002, "test") + + with patch.object(mgr, "_pid_alive", return_value=True), \ + patch.object(mgr, "_kill"): + stopped = mgr.stop() + + assert set(stopped) == {"api", "frontend"} + + +def test_stop_not_running_returns_empty(mgr: NativeManager) -> None: + # No PID file → nothing to stop + stopped = mgr.stop("api") + assert stopped == [] + + +# ── status ──────────────────────────────────────────────────────────────────── + +def test_status_running(mgr: NativeManager) -> None: + mgr._write_pid("api", os.getpid(), "test") + + rows = mgr.status() + api_row = next(r for r in rows if r.name == "api") + assert api_row.running is True + assert api_row.pid == os.getpid() + assert api_row.port == 8000 + + +def test_status_stopped(mgr: NativeManager) -> None: + rows = mgr.status() + for row in rows: + assert row.running is False + assert row.pid is None + + +# ── logs ────────────────────────────────────────────────────────────────────── + +def test_logs_prints_last_lines(mgr: NativeManager, capsys: pytest.CaptureFixture) -> None: # type: ignore[type-arg] + log = mgr._log_path("api") + log.write_text("\n".join(f"line {i}" for i in range(100))) + + mgr.logs("api", follow=False, lines=10) + + out = capsys.readouterr().out + assert "line 99" in out + assert "line 90" in out + assert "line 89" not in out # only last 10 + + +def test_logs_missing_file_prints_warning(mgr: NativeManager, capsys: pytest.CaptureFixture) -> None: # type: ignore[type-arg] + mgr.logs("api", follow=False) + err = capsys.readouterr().err + assert "No log file" in err