pyservectl init

This commit is contained in:
Илья Глазунов
2025-12-04 02:55:14 +03:00
parent b4f63c6804
commit 80544d5b95
21 changed files with 3391 additions and 14 deletions
+25
View File
@@ -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",
]
+420
View File
@@ -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()
+122
View File
@@ -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
+160
View File
@@ -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
+433
View File
@@ -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<version>\\\\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()
+290
View File
@@ -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]")
+89
View File
@@ -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()
+190
View File
@@ -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()
+153
View File
@@ -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()
+182
View File
@@ -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())
+174
View File
@@ -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()