feat: manage.py cross-platform product manager (closes #6)

- 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)
This commit is contained in:
pyr0ball 2026-04-02 23:04:35 -07:00
parent 6e3474b97b
commit 8d87ed4c9f
15 changed files with 1199 additions and 1 deletions

View file

@ -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",
]

View file

@ -0,0 +1,4 @@
"""Entry point for `python -m circuitforge_core.manage`."""
from .cli import app
app()

View file

@ -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()

View file

@ -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)

View file

@ -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)

View file

@ -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) / <service>.pid
Log files : user_log_dir(app_name) / <service>.log
PID file format (one line each):
<pid>
<command_fingerprint> (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

View file

@ -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

View file

@ -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 "$@"

View file

@ -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"

View file

@ -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 = ["."]

View file

View file

@ -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"

View file

@ -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

View file

@ -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