From 80544d5b95d9d5b2d57b98dbbf319a1cd86b692b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=20=D0=93=D0=BB=D0=B0=D0=B7=D1=83?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2?= Date: Thu, 4 Dec 2025 02:55:14 +0300 Subject: [PATCH] pyservectl init --- poetry.lock | 96 ++++++- pyproject.toml | 4 + pyserve/_wsgi_wrapper.py | 13 +- pyserve/cli.py | 37 ++- pyserve/ctl/__init__.py | 26 ++ pyserve/ctl/_daemon.py | 93 +++++++ pyserve/ctl/_runner.py | 391 ++++++++++++++++++++++++++++ pyserve/ctl/commands/__init__.py | 25 ++ pyserve/ctl/commands/config.py | 420 ++++++++++++++++++++++++++++++ pyserve/ctl/commands/down.py | 122 +++++++++ pyserve/ctl/commands/health.py | 160 ++++++++++++ pyserve/ctl/commands/init.py | 433 +++++++++++++++++++++++++++++++ pyserve/ctl/commands/logs.py | 290 +++++++++++++++++++++ pyserve/ctl/commands/scale.py | 89 +++++++ pyserve/ctl/commands/service.py | 190 ++++++++++++++ pyserve/ctl/commands/status.py | 153 +++++++++++ pyserve/ctl/commands/top.py | 182 +++++++++++++ pyserve/ctl/commands/up.py | 174 +++++++++++++ pyserve/ctl/main.py | 165 ++++++++++++ pyserve/ctl/output/__init__.py | 108 ++++++++ pyserve/ctl/state/__init__.py | 234 +++++++++++++++++ 21 files changed, 3391 insertions(+), 14 deletions(-) create mode 100644 pyserve/ctl/__init__.py create mode 100644 pyserve/ctl/_daemon.py create mode 100644 pyserve/ctl/_runner.py create mode 100644 pyserve/ctl/commands/__init__.py create mode 100644 pyserve/ctl/commands/config.py create mode 100644 pyserve/ctl/commands/down.py create mode 100644 pyserve/ctl/commands/health.py create mode 100644 pyserve/ctl/commands/init.py create mode 100644 pyserve/ctl/commands/logs.py create mode 100644 pyserve/ctl/commands/scale.py create mode 100644 pyserve/ctl/commands/service.py create mode 100644 pyserve/ctl/commands/status.py create mode 100644 pyserve/ctl/commands/top.py create mode 100644 pyserve/ctl/commands/up.py create mode 100644 pyserve/ctl/main.py create mode 100644 pyserve/ctl/output/__init__.py create mode 100644 pyserve/ctl/state/__init__.py diff --git a/poetry.lock b/poetry.lock index 6443560..86efa0c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -147,14 +147,14 @@ files = [ [[package]] name = "click" -version = "8.2.1" +version = "8.3.1" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" groups = ["main", "dev"] files = [ - {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"}, - {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"}, + {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, + {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, ] [package.dependencies] @@ -602,6 +602,30 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] +[[package]] +name = "markdown-it-py" +version = "4.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"}, + {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins (>=0.5.0)"] +profiling = ["gprof2dot"] +rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"] + [[package]] name = "markupsafe" version = "3.0.3" @@ -714,6 +738,18 @@ files = [ {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, ] +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + [[package]] name = "mypy" version = "1.17.1" @@ -843,6 +879,39 @@ files = [ dev = ["pre-commit", "tox"] testing = ["coverage", "pytest", "pytest-benchmark"] +[[package]] +name = "psutil" +version = "7.1.3" +description = "Cross-platform lib for process and system monitoring." +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "psutil-7.1.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0005da714eee687b4b8decd3d6cc7c6db36215c9e74e5ad2264b90c3df7d92dc"}, + {file = "psutil-7.1.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:19644c85dcb987e35eeeaefdc3915d059dac7bd1167cdcdbf27e0ce2df0c08c0"}, + {file = "psutil-7.1.3-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95ef04cf2e5ba0ab9eaafc4a11eaae91b44f4ef5541acd2ee91d9108d00d59a7"}, + {file = "psutil-7.1.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1068c303be3a72f8e18e412c5b2a8f6d31750fb152f9cb106b54090296c9d251"}, + {file = "psutil-7.1.3-cp313-cp313t-win_amd64.whl", hash = "sha256:18349c5c24b06ac5612c0428ec2a0331c26443d259e2a0144a9b24b4395b58fa"}, + {file = "psutil-7.1.3-cp313-cp313t-win_arm64.whl", hash = "sha256:c525ffa774fe4496282fb0b1187725793de3e7c6b29e41562733cae9ada151ee"}, + {file = "psutil-7.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b403da1df4d6d43973dc004d19cee3b848e998ae3154cc8097d139b77156c353"}, + {file = "psutil-7.1.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad81425efc5e75da3f39b3e636293360ad8d0b49bed7df824c79764fb4ba9b8b"}, + {file = "psutil-7.1.3-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f33a3702e167783a9213db10ad29650ebf383946e91bc77f28a5eb083496bc9"}, + {file = "psutil-7.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fac9cd332c67f4422504297889da5ab7e05fd11e3c4392140f7370f4208ded1f"}, + {file = "psutil-7.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:3792983e23b69843aea49c8f5b8f115572c5ab64c153bada5270086a2123c7e7"}, + {file = "psutil-7.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:31d77fcedb7529f27bb3a0472bea9334349f9a04160e8e6e5020f22c59893264"}, + {file = "psutil-7.1.3-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2bdbcd0e58ca14996a42adf3621a6244f1bb2e2e528886959c72cf1e326677ab"}, + {file = "psutil-7.1.3-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:bc31fa00f1fbc3c3802141eede66f3a2d51d89716a194bf2cd6fc68310a19880"}, + {file = "psutil-7.1.3-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb428f9f05c1225a558f53e30ccbad9930b11c3fc206836242de1091d3e7dd3"}, + {file = "psutil-7.1.3-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56d974e02ca2c8eb4812c3f76c30e28836fffc311d55d979f1465c1feeb2b68b"}, + {file = "psutil-7.1.3-cp37-abi3-win_amd64.whl", hash = "sha256:f39c2c19fe824b47484b96f9692932248a54c43799a84282cfe58d05a6449efd"}, + {file = "psutil-7.1.3-cp37-abi3-win_arm64.whl", hash = "sha256:bd0d69cee829226a761e92f28140bec9a5ee9d5b4fb4b0cc589068dbfff559b1"}, + {file = "psutil-7.1.3.tar.gz", hash = "sha256:6c86281738d77335af7aec228328e944b30930899ea760ecf33a4dba66be5e74"}, +] + +[package.extras] +dev = ["abi3audit", "black", "check-manifest", "colorama ; os_name == \"nt\"", "coverage", "packaging", "pylint", "pyperf", "pypinfo", "pyreadline ; os_name == \"nt\"", "pytest", "pytest-cov", "pytest-instafail", "pytest-subtests", "pytest-xdist", "pywin32 ; os_name == \"nt\" and platform_python_implementation != \"PyPy\"", "requests", "rstcheck", "ruff", "setuptools", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "validate-pyproject[all]", "virtualenv", "vulture", "wheel", "wheel ; os_name == \"nt\" and platform_python_implementation != \"PyPy\"", "wmi ; os_name == \"nt\" and platform_python_implementation != \"PyPy\""] +test = ["pytest", "pytest-instafail", "pytest-subtests", "pytest-xdist", "pywin32 ; os_name == \"nt\" and platform_python_implementation != \"PyPy\"", "setuptools", "wheel ; os_name == \"nt\" and platform_python_implementation != \"PyPy\"", "wmi ; os_name == \"nt\" and platform_python_implementation != \"PyPy\""] + [[package]] name = "pycodestyle" version = "2.14.0" @@ -1180,6 +1249,25 @@ files = [ {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, ] +[[package]] +name = "rich" +version = "14.2.0" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.8.0" +groups = ["main"] +files = [ + {file = "rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd"}, + {file = "rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + [[package]] name = "setuptools" version = "80.9.0" @@ -1621,4 +1709,4 @@ wsgi = ["a2wsgi"] [metadata] lock-version = "2.1" python-versions = ">=3.12" -content-hash = "32ebf260f6792987cb4236fe29ad3329374e063504d507b5a0319684e24a30a8" +content-hash = "359245cc9d83f36b9eff32deaf7a4665b25149c89b4062abab72db1c07607500" diff --git a/pyproject.toml b/pyproject.toml index 3e9fab4..ced1eff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,10 +15,14 @@ dependencies = [ "types-pyyaml (>=6.0.12.20250822,<7.0.0.0)", "structlog (>=25.4.0,<26.0.0)", "httpx (>=0.27.0,<0.28.0)", + "click (>=8.0)", + "rich (>=13.0)", + "psutil (>=5.9)", ] [project.scripts] pyserve = "pyserve.cli:main" +pyservectl = "pyserve.ctl:main" [project.optional-dependencies] dev = [ diff --git a/pyserve/_wsgi_wrapper.py b/pyserve/_wsgi_wrapper.py index 191e8a5..05b817d 100644 --- a/pyserve/_wsgi_wrapper.py +++ b/pyserve/_wsgi_wrapper.py @@ -11,22 +11,25 @@ The WSGI app path is passed via environment variables: import importlib import os -from typing import Any, Callable, Optional +from typing import Any, Callable, Optional, Type +WSGIMiddlewareType = Optional[Type[Any]] WSGI_ADAPTER: Optional[str] = None +WSGIMiddleware: WSGIMiddlewareType = None try: - from a2wsgi import WSGIMiddleware + from a2wsgi import WSGIMiddleware as _A2WSGIMiddleware + WSGIMiddleware = _A2WSGIMiddleware WSGI_ADAPTER = "a2wsgi" except ImportError: try: - from asgiref.wsgi import WsgiToAsgi as WSGIMiddleware # type: ignore + from asgiref.wsgi import WsgiToAsgi as _AsgirefMiddleware + WSGIMiddleware = _AsgirefMiddleware WSGI_ADAPTER = "asgiref" except ImportError: - WSGIMiddleware = None # type: ignore - WSGI_ADAPTER = None + pass def _load_wsgi_app() -> Callable[..., Any]: diff --git a/pyserve/cli.py b/pyserve/cli.py index ed57227..f154b93 100644 --- a/pyserve/cli.py +++ b/pyserve/cli.py @@ -1,3 +1,10 @@ +""" +PyServe CLI - Server entry point + +Simple CLI for running the PyServe HTTP server. +For service management, use pyservectl. +""" + import argparse import sys from pathlib import Path @@ -9,12 +16,32 @@ def main() -> None: parser = argparse.ArgumentParser( description="PyServe - HTTP web server", prog="pyserve", + epilog="For service management (start/stop/restart/logs), use: pyservectl", + ) + parser.add_argument( + "-c", "--config", + default="config.yaml", + help="Path to configuration file (default: config.yaml)", + ) + parser.add_argument( + "--host", + help="Host to bind the server to", + ) + parser.add_argument( + "--port", + type=int, + help="Port to bind the server to", + ) + parser.add_argument( + "--debug", + action="store_true", + help="Enable debug mode", + ) + parser.add_argument( + "--version", + action="version", + version=f"%(prog)s {__version__}", ) - parser.add_argument("-c", "--config", default="config.yaml", help="Path to configuration file (default: config.yaml)") - parser.add_argument("--host", help="Host to bind the server to") - parser.add_argument("--port", type=int, help="Port to bind the server to") - parser.add_argument("--debug", action="store_true", help="Enable debug mode") - parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") args = parser.parse_args() diff --git a/pyserve/ctl/__init__.py b/pyserve/ctl/__init__.py new file mode 100644 index 0000000..231036b --- /dev/null +++ b/pyserve/ctl/__init__.py @@ -0,0 +1,26 @@ +""" +PyServeCtl - Service management CLI + +Docker-compose-like tool for managing PyServe services. + +Usage: + pyservectl [OPTIONS] COMMAND [ARGS]... + +Commands: + init Initialize a new project + config Configuration management + up Start all services + down Stop all services + start Start specific services + stop Stop specific services + restart Restart services + ps Show service status + logs View service logs + top Live monitoring dashboard + health Check service health + scale Scale services +""" + +from .main import cli, main + +__all__ = ["cli", "main"] diff --git a/pyserve/ctl/_daemon.py b/pyserve/ctl/_daemon.py new file mode 100644 index 0000000..5639d8c --- /dev/null +++ b/pyserve/ctl/_daemon.py @@ -0,0 +1,93 @@ +""" +PyServe Daemon Process + +Runs pyserve services in background mode. +""" + +import argparse +import asyncio +import logging +import os +import signal +import sys +from pathlib import Path + + +def main(): + parser = argparse.ArgumentParser(description="PyServe Daemon") + parser.add_argument("--config", required=True, help="Configuration file path") + parser.add_argument("--state-dir", required=True, help="State directory path") + parser.add_argument("--services", default=None, help="Comma-separated list of services") + parser.add_argument("--scale", action="append", default=[], help="Scale overrides (name=workers)") + parser.add_argument("--force-recreate", action="store_true", help="Force recreate services") + + args = parser.parse_args() + + config_path = Path(args.config) + state_dir = Path(args.state_dir) + + services = args.services.split(",") if args.services else None + + scale_map = {} + for scale in args.scale: + name, workers = scale.split("=") + scale_map[name] = int(workers) + + from ..config import Config + + config = Config.from_yaml(str(config_path)) + + from .state import StateManager + + state_manager = StateManager(state_dir) + + log_file = state_dir / "logs" / "daemon.log" + log_file.parent.mkdir(parents=True, exist_ok=True) + + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[ + logging.FileHandler(log_file), + ], + ) + + logger = logging.getLogger("pyserve.daemon") + + pid_file = state_dir / "pyserve.pid" + pid_file.write_text(str(os.getpid())) + + logger.info(f"Starting daemon with PID {os.getpid()}") + + from ._runner import ServiceRunner + + runner = ServiceRunner(config, state_manager) + + def signal_handler(signum, frame): + logger.info(f"Received signal {signum}, shutting down...") + runner.stop() + + signal.signal(signal.SIGTERM, signal_handler) + signal.signal(signal.SIGINT, signal_handler) + + try: + asyncio.run( + runner.start( + services=services, + scale_map=scale_map, + force_recreate=args.force_recreate, + ) + ) + except Exception as e: + logger.error(f"Daemon error: {e}") + sys.exit(1) + finally: + if pid_file.exists(): + pid_file.unlink() + logger.info("Daemon stopped") + + +if __name__ == "__main__": + import os + + main() diff --git a/pyserve/ctl/_runner.py b/pyserve/ctl/_runner.py new file mode 100644 index 0000000..e9451a1 --- /dev/null +++ b/pyserve/ctl/_runner.py @@ -0,0 +1,391 @@ +""" +PyServe Service Runner + +Handles starting, stopping, and managing services. +Integrates with ProcessManager for actual process management. +""" + +import asyncio +import os +import signal +import sys +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional + +from ..config import Config +from ..process_manager import ProcessConfig, ProcessInfo, ProcessManager, ProcessState +from .state import ServiceState, StateManager + + +@dataclass +class ServiceDefinition: + name: str + path: str + app_path: str + app_type: str = "asgi" + module_path: Optional[str] = None + workers: int = 1 + health_check_path: str = "/health" + health_check_interval: float = 10.0 + health_check_timeout: float = 5.0 + health_check_retries: int = 3 + max_restart_count: int = 5 + restart_delay: float = 1.0 + shutdown_timeout: float = 30.0 + strip_path: bool = True + env: Dict[str, str] = field(default_factory=dict) + + +class ServiceRunner: + def __init__(self, config: Config, state_manager: StateManager): + self.config = config + self.state_manager = state_manager + self._process_manager: Optional[ProcessManager] = None + self._services: Dict[str, ServiceDefinition] = {} + self._running = False + + self._parse_services() + + def _parse_services(self) -> None: + for ext in self.config.extensions: + if ext.type == "process_orchestration": + apps = ext.config.get("apps", []) + for app_config in apps: + service = ServiceDefinition( + name=app_config.get("name", "unnamed"), + path=app_config.get("path", "/"), + app_path=app_config.get("app_path", ""), + app_type=app_config.get("app_type", "asgi"), + module_path=app_config.get("module_path"), + workers=app_config.get("workers", 1), + health_check_path=app_config.get("health_check_path", "/health"), + health_check_interval=app_config.get("health_check_interval", 10.0), + health_check_timeout=app_config.get("health_check_timeout", 5.0), + health_check_retries=app_config.get("health_check_retries", 3), + max_restart_count=app_config.get("max_restart_count", 5), + restart_delay=app_config.get("restart_delay", 1.0), + shutdown_timeout=app_config.get("shutdown_timeout", 30.0), + strip_path=app_config.get("strip_path", True), + env=app_config.get("env", {}), + ) + self._services[service.name] = service + + def get_services(self) -> Dict[str, ServiceDefinition]: + return self._services.copy() + + def get_service(self, name: str) -> Optional[ServiceDefinition]: + return self._services.get(name) + + async def start( + self, + services: Optional[List[str]] = None, + scale_map: Optional[Dict[str, int]] = None, + force_recreate: bool = False, + wait_healthy: bool = False, + timeout: int = 60, + ) -> None: + from .output import console, print_error, print_info, print_success + + scale_map = scale_map or {} + + target_services = services or list(self._services.keys()) + + if not target_services: + print_info("No services configured. Add services to your config.yaml") + return + + for name in target_services: + if name not in self._services: + print_error(f"Service '{name}' not found in configuration") + return + + port_range = (9000, 9999) + for ext in self.config.extensions: + if ext.type == "process_orchestration": + port_range = tuple(ext.config.get("port_range", [9000, 9999])) + break + + self._process_manager = ProcessManager( + port_range=port_range, + health_check_enabled=True, + ) + await self._process_manager.start() + + self._running = True + + for name in target_services: + service = self._services[name] + workers = scale_map.get(name, service.workers) + + proc_config = ProcessConfig( + name=name, + app_path=service.app_path, + app_type=service.app_type, + workers=workers, + module_path=service.module_path, + health_check_enabled=True, + health_check_path=service.health_check_path, + health_check_interval=service.health_check_interval, + health_check_timeout=service.health_check_timeout, + health_check_retries=service.health_check_retries, + max_restart_count=service.max_restart_count, + restart_delay=service.restart_delay, + shutdown_timeout=service.shutdown_timeout, + env=service.env, + ) + + try: + await self._process_manager.register(proc_config) + success = await self._process_manager.start_process(name) + + if success: + info = self._process_manager.get_process(name) + if info: + self.state_manager.update_service( + name, + state="running", + pid=info.pid, + port=info.port, + workers=workers, + started_at=time.time(), + ) + print_success(f"Started service: {name}") + else: + self.state_manager.update_service(name, state="failed") + print_error(f"Failed to start service: {name}") + + except Exception as e: + print_error(f"Error starting {name}: {e}") + self.state_manager.update_service(name, state="failed") + + if wait_healthy: + print_info("Waiting for services to be healthy...") + await self._wait_healthy(target_services, timeout) + + console.print("\n[bold]Services running. Press Ctrl+C to stop.[/bold]\n") + + try: + while self._running: + await asyncio.sleep(1) + await self._sync_state() + except asyncio.CancelledError: + pass + finally: + await self.stop_all() + + async def _sync_state(self) -> None: + if not self._process_manager: + return + + for name, info in self._process_manager.get_all_processes().items(): + state_str = info.state.value + health_status = "healthy" if info.health_check_failures == 0 else "unhealthy" + + self.state_manager.update_service( + name, + state=state_str, + pid=info.pid, + port=info.port, + ) + + service_state = self.state_manager.get_service(name) + if service_state: + service_state.health.status = health_status + service_state.health.failures = info.health_check_failures + self.state_manager.save() + + async def _wait_healthy(self, services: List[str], timeout: int) -> None: + from .output import print_info, print_warning + + start_time = time.time() + + while time.time() - start_time < timeout: + all_healthy = True + + for name in services: + if not self._process_manager: + continue + + info = self._process_manager.get_process(name) + if not info or info.state != ProcessState.RUNNING: + all_healthy = False + break + + if all_healthy: + print_info("All services healthy") + return + + await asyncio.sleep(1) + + print_warning("Timeout waiting for services to become healthy") + + async def stop_all(self, timeout: int = 30) -> None: + from .output import print_info + + self._running = False + + if self._process_manager: + print_info("Stopping all services...") + await self._process_manager.stop() + self._process_manager = None + + for name in self._services: + self.state_manager.update_service( + name, + state="stopped", + pid=None, + ) + + def stop(self) -> None: + self._running = False + + async def start_service(self, name: str, timeout: int = 60) -> bool: + from .output import print_error + + service = self._services.get(name) + if not service: + print_error(f"Service '{name}' not found") + return False + + if not self._process_manager: + self._process_manager = ProcessManager() + await self._process_manager.start() + + proc_config = ProcessConfig( + name=name, + app_path=service.app_path, + app_type=service.app_type, + workers=service.workers, + module_path=service.module_path, + health_check_enabled=True, + health_check_path=service.health_check_path, + env=service.env, + ) + + try: + existing = self._process_manager.get_process(name) + if not existing: + await self._process_manager.register(proc_config) + + success = await self._process_manager.start_process(name) + + if success: + info = self._process_manager.get_process(name) + self.state_manager.update_service( + name, + state="running", + pid=info.pid if info else None, + port=info.port if info else 0, + started_at=time.time(), + ) + + return success + + except Exception as e: + print_error(f"Error starting {name}: {e}") + return False + + async def stop_service(self, name: str, timeout: int = 30, force: bool = False) -> bool: + if not self._process_manager: + self.state_manager.update_service(name, state="stopped", pid=None) + return True + + try: + success = await self._process_manager.stop_process(name) + + if success: + self.state_manager.update_service( + name, + state="stopped", + pid=None, + ) + + return success + + except Exception as e: + from .output import print_error + + print_error(f"Error stopping {name}: {e}") + return False + + async def restart_service(self, name: str, timeout: int = 60) -> bool: + if not self._process_manager: + return False + + try: + self.state_manager.update_service(name, state="restarting") + success = await self._process_manager.restart_process(name) + + if success: + info = self._process_manager.get_process(name) + self.state_manager.update_service( + name, + state="running", + pid=info.pid if info else None, + port=info.port if info else 0, + started_at=time.time(), + ) + + return success + + except Exception as e: + from .output import print_error + + print_error(f"Error restarting {name}: {e}") + return False + + async def scale_service(self, name: str, workers: int, timeout: int = 60, wait: bool = True) -> bool: + # For now, this requires restart with new worker count + # In future, could implement hot-reloading + + service = self._services.get(name) + if not service: + return False + + # Update service definition + service.workers = workers + + # Restart with new configuration + return await self.restart_service(name, timeout) + + def start_daemon( + self, + services: Optional[List[str]] = None, + scale_map: Optional[Dict[str, int]] = None, + force_recreate: bool = False, + ) -> int: + import subprocess + + cmd = [ + sys.executable, + "-m", + "pyserve.cli._daemon", + "--config", + str(self.state_manager.state_dir.parent / "config.yaml"), + "--state-dir", + str(self.state_manager.state_dir), + ] + + if services: + cmd.extend(["--services", ",".join(services)]) + + if scale_map: + for name, workers in scale_map.items(): + cmd.extend(["--scale", f"{name}={workers}"]) + + if force_recreate: + cmd.append("--force-recreate") + + env = os.environ.copy() + + process = subprocess.Popen( + cmd, + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + + return process.pid diff --git a/pyserve/ctl/commands/__init__.py b/pyserve/ctl/commands/__init__.py new file mode 100644 index 0000000..f7b66f6 --- /dev/null +++ b/pyserve/ctl/commands/__init__.py @@ -0,0 +1,25 @@ +from .config import config_cmd +from .down import down_cmd +from .health import health_cmd +from .init import init_cmd +from .logs import logs_cmd +from .scale import scale_cmd +from .service import restart_cmd, start_cmd, stop_cmd +from .status import ps_cmd +from .top import top_cmd +from .up import up_cmd + +__all__ = [ + "init_cmd", + "config_cmd", + "up_cmd", + "down_cmd", + "start_cmd", + "stop_cmd", + "restart_cmd", + "ps_cmd", + "logs_cmd", + "top_cmd", + "health_cmd", + "scale_cmd", +] diff --git a/pyserve/ctl/commands/config.py b/pyserve/ctl/commands/config.py new file mode 100644 index 0000000..e21da9f --- /dev/null +++ b/pyserve/ctl/commands/config.py @@ -0,0 +1,420 @@ +""" +pyserve config - Configuration management commands +""" + +import json +from pathlib import Path +from typing import Any, Optional + +import click +import yaml + + +@click.group("config") +def config_cmd(): + """ + Configuration management commands. + + \b + Commands: + validate Validate configuration file + show Display current configuration + get Get a specific configuration value + set Set a configuration value + diff Compare two configuration files + """ + pass + + +@config_cmd.command("validate") +@click.option( + "-c", + "--config", + "config_file", + default=None, + help="Path to configuration file", +) +@click.option( + "--strict", + is_flag=True, + help="Enable strict validation (warn on unknown fields)", +) +@click.pass_obj +def validate_cmd(ctx, config_file: Optional[str], strict: bool): + """ + Validate a configuration file. + + Checks for syntax errors, missing required fields, and invalid values. + + \b + Examples: + pyserve config validate + pyserve config validate -c production.yaml + pyserve config validate --strict + """ + from ..output import console, print_error, print_success, print_warning + + config_path = Path(config_file or ctx.config_file) + + if not config_path.exists(): + print_error(f"Configuration file not found: {config_path}") + raise click.Abort() + + console.print(f"Validating [cyan]{config_path}[/cyan]...") + + try: + with open(config_path) as f: + data = yaml.safe_load(f) + + if data is None: + print_error("Configuration file is empty") + raise click.Abort() + + from ...config import Config + + config = Config.from_yaml(str(config_path)) + + errors = [] + warnings = [] + + if not (1 <= config.server.port <= 65535): + errors.append(f"Invalid server port: {config.server.port}") + + valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] + if config.logging.level.upper() not in valid_levels: + errors.append(f"Invalid logging level: {config.logging.level}") + + if config.ssl.enabled: + if not Path(config.ssl.cert_file).exists(): + warnings.append(f"SSL cert file not found: {config.ssl.cert_file}") + if not Path(config.ssl.key_file).exists(): + warnings.append(f"SSL key file not found: {config.ssl.key_file}") + + valid_extension_types = [ + "routing", + "process_orchestration", + "asgi_mount", + ] + for ext in config.extensions: + if ext.type not in valid_extension_types: + warnings.append(f"Unknown extension type: {ext.type}") + + if strict: + known_top_level = {"http", "server", "ssl", "logging", "extensions"} + for key in data.keys(): + if key not in known_top_level: + warnings.append(f"Unknown top-level field: {key}") + + if errors: + for error in errors: + print_error(error) + raise click.Abort() + + if warnings: + for warning in warnings: + print_warning(warning) + + print_success("Configuration is valid!") + + except yaml.YAMLError as e: + print_error(f"YAML syntax error: {e}") + raise click.Abort() + except Exception as e: + print_error(f"Validation error: {e}") + raise click.Abort() + + +@config_cmd.command("show") +@click.option( + "-c", + "--config", + "config_file", + default=None, + help="Path to configuration file", +) +@click.option( + "--format", + "output_format", + type=click.Choice(["yaml", "json", "table"]), + default="yaml", + help="Output format", +) +@click.option( + "--section", + "section", + default=None, + help="Show only a specific section (e.g., server, logging)", +) +@click.pass_obj +def show_cmd(ctx, config_file: Optional[str], output_format: str, section: Optional[str]): + """ + Display current configuration. + + \b + Examples: + pyserve config show + pyserve config show --format json + pyserve config show --section server + """ + from ..output import console, print_error + + config_path = Path(config_file or ctx.config_file) + + if not config_path.exists(): + print_error(f"Configuration file not found: {config_path}") + raise click.Abort() + + try: + with open(config_path) as f: + data = yaml.safe_load(f) + + if section: + if section in data: + data = {section: data[section]} + else: + print_error(f"Section '{section}' not found in configuration") + raise click.Abort() + + if output_format == "yaml": + from rich.syntax import Syntax + + yaml_str = yaml.dump(data, default_flow_style=False, sort_keys=False) + syntax = Syntax(yaml_str, "yaml", theme="monokai", line_numbers=False) + console.print(syntax) + + elif output_format == "json": + from rich.syntax import Syntax + + json_str = json.dumps(data, indent=2) + syntax = Syntax(json_str, "json", theme="monokai", line_numbers=False) + console.print(syntax) + + elif output_format == "table": + from rich.table import Table + from rich.tree import Tree + + def build_tree(data, tree): + if isinstance(data, dict): + for key, value in data.items(): + if isinstance(value, (dict, list)): + branch = tree.add(f"[cyan]{key}[/cyan]") + build_tree(value, branch) + else: + tree.add(f"[cyan]{key}[/cyan]: [green]{value}[/green]") + elif isinstance(data, list): + for i, item in enumerate(data): + if isinstance(item, (dict, list)): + branch = tree.add(f"[dim][{i}][/dim]") + build_tree(item, branch) + else: + tree.add(f"[dim][{i}][/dim] [green]{item}[/green]") + + tree = Tree(f"[bold]Configuration: {config_path}[/bold]") + build_tree(data, tree) + console.print(tree) + + except Exception as e: + print_error(f"Error reading configuration: {e}") + raise click.Abort() + + +@config_cmd.command("get") +@click.argument("key") +@click.option( + "-c", + "--config", + "config_file", + default=None, + help="Path to configuration file", +) +@click.pass_obj +def get_cmd(ctx, key: str, config_file: Optional[str]): + """ + Get a specific configuration value. + + Use dot notation to access nested values. + + \b + Examples: + pyserve config get server.port + pyserve config get logging.level + pyserve config get extensions.0.type + """ + from ..output import console, print_error + + config_path = Path(config_file or ctx.config_file) + + if not config_path.exists(): + print_error(f"Configuration file not found: {config_path}") + raise click.Abort() + + try: + with open(config_path) as f: + data = yaml.safe_load(f) + + value = data + for part in key.split("."): + if isinstance(value, dict): + if part in value: + value = value[part] + else: + print_error(f"Key '{key}' not found") + raise click.Abort() + elif isinstance(value, list): + try: + index = int(part) + value = value[index] + except (ValueError, IndexError): + print_error(f"Invalid index '{part}' in key '{key}'") + raise click.Abort() + else: + print_error(f"Cannot access '{part}' in {type(value).__name__}") + raise click.Abort() + + if isinstance(value, (dict, list)): + console.print(yaml.dump(value, default_flow_style=False)) + else: + console.print(str(value)) + + except Exception as e: + print_error(f"Error: {e}") + raise click.Abort() + + +@config_cmd.command("set") +@click.argument("key") +@click.argument("value") +@click.option( + "-c", + "--config", + "config_file", + default=None, + help="Path to configuration file", +) +@click.pass_obj +def set_cmd(ctx, key: str, value: str, config_file: Optional[str]): + """ + Set a configuration value. + + Use dot notation to access nested values. + + \b + Examples: + pyserve config set server.port 8080 + pyserve config set logging.level DEBUG + """ + from ..output import print_error, print_success + + config_path = Path(config_file or ctx.config_file) + + if not config_path.exists(): + print_error(f"Configuration file not found: {config_path}") + raise click.Abort() + + try: + with open(config_path) as f: + data = yaml.safe_load(f) + + parsed_value: Any + if value.lower() == "true": + parsed_value = True + elif value.lower() == "false": + parsed_value = False + elif value.isdigit(): + parsed_value = int(value) + else: + try: + parsed_value = float(value) + except ValueError: + parsed_value = value + + parts = key.split(".") + current = data + for part in parts[:-1]: + if isinstance(current, dict): + if part not in current: + current[part] = {} + current = current[part] + elif isinstance(current, list): + index = int(part) + current = current[index] + + final_key = parts[-1] + if isinstance(current, dict): + current[final_key] = parsed_value + elif isinstance(current, list): + current[int(final_key)] = parsed_value + + with open(config_path, "w") as f: + yaml.dump(data, f, default_flow_style=False, sort_keys=False) + + print_success(f"Set {key} = {parsed_value}") + + except Exception as e: + print_error(f"Error: {e}") + raise click.Abort() + + +@config_cmd.command("diff") +@click.argument("file1", type=click.Path(exists=True)) +@click.argument("file2", type=click.Path(exists=True)) +def diff_cmd(file1: str, file2: str): + """ + Compare two configuration files. + + \b + Examples: + pyserve config diff config.yaml production.yaml + """ + from ..output import console, print_error + + try: + with open(file1) as f: + data1 = yaml.safe_load(f) + with open(file2) as f: + data2 = yaml.safe_load(f) + + def compare_dicts(d1, d2, path=""): + differences = [] + + all_keys = set(d1.keys() if d1 else []) | set(d2.keys() if d2 else []) + + for key in sorted(all_keys): + current_path = f"{path}.{key}" if path else key + v1 = d1.get(key) if d1 else None + v2 = d2.get(key) if d2 else None + + if key not in (d1 or {}): + differences.append(("added", current_path, None, v2)) + elif key not in (d2 or {}): + differences.append(("removed", current_path, v1, None)) + elif isinstance(v1, dict) and isinstance(v2, dict): + differences.extend(compare_dicts(v1, v2, current_path)) + elif v1 != v2: + differences.append(("changed", current_path, v1, v2)) + + return differences + + differences = compare_dicts(data1, data2) + + if not differences: + console.print("[green]Files are identical[/green]") + return + + console.print(f"\n[bold]Differences between {file1} and {file2}:[/bold]\n") + + for diff_type, path, v1, v2 in differences: + if diff_type == "added": + console.print(f" [green]+ {path}: {v2}[/green]") + elif diff_type == "removed": + console.print(f" [red]- {path}: {v1}[/red]") + elif diff_type == "changed": + console.print(f" [yellow]~ {path}:[/yellow]") + console.print(f" [red]- {v1}[/red]") + console.print(f" [green]+ {v2}[/green]") + + console.print() + + except Exception as e: + print_error(f"Error: {e}") + raise click.Abort() diff --git a/pyserve/ctl/commands/down.py b/pyserve/ctl/commands/down.py new file mode 100644 index 0000000..df5d3cf --- /dev/null +++ b/pyserve/ctl/commands/down.py @@ -0,0 +1,122 @@ +""" +pyserve down - Stop all services +""" + +import signal +import time +from pathlib import Path +from typing import Optional, cast + +import click + + +@click.command("down") +@click.option( + "--timeout", + "timeout", + default=30, + type=int, + help="Timeout in seconds for graceful shutdown", +) +@click.option( + "-v", + "--volumes", + is_flag=True, + help="Remove volumes/data", +) +@click.option( + "--remove-orphans", + is_flag=True, + help="Remove orphaned services", +) +@click.pass_obj +def down_cmd( + ctx, + timeout: int, + volumes: bool, + remove_orphans: bool, +): + """ + Stop and remove all services. + + \b + Examples: + pyserve down # Stop all services + pyserve down --timeout 60 # Extended shutdown timeout + pyserve down -v # Remove volumes too + """ + from ..output import console, print_error, print_info, print_success, print_warning + from ..state import StateManager + + state_manager = StateManager(Path(".pyserve"), ctx.project) + + if state_manager.is_daemon_running(): + daemon_pid = state_manager.get_daemon_pid() + console.print(f"[bold]Stopping PyServe daemon (PID: {daemon_pid})...[/bold]") + + try: + import os + # FIXME: Please fix the cast usage here + os.kill(cast(int, daemon_pid), signal.SIGTERM) + + start_time = time.time() + while time.time() - start_time < timeout: + try: + # FIXME: Please fix the cast usage here + os.kill(cast(int, daemon_pid), 0) + time.sleep(0.5) + except ProcessLookupError: + break + else: + print_warning("Graceful shutdown timed out, forcing...") + try: + # FIXME: Please fix the cast usage here + os.kill(cast(int, daemon_pid), signal.SIGKILL) + except ProcessLookupError: + pass + + state_manager.clear_daemon_pid() + print_success("PyServe daemon stopped") + + except ProcessLookupError: + print_info("Daemon was not running") + state_manager.clear_daemon_pid() + except PermissionError: + print_error("Permission denied to stop daemon") + raise click.Abort() + else: + services = state_manager.get_all_services() + + if not services: + print_info("No services are running") + return + + console.print("[bold]Stopping services...[/bold]") + + from .._runner import ServiceRunner + from ...config import Config + + config_path = Path(ctx.config_file) + if config_path.exists(): + config = Config.from_yaml(str(config_path)) + else: + config = Config() + + runner = ServiceRunner(config, state_manager) + + import asyncio + + try: + asyncio.run(runner.stop_all(timeout=timeout)) + print_success("All services stopped") + except Exception as e: + print_error(f"Error stopping services: {e}") + + if volumes: + console.print("Cleaning up state...") + state_manager.clear() + print_info("State cleared") + + if remove_orphans: + # This would remove services that are in state but not in config + pass diff --git a/pyserve/ctl/commands/health.py b/pyserve/ctl/commands/health.py new file mode 100644 index 0000000..f787f30 --- /dev/null +++ b/pyserve/ctl/commands/health.py @@ -0,0 +1,160 @@ +""" +pyserve health - Check health of services +""" + +import asyncio +from pathlib import Path +from typing import Optional + +import click + + +@click.command("health") +@click.argument("services", nargs=-1) +@click.option( + "--timeout", + "timeout", + default=5, + type=int, + help="Health check timeout in seconds", +) +@click.option( + "--format", + "output_format", + type=click.Choice(["table", "json"]), + default="table", + help="Output format", +) +@click.pass_obj +def health_cmd(ctx, services: tuple, timeout: int, output_format: str): + """ + Check health of services. + + Performs active health checks on running services. + + \b + Examples: + pyserve health # Check all services + pyserve health api admin # Check specific services + pyserve health --format json # JSON output + """ + from ..output import console, print_error, print_info + from ..state import StateManager + + state_manager = StateManager(Path(".pyserve"), ctx.project) + all_services = state_manager.get_all_services() + + if services: + all_services = {k: v for k, v in all_services.items() if k in services} + + if not all_services: + print_info("No services to check") + return + + results = asyncio.run(_check_health(all_services, timeout)) + + if output_format == "json": + import json + + console.print(json.dumps(results, indent=2)) + return + + from rich.table import Table + from ..output import format_health + + table = Table(show_header=True, header_style="bold") + table.add_column("SERVICE", style="cyan") + table.add_column("HEALTH") + table.add_column("CHECKS", justify="right") + table.add_column("LAST CHECK", style="dim") + table.add_column("RESPONSE TIME", justify="right") + + for name, result in results.items(): + health_str = format_health(result["status"]) + checks = f"{result['successes']}/{result['total']}" + last_check = result.get("last_check", "-") + response_time = f"{result['response_time_ms']:.0f}ms" if result.get("response_time_ms") else "-" + + table.add_row(name, health_str, checks, last_check, response_time) + + console.print() + console.print(table) + console.print() + + healthy = sum(1 for r in results.values() if r["status"] == "healthy") + unhealthy = sum(1 for r in results.values() if r["status"] == "unhealthy") + + if unhealthy: + print_error(f"{unhealthy} service(s) unhealthy") + raise SystemExit(1) + else: + from ..output import print_success + + print_success(f"All {healthy} service(s) healthy") + + +async def _check_health(services: dict, timeout: int) -> dict: + import time + + try: + import httpx + except ImportError: + return {name: {"status": "unknown", "error": "httpx not installed"} for name in services} + + results = {} + + for name, service in services.items(): + if service.state != "running" or not service.port: + results[name] = { + "status": "unknown", + "successes": 0, + "total": 0, + "error": "Service not running", + } + continue + + health_path = "/health" + url = f"http://127.0.0.1:{service.port}{health_path}" + + start_time = time.time() + try: + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await client.get(url) + response_time = (time.time() - start_time) * 1000 + + if resp.status_code < 500: + results[name] = { + "status": "healthy", + "successes": 1, + "total": 1, + "response_time_ms": response_time, + "last_check": "just now", + "status_code": resp.status_code, + } + else: + results[name] = { + "status": "unhealthy", + "successes": 0, + "total": 1, + "response_time_ms": response_time, + "last_check": "just now", + "status_code": resp.status_code, + } + except httpx.TimeoutException: + results[name] = { + "status": "unhealthy", + "successes": 0, + "total": 1, + "error": "timeout", + "last_check": "just now", + } + except Exception as e: + results[name] = { + "status": "unhealthy", + "successes": 0, + "total": 1, + "error": str(e), + "last_check": "just now", + } + + return results diff --git a/pyserve/ctl/commands/init.py b/pyserve/ctl/commands/init.py new file mode 100644 index 0000000..dae4a33 --- /dev/null +++ b/pyserve/ctl/commands/init.py @@ -0,0 +1,433 @@ +""" +pyserve init - Initialize a new pyserve project +""" + +from pathlib import Path +from typing import Optional + +import click + +TEMPLATES = { + "basic": { + "description": "Basic configuration with static files and routing", + "filename": "config.yaml", + }, + "orchestration": { + "description": "Process orchestration with multiple ASGI/WSGI apps", + "filename": "config.yaml", + }, + "asgi": { + "description": "ASGI mount configuration for in-process apps", + "filename": "config.yaml", + }, + "full": { + "description": "Full configuration with all features", + "filename": "config.yaml", + }, +} + +BASIC_TEMPLATE = """\ +# PyServe Configuration +# Generated by: pyserve init + +http: + static_dir: ./static + templates_dir: ./templates + +server: + host: 0.0.0.0 + port: 8080 + backlog: 100 + proxy_timeout: 30.0 + +ssl: + enabled: false + cert_file: ./ssl/cert.pem + key_file: ./ssl/key.pem + +logging: + level: INFO + console_output: true + format: + type: standard + use_colors: true + show_module: true + timestamp_format: "%Y-%m-%d %H:%M:%S" + console: + level: INFO + format: + type: standard + use_colors: true + files: + - path: ./logs/pyserve.log + level: INFO + format: + type: standard + use_colors: false + +extensions: + - type: routing + config: + regex_locations: + # Health check endpoint + "=/health": + return: "200 OK" + content_type: "text/plain" + + # Static files + "^/static/": + root: "./static" + strip_prefix: "/static" + + # Default fallback + "__default__": + spa_fallback: true + root: "./static" + index_file: "index.html" +""" + +ORCHESTRATION_TEMPLATE = """\ +# PyServe Process Orchestration Configuration +# Generated by: pyserve init --template orchestration +# +# This configuration runs multiple ASGI/WSGI apps as isolated processes +# with automatic health monitoring and restart. + +server: + host: 0.0.0.0 + port: 8080 + backlog: 2048 + proxy_timeout: 60.0 + +logging: + level: INFO + console_output: true + format: + type: standard + use_colors: true + files: + - path: ./logs/pyserve.log + level: DEBUG + format: + type: standard + use_colors: false + +extensions: + # Process Orchestration - runs each app in its own process + - type: process_orchestration + config: + port_range: [9000, 9999] + health_check_enabled: true + proxy_timeout: 60.0 + + apps: + # Example: FastAPI application + - name: api + path: /api + app_path: myapp.api:app + module_path: "." + workers: 2 + health_check_path: /health + health_check_interval: 10.0 + health_check_timeout: 5.0 + health_check_retries: 3 + max_restart_count: 5 + restart_delay: 1.0 + strip_path: true + env: + APP_ENV: "production" + + # Example: Flask application (WSGI) + # - name: admin + # path: /admin + # app_path: myapp.admin:app + # app_type: wsgi + # module_path: "." + # workers: 1 + # health_check_path: /health + # strip_path: true + + # Static files routing + - type: routing + config: + regex_locations: + "=/health": + return: "200 OK" + content_type: "text/plain" + + "^/static/": + root: "./static" + strip_prefix: "/static" +""" + +ASGI_TEMPLATE = """\ +# PyServe ASGI Mount Configuration +# Generated by: pyserve init --template asgi +# +# This configuration mounts ASGI apps in-process (like ASGI Lifespan). +# More efficient but apps share the same process. + +server: + host: 0.0.0.0 + port: 8080 + backlog: 100 + proxy_timeout: 30.0 + +logging: + level: INFO + console_output: true + format: + type: standard + use_colors: true + files: + - path: ./logs/pyserve.log + level: DEBUG + +extensions: + - type: asgi_mount + config: + mounts: + # FastAPI app mounted at /api + - path: /api + app: myapp.api:app + # factory: false # Set to true if app is a factory function + + # Starlette app mounted at /web + # - path: /web + # app: myapp.web:app + + - type: routing + config: + regex_locations: + "=/health": + return: "200 OK" + content_type: "text/plain" + + "^/static/": + root: "./static" + strip_prefix: "/static" + + "__default__": + spa_fallback: true + root: "./static" + index_file: "index.html" +""" + +FULL_TEMPLATE = """\ +# PyServe Full Configuration +# Generated by: pyserve init --template full +# +# Comprehensive configuration showcasing all PyServe features. + +http: + static_dir: ./static + templates_dir: ./templates + +server: + host: 0.0.0.0 + port: 8080 + backlog: 2048 + default_root: false + proxy_timeout: 60.0 + redirect_instructions: + "/old-path": "/new-path" + +ssl: + enabled: false + cert_file: ./ssl/cert.pem + key_file: ./ssl/key.pem + +logging: + level: INFO + console_output: true + format: + type: standard + use_colors: true + show_module: true + timestamp_format: "%Y-%m-%d %H:%M:%S" + console: + level: DEBUG + format: + type: standard + use_colors: true + files: + # Main log file + - path: ./logs/pyserve.log + level: DEBUG + format: + type: standard + use_colors: false + + # JSON logs for log aggregation + - path: ./logs/pyserve.json + level: INFO + format: + type: json + + # Access logs + - path: ./logs/access.log + level: INFO + loggers: ["pyserve.access"] + max_bytes: 10485760 # 10MB + backup_count: 10 + +extensions: + # Process Orchestration for background services + - type: process_orchestration + config: + port_range: [9000, 9999] + health_check_enabled: true + proxy_timeout: 60.0 + + apps: + - name: api + path: /api + app_path: myapp.api:app + module_path: "." + workers: 2 + health_check_path: /health + strip_path: true + env: + APP_ENV: "production" + + # Advanced routing with regex + - type: routing + config: + regex_locations: + # API versioning + "~^/api/v(?P\\\\d+)/": + proxy_pass: "http://localhost:9001" + headers: + - "API-Version: {version}" + - "X-Forwarded-For: $remote_addr" + + # Static files with caching + "~*\\\\.(js|css|png|jpg|gif|ico|svg|woff2?)$": + root: "./static" + cache_control: "public, max-age=31536000" + headers: + - "Access-Control-Allow-Origin: *" + + # Health check + "=/health": + return: "200 OK" + content_type: "text/plain" + + # Static files + "^/static/": + root: "./static" + strip_prefix: "/static" + + # SPA fallback + "__default__": + spa_fallback: true + root: "./static" + index_file: "index.html" +""" + + +def get_template_content(template: str) -> str: + templates = { + "basic": BASIC_TEMPLATE, + "orchestration": ORCHESTRATION_TEMPLATE, + "asgi": ASGI_TEMPLATE, + "full": FULL_TEMPLATE, + } + return templates.get(template, BASIC_TEMPLATE) + + +@click.command("init") +@click.option( + "-t", + "--template", + "template", + type=click.Choice(list(TEMPLATES.keys())), + default="basic", + help="Configuration template to use", +) +@click.option( + "-o", + "--output", + "output_file", + default="config.yaml", + help="Output file path (default: config.yaml)", +) +@click.option( + "-f", + "--force", + is_flag=True, + help="Overwrite existing configuration", +) +@click.option( + "--list-templates", + is_flag=True, + help="List available templates", +) +@click.pass_context +def init_cmd( + ctx, + template: str, + output_file: str, + force: bool, + list_templates: bool, +): + """ + Initialize a new pyserve project. + + Creates a configuration file with sensible defaults and directory structure. + + \b + Examples: + pyserve init # Basic configuration + pyserve init -t orchestration # Process orchestration setup + pyserve init -t asgi # ASGI mount setup + pyserve init -t full # All features + pyserve init -o production.yaml # Custom output file + """ + from ..output import console, print_success, print_warning, print_info + + if list_templates: + console.print("\n[bold]Available Templates:[/bold]\n") + for name, info in TEMPLATES.items(): + console.print(f" [cyan]{name:15}[/cyan] - {info['description']}") + console.print() + return + + output_path = Path(output_file) + + if output_path.exists() and not force: + print_warning(f"Configuration file '{output_file}' already exists.") + if not click.confirm("Do you want to overwrite it?"): + raise click.Abort() + + dirs_to_create = ["static", "templates", "logs"] + if template == "orchestration": + dirs_to_create.append("apps") + + for dir_name in dirs_to_create: + dir_path = Path(dir_name) + if not dir_path.exists(): + dir_path.mkdir(parents=True) + print_info(f"Created directory: {dir_name}/") + + state_dir = Path(".pyserve") + if not state_dir.exists(): + state_dir.mkdir() + print_info("Created directory: .pyserve/") + + content = get_template_content(template) + output_path.write_text(content) + + print_success(f"Created configuration file: {output_file}") + print_info(f"Template: {template}") + + gitignore_path = Path(".pyserve/.gitignore") + if not gitignore_path.exists(): + gitignore_path.write_text("*\n!.gitignore\n") + + console.print() + console.print("[bold]Next steps:[/bold]") + console.print(f" 1. Edit [cyan]{output_file}[/cyan] to configure your services") + console.print(" 2. Run [cyan]pyserve config validate[/cyan] to check configuration") + console.print(" 3. Run [cyan]pyserve up[/cyan] to start services") + console.print() diff --git a/pyserve/ctl/commands/logs.py b/pyserve/ctl/commands/logs.py new file mode 100644 index 0000000..9d563db --- /dev/null +++ b/pyserve/ctl/commands/logs.py @@ -0,0 +1,290 @@ +""" +pyserve logs - View service logs +""" + +import asyncio +import sys +import time +from pathlib import Path +from typing import Optional + +import click + + +@click.command("logs") +@click.argument("services", nargs=-1) +@click.option( + "-f", + "--follow", + is_flag=True, + help="Follow log output", +) +@click.option( + "--tail", + "tail", + default=100, + type=int, + help="Number of lines to show from the end", +) +@click.option( + "--since", + "since", + default=None, + help="Show logs since timestamp (e.g., '10m', '1h', '2024-01-01')", +) +@click.option( + "--until", + "until_time", + default=None, + help="Show logs until timestamp", +) +@click.option( + "-t", + "--timestamps", + is_flag=True, + help="Show timestamps", +) +@click.option( + "--no-color", + is_flag=True, + help="Disable colored output", +) +@click.option( + "--filter", + "filter_pattern", + default=None, + help="Filter logs by pattern", +) +@click.pass_obj +def logs_cmd( + ctx, + services: tuple, + follow: bool, + tail: int, + since: Optional[str], + until_time: Optional[str], + timestamps: bool, + no_color: bool, + filter_pattern: Optional[str], +): + """ + View service logs. + + If no services are specified, shows logs from all services. + + \b + Examples: + pyserve logs # All logs + pyserve logs api # Logs from api service + pyserve logs api admin # Logs from multiple services + pyserve logs -f # Follow logs + pyserve logs --tail 50 # Last 50 lines + pyserve logs --since "10m" # Logs from last 10 minutes + """ + from ..output import console, print_error, print_info + from ..state import StateManager + + state_manager = StateManager(Path(".pyserve"), ctx.project) + + if services: + log_files = [ + (name, state_manager.get_service_log_file(name)) for name in services + ] + else: + all_services = state_manager.get_all_services() + if not all_services: + main_log = Path("logs/pyserve.log") + if main_log.exists(): + log_files = [("pyserve", main_log)] + else: + print_info("No logs available. Start services with 'pyserve up'") + return + else: + log_files = [ + (name, state_manager.get_service_log_file(name)) + for name in all_services + ] + + existing_logs = [(name, path) for name, path in log_files if path.exists()] + + if not existing_logs: + print_info("No log files found") + return + + since_time = _parse_time(since) if since else None + until_timestamp = _parse_time(until_time) if until_time else None + + colors = ["cyan", "green", "yellow", "blue", "magenta"] + service_colors = { + name: colors[i % len(colors)] for i, (name, _) in enumerate(existing_logs) + } + + if follow: + asyncio.run( + _follow_logs( + existing_logs, + service_colors, + timestamps, + no_color, + filter_pattern, + ) + ) + else: + _read_logs( + existing_logs, + service_colors, + tail, + since_time, + until_timestamp, + timestamps, + no_color, + filter_pattern, + ) + + +def _parse_time(time_str: str) -> Optional[float]: + import re + from datetime import datetime, timedelta + + # Relative time (e.g., "10m", "1h", "2d") + match = re.match(r"^(\d+)([smhd])$", time_str) + if match: + value = int(match.group(1)) + unit = match.group(2) + units = {"s": 1, "m": 60, "h": 3600, "d": 86400} + return time.time() - (value * units[unit]) + + # Relative phrase (e.g., "10m ago") + match = re.match(r"^(\d+)([smhd])\s+ago$", time_str) + if match: + value = int(match.group(1)) + unit = match.group(2) + units = {"s": 1, "m": 60, "h": 3600, "d": 86400} + return time.time() - (value * units[unit]) + + # ISO format + try: + dt = datetime.fromisoformat(time_str) + return dt.timestamp() + except ValueError: + pass + + return None + + +def _read_logs( + log_files, + service_colors, + tail: int, + since_time: Optional[float], + until_time: Optional[float], + timestamps: bool, + no_color: bool, + filter_pattern: Optional[str], +): + from ..output import console + + import re + + all_lines = [] + + for service_name, log_path in log_files: + try: + with open(log_path) as f: + lines = f.readlines() + + # Take last N lines + lines = lines[-tail:] if tail else lines + + for line in lines: + line = line.rstrip() + if not line: + continue + + if filter_pattern and filter_pattern not in line: + continue + + line_time = None + timestamp_match = re.match(r"^(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2})", line) + if timestamp_match: + try: + from datetime import datetime + + line_time = datetime.fromisoformat( + timestamp_match.group(1).replace(" ", "T") + ).timestamp() + except ValueError: + pass + + if since_time and line_time and line_time < since_time: + continue + if until_time and line_time and line_time > until_time: + continue + + all_lines.append((line_time or 0, service_name, line)) + + except Exception as e: + console.print(f"[red]Error reading {log_path}: {e}[/red]") + + all_lines.sort(key=lambda x: x[0]) + + for _, service_name, line in all_lines: + if len(log_files) > 1: + # Multiple services - prefix with service name + if no_color: + console.print(f"{service_name} | {line}") + else: + color = service_colors.get(service_name, "white") + console.print(f"[{color}]{service_name}[/{color}] | {line}") + else: + console.print(line) + + +async def _follow_logs( + log_files, + service_colors, + timestamps: bool, + no_color: bool, + filter_pattern: Optional[str], +): + from ..output import console + + positions = {} + for service_name, log_path in log_files: + if log_path.exists(): + positions[service_name] = log_path.stat().st_size + else: + positions[service_name] = 0 + + console.print("[dim]Following logs... Press Ctrl+C to stop[/dim]\n") + + try: + while True: + for service_name, log_path in log_files: + if not log_path.exists(): + continue + + current_size = log_path.stat().st_size + if current_size > positions[service_name]: + with open(log_path) as f: + f.seek(positions[service_name]) + new_content = f.read() + positions[service_name] = f.tell() + + for line in new_content.splitlines(): + if filter_pattern and filter_pattern not in line: + continue + + if len(log_files) > 1: + if no_color: + console.print(f"{service_name} | {line}") + else: + color = service_colors.get(service_name, "white") + console.print(f"[{color}]{service_name}[/{color}] | {line}") + else: + console.print(line) + + await asyncio.sleep(0.5) + + except KeyboardInterrupt: + console.print("\n[dim]Stopped following logs[/dim]") diff --git a/pyserve/ctl/commands/scale.py b/pyserve/ctl/commands/scale.py new file mode 100644 index 0000000..607c399 --- /dev/null +++ b/pyserve/ctl/commands/scale.py @@ -0,0 +1,89 @@ +""" +pyserve scale - Scale services +""" + +import asyncio +from pathlib import Path + +import click + + +@click.command("scale") +@click.argument("scales", nargs=-1, required=True) +@click.option( + "--timeout", + "timeout", + default=60, + type=int, + help="Timeout in seconds for scaling operation", +) +@click.option( + "--no-wait", + is_flag=True, + help="Don't wait for services to be ready", +) +@click.pass_obj +def scale_cmd(ctx, scales: tuple, timeout: int, no_wait: bool): + """ + Scale services to specified number of workers. + + Use SERVICE=NUM format to specify scaling. + + \b + Examples: + pyserve scale api=4 # Scale api to 4 workers + pyserve scale api=4 admin=2 # Scale multiple services + """ + from ..output import console, print_error, print_info, print_success + from ..state import StateManager + from .._runner import ServiceRunner + from ...config import Config + + scale_map = {} + for scale in scales: + try: + service, num = scale.split("=") + scale_map[service] = int(num) + except ValueError: + print_error(f"Invalid scale format: {scale}. Use SERVICE=NUM") + raise click.Abort() + + config_path = Path(ctx.config_file) + if not config_path.exists(): + print_error(f"Configuration file not found: {config_path}") + raise click.Abort() + + config = Config.from_yaml(str(config_path)) + state_manager = StateManager(Path(".pyserve"), ctx.project) + + all_services = state_manager.get_all_services() + for service in scale_map: + if service not in all_services: + print_error(f"Service '{service}' not found") + raise click.Abort() + + runner = ServiceRunner(config, state_manager) + + console.print("[bold]Scaling services...[/bold]") + + async def do_scale(): + for service, workers in scale_map.items(): + current = all_services[service].workers or 1 + print_info(f"Scaling {service}: {current} → {workers} workers") + + try: + success = await runner.scale_service( + service, workers, timeout=timeout, wait=not no_wait + ) + if success: + print_success(f"Scaled {service} to {workers} workers") + else: + print_error(f"Failed to scale {service}") + except Exception as e: + print_error(f"Error scaling {service}: {e}") + + try: + asyncio.run(do_scale()) + except Exception as e: + print_error(f"Scaling failed: {e}") + raise click.Abort() diff --git a/pyserve/ctl/commands/service.py b/pyserve/ctl/commands/service.py new file mode 100644 index 0000000..c4d9f2a --- /dev/null +++ b/pyserve/ctl/commands/service.py @@ -0,0 +1,190 @@ +""" +pyserve start/stop/restart - Service management commands +""" + +import asyncio +from pathlib import Path +from typing import List + +import click + + +@click.command("start") +@click.argument("services", nargs=-1, required=True) +@click.option( + "--timeout", + "timeout", + default=60, + type=int, + help="Timeout in seconds for service startup", +) +@click.pass_obj +def start_cmd(ctx, services: tuple, timeout: int): + """ + Start one or more services. + + \b + Examples: + pyserve start api # Start api service + pyserve start api admin # Start multiple services + """ + from ..output import console, print_error, print_info, print_success + from ..state import StateManager + from .._runner import ServiceRunner + from ...config import Config + + config_path = Path(ctx.config_file) + if not config_path.exists(): + print_error(f"Configuration file not found: {config_path}") + raise click.Abort() + + config = Config.from_yaml(str(config_path)) + state_manager = StateManager(Path(".pyserve"), ctx.project) + runner = ServiceRunner(config, state_manager) + + console.print(f"[bold]Starting services: {', '.join(services)}[/bold]") + + async def do_start(): + results = {} + for service in services: + try: + success = await runner.start_service(service, timeout=timeout) + results[service] = success + if success: + print_success(f"Started {service}") + else: + print_error(f"Failed to start {service}") + except Exception as e: + print_error(f"Error starting {service}: {e}") + results[service] = False + return results + + try: + results = asyncio.run(do_start()) + if not all(results.values()): + raise click.Abort() + except Exception as e: + print_error(f"Error: {e}") + raise click.Abort() + + +@click.command("stop") +@click.argument("services", nargs=-1, required=True) +@click.option( + "--timeout", + "timeout", + default=30, + type=int, + help="Timeout in seconds for graceful shutdown", +) +@click.option( + "-f", + "--force", + is_flag=True, + help="Force stop (SIGKILL)", +) +@click.pass_obj +def stop_cmd(ctx, services: tuple, timeout: int, force: bool): + """ + Stop one or more services. + + \b + Examples: + pyserve stop api # Stop api service + pyserve stop api admin # Stop multiple services + pyserve stop api --force # Force stop + """ + from ..output import console, print_error, print_success + from ..state import StateManager + from .._runner import ServiceRunner + from ...config import Config + + config_path = Path(ctx.config_file) + config = Config.from_yaml(str(config_path)) if config_path.exists() else Config() + + state_manager = StateManager(Path(".pyserve"), ctx.project) + runner = ServiceRunner(config, state_manager) + + console.print(f"[bold]Stopping services: {', '.join(services)}[/bold]") + + async def do_stop(): + results = {} + for service in services: + try: + success = await runner.stop_service(service, timeout=timeout, force=force) + results[service] = success + if success: + print_success(f"Stopped {service}") + else: + print_error(f"Failed to stop {service}") + except Exception as e: + print_error(f"Error stopping {service}: {e}") + results[service] = False + return results + + try: + results = asyncio.run(do_stop()) + if not all(results.values()): + raise click.Abort() + except Exception as e: + print_error(f"Error: {e}") + raise click.Abort() + + +@click.command("restart") +@click.argument("services", nargs=-1, required=True) +@click.option( + "--timeout", + "timeout", + default=60, + type=int, + help="Timeout in seconds for restart", +) +@click.pass_obj +def restart_cmd(ctx, services: tuple, timeout: int): + """ + Restart one or more services. + + \b + Examples: + pyserve restart api # Restart api service + pyserve restart api admin # Restart multiple services + """ + from ..output import console, print_error, print_success + from ..state import StateManager + from .._runner import ServiceRunner + from ...config import Config + + config_path = Path(ctx.config_file) + if not config_path.exists(): + print_error(f"Configuration file not found: {config_path}") + raise click.Abort() + + config = Config.from_yaml(str(config_path)) + state_manager = StateManager(Path(".pyserve"), ctx.project) + runner = ServiceRunner(config, state_manager) + + console.print(f"[bold]Restarting services: {', '.join(services)}[/bold]") + + async def do_restart(): + results = {} + for service in services: + try: + success = await runner.restart_service(service, timeout=timeout) + results[service] = success + if success: + print_success(f"Restarted {service}") + else: + print_error(f"Failed to restart {service}") + except Exception as e: + print_error(f"Error restarting {service}: {e}") + results[service] = False + return results + + try: + results = asyncio.run(do_restart()) + if not all(results.values()): + raise click.Abort() + except Exception as e: + print_error(f"Error: {e}") + raise click.Abort() diff --git a/pyserve/ctl/commands/status.py b/pyserve/ctl/commands/status.py new file mode 100644 index 0000000..0ff0ee0 --- /dev/null +++ b/pyserve/ctl/commands/status.py @@ -0,0 +1,153 @@ +""" +pyserve ps / status - Show service status +""" + +import json +from pathlib import Path +from typing import Optional + +import click + + +@click.command("ps") +@click.argument("services", nargs=-1) +@click.option( + "-a", + "--all", + "show_all", + is_flag=True, + help="Show all services (including stopped)", +) +@click.option( + "-q", + "--quiet", + is_flag=True, + help="Only show service names", +) +@click.option( + "--format", + "output_format", + type=click.Choice(["table", "json", "yaml"]), + default="table", + help="Output format", +) +@click.option( + "--filter", + "filter_status", + default=None, + help="Filter by status (running, stopped, failed)", +) +@click.pass_obj +def ps_cmd( + ctx, + services: tuple, + show_all: bool, + quiet: bool, + output_format: str, + filter_status: Optional[str], +): + """ + Show status of services. + + \b + Examples: + pyserve ps # Show running services + pyserve ps -a # Show all services + pyserve ps api admin # Show specific services + pyserve ps --format json # JSON output + pyserve ps --filter running # Filter by status + """ + from ..output import ( + console, + create_services_table, + format_health, + format_status, + format_uptime, + print_info, + ) + from ..state import StateManager + + state_manager = StateManager(Path(".pyserve"), ctx.project) + all_services = state_manager.get_all_services() + + # Check if daemon is running + daemon_running = state_manager.is_daemon_running() + + # Filter services + if services: + all_services = {k: v for k, v in all_services.items() if k in services} + + if filter_status: + all_services = { + k: v for k, v in all_services.items() if v.state.lower() == filter_status.lower() + } + + if not show_all: + # By default, show only running/starting/failed services + all_services = { + k: v + for k, v in all_services.items() + if v.state.lower() in ("running", "starting", "stopping", "failed", "restarting") + } + + if not all_services: + if daemon_running: + print_info("No services found. Daemon is running but no services are configured.") + else: + print_info("No services running. Use 'pyserve up' to start services.") + return + + if quiet: + for name in all_services: + click.echo(name) + return + + if output_format == "json": + data = {name: svc.to_dict() for name, svc in all_services.items()} + console.print(json.dumps(data, indent=2)) + return + + if output_format == "yaml": + import yaml + + data = {name: svc.to_dict() for name, svc in all_services.items()} + console.print(yaml.dump(data, default_flow_style=False)) + return + + table = create_services_table() + + for name, service in sorted(all_services.items()): + ports = f"{service.port}" if service.port else "-" + uptime = format_uptime(service.uptime) if service.state == "running" else "-" + health = format_health(service.health.status if service.state == "running" else "-") + pid = str(service.pid) if service.pid else "-" + workers = f"{service.workers}" if service.workers else "-" + + table.add_row( + name, + format_status(service.state), + ports, + uptime, + health, + pid, + workers, + ) + + console.print() + console.print(table) + console.print() + + total = len(all_services) + running = sum(1 for s in all_services.values() if s.state == "running") + failed = sum(1 for s in all_services.values() if s.state == "failed") + + summary_parts = [f"[bold]{total}[/bold] service(s)"] + if running: + summary_parts.append(f"[green]{running} running[/green]") + if failed: + summary_parts.append(f"[red]{failed} failed[/red]") + if total - running - failed > 0: + summary_parts.append(f"[dim]{total - running - failed} stopped[/dim]") + + console.print(" | ".join(summary_parts)) + console.print() diff --git a/pyserve/ctl/commands/top.py b/pyserve/ctl/commands/top.py new file mode 100644 index 0000000..a26a850 --- /dev/null +++ b/pyserve/ctl/commands/top.py @@ -0,0 +1,182 @@ +""" +pyserve top - Live monitoring dashboard +""" + +import asyncio +import time +from pathlib import Path +from typing import Optional + +import click + + +@click.command("top") +@click.argument("services", nargs=-1) +@click.option( + "--refresh", + "refresh_interval", + default=2, + type=float, + help="Refresh interval in seconds", +) +@click.option( + "--no-color", + is_flag=True, + help="Disable colored output", +) +@click.pass_obj +def top_cmd(ctx, services: tuple, refresh_interval: float, no_color: bool): + """ + Live monitoring dashboard for services. + + Shows real-time CPU, memory usage, and request metrics. + + \b + Examples: + pyserve top # Monitor all services + pyserve top api admin # Monitor specific services + pyserve top --refresh 5 # Slower refresh rate + """ + from ..output import console, print_info + from ..state import StateManager + + state_manager = StateManager(Path(".pyserve"), ctx.project) + + if not state_manager.is_daemon_running(): + print_info("No services running. Start with 'pyserve up -d'") + return + + try: + asyncio.run( + _run_dashboard( + state_manager, + list(services) if services else None, + refresh_interval, + no_color, + ) + ) + except KeyboardInterrupt: + console.print("\n") + + +async def _run_dashboard( + state_manager, + filter_services: Optional[list], + refresh_interval: float, + no_color: bool, +): + from rich.live import Live + from rich.table import Table + from rich.panel import Panel + from rich.layout import Layout + from rich.text import Text + from ..output import console, format_uptime, format_bytes + + try: + import psutil + except ImportError: + console.print("[yellow]psutil not installed. Install with: pip install psutil[/yellow]") + return + + start_time = time.time() + + def make_dashboard(): + all_services = state_manager.get_all_services() + + if filter_services: + all_services = {k: v for k, v in all_services.items() if k in filter_services} + + table = Table( + title=None, + show_header=True, + header_style="bold", + border_style="dim", + expand=True, + ) + table.add_column("SERVICE", style="cyan", no_wrap=True) + table.add_column("STATUS", no_wrap=True) + table.add_column("CPU%", justify="right") + table.add_column("MEM", justify="right") + table.add_column("PID", style="dim") + table.add_column("UPTIME", style="dim") + table.add_column("HEALTH", no_wrap=True) + + total_cpu = 0.0 + total_mem = 0 + running_count = 0 + total_count = len(all_services) + + for name, service in sorted(all_services.items()): + status_style = { + "running": "[green]● RUN[/green]", + "stopped": "[dim]○ STOP[/dim]", + "failed": "[red]✗ FAIL[/red]", + "starting": "[yellow]◐ START[/yellow]", + "stopping": "[yellow]◑ STOP[/yellow]", + }.get(service.state, service.state) + + cpu_str = "-" + mem_str = "-" + + if service.pid and service.state == "running": + try: + proc = psutil.Process(service.pid) + cpu = proc.cpu_percent(interval=0.1) + mem = proc.memory_info().rss + cpu_str = f"{cpu:.1f}%" + mem_str = format_bytes(mem) + total_cpu += cpu + total_mem += mem + running_count += 1 + except (psutil.NoSuchProcess, psutil.AccessDenied): + pass + + health_style = { + "healthy": "[green]✓[/green]", + "unhealthy": "[red]✗[/red]", + "degraded": "[yellow]⚠[/yellow]", + "unknown": "[dim]?[/dim]", + }.get(service.health.status, "[dim]-[/dim]") + + uptime = format_uptime(service.uptime) if service.state == "running" else "-" + pid = str(service.pid) if service.pid else "-" + + table.add_row( + name, + status_style, + cpu_str, + mem_str, + pid, + uptime, + health_style, + ) + + elapsed = format_uptime(time.time() - start_time) + summary = Text() + summary.append(f"Running: {running_count}/{total_count}", style="bold") + summary.append(" | ") + summary.append(f"CPU: {total_cpu:.1f}%", style="cyan") + summary.append(" | ") + summary.append(f"MEM: {format_bytes(total_mem)}", style="cyan") + summary.append(" | ") + summary.append(f"Session: {elapsed}", style="dim") + + layout = Layout() + layout.split_column( + Layout( + Panel( + Text("PyServe Dashboard", style="bold cyan", justify="center"), + border_style="cyan", + ), + size=3, + ), + Layout(table), + Layout(Panel(summary, border_style="dim"), size=3), + ) + + return layout + + with Live(make_dashboard(), refresh_per_second=1 / refresh_interval, console=console) as live: + while True: + await asyncio.sleep(refresh_interval) + live.update(make_dashboard()) diff --git a/pyserve/ctl/commands/up.py b/pyserve/ctl/commands/up.py new file mode 100644 index 0000000..771c366 --- /dev/null +++ b/pyserve/ctl/commands/up.py @@ -0,0 +1,174 @@ +""" +pyserve up - Start all services +""" + +import asyncio +import signal +import sys +import time +from pathlib import Path +from typing import List, Optional + +import click + + +@click.command("up") +@click.argument("services", nargs=-1) +@click.option( + "-d", + "--detach", + is_flag=True, + help="Run in background (detached mode)", +) +@click.option( + "--build", + is_flag=True, + help="Build/reload applications before starting", +) +@click.option( + "--force-recreate", + is_flag=True, + help="Recreate services even if configuration hasn't changed", +) +@click.option( + "--scale", + "scales", + multiple=True, + help="Scale SERVICE to NUM workers (e.g., --scale api=4)", +) +@click.option( + "--timeout", + "timeout", + default=60, + type=int, + help="Timeout in seconds for service startup", +) +@click.option( + "--wait", + is_flag=True, + help="Wait for services to be healthy before returning", +) +@click.option( + "--remove-orphans", + is_flag=True, + help="Remove services not defined in configuration", +) +@click.pass_obj +def up_cmd( + ctx, + services: tuple, + detach: bool, + build: bool, + force_recreate: bool, + scales: tuple, + timeout: int, + wait: bool, + remove_orphans: bool, +): + """ + Start services defined in configuration. + + If no services are specified, all services will be started. + + \b + Examples: + pyserve up # Start all services + pyserve up -d # Start in background + pyserve up api admin # Start specific services + pyserve up --scale api=4 # Scale api to 4 workers + pyserve up --wait # Wait for healthy status + """ + from ..output import console, print_error, print_info, print_success, print_warning + from ..state import StateManager + from .._runner import ServiceRunner + + config_path = Path(ctx.config_file) + + if not config_path.exists(): + print_error(f"Configuration file not found: {config_path}") + print_info("Run 'pyserve init' to create a configuration file") + raise click.Abort() + + scale_map = {} + for scale in scales: + try: + service, num = scale.split("=") + scale_map[service] = int(num) + except ValueError: + print_error(f"Invalid scale format: {scale}. Use SERVICE=NUM") + raise click.Abort() + + try: + from ...config import Config + + config = Config.from_yaml(str(config_path)) + except Exception as e: + print_error(f"Failed to load configuration: {e}") + raise click.Abort() + + state_manager = StateManager(Path(".pyserve"), ctx.project) + + if state_manager.is_daemon_running(): + daemon_pid = state_manager.get_daemon_pid() + print_warning(f"PyServe daemon is already running (PID: {daemon_pid})") + if not click.confirm("Do you want to restart it?"): + raise click.Abort() + try: + import os + from typing import cast + # FIXME: Please fix the cast usage here + os.kill(cast(int, daemon_pid), signal.SIGTERM) + time.sleep(2) + except ProcessLookupError: + pass + state_manager.clear_daemon_pid() + + runner = ServiceRunner(config, state_manager) + + service_list = list(services) if services else None + + if detach: + console.print("[bold]Starting PyServe in background...[/bold]") + + try: + pid = runner.start_daemon( + service_list, + scale_map=scale_map, + force_recreate=force_recreate, + ) + state_manager.set_daemon_pid(pid) + print_success(f"PyServe started in background (PID: {pid})") + print_info("Use 'pyserve ps' to see service status") + print_info("Use 'pyserve logs -f' to follow logs") + print_info("Use 'pyserve down' to stop") + except Exception as e: + print_error(f"Failed to start daemon: {e}") + raise click.Abort() + else: + console.print("[bold]Starting PyServe...[/bold]") + + def signal_handler(signum, frame): + console.print("\n[yellow]Received shutdown signal...[/yellow]") + runner.stop() + sys.exit(0) + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + try: + asyncio.run( + runner.start( + service_list, + scale_map=scale_map, + force_recreate=force_recreate, + wait_healthy=wait, + timeout=timeout, + ) + ) + except KeyboardInterrupt: + console.print("\n[yellow]Shutting down...[/yellow]") + except Exception as e: + print_error(f"Failed to start services: {e}") + if ctx.debug: + raise + raise click.Abort() diff --git a/pyserve/ctl/main.py b/pyserve/ctl/main.py new file mode 100644 index 0000000..1579026 --- /dev/null +++ b/pyserve/ctl/main.py @@ -0,0 +1,165 @@ +""" +PyServeCTL - Main entry point + +Usage: + pyservectl [OPTIONS] COMMAND [ARGS]... +""" + +import sys +from pathlib import Path +from typing import Optional + +import click + +from .. import __version__ + +DEFAULT_CONFIG = "config.yaml" +DEFAULT_STATE_DIR = ".pyserve" + + +class Context: + def __init__(self): + self.config_file: str = DEFAULT_CONFIG + self.state_dir: Path = Path(DEFAULT_STATE_DIR) + self.verbose: bool = False + self.debug: bool = False + self.project: Optional[str] = None + self._config = None + self._state = None + + @property + def config(self): + if self._config is None: + from ..config import Config + + if Path(self.config_file).exists(): + self._config = Config.from_yaml(self.config_file) + else: + self._config = Config() + return self._config + + @property + def state(self): + if self._state is None: + from .state import StateManager + + self._state = StateManager(self.state_dir, self.project) + return self._state + + +pass_context = click.make_pass_decorator(Context, ensure=True) + + +@click.group(invoke_without_command=True) +@click.option( + "-c", + "--config", + "config_file", + default=DEFAULT_CONFIG, + envvar="PYSERVE_CONFIG", + help=f"Path to configuration file (default: {DEFAULT_CONFIG})", + type=click.Path(), +) +@click.option( + "-p", + "--project", + "project", + default=None, + envvar="PYSERVE_PROJECT", + help="Project name for isolation", +) +@click.option( + "-v", + "--verbose", + is_flag=True, + help="Enable verbose output", +) +@click.option( + "--debug", + is_flag=True, + help="Enable debug mode", +) +@click.version_option(version=__version__, prog_name="pyservectl") +@click.pass_context +def cli(ctx, config_file: str, project: Optional[str], verbose: bool, debug: bool): + """ + PyServeCTL - Service management CLI for PyServe. + + Docker-compose-like tool for managing PyServe services. + + \b + Quick Start: + pyservectl init # Initialize a new project + pyservectl up # Start all services + pyservectl ps # Show service status + pyservectl logs -f # Follow logs + pyservectl down # Stop all services + + \b + Examples: + pyservectl up -d # Start in background + pyservectl up -c prod.yaml # Use custom config + pyservectl logs api -f --tail 100 # Follow api logs + pyservectl restart api admin # Restart specific services + pyservectl scale api=4 # Scale api to 4 workers + """ + ctx.ensure_object(Context) + ctx.obj.config_file = config_file + ctx.obj.verbose = verbose + ctx.obj.debug = debug + ctx.obj.project = project + + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + + +from .commands import ( + config_cmd, + down_cmd, + health_cmd, + init_cmd, + logs_cmd, + ps_cmd, + restart_cmd, + scale_cmd, + start_cmd, + stop_cmd, + top_cmd, + up_cmd, +) + +cli.add_command(init_cmd, name="init") +cli.add_command(config_cmd, name="config") +cli.add_command(up_cmd, name="up") +cli.add_command(down_cmd, name="down") +cli.add_command(start_cmd, name="start") +cli.add_command(stop_cmd, name="stop") +cli.add_command(restart_cmd, name="restart") +cli.add_command(ps_cmd, name="ps") +cli.add_command(logs_cmd, name="logs") +cli.add_command(top_cmd, name="top") +cli.add_command(health_cmd, name="health") +cli.add_command(scale_cmd, name="scale") + +# Alias 'status' -> 'ps' +cli.add_command(ps_cmd, name="status") + + +def main(): + try: + cli(standalone_mode=False) + except click.ClickException as e: + e.show() + sys.exit(e.exit_code) + except KeyboardInterrupt: + click.echo("\nInterrupted by user") + sys.exit(130) + except Exception as e: + if "--debug" in sys.argv: + raise + click.secho(f"Error: {e}", fg="red", err=True) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/pyserve/ctl/output/__init__.py b/pyserve/ctl/output/__init__.py new file mode 100644 index 0000000..72faafb --- /dev/null +++ b/pyserve/ctl/output/__init__.py @@ -0,0 +1,108 @@ +""" +PyServe CLI Output utilities + +Rich-based formatters and helpers for CLI output. +""" + +from rich.console import Console +from rich.table import Table +from rich.theme import Theme + +pyserve_theme = Theme( + { + "info": "cyan", + "warning": "yellow", + "error": "red bold", + "success": "green", + "service.running": "green", + "service.stopped": "dim", + "service.failed": "red", + "service.starting": "yellow", + } +) + +console = Console(theme=pyserve_theme) + + +def print_error(message: str) -> None: + console.print(f"[error] {message}[/error]") + + +def print_warning(message: str) -> None: + console.print(f"[warning] {message}[/warning]") + + +def print_success(message: str) -> None: + console.print(f"[success] {message}[/success]") + +def print_info(message: str) -> None: + console.print(f"[info] {message}[/info]") + +def create_services_table() -> Table: + table = Table( + title=None, + show_header=True, + header_style="bold", + border_style="dim", + ) + table.add_column("NAME", style="cyan", no_wrap=True) + table.add_column("STATUS", no_wrap=True) + table.add_column("PORTS", style="dim") + table.add_column("UPTIME", style="dim") + table.add_column("HEALTH", no_wrap=True) + table.add_column("PID", style="dim") + table.add_column("WORKERS", style="dim") + return table + + +def format_status(status: str) -> str: + status_styles = { + "running": "[service.running]● running[/service.running]", + "stopped": "[service.stopped]○ stopped[/service.stopped]", + "failed": "[service.failed]✗ failed[/service.failed]", + "starting": "[service.starting]◐ starting[/service.starting]", + "stopping": "[service.starting]◑ stopping[/service.starting]", + "restarting": "[service.starting]↻ restarting[/service.starting]", + "pending": "[service.stopped]○ pending[/service.stopped]", + } + return status_styles.get(status.lower(), status) + + +def format_health(health: str) -> str: + health_styles = { + "healthy": "[green] healthy[/green]", + "unhealthy": "[red] unhealthy[/red]", + "degraded": "[yellow] degraded[/yellow]", + "unknown": "[dim] unknown[/dim]", + "-": "[dim]-[/dim]", + } + return health_styles.get(health.lower(), health) + + +def format_uptime(seconds: float) -> str: + if seconds <= 0: + return "-" + + if seconds < 60: + return f"{int(seconds)}s" + elif seconds < 3600: + minutes = int(seconds / 60) + secs = int(seconds % 60) + return f"{minutes}m {secs}s" + elif seconds < 86400: + hours = int(seconds / 3600) + minutes = int((seconds % 3600) / 60) + return f"{hours}h {minutes}m" + else: + days = int(seconds / 86400) + hours = int((seconds % 86400) / 3600) + return f"{days}d {hours}h" + + +def format_bytes(num_bytes: int) -> str: + value = float(num_bytes) + for unit in ["B", "KB", "MB", "GB", "TB"]: + if abs(value) < 1024.0: + return f"{value:.1f}{unit}" + value /= 1024.0 + return f"{value:.1f}PB" diff --git a/pyserve/ctl/state/__init__.py b/pyserve/ctl/state/__init__.py new file mode 100644 index 0000000..49f541d --- /dev/null +++ b/pyserve/ctl/state/__init__.py @@ -0,0 +1,234 @@ +""" +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, List, Optional + +import yaml + + +@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) -> 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]