""" PyServe CLI State Management Manages the state of running services. """ import json import os import time from dataclasses import asdict, dataclass, field from pathlib import Path from typing import Any, Dict, Optional @dataclass class ServiceHealth: status: str = "unknown" # healthy, unhealthy, degraded, unknown last_check: Optional[float] = None failures: int = 0 response_time_ms: Optional[float] = None @dataclass class ServiceState: name: str state: str = "stopped" # pending, starting, running, stopping, stopped, failed, restarting pid: Optional[int] = None port: int = 0 workers: int = 0 started_at: Optional[float] = None restart_count: int = 0 health: ServiceHealth = field(default_factory=ServiceHealth) config_hash: str = "" @property def uptime(self) -> float: if self.started_at is None: return 0.0 return time.time() - self.started_at def to_dict(self) -> Dict[str, Any]: return { "name": self.name, "state": self.state, "pid": self.pid, "port": self.port, "workers": self.workers, "started_at": self.started_at, "restart_count": self.restart_count, "health": asdict(self.health), "config_hash": self.config_hash, } @classmethod def from_dict(cls, data: Dict[str, Any]) -> "ServiceState": health_data = data.pop("health", {}) health = ServiceHealth(**health_data) if health_data else ServiceHealth() return cls(**data, health=health) @dataclass class ProjectState: version: str = "1.0" project: str = "" config_file: str = "" config_hash: str = "" started_at: Optional[float] = None daemon_pid: Optional[int] = None services: Dict[str, ServiceState] = field(default_factory=dict) def to_dict(self) -> Dict[str, Any]: return { "version": self.version, "project": self.project, "config_file": self.config_file, "config_hash": self.config_hash, "started_at": self.started_at, "daemon_pid": self.daemon_pid, "services": {name: svc.to_dict() for name, svc in self.services.items()}, } @classmethod def from_dict(cls, data: Dict[str, Any]) -> "ProjectState": services_data = data.pop("services", {}) services = {name: ServiceState.from_dict(svc) for name, svc in services_data.items()} return cls(**data, services=services) class StateManager: STATE_FILE = "state.json" PID_FILE = "pyserve.pid" SOCKET_FILE = "pyserve.sock" LOGS_DIR = "logs" def __init__(self, state_dir: Path, project: Optional[str] = None): self.state_dir = Path(state_dir) self.project = project or self._detect_project() self._state: Optional[ProjectState] = None def _detect_project(self) -> str: return Path.cwd().name @property def state_file(self) -> Path: return self.state_dir / self.STATE_FILE @property def pid_file(self) -> Path: return self.state_dir / self.PID_FILE @property def socket_file(self) -> Path: return self.state_dir / self.SOCKET_FILE @property def logs_dir(self) -> Path: return self.state_dir / self.LOGS_DIR def ensure_dirs(self) -> None: self.state_dir.mkdir(parents=True, exist_ok=True) self.logs_dir.mkdir(parents=True, exist_ok=True) def load(self) -> ProjectState: if self._state is not None: return self._state if self.state_file.exists(): try: with open(self.state_file) as f: data = json.load(f) self._state = ProjectState.from_dict(data) except (json.JSONDecodeError, KeyError): self._state = ProjectState(project=self.project) else: self._state = ProjectState(project=self.project) return self._state def save(self) -> None: if self._state is None: return self.ensure_dirs() with open(self.state_file, "w") as f: json.dump(self._state.to_dict(), f, indent=2) def get_state(self) -> ProjectState: return self.load() def update_service(self, name: str, **kwargs: Any) -> ServiceState: state = self.load() if name not in state.services: state.services[name] = ServiceState(name=name) service = state.services[name] for key, value in kwargs.items(): if hasattr(service, key): setattr(service, key, value) self.save() return service def remove_service(self, name: str) -> None: state = self.load() if name in state.services: del state.services[name] self.save() def get_service(self, name: str) -> Optional[ServiceState]: state = self.load() return state.services.get(name) def get_all_services(self) -> Dict[str, ServiceState]: state = self.load() return state.services.copy() def clear(self) -> None: self._state = ProjectState(project=self.project) self.save() def is_daemon_running(self) -> bool: if not self.pid_file.exists(): return False try: pid = int(self.pid_file.read_text().strip()) # Check if process exists os.kill(pid, 0) return True except (ValueError, ProcessLookupError, PermissionError): return False def get_daemon_pid(self) -> Optional[int]: if not self.is_daemon_running(): return None try: return int(self.pid_file.read_text().strip()) except ValueError: return None def set_daemon_pid(self, pid: int) -> None: self.ensure_dirs() self.pid_file.write_text(str(pid)) state = self.load() state.daemon_pid = pid self.save() def clear_daemon_pid(self) -> None: if self.pid_file.exists(): self.pid_file.unlink() state = self.load() state.daemon_pid = None self.save() def get_service_log_file(self, service_name: str) -> Path: self.ensure_dirs() return self.logs_dir / f"{service_name}.log" def compute_config_hash(self, config_file: str) -> str: import hashlib path = Path(config_file) if not path.exists(): return "" content = path.read_bytes() return hashlib.sha256(content).hexdigest()[:16]