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:
parent
6e3474b97b
commit
8d87ed4c9f
15 changed files with 1199 additions and 1 deletions
12
circuitforge_core/manage/__init__.py
Normal file
12
circuitforge_core/manage/__init__.py
Normal 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",
|
||||
]
|
||||
4
circuitforge_core/manage/__main__.py
Normal file
4
circuitforge_core/manage/__main__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
"""Entry point for `python -m circuitforge_core.manage`."""
|
||||
from .cli import app
|
||||
|
||||
app()
|
||||
237
circuitforge_core/manage/cli.py
Normal file
237
circuitforge_core/manage/cli.py
Normal 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()
|
||||
119
circuitforge_core/manage/config.py
Normal file
119
circuitforge_core/manage/config.py
Normal 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)
|
||||
115
circuitforge_core/manage/docker_mode.py
Normal file
115
circuitforge_core/manage/docker_mode.py
Normal 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)
|
||||
217
circuitforge_core/manage/native_mode.py
Normal file
217
circuitforge_core/manage/native_mode.py
Normal 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
|
||||
0
circuitforge_core/manage/templates/__init__.py
Normal file
0
circuitforge_core/manage/templates/__init__.py
Normal file
30
circuitforge_core/manage/templates/manage.ps1
Normal file
30
circuitforge_core/manage/templates/manage.ps1
Normal 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
|
||||
28
circuitforge_core/manage/templates/manage.sh
Normal file
28
circuitforge_core/manage/templates/manage.sh
Normal 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 "$@"
|
||||
38
circuitforge_core/manage/templates/manage.toml.example
Normal file
38
circuitforge_core/manage/templates/manage.toml.example
Normal 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"
|
||||
|
|
@ -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 = ["."]
|
||||
|
|
|
|||
0
tests/test_manage/__init__.py
Normal file
0
tests/test_manage/__init__.py
Normal file
98
tests/test_manage/test_config.py
Normal file
98
tests/test_manage/test_config.py
Normal 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"
|
||||
118
tests/test_manage/test_docker_mode.py
Normal file
118
tests/test_manage/test_docker_mode.py
Normal 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
|
||||
176
tests/test_manage/test_native_mode.py
Normal file
176
tests/test_manage/test_native_mode.py
Normal 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
|
||||
Loading…
Reference in a new issue