Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4f63c6804 | ||
|
|
59d6ae2fd2 | ||
|
|
edaccb59bb | ||
|
|
3454801be7 | ||
|
|
bb2c3aa357 | ||
|
|
6761b791c3 | ||
|
|
fb87445cbd | ||
|
|
5d863bc97c |
+15
-1
@@ -13,4 +13,18 @@ static/*
|
||||
.coverage
|
||||
|
||||
docs/
|
||||
dist/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Cython generated files
|
||||
*.c
|
||||
*.so
|
||||
*.pyd
|
||||
*.html
|
||||
*.egg-info/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: help install build clean test lint format run dev-install dev-deps check release-patch release-minor release-major pipeline-check
|
||||
.PHONY: help install build build-cython clean test lint format run dev-install dev-deps check release-patch release-minor release-major pipeline-check benchmark
|
||||
|
||||
PYTHON = python3
|
||||
POETRY = poetry
|
||||
@@ -21,12 +21,14 @@ help:
|
||||
@printf " $(YELLOW)%-20s$(CYAN) %s$(NC)\n" "install-package" "Installing package locally"
|
||||
@echo ""
|
||||
@echo "$(YELLOW)Building:$(NC)"
|
||||
@printf " $(YELLOW)%-20s$(CYAN) %s$(NC)\n" "build" "Building package"
|
||||
@printf " $(YELLOW)%-20s$(CYAN) %s$(NC)\n" "build" "Building package (with Cython)"
|
||||
@printf " $(YELLOW)%-20s$(CYAN) %s$(NC)\n" "build-cython" "Building Cython extensions only"
|
||||
@printf " $(YELLOW)%-20s$(CYAN) %s$(NC)\n" "clean" "Cleaning temporary files"
|
||||
@echo ""
|
||||
@echo "$(YELLOW)Testing:$(NC)"
|
||||
@printf " $(YELLOW)%-20s$(CYAN) %s$(NC)\n" "test" "Running tests"
|
||||
@printf " $(YELLOW)%-20s$(CYAN) %s$(NC)\n" "test-cov" "Running tests with coverage"
|
||||
@printf " $(YELLOW)%-20s$(CYAN) %s$(NC)\n" "benchmark" "Running performance benchmarks"
|
||||
@printf " $(YELLOW)%-20s$(CYAN) %s$(NC)\n" "lint" "Checking code with linters"
|
||||
@printf " $(YELLOW)%-20s$(CYAN) %s$(NC)\n" "format" "Formatting code"
|
||||
@printf " $(YELLOW)%-20s$(CYAN) %s$(NC)\n" "check" "Lint and test"
|
||||
@@ -75,18 +77,27 @@ dev-deps:
|
||||
@echo "$(GREEN)Installing additional tools...$(NC)"
|
||||
$(POETRY) add --group dev pytest pytest-cov black isort mypy flake8
|
||||
|
||||
build: clean
|
||||
build: clean build-cython
|
||||
@echo "$(GREEN)Building package...$(NC)"
|
||||
$(POETRY) build
|
||||
|
||||
build-cython:
|
||||
@echo "$(GREEN)Building Cython extensions...$(NC)"
|
||||
$(POETRY) run python scripts/build_cython.py build_ext --inplace || echo "$(YELLOW)Cython build skipped (optional)$(NC)"
|
||||
|
||||
clean:
|
||||
@echo "$(GREEN)Cleaning temporary files...$(NC)"
|
||||
rm -rf dist/
|
||||
rm -rf build/
|
||||
rm -rf *.egg-info/
|
||||
find . -type d -name __pycache__ -exec rm -rf {} +
|
||||
find . -type f -name "*.pyc" -delete
|
||||
find . -type f -name "*.pyo" -delete
|
||||
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type f -name "*.pyc" -delete 2>/dev/null || true
|
||||
find . -type f -name "*.pyo" -delete 2>/dev/null || true
|
||||
@# Cython artifacts
|
||||
find $(PACKAGE_NAME) -type f -name "*.c" -delete 2>/dev/null || true
|
||||
find $(PACKAGE_NAME) -type f -name "*.so" -delete 2>/dev/null || true
|
||||
find $(PACKAGE_NAME) -type f -name "*.pyd" -delete 2>/dev/null || true
|
||||
find $(PACKAGE_NAME) -type f -name "*.html" -delete 2>/dev/null || true
|
||||
|
||||
test:
|
||||
@echo "$(GREEN)Running tests...$(NC)"
|
||||
@@ -98,16 +109,20 @@ test-cov:
|
||||
|
||||
lint:
|
||||
@echo "$(GREEN)Checking code with linters...$(NC)"
|
||||
$(POETRY) run flake8 $(PACKAGE_NAME)/
|
||||
$(POETRY) run flake8 $(PACKAGE_NAME)/ --exclude='*.pyx,*.pxd'
|
||||
$(POETRY) run mypy $(PACKAGE_NAME)/
|
||||
|
||||
format:
|
||||
@echo "$(GREEN)Formatting code...$(NC)"
|
||||
$(POETRY) run black $(PACKAGE_NAME)/
|
||||
$(POETRY) run isort $(PACKAGE_NAME)/
|
||||
$(POETRY) run black $(PACKAGE_NAME)/ --exclude='\.pyx$$'
|
||||
$(POETRY) run isort $(PACKAGE_NAME)/ --skip-glob='*.pyx'
|
||||
|
||||
check: lint test
|
||||
|
||||
benchmark: build-cython
|
||||
@echo "$(GREEN)Running benchmarks...$(NC)"
|
||||
$(POETRY) run python benchmarks/bench_path_matcher.py
|
||||
|
||||
run:
|
||||
@echo "$(GREEN)Starting server in development mode...$(NC)"
|
||||
$(POETRY) run python run.py --debug
|
||||
|
||||
@@ -1,145 +1,97 @@
|
||||
# PyServe
|
||||
|
||||
PyServe is a modern, async HTTP server written in Python. Originally created for educational purposes, it has evolved into a powerful tool for rapid prototyping and serving web applications with unique features like AI-generated content.
|
||||
Python application orchestrator and HTTP server. Runs multiple ASGI/WSGI applications through a single entry point with process isolation, health monitoring, and auto-restart.
|
||||
|
||||
<img src="https://raw.githubusercontent.com/ShiftyX1/PyServe/refs/heads/master/images/logo.png" alt="isolated" width="150"/>
|
||||
<img src="https://raw.githubusercontent.com/ShiftyX1/PyServe/refs/heads/master/images/logo.png" alt="PyServe Logo" width="150"/>
|
||||
|
||||
[More on web page](https://pyserve.org/)
|
||||
Website: [pyserve.org](https://pyserve.org) · Documentation: [docs.pyserve.org](https://docs.pyserve.org)
|
||||
|
||||
## Project Overview
|
||||
## Overview
|
||||
|
||||
PyServe v0.6.0 introduces a completely refactored architecture with modern async/await syntax and new exciting features like **Vibe-Serving** - AI-powered dynamic content generation.
|
||||
PyServe manages multiple Python web applications (FastAPI, Flask, Django, etc.) as isolated subprocesses behind a single gateway. Each app runs on its own port with independent lifecycle, health checks, and automatic restarts on failure.
|
||||
|
||||
### Key Features:
|
||||
```
|
||||
PyServe Gateway (:8000)
|
||||
│
|
||||
┌────────────────┼────────────────┐
|
||||
▼ ▼ ▼
|
||||
FastAPI Flask Starlette
|
||||
:9001 :9002 :9003
|
||||
/api/* /admin/* /ws/*
|
||||
```
|
||||
|
||||
- **Async HTTP Server** - Built with Python's asyncio for high performance
|
||||
- **Advanced Configuration System V2** - Powerful extensible configuration with full backward compatibility
|
||||
- **Regex Routing & SPA Support** - nginx-style routing patterns with Single Page Application fallback
|
||||
- **Static File Serving** - Efficient serving with correct MIME types
|
||||
- **Template System** - Dynamic content generation
|
||||
- **Vibe-Serving Mode** - AI-generated content using language models (OpenAI, Claude, etc.)
|
||||
- **Reverse Proxy** - Forward requests to backend services with advanced routing
|
||||
- **SSL/HTTPS Support** - Secure connections with certificate configuration
|
||||
- **Modular Extensions** - Plugin-like architecture for security, caching, monitoring
|
||||
- **Beautiful Logging** - Colored terminal output with file rotation
|
||||
- **Error Handling** - Styled error pages and graceful fallbacks
|
||||
- **CLI Interface** - Command-line interface for easy deployment and configuration
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.12 or higher
|
||||
- Poetry (recommended) or pip
|
||||
|
||||
### Installation
|
||||
|
||||
#### Via Poetry (рекомендуется)
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ShiftyX1/PyServe.git
|
||||
cd PyServe
|
||||
make init # Initialize project
|
||||
make init
|
||||
```
|
||||
|
||||
#### Или установка пакета
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# local install
|
||||
make install-package
|
||||
```yaml
|
||||
# config.yaml
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 8000
|
||||
|
||||
# after installing project you can use command pyserve
|
||||
pyserve --help
|
||||
extensions:
|
||||
- type: process_orchestration
|
||||
config:
|
||||
apps:
|
||||
- name: api
|
||||
path: /api
|
||||
app_path: myapp.api:app
|
||||
|
||||
- name: admin
|
||||
path: /admin
|
||||
app_path: myapp.admin:app
|
||||
```
|
||||
|
||||
### Running the Server
|
||||
|
||||
#### Using Makefile (recommended)
|
||||
|
||||
```bash
|
||||
# start in development mode
|
||||
make run
|
||||
|
||||
# start in production mode
|
||||
make run-prod
|
||||
|
||||
# show all available commands
|
||||
make help
|
||||
pyserve -c config.yaml
|
||||
```
|
||||
|
||||
#### Using CLI directly
|
||||
Requests to `/api/*` are proxied to the api process, `/admin/*` to admin.
|
||||
|
||||
## Process Orchestration
|
||||
|
||||
The main case of using PyServe is orchestration of python web applications. Each application runs as a separate uvicorn process on a dynamically or manually allocated port (9000-9999 by default). PyServe proxies requests to the appropriate process based on URL path.
|
||||
|
||||
For each application you can configure the number of workers, environment variables, health check endpoint path, and auto-restart parameters. If a process crashes or stops responding to health checks, PyServe automatically restarts it with exponential backoff.
|
||||
|
||||
WSGI applications (Flask, Django) are supported through automatic wrapping — just specify `app_type: wsgi`.
|
||||
|
||||
## In-Process Mounting
|
||||
|
||||
For simpler cases when process isolation is not needed, applications can be mounted directly into the PyServe process via the `asgi` extension. This is lighter and faster, but all applications share one process.
|
||||
|
||||
## Static Files & Routing
|
||||
|
||||
PyServe can serve static files with nginx-like routing: regex patterns, SPA fallback for frontend applications, custom caching headers. Routes are processed in priority order — exact match, then regex, then default.
|
||||
|
||||
## Reverse Proxy
|
||||
|
||||
Requests can be proxied to external backends. Useful for integration with legacy services or microservices in other languages.
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
# after installing package
|
||||
pyserve
|
||||
|
||||
# or with Poetry
|
||||
poetry run pyserve
|
||||
|
||||
# or legacy (for backward compatibility)
|
||||
python run.py
|
||||
```
|
||||
|
||||
#### CLI options
|
||||
|
||||
```bash
|
||||
# help
|
||||
pyserve --help
|
||||
|
||||
# path to config
|
||||
pyserve -c /path/to/config.yaml
|
||||
|
||||
# rewrite host and port
|
||||
pyserve -c config.yaml
|
||||
pyserve --host 0.0.0.0 --port 9000
|
||||
|
||||
# debug mode
|
||||
pyserve --debug
|
||||
|
||||
# show version
|
||||
pyserve --version
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Makefile Commands
|
||||
|
||||
```bash
|
||||
make help # Show help for commands
|
||||
make install # Install dependencies
|
||||
make dev-install # Install development dependencies
|
||||
make build # Build the package
|
||||
make test # Run tests
|
||||
make test-cov # Tests with code coverage
|
||||
make lint # Check code with linters
|
||||
make format # Format code
|
||||
make clean # Clean up temporary files
|
||||
make version # Show version
|
||||
make publish-test # Publish to Test PyPI
|
||||
make publish # Publish to PyPI
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
pyserveX/
|
||||
├── pyserve/ # Main package
|
||||
│ ├── __init__.py
|
||||
│ ├── cli.py # CLI interface
|
||||
│ ├── server.py # Main server module
|
||||
│ ├── config.py # Configuration system
|
||||
│ ├── routing.py # Routing
|
||||
│ ├── extensions.py # Extensions
|
||||
│ └── logging_utils.py
|
||||
├── tests/ # Tests
|
||||
├── static/ # Static files
|
||||
├── templates/ # Templates
|
||||
├── logs/ # Logs
|
||||
├── Makefile # Automation tasks
|
||||
├── pyproject.toml # Project configuration
|
||||
├── config.yaml # Server configuration
|
||||
└── run.py # Entry point (backward compatibility)
|
||||
make test # run tests
|
||||
make lint # linting
|
||||
make format # formatting
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
This project is distributed under the MIT license.
|
||||
[MIT License](./LICENSE)
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Benchmark script for path_matcher performance comparison.
|
||||
|
||||
Compares:
|
||||
- Pure Python implementation
|
||||
- Cython implementation (if available)
|
||||
- Original MountedApp from asgi_mount.py
|
||||
|
||||
Usage:
|
||||
python benchmarks/bench_path_matcher.py
|
||||
"""
|
||||
|
||||
import time
|
||||
import statistics
|
||||
from typing import Callable, List, Tuple
|
||||
|
||||
from pyserve._path_matcher_py import (
|
||||
FastMountedPath as PyFastMountedPath,
|
||||
FastMountManager as PyFastMountManager,
|
||||
path_matches_prefix as py_path_matches_prefix,
|
||||
)
|
||||
|
||||
try:
|
||||
from pyserve._path_matcher import (
|
||||
FastMountedPath as CyFastMountedPath,
|
||||
FastMountManager as CyFastMountManager,
|
||||
path_matches_prefix as cy_path_matches_prefix,
|
||||
)
|
||||
CYTHON_AVAILABLE = True
|
||||
except ImportError:
|
||||
CYTHON_AVAILABLE = False
|
||||
print("Cython module not compiled. Run: python setup_cython.py build_ext --inplace\n")
|
||||
|
||||
from pyserve.asgi_mount import MountedApp
|
||||
|
||||
|
||||
def benchmark(func: Callable, iterations: int = 100000) -> Tuple[float, float]:
|
||||
times = []
|
||||
|
||||
for _ in range(1000):
|
||||
func()
|
||||
|
||||
for _ in range(iterations):
|
||||
start = time.perf_counter_ns()
|
||||
func()
|
||||
end = time.perf_counter_ns()
|
||||
times.append(end - start)
|
||||
|
||||
return statistics.mean(times), statistics.stdev(times)
|
||||
|
||||
|
||||
def format_time(ns: float) -> str:
|
||||
if ns < 1000:
|
||||
return f"{ns:.1f} ns"
|
||||
elif ns < 1_000_000:
|
||||
return f"{ns/1000:.2f} µs"
|
||||
else:
|
||||
return f"{ns/1_000_000:.2f} ms"
|
||||
|
||||
|
||||
def run_benchmarks():
|
||||
print("=" * 70)
|
||||
print("PATH MATCHER BENCHMARK")
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
# Test paths
|
||||
mount_path = "/api/v1"
|
||||
test_paths = [
|
||||
"/api/v1/users/123/posts", # Matching - long
|
||||
"/api/v1", # Matching - exact
|
||||
"/api/v2/users", # Not matching - similar prefix
|
||||
"/other/path", # Not matching - completely different
|
||||
]
|
||||
|
||||
iterations = 100000
|
||||
|
||||
# =========================================================================
|
||||
# Benchmark 1: Single path matching
|
||||
# =========================================================================
|
||||
print("BENCHMARK 1: Single Path Matching")
|
||||
print("-" * 70)
|
||||
print(f" Mount path: {mount_path}")
|
||||
print(f" Iterations: {iterations:,}")
|
||||
print()
|
||||
|
||||
results = {}
|
||||
|
||||
# Original MountedApp
|
||||
original_mount = MountedApp(mount_path, app=None, name="test") # type: ignore
|
||||
|
||||
for test_path in test_paths:
|
||||
print(f" Test path: {test_path}")
|
||||
|
||||
# Original
|
||||
mean, std = benchmark(lambda: original_mount.matches(test_path), iterations)
|
||||
results[("Original", test_path)] = mean
|
||||
print(f" Original MountedApp: {format_time(mean):>12} ± {format_time(std)}")
|
||||
|
||||
# Pure Python
|
||||
py_mount = PyFastMountedPath(mount_path)
|
||||
mean, std = benchmark(lambda: py_mount.matches(test_path), iterations)
|
||||
results[("Python", test_path)] = mean
|
||||
print(f" Pure Python: {format_time(mean):>12} ± {format_time(std)}")
|
||||
|
||||
# Cython (if available)
|
||||
if CYTHON_AVAILABLE:
|
||||
cy_mount = CyFastMountedPath(mount_path)
|
||||
mean, std = benchmark(lambda: cy_mount.matches(test_path), iterations)
|
||||
results[("Cython", test_path)] = mean
|
||||
print(f" Cython: {format_time(mean):>12} ± {format_time(std)}")
|
||||
|
||||
print()
|
||||
|
||||
# =========================================================================
|
||||
# Benchmark 2: Mount Manager lookup
|
||||
# =========================================================================
|
||||
print()
|
||||
print("BENCHMARK 2: Mount Manager Lookup (10 mounts)")
|
||||
print("-" * 70)
|
||||
|
||||
# Setup managers with 10 mounts
|
||||
mount_paths = [f"/api/v{i}" for i in range(10)]
|
||||
|
||||
py_manager = PyFastMountManager()
|
||||
for p in mount_paths:
|
||||
py_manager.add_mount(PyFastMountedPath(p, name=p))
|
||||
|
||||
if CYTHON_AVAILABLE:
|
||||
cy_manager = CyFastMountManager()
|
||||
for p in mount_paths:
|
||||
cy_manager.add_mount(CyFastMountedPath(p, name=p))
|
||||
|
||||
test_lookups = [
|
||||
"/api/v5/users/123", # Middle mount
|
||||
"/api/v0/items", # First mount (longest)
|
||||
"/api/v9/data", # Last mount
|
||||
"/other/not/found", # No match
|
||||
]
|
||||
|
||||
for test_path in test_lookups:
|
||||
print(f" Lookup path: {test_path}")
|
||||
|
||||
# Pure Python
|
||||
mean, std = benchmark(lambda: py_manager.get_mount(test_path), iterations)
|
||||
print(f" Pure Python: {format_time(mean):>12} ± {format_time(std)}")
|
||||
|
||||
# Cython
|
||||
if CYTHON_AVAILABLE:
|
||||
mean, std = benchmark(lambda: cy_manager.get_mount(test_path), iterations)
|
||||
print(f" Cython: {format_time(mean):>12} ± {format_time(std)}")
|
||||
|
||||
print()
|
||||
|
||||
# =========================================================================
|
||||
# Benchmark 3: Combined match + modify
|
||||
# =========================================================================
|
||||
print()
|
||||
print("BENCHMARK 3: Combined Match + Modify Path")
|
||||
print("-" * 70)
|
||||
|
||||
from pyserve._path_matcher_py import match_and_modify_path as py_match_modify
|
||||
if CYTHON_AVAILABLE:
|
||||
from pyserve._path_matcher import match_and_modify_path as cy_match_modify
|
||||
|
||||
test_path = "/api/v1/users/123/posts"
|
||||
print(f" Test path: {test_path}")
|
||||
print(f" Mount path: {mount_path}")
|
||||
print()
|
||||
|
||||
# Original (separate calls)
|
||||
def original_match_modify():
|
||||
if original_mount.matches(test_path):
|
||||
return original_mount.get_modified_path(test_path)
|
||||
return None
|
||||
|
||||
mean, std = benchmark(original_match_modify, iterations)
|
||||
print(f" Original (2 calls): {format_time(mean):>12} ± {format_time(std)}")
|
||||
|
||||
# Pure Python combined
|
||||
mean, std = benchmark(lambda: py_match_modify(test_path, mount_path), iterations)
|
||||
print(f" Pure Python (combined): {format_time(mean):>12} ± {format_time(std)}")
|
||||
|
||||
# Cython combined
|
||||
if CYTHON_AVAILABLE:
|
||||
mean, std = benchmark(lambda: cy_match_modify(test_path, mount_path), iterations)
|
||||
print(f" Cython (combined): {format_time(mean):>12} ± {format_time(std)}")
|
||||
|
||||
# =========================================================================
|
||||
# Summary
|
||||
# =========================================================================
|
||||
print()
|
||||
print("=" * 70)
|
||||
print("SUMMARY")
|
||||
print("=" * 70)
|
||||
|
||||
if CYTHON_AVAILABLE:
|
||||
print("Cython module is available and was benchmarked")
|
||||
else:
|
||||
print("Cython module not available - only Pure Python was benchmarked")
|
||||
print(" To build Cython module:")
|
||||
print(" 1. Install Cython: pip install cython")
|
||||
print(" 2. Build: python setup_cython.py build_ext --inplace")
|
||||
|
||||
print()
|
||||
print("The optimized path matcher provides:")
|
||||
print(" - Pre-computed path length and trailing slash")
|
||||
print(" - Boundary-aware prefix matching (prevents /api matching /api-v2)")
|
||||
print(" - Combined match+modify operation to reduce function calls")
|
||||
print(" - Longest-prefix-first ordering in MountManager")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_benchmarks()
|
||||
@@ -0,0 +1,113 @@
|
||||
# PyServe Process Orchestration Example
|
||||
#
|
||||
# This configuration demonstrates running multiple ASGI/WSGI applications
|
||||
# as isolated processes with automatic health monitoring and restart.
|
||||
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8000
|
||||
backlog: 2048
|
||||
proxy_timeout: 60.0
|
||||
|
||||
logging:
|
||||
level: DEBUG
|
||||
console_output: true
|
||||
format:
|
||||
type: standard
|
||||
use_colors: true
|
||||
|
||||
extensions:
|
||||
# Process Orchestration - runs each app in its own process
|
||||
- type: process_orchestration
|
||||
config:
|
||||
# Port range for worker processes
|
||||
port_range: [9000, 9999]
|
||||
|
||||
# Enable health monitoring
|
||||
health_check_enabled: true
|
||||
|
||||
# Proxy timeout for requests
|
||||
proxy_timeout: 60.0
|
||||
|
||||
apps:
|
||||
# FastAPI application
|
||||
- name: api
|
||||
path: /api
|
||||
app_path: examples.apps.fastapi_app: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
|
||||
shutdown_timeout: 30.0
|
||||
strip_path: true
|
||||
env:
|
||||
APP_ENV: "production"
|
||||
DEBUG: "false"
|
||||
|
||||
# Flask application (WSGI wrapped to ASGI)
|
||||
- name: admin
|
||||
path: /admin
|
||||
app_path: examples.apps.flask_app:app
|
||||
app_type: wsgi
|
||||
module_path: "."
|
||||
workers: 1
|
||||
health_check_path: /health
|
||||
strip_path: true
|
||||
|
||||
# Starlette application
|
||||
- name: web
|
||||
path: /web
|
||||
app_path: examples.apps.starlette_app:app
|
||||
module_path: "."
|
||||
workers: 2
|
||||
health_check_path: /health
|
||||
strip_path: true
|
||||
|
||||
# Custom ASGI application
|
||||
- name: custom
|
||||
path: /custom
|
||||
app_path: examples.apps.custom_asgi:app
|
||||
module_path: "."
|
||||
workers: 1
|
||||
health_check_path: /health
|
||||
strip_path: true
|
||||
|
||||
# Routing for static files and reverse proxy
|
||||
- type: routing
|
||||
config:
|
||||
regex_locations:
|
||||
# Static files
|
||||
"^/static/.*":
|
||||
type: static
|
||||
root: "./static"
|
||||
strip_prefix: "/static"
|
||||
|
||||
# Documentation
|
||||
"^/docs/?.*":
|
||||
type: static
|
||||
root: "./docs"
|
||||
strip_prefix: "/docs"
|
||||
|
||||
# External API proxy
|
||||
"^/external/.*":
|
||||
type: proxy
|
||||
upstream: "https://api.example.com"
|
||||
strip_prefix: "/external"
|
||||
|
||||
# Security headers
|
||||
- type: security
|
||||
config:
|
||||
security_headers:
|
||||
X-Content-Type-Options: "nosniff"
|
||||
X-Frame-Options: "DENY"
|
||||
X-XSS-Protection: "1; mode=block"
|
||||
Strict-Transport-Security: "max-age=31536000; includeSubDomains"
|
||||
|
||||
# Monitoring
|
||||
- type: monitoring
|
||||
config:
|
||||
enable_metrics: true
|
||||
@@ -12,6 +12,22 @@ warn_unused_ignores = True
|
||||
warn_no_return = True
|
||||
warn_unreachable = True
|
||||
strict_equality = True
|
||||
exclude = (?x)(
|
||||
^pyserve/_path_matcher\.pyx$
|
||||
)
|
||||
|
||||
[mypy-tests.*]
|
||||
disallow_untyped_defs = False
|
||||
|
||||
[mypy-pyserve._path_matcher]
|
||||
ignore_missing_imports = True
|
||||
follow_imports = skip
|
||||
|
||||
[mypy-django.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-a2wsgi]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-asgiref.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
Generated
+71
-1
@@ -274,6 +274,55 @@ files = [
|
||||
[package.extras]
|
||||
toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
|
||||
|
||||
[[package]]
|
||||
name = "cython"
|
||||
version = "3.2.2"
|
||||
description = "The Cython compiler for writing C extensions in the Python language."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "cython-3.2.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b5afac4e77e71a9010dc7fd3191ced00f9b12b494dd7525c140781054ce63a73"},
|
||||
{file = "cython-3.2.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd2ede6af225499ad22888dbfb13b92d71fc1016f401ee637559a5831b177c2"},
|
||||
{file = "cython-3.2.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8c9265b3e84ae2d999b7c3165c683e366bbbbbe4346468055ca2366fe013f2df"},
|
||||
{file = "cython-3.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:d7b3447b2005dffc5f276d420a480d2b57d15091242652d410b6a46fb00ed251"},
|
||||
{file = "cython-3.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d140c2701cbb8cf960300cf1b67f3b4fa9d294d32e51b85f329bff56936a82fd"},
|
||||
{file = "cython-3.2.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50bbaabee733fd2780985e459fc20f655e02def83e8eff10220ad88455a34622"},
|
||||
{file = "cython-3.2.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9509f1e9c41c86b790cff745bb31927bbc861662a3b462596d71d3d2a578abb"},
|
||||
{file = "cython-3.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:034ab96cb8bc8e7432bc27491f8d66f51e435b1eb21ddc03aa844be8f21ad847"},
|
||||
{file = "cython-3.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:692a41c8fe06fb2dc55ca2c8d71c80c469fd16fe69486ed99f3b3cbb2d3af83f"},
|
||||
{file = "cython-3.2.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:098590c1dc309f8a0406ade031963a95a87714296b425539f9920aebf924560d"},
|
||||
{file = "cython-3.2.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3898c076e9c458bcb3e4936187919fda5f5365fe4c567d35d2b003444b6f3fe"},
|
||||
{file = "cython-3.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:2b910b89a2a71004064c5e890b9512a251eda63fae252caa0feb9835057035f9"},
|
||||
{file = "cython-3.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa24cd0bdab27ca099b2467806c684404add597c1108e07ddf7b6471653c85d7"},
|
||||
{file = "cython-3.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60f4aa425e1ff98abf8d965ae7020f06dd2cbc01dbd945137d2f9cca4ff0524a"},
|
||||
{file = "cython-3.2.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a473df474ba89e9fee81ee82b31062a267f9e598096b222783477e56d02ad12c"},
|
||||
{file = "cython-3.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:b4df52101209817fde7284cf779156f79142fb639b1d7840f11680ff4bb30604"},
|
||||
{file = "cython-3.2.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:177faf4d61e9f2d4d2db61194ac9ec16d3fe3041c1b6830f871a01935319eeb3"},
|
||||
{file = "cython-3.2.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8db28aef793c81dc69383b619ca508668998aaf099cd839d3cbae85184cce744"},
|
||||
{file = "cython-3.2.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3de43a5786033a27fae1c882feb5ff0d023c38b83356e6800c1be0bcd6cf9f11"},
|
||||
{file = "cython-3.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:fed44d0ab2d36f1b0301c770b0dafec23bcb9700d58e7769cd6d9136b3304c11"},
|
||||
{file = "cython-3.2.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e7200309b81f4066cf36a96efeec646716ca74afd73d159045169263db891133"},
|
||||
{file = "cython-3.2.2-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e72ee88a9a5381d30a6da116a3c8352730b9b038a49ed9bc5c3d0ed6d69b06c"},
|
||||
{file = "cython-3.2.2-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e35ff0f1bb3a7a5c40afb8fb540e4178b6551909f10748bf39d323f8140ccf3"},
|
||||
{file = "cython-3.2.2-cp38-cp38-win_amd64.whl", hash = "sha256:b223c1f84c3420c24f6a4858e979524bd35a79437a5839e29d41201c87ed119d"},
|
||||
{file = "cython-3.2.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:a6387e3ad31342443916db9a419509935fddd8d4cbac34aab9c895ae55326a56"},
|
||||
{file = "cython-3.2.2-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:436eb562d0affbc0b959f62f3f9c1ed251b9499e4f29c1d19514ae859894b6bf"},
|
||||
{file = "cython-3.2.2-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f560ff3aea5b5df93853ec7bf1a1e9623d6d511f4192f197559aca18fca43392"},
|
||||
{file = "cython-3.2.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d8c93fe128b58942832b1fcac96e48f93c2c69b569eff0d38d30fb5995fecfa0"},
|
||||
{file = "cython-3.2.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b4fe499eed7cd70b2aa4e096b9ce2588f5e6fdf049b46d40a5e55efcde6e4904"},
|
||||
{file = "cython-3.2.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:14432d7f207245a3c35556155873f494784169297b28978a6204f1c60d31553e"},
|
||||
{file = "cython-3.2.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:820c4a99dbf6b3e6c0300be42b4040b501eff0e1feeb80cfa52c48a346fb0df2"},
|
||||
{file = "cython-3.2.2-cp39-abi3-win32.whl", hash = "sha256:826cad0ad43ab05a26e873b5d625f64d458dc739ec6fdeecab848b60a91c4252"},
|
||||
{file = "cython-3.2.2-cp39-abi3-win_arm64.whl", hash = "sha256:5f818d40bbcf17e2089e2de7840f0de1c0ca527acf9b044aba79d5f5d8a5bdba"},
|
||||
{file = "cython-3.2.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ff07e784ea748225bbdea07fec0ac451379e9e41a0a84cb57b36db19dd01ae71"},
|
||||
{file = "cython-3.2.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aff11412ed5fc78bd8b148621f4d1034fcad6cfcba468c20cd9f327b4f61ec3e"},
|
||||
{file = "cython-3.2.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca18d9d53c0e2f0c9347478b37532b46e0dc34c704e052ab1b0d8b21a290fc0f"},
|
||||
{file = "cython-3.2.2-cp39-cp39-win_amd64.whl", hash = "sha256:86b1d39a1ea974dd16fe3bcef0df7b64dadd0bd38d05a339f287b48d37cb109f"},
|
||||
{file = "cython-3.2.2-py3-none-any.whl", hash = "sha256:13b99ecb9482aff6a6c12d1ca6feef6940c507af909914b49f568de74fa965fb"},
|
||||
{file = "cython-3.2.2.tar.gz", hash = "sha256:c3add3d483acc73129a61d105389344d792c17e7c1cee24863f16416bd071634"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django"
|
||||
version = "5.2.9"
|
||||
@@ -1131,6 +1180,27 @@ files = [
|
||||
{file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "setuptools"
|
||||
version = "80.9.0"
|
||||
description = "Easily download, build, install, upgrade, and uninstall Python packages"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"},
|
||||
{file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""]
|
||||
core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"]
|
||||
cover = ["pytest-cov"]
|
||||
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"]
|
||||
enabler = ["pytest-enabler (>=2.2)"]
|
||||
test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"]
|
||||
type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"]
|
||||
|
||||
[[package]]
|
||||
name = "sniffio"
|
||||
version = "1.3.1"
|
||||
@@ -1551,4 +1621,4 @@ wsgi = ["a2wsgi"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.12"
|
||||
content-hash = "a08668c23222843b27b3977933c93b261328f43e90f22f35212c6c6f6030e3dc"
|
||||
content-hash = "32ebf260f6792987cb4236fe29ad3329374e063504d507b5a0319684e24a30a8"
|
||||
|
||||
+5
-3
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "pyserve"
|
||||
version = "0.8.0"
|
||||
description = "Simple HTTP Web server written in Python"
|
||||
version = "0.9.10"
|
||||
description = "Python Application Orchestrator & HTTP Server - unified gateway for multiple Python web apps"
|
||||
authors = [
|
||||
{name = "Илья Глазунов",email = "i.glazunov@sapiens.solutions"}
|
||||
]
|
||||
@@ -52,7 +52,7 @@ all-frameworks = [
|
||||
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||
requires = ["poetry-core>=2.0.0,<3.0.0", "setuptools", "cython>=3.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.black]
|
||||
@@ -95,4 +95,6 @@ isort = "^6.0.1"
|
||||
mypy = "^1.17.1"
|
||||
flake8 = "^7.3.0"
|
||||
pytest-asyncio = "^1.3.0"
|
||||
cython = "^3.0.0"
|
||||
setuptools = "^80.0.0"
|
||||
|
||||
|
||||
+22
-5
@@ -2,26 +2,35 @@
|
||||
PyServe - HTTP web server written on Python
|
||||
"""
|
||||
|
||||
__version__ = "0.8.0"
|
||||
__version__ = "0.9.10"
|
||||
__author__ = "Ilya Glazunov"
|
||||
|
||||
from .server import PyServeServer
|
||||
from .config import Config
|
||||
from .asgi_mount import (
|
||||
ASGIAppLoader,
|
||||
ASGIMountManager,
|
||||
MountedApp,
|
||||
create_django_app,
|
||||
create_fastapi_app,
|
||||
create_flask_app,
|
||||
create_django_app,
|
||||
create_starlette_app,
|
||||
)
|
||||
from .config import Config
|
||||
from .process_manager import (
|
||||
ProcessConfig,
|
||||
ProcessInfo,
|
||||
ProcessManager,
|
||||
ProcessState,
|
||||
get_process_manager,
|
||||
init_process_manager,
|
||||
shutdown_process_manager,
|
||||
)
|
||||
from .server import PyServeServer
|
||||
|
||||
__all__ = [
|
||||
"PyServeServer",
|
||||
"Config",
|
||||
"__version__",
|
||||
# ASGI mounting
|
||||
# ASGI mounting (in-process)
|
||||
"ASGIAppLoader",
|
||||
"ASGIMountManager",
|
||||
"MountedApp",
|
||||
@@ -29,4 +38,12 @@ __all__ = [
|
||||
"create_flask_app",
|
||||
"create_django_app",
|
||||
"create_starlette_app",
|
||||
# Process orchestration (multi-process)
|
||||
"ProcessManager",
|
||||
"ProcessConfig",
|
||||
"ProcessInfo",
|
||||
"ProcessState",
|
||||
"get_process_manager",
|
||||
"init_process_manager",
|
||||
"shutdown_process_manager",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
# cython: language_level=3
|
||||
# cython: boundscheck=False
|
||||
# cython: wraparound=False
|
||||
# cython: cdivision=True
|
||||
"""
|
||||
Fast path matching module for PyServe.
|
||||
|
||||
This Cython module provides optimized path matching operations
|
||||
for ASGI mount routing, significantly reducing overhead on hot paths.
|
||||
"""
|
||||
|
||||
from cpython.object cimport PyObject
|
||||
|
||||
|
||||
cdef class FastMountedPath:
|
||||
cdef:
|
||||
str _path
|
||||
str _path_with_slash
|
||||
Py_ssize_t _path_len
|
||||
bint _is_root
|
||||
public str name
|
||||
public bint strip_path
|
||||
|
||||
def __cinit__(self):
|
||||
self._path = ""
|
||||
self._path_with_slash = "/"
|
||||
self._path_len = 0
|
||||
self._is_root = 1
|
||||
self.name = ""
|
||||
self.strip_path = 1
|
||||
|
||||
def __init__(self, str path, str name="", bint strip_path=True):
|
||||
cdef Py_ssize_t path_len
|
||||
|
||||
path_len = len(path)
|
||||
if path_len > 1 and path[path_len - 1] == '/':
|
||||
path = path[:path_len - 1]
|
||||
|
||||
self._path = path
|
||||
self._path_len = len(path)
|
||||
self._is_root = 1 if (path == "" or path == "/") else 0
|
||||
self._path_with_slash = path + "/" if self._is_root == 0 else "/"
|
||||
self.name = name if name else path
|
||||
self.strip_path = 1 if strip_path else 0
|
||||
|
||||
@property
|
||||
def path(self) -> str:
|
||||
return self._path
|
||||
|
||||
cpdef bint matches(self, str request_path):
|
||||
cdef Py_ssize_t req_len
|
||||
|
||||
if self._is_root:
|
||||
return 1
|
||||
|
||||
req_len = len(request_path)
|
||||
|
||||
if req_len < self._path_len:
|
||||
return 0
|
||||
|
||||
if req_len == self._path_len:
|
||||
return 1 if request_path == self._path else 0
|
||||
|
||||
if request_path[self._path_len] == '/':
|
||||
return 1 if request_path[:self._path_len] == self._path else 0
|
||||
|
||||
return 0
|
||||
|
||||
cpdef str get_modified_path(self, str original_path):
|
||||
cdef str new_path
|
||||
|
||||
if not self.strip_path:
|
||||
return original_path
|
||||
|
||||
if self._is_root:
|
||||
return original_path
|
||||
|
||||
new_path = original_path[self._path_len:]
|
||||
|
||||
if not new_path:
|
||||
return "/"
|
||||
|
||||
return new_path
|
||||
|
||||
def __repr__(self):
|
||||
return f"FastMountedPath(path={self._path!r}, name={self.name!r})"
|
||||
|
||||
|
||||
def _get_path_len_neg(mount):
|
||||
return -len(mount.path)
|
||||
|
||||
|
||||
cdef class FastMountManager:
|
||||
cdef:
|
||||
list _mounts
|
||||
int _mount_count
|
||||
|
||||
def __cinit__(self):
|
||||
self._mounts = []
|
||||
self._mount_count = 0
|
||||
|
||||
def __init__(self):
|
||||
self._mounts = []
|
||||
self._mount_count = 0
|
||||
|
||||
cpdef void add_mount(self, FastMountedPath mount):
|
||||
self._mounts.append(mount)
|
||||
self._mounts = sorted(self._mounts, key=_get_path_len_neg, reverse=False)
|
||||
self._mount_count = len(self._mounts)
|
||||
|
||||
cpdef FastMountedPath get_mount(self, str request_path):
|
||||
cdef:
|
||||
int i
|
||||
FastMountedPath mount
|
||||
|
||||
for i in range(self._mount_count):
|
||||
mount = <FastMountedPath>self._mounts[i]
|
||||
if mount.matches(request_path):
|
||||
return mount
|
||||
|
||||
return None
|
||||
|
||||
cpdef bint remove_mount(self, str path):
|
||||
cdef:
|
||||
int i
|
||||
Py_ssize_t path_len
|
||||
FastMountedPath mount
|
||||
|
||||
path_len = len(path)
|
||||
if path_len > 1 and path[path_len - 1] == '/':
|
||||
path = path[:path_len - 1]
|
||||
|
||||
for i in range(self._mount_count):
|
||||
mount = <FastMountedPath>self._mounts[i]
|
||||
if mount._path == path:
|
||||
del self._mounts[i]
|
||||
self._mount_count -= 1
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
@property
|
||||
def mounts(self) -> list:
|
||||
return list(self._mounts)
|
||||
|
||||
@property
|
||||
def mount_count(self) -> int:
|
||||
return self._mount_count
|
||||
|
||||
cpdef list list_mounts(self):
|
||||
cdef:
|
||||
list result = []
|
||||
FastMountedPath mount
|
||||
|
||||
for mount in self._mounts:
|
||||
result.append({
|
||||
"path": mount._path,
|
||||
"name": mount.name,
|
||||
"strip_path": mount.strip_path,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
cpdef bint path_matches_prefix(str request_path, str mount_path):
|
||||
cdef:
|
||||
Py_ssize_t mount_len = len(mount_path)
|
||||
Py_ssize_t req_len = len(request_path)
|
||||
|
||||
if mount_len == 0 or mount_path == "/":
|
||||
return 1
|
||||
|
||||
if req_len < mount_len:
|
||||
return 0
|
||||
|
||||
if req_len == mount_len:
|
||||
return 1 if request_path == mount_path else 0
|
||||
|
||||
if request_path[mount_len] == '/':
|
||||
return 1 if request_path[:mount_len] == mount_path else 0
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
cpdef str strip_path_prefix(str original_path, str mount_path):
|
||||
cdef:
|
||||
Py_ssize_t mount_len = len(mount_path)
|
||||
str result
|
||||
|
||||
if mount_len == 0 or mount_path == "/":
|
||||
return original_path
|
||||
|
||||
result = original_path[mount_len:]
|
||||
|
||||
if not result:
|
||||
return "/"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
cpdef tuple match_and_modify_path(str request_path, str mount_path, bint strip_path=True):
|
||||
cdef:
|
||||
Py_ssize_t mount_len = len(mount_path)
|
||||
Py_ssize_t req_len = len(request_path)
|
||||
bint is_root = 1 if (mount_len == 0 or mount_path == "/") else 0
|
||||
str modified
|
||||
|
||||
if is_root:
|
||||
return (True, request_path if strip_path else request_path)
|
||||
|
||||
if req_len < mount_len:
|
||||
return (False, None)
|
||||
|
||||
if req_len == mount_len:
|
||||
if request_path == mount_path:
|
||||
return (True, "/" if strip_path else request_path)
|
||||
return (False, None)
|
||||
|
||||
if request_path[mount_len] == '/' and request_path[:mount_len] == mount_path:
|
||||
if strip_path:
|
||||
modified = request_path[mount_len:]
|
||||
return (True, modified if modified else "/")
|
||||
return (True, request_path)
|
||||
|
||||
return (False, None)
|
||||
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
Pure Python fallback for _path_matcher when Cython is not available.
|
||||
|
||||
This module provides the same interface as the Cython _path_matcher module,
|
||||
allowing the application to run without compilation.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
class FastMountedPath:
|
||||
__slots__ = ("_path", "_path_with_slash", "_path_len", "_is_root", "name", "strip_path")
|
||||
|
||||
def __init__(self, path: str, name: str = "", strip_path: bool = True):
|
||||
if path.endswith("/") and len(path) > 1:
|
||||
path = path[:-1]
|
||||
|
||||
self._path = path
|
||||
self._path_len = len(path)
|
||||
self._is_root = path == "" or path == "/"
|
||||
self._path_with_slash = path + "/" if not self._is_root else "/"
|
||||
self.name = name or path
|
||||
self.strip_path = strip_path
|
||||
|
||||
@property
|
||||
def path(self) -> str:
|
||||
return self._path
|
||||
|
||||
def matches(self, request_path: str) -> bool:
|
||||
if self._is_root:
|
||||
return True
|
||||
|
||||
req_len = len(request_path)
|
||||
|
||||
if req_len < self._path_len:
|
||||
return False
|
||||
|
||||
if req_len == self._path_len:
|
||||
return request_path == self._path
|
||||
|
||||
if request_path[self._path_len] == "/":
|
||||
return request_path[: self._path_len] == self._path
|
||||
|
||||
return False
|
||||
|
||||
def get_modified_path(self, original_path: str) -> str:
|
||||
if not self.strip_path:
|
||||
return original_path
|
||||
|
||||
if self._is_root:
|
||||
return original_path
|
||||
|
||||
new_path = original_path[self._path_len :]
|
||||
|
||||
if not new_path:
|
||||
return "/"
|
||||
|
||||
return new_path
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"FastMountedPath(path={self._path!r}, name={self.name!r})"
|
||||
|
||||
|
||||
class FastMountManager:
|
||||
__slots__ = ("_mounts", "_mount_count")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._mounts: List[FastMountedPath] = []
|
||||
self._mount_count: int = 0
|
||||
|
||||
def add_mount(self, mount: FastMountedPath) -> None:
|
||||
self._mounts.append(mount)
|
||||
self._mounts.sort(key=lambda m: len(m.path), reverse=True)
|
||||
self._mount_count = len(self._mounts)
|
||||
|
||||
def get_mount(self, request_path: str) -> Optional[FastMountedPath]:
|
||||
for mount in self._mounts:
|
||||
if mount.matches(request_path):
|
||||
return mount
|
||||
return None
|
||||
|
||||
def remove_mount(self, path: str) -> bool:
|
||||
if path.endswith("/") and len(path) > 1:
|
||||
path = path[:-1]
|
||||
|
||||
for i, mount in enumerate(self._mounts):
|
||||
if mount._path == path:
|
||||
del self._mounts[i]
|
||||
self._mount_count -= 1
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@property
|
||||
def mounts(self) -> List[FastMountedPath]:
|
||||
return self._mounts.copy()
|
||||
|
||||
@property
|
||||
def mount_count(self) -> int:
|
||||
return self._mount_count
|
||||
|
||||
def list_mounts(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"path": mount._path,
|
||||
"name": mount.name,
|
||||
"strip_path": mount.strip_path,
|
||||
}
|
||||
for mount in self._mounts
|
||||
]
|
||||
|
||||
|
||||
def path_matches_prefix(request_path: str, mount_path: str) -> bool:
|
||||
mount_len = len(mount_path)
|
||||
req_len = len(request_path)
|
||||
|
||||
if mount_len == 0 or mount_path == "/":
|
||||
return True
|
||||
|
||||
if req_len < mount_len:
|
||||
return False
|
||||
|
||||
if req_len == mount_len:
|
||||
return request_path == mount_path
|
||||
|
||||
if request_path[mount_len] == "/":
|
||||
return request_path[:mount_len] == mount_path
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def strip_path_prefix(original_path: str, mount_path: str) -> str:
|
||||
mount_len = len(mount_path)
|
||||
|
||||
if mount_len == 0 or mount_path == "/":
|
||||
return original_path
|
||||
|
||||
result = original_path[mount_len:]
|
||||
|
||||
if not result:
|
||||
return "/"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def match_and_modify_path(request_path: str, mount_path: str, strip_path: bool = True) -> Tuple[bool, Optional[str]]:
|
||||
mount_len = len(mount_path)
|
||||
req_len = len(request_path)
|
||||
is_root = mount_len == 0 or mount_path == "/"
|
||||
|
||||
if is_root:
|
||||
return (True, request_path)
|
||||
|
||||
if req_len < mount_len:
|
||||
return (False, None)
|
||||
|
||||
if req_len == mount_len:
|
||||
if request_path == mount_path:
|
||||
return (True, "/" if strip_path else request_path)
|
||||
return (False, None)
|
||||
|
||||
if request_path[mount_len] == "/" and request_path[:mount_len] == mount_path:
|
||||
if strip_path:
|
||||
modified = request_path[mount_len:]
|
||||
return (True, modified if modified else "/")
|
||||
return (True, request_path)
|
||||
|
||||
return (False, None)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
WSGI Wrapper Module for Process Orchestration.
|
||||
|
||||
This module provides a wrapper that allows WSGI applications to be run
|
||||
via uvicorn by wrapping them with a2wsgi.
|
||||
|
||||
The WSGI app path is passed via environment variables:
|
||||
- PYSERVE_WSGI_APP: The app path (e.g., "myapp:app" or "myapp.main:create_app")
|
||||
- PYSERVE_WSGI_FACTORY: "1" if the app path points to a factory function
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
WSGI_ADAPTER: Optional[str] = None
|
||||
|
||||
try:
|
||||
from a2wsgi import WSGIMiddleware
|
||||
|
||||
WSGI_ADAPTER = "a2wsgi"
|
||||
except ImportError:
|
||||
try:
|
||||
from asgiref.wsgi import WsgiToAsgi as WSGIMiddleware # type: ignore
|
||||
|
||||
WSGI_ADAPTER = "asgiref"
|
||||
except ImportError:
|
||||
WSGIMiddleware = None # type: ignore
|
||||
WSGI_ADAPTER = None
|
||||
|
||||
|
||||
def _load_wsgi_app() -> Callable[..., Any]:
|
||||
app_path = os.environ.get("PYSERVE_WSGI_APP")
|
||||
is_factory = os.environ.get("PYSERVE_WSGI_FACTORY", "0") == "1"
|
||||
|
||||
if not app_path:
|
||||
raise RuntimeError("PYSERVE_WSGI_APP environment variable not set. " "This module should only be used by PyServe process orchestration.")
|
||||
|
||||
if ":" in app_path:
|
||||
module_name, attr_name = app_path.rsplit(":", 1)
|
||||
else:
|
||||
module_name = app_path
|
||||
attr_name = "app"
|
||||
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
except ImportError as e:
|
||||
raise RuntimeError(f"Failed to import WSGI module '{module_name}': {e}")
|
||||
|
||||
try:
|
||||
app_or_factory = getattr(module, attr_name)
|
||||
except AttributeError:
|
||||
raise RuntimeError(f"Module '{module_name}' has no attribute '{attr_name}'")
|
||||
|
||||
if is_factory:
|
||||
result: Callable[..., Any] = app_or_factory()
|
||||
return result
|
||||
loaded_app: Callable[..., Any] = app_or_factory
|
||||
return loaded_app
|
||||
|
||||
|
||||
def _create_asgi_app() -> Any:
|
||||
if WSGIMiddleware is None:
|
||||
raise RuntimeError("No WSGI adapter available. " "Install a2wsgi (recommended) or asgiref: pip install a2wsgi")
|
||||
|
||||
wsgi_app = _load_wsgi_app()
|
||||
return WSGIMiddleware(wsgi_app)
|
||||
|
||||
|
||||
app = _create_asgi_app()
|
||||
+10
-14
@@ -8,7 +8,8 @@ This module provides functionality to mount external ASGI/WSGI applications
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, Callable, cast
|
||||
from typing import Any, Callable, Dict, Optional, cast
|
||||
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
from .logging_utils import get_logger
|
||||
@@ -74,20 +75,17 @@ class ASGIAppLoader:
|
||||
def _wrap_wsgi(self, wsgi_app: Callable) -> ASGIApp:
|
||||
try:
|
||||
from a2wsgi import WSGIMiddleware
|
||||
|
||||
return cast(ASGIApp, WSGIMiddleware(wsgi_app))
|
||||
except ImportError:
|
||||
logger.warning("a2wsgi not installed, trying asgiref")
|
||||
try:
|
||||
from asgiref.wsgi import WsgiToAsgi
|
||||
|
||||
return cast(ASGIApp, WsgiToAsgi(wsgi_app))
|
||||
except ImportError:
|
||||
logger.error(
|
||||
"Neither a2wsgi nor asgiref installed. "
|
||||
"Install with: pip install a2wsgi or pip install asgiref"
|
||||
)
|
||||
raise ImportError(
|
||||
"WSGI adapter not available. Install a2wsgi or asgiref."
|
||||
)
|
||||
logger.error("Neither a2wsgi nor asgiref installed. " "Install with: pip install a2wsgi or pip install asgiref")
|
||||
raise ImportError("WSGI adapter not available. Install a2wsgi or asgiref.")
|
||||
|
||||
def get_app(self, app_path: str) -> Optional[ASGIApp]:
|
||||
return self._apps.get(app_path)
|
||||
@@ -132,7 +130,7 @@ class MountedApp:
|
||||
if self.path == "":
|
||||
return original_path
|
||||
|
||||
new_path = original_path[len(self.path):]
|
||||
new_path = original_path[len(self.path) :]
|
||||
return new_path if new_path else "/"
|
||||
|
||||
|
||||
@@ -215,10 +213,7 @@ class ASGIMountManager:
|
||||
modified_scope["path"] = mount.get_modified_path(path)
|
||||
modified_scope["root_path"] = scope.get("root_path", "") + mount.path
|
||||
|
||||
logger.debug(
|
||||
f"Routing request to mounted app '{mount.name}': "
|
||||
f"{path} -> {modified_scope['path']}"
|
||||
)
|
||||
logger.debug(f"Routing request to mounted app '{mount.name}': " f"{path} -> {modified_scope['path']}")
|
||||
|
||||
try:
|
||||
await mount.app(modified_scope, receive, send)
|
||||
@@ -288,7 +283,8 @@ def create_django_app(
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", settings_module)
|
||||
|
||||
try:
|
||||
from django.core.asgi import get_asgi_application # type: ignore[import-untyped]
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
return cast(ASGIApp, get_asgi_application())
|
||||
except ImportError as e:
|
||||
logger.error(f"Failed to load Django application: {e}")
|
||||
|
||||
+7
-26
@@ -1,8 +1,8 @@
|
||||
import sys
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from . import PyServeServer, Config, __version__
|
||||
from . import Config, PyServeServer, __version__
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -10,30 +10,11 @@ def main() -> None:
|
||||
description="PyServe - HTTP web server",
|
||||
prog="pyserve",
|
||||
)
|
||||
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()
|
||||
|
||||
|
||||
+101
-115
@@ -1,8 +1,10 @@
|
||||
import yaml
|
||||
import os
|
||||
from typing import Dict, Any, List, cast
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, cast
|
||||
|
||||
import yaml
|
||||
|
||||
from .logging_utils import setup_logging
|
||||
|
||||
|
||||
@@ -84,7 +86,7 @@ class Config:
|
||||
@classmethod
|
||||
def from_yaml(cls, file_path: str) -> "Config":
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
return cls._from_dict(data)
|
||||
@@ -99,133 +101,117 @@ class Config:
|
||||
def _from_dict(cls, data: Dict[str, Any]) -> "Config":
|
||||
config = cls()
|
||||
|
||||
if 'http' in data:
|
||||
http_data = data['http']
|
||||
if "http" in data:
|
||||
http_data = data["http"]
|
||||
config.http = HttpConfig(
|
||||
static_dir=http_data.get('static_dir', config.http.static_dir),
|
||||
templates_dir=http_data.get('templates_dir', config.http.templates_dir)
|
||||
static_dir=http_data.get("static_dir", config.http.static_dir),
|
||||
templates_dir=http_data.get("templates_dir", config.http.templates_dir),
|
||||
)
|
||||
|
||||
if 'server' in data:
|
||||
server_data = data['server']
|
||||
if "server" in data:
|
||||
server_data = data["server"]
|
||||
config.server = ServerConfig(
|
||||
host=server_data.get('host', config.server.host),
|
||||
port=server_data.get('port', config.server.port),
|
||||
backlog=server_data.get('backlog', config.server.backlog),
|
||||
default_root=server_data.get('default_root', config.server.default_root),
|
||||
proxy_timeout=server_data.get('proxy_timeout', config.server.proxy_timeout),
|
||||
redirect_instructions=server_data.get('redirect_instructions', {})
|
||||
host=server_data.get("host", config.server.host),
|
||||
port=server_data.get("port", config.server.port),
|
||||
backlog=server_data.get("backlog", config.server.backlog),
|
||||
default_root=server_data.get("default_root", config.server.default_root),
|
||||
proxy_timeout=server_data.get("proxy_timeout", config.server.proxy_timeout),
|
||||
redirect_instructions=server_data.get("redirect_instructions", {}),
|
||||
)
|
||||
|
||||
if 'ssl' in data:
|
||||
ssl_data = data['ssl']
|
||||
if "ssl" in data:
|
||||
ssl_data = data["ssl"]
|
||||
config.ssl = SSLConfig(
|
||||
enabled=ssl_data.get('enabled', config.ssl.enabled),
|
||||
cert_file=ssl_data.get('cert_file', config.ssl.cert_file),
|
||||
key_file=ssl_data.get('key_file', config.ssl.key_file)
|
||||
enabled=ssl_data.get("enabled", config.ssl.enabled),
|
||||
cert_file=ssl_data.get("cert_file", config.ssl.cert_file),
|
||||
key_file=ssl_data.get("key_file", config.ssl.key_file),
|
||||
)
|
||||
|
||||
if 'logging' in data:
|
||||
log_data = data['logging']
|
||||
format_data = log_data.get('format', {})
|
||||
if "logging" in data:
|
||||
log_data = data["logging"]
|
||||
format_data = log_data.get("format", {})
|
||||
global_format = LogFormatConfig(
|
||||
type=format_data.get('type', 'standard'),
|
||||
use_colors=format_data.get('use_colors', True),
|
||||
show_module=format_data.get('show_module', True),
|
||||
timestamp_format=format_data.get('timestamp_format', '%Y-%m-%d %H:%M:%S')
|
||||
type=format_data.get("type", "standard"),
|
||||
use_colors=format_data.get("use_colors", True),
|
||||
show_module=format_data.get("show_module", True),
|
||||
timestamp_format=format_data.get("timestamp_format", "%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
console_data = log_data.get('console', {})
|
||||
console_format_data = console_data.get('format', {})
|
||||
console_data = log_data.get("console", {})
|
||||
console_format_data = console_data.get("format", {})
|
||||
console_format = LogFormatConfig(
|
||||
type=console_format_data.get('type', global_format.type),
|
||||
use_colors=console_format_data.get('use_colors', global_format.use_colors),
|
||||
show_module=console_format_data.get('show_module', global_format.show_module),
|
||||
timestamp_format=console_format_data.get('timestamp_format', global_format.timestamp_format)
|
||||
)
|
||||
console_config = LogHandlerConfig(
|
||||
level=console_data.get('level', log_data.get('level', 'INFO')),
|
||||
format=console_format
|
||||
type=console_format_data.get("type", global_format.type),
|
||||
use_colors=console_format_data.get("use_colors", global_format.use_colors),
|
||||
show_module=console_format_data.get("show_module", global_format.show_module),
|
||||
timestamp_format=console_format_data.get("timestamp_format", global_format.timestamp_format),
|
||||
)
|
||||
console_config = LogHandlerConfig(level=console_data.get("level", log_data.get("level", "INFO")), format=console_format)
|
||||
files_config = []
|
||||
if 'log_file' in log_data:
|
||||
if "log_file" in log_data:
|
||||
default_file_format = LogFormatConfig(
|
||||
type=global_format.type,
|
||||
use_colors=False,
|
||||
show_module=global_format.show_module,
|
||||
timestamp_format=global_format.timestamp_format
|
||||
type=global_format.type, use_colors=False, show_module=global_format.show_module, timestamp_format=global_format.timestamp_format
|
||||
)
|
||||
default_file = LogFileConfig(
|
||||
path=log_data['log_file'],
|
||||
level=log_data.get('level', 'INFO'),
|
||||
path=log_data["log_file"],
|
||||
level=log_data.get("level", "INFO"),
|
||||
format=default_file_format,
|
||||
loggers=[], # Empty list means including all loggers
|
||||
max_bytes=10 * 1024 * 1024,
|
||||
backup_count=5
|
||||
backup_count=5,
|
||||
)
|
||||
files_config.append(default_file)
|
||||
|
||||
if 'files' in log_data:
|
||||
for file_data in log_data['files']:
|
||||
file_format_data = file_data.get('format', {})
|
||||
if "files" in log_data:
|
||||
for file_data in log_data["files"]:
|
||||
file_format_data = file_data.get("format", {})
|
||||
file_format = LogFormatConfig(
|
||||
type=file_format_data.get('type', global_format.type),
|
||||
use_colors=file_format_data.get('use_colors', False),
|
||||
show_module=file_format_data.get('show_module', global_format.show_module),
|
||||
timestamp_format=file_format_data.get('timestamp_format', global_format.timestamp_format)
|
||||
type=file_format_data.get("type", global_format.type),
|
||||
use_colors=file_format_data.get("use_colors", False),
|
||||
show_module=file_format_data.get("show_module", global_format.show_module),
|
||||
timestamp_format=file_format_data.get("timestamp_format", global_format.timestamp_format),
|
||||
)
|
||||
file_config = LogFileConfig(
|
||||
path=file_data.get('path', './logs/pyserve.log'),
|
||||
level=file_data.get('level', log_data.get('level', 'INFO')),
|
||||
path=file_data.get("path", "./logs/pyserve.log"),
|
||||
level=file_data.get("level", log_data.get("level", "INFO")),
|
||||
format=file_format,
|
||||
loggers=file_data.get('loggers', []),
|
||||
max_bytes=file_data.get('max_bytes', 10 * 1024 * 1024),
|
||||
backup_count=file_data.get('backup_count', 5)
|
||||
loggers=file_data.get("loggers", []),
|
||||
max_bytes=file_data.get("max_bytes", 10 * 1024 * 1024),
|
||||
backup_count=file_data.get("backup_count", 5),
|
||||
)
|
||||
files_config.append(file_config)
|
||||
|
||||
if 'show_module' in console_format_data:
|
||||
print(
|
||||
"\033[33mWARNING: Parameter 'show_module' in console.format in development and may work incorrectly\033[0m"
|
||||
)
|
||||
console_config.format.show_module = console_format_data.get('show_module')
|
||||
if "show_module" in console_format_data:
|
||||
print("\033[33mWARNING: Parameter 'show_module' in console.format in development and may work incorrectly\033[0m")
|
||||
console_config.format.show_module = console_format_data.get("show_module")
|
||||
|
||||
for i, file_data in enumerate(log_data.get('files', [])):
|
||||
if 'format' in file_data and 'show_module' in file_data['format']:
|
||||
print(
|
||||
f"\033[33mWARNING: Parameter 'show_module' in files[{i}].format in development and may work incorrectly\033[0m"
|
||||
)
|
||||
for i, file_data in enumerate(log_data.get("files", [])):
|
||||
if "format" in file_data and "show_module" in file_data["format"]:
|
||||
print(f"\033[33mWARNING: Parameter 'show_module' in files[{i}].format in development and may work incorrectly\033[0m")
|
||||
|
||||
if not files_config:
|
||||
default_file_format = LogFormatConfig(
|
||||
type=global_format.type,
|
||||
use_colors=False,
|
||||
show_module=global_format.show_module,
|
||||
timestamp_format=global_format.timestamp_format
|
||||
type=global_format.type, use_colors=False, show_module=global_format.show_module, timestamp_format=global_format.timestamp_format
|
||||
)
|
||||
default_file = LogFileConfig(
|
||||
path='./logs/pyserve.log',
|
||||
level=log_data.get('level', 'INFO'),
|
||||
path="./logs/pyserve.log",
|
||||
level=log_data.get("level", "INFO"),
|
||||
format=default_file_format,
|
||||
loggers=[],
|
||||
max_bytes=10 * 1024 * 1024,
|
||||
backup_count=5
|
||||
backup_count=5,
|
||||
)
|
||||
files_config.append(default_file)
|
||||
|
||||
config.logging = LoggingConfig(
|
||||
level=log_data.get('level', 'INFO'),
|
||||
console_output=log_data.get('console_output', True),
|
||||
level=log_data.get("level", "INFO"),
|
||||
console_output=log_data.get("console_output", True),
|
||||
format=global_format,
|
||||
console=console_config,
|
||||
files=files_config
|
||||
files=files_config,
|
||||
)
|
||||
|
||||
if 'extensions' in data:
|
||||
for ext_data in data['extensions']:
|
||||
extension = ExtensionConfig(
|
||||
type=ext_data.get('type', ''),
|
||||
config=ext_data.get('config', {})
|
||||
)
|
||||
if "extensions" in data:
|
||||
for ext_data in data["extensions"]:
|
||||
extension = ExtensionConfig(type=ext_data.get("type", ""), config=ext_data.get("config", {}))
|
||||
config.extensions.append(extension)
|
||||
|
||||
return config
|
||||
@@ -245,14 +231,14 @@ class Config:
|
||||
if not (1 <= self.server.port <= 65535):
|
||||
errors.append(f"Invalid port: {self.server.port}")
|
||||
|
||||
valid_log_levels = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
|
||||
valid_log_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
||||
if self.logging.level.upper() not in valid_log_levels:
|
||||
errors.append(f"Invalid logging level: {self.logging.level}")
|
||||
|
||||
if self.logging.console.level.upper() not in valid_log_levels:
|
||||
errors.append(f"Invalid console logging level: {self.logging.console.level}")
|
||||
|
||||
valid_format_types = ['standard', 'json']
|
||||
valid_format_types = ["standard", "json"]
|
||||
|
||||
if self.logging.format.type not in valid_format_types:
|
||||
errors.append(f"Invalid logging format type: {self.logging.format.type}")
|
||||
@@ -283,40 +269,40 @@ class Config:
|
||||
|
||||
def setup_logging(self) -> None:
|
||||
config_dict = {
|
||||
'level': self.logging.level,
|
||||
'console_output': self.logging.console_output,
|
||||
'format': {
|
||||
'type': self.logging.format.type,
|
||||
'use_colors': self.logging.format.use_colors,
|
||||
'show_module': self.logging.format.show_module,
|
||||
'timestamp_format': self.logging.format.timestamp_format
|
||||
"level": self.logging.level,
|
||||
"console_output": self.logging.console_output,
|
||||
"format": {
|
||||
"type": self.logging.format.type,
|
||||
"use_colors": self.logging.format.use_colors,
|
||||
"show_module": self.logging.format.show_module,
|
||||
"timestamp_format": self.logging.format.timestamp_format,
|
||||
},
|
||||
'console': {
|
||||
'level': self.logging.console.level,
|
||||
'format': {
|
||||
'type': self.logging.console.format.type,
|
||||
'use_colors': self.logging.console.format.use_colors,
|
||||
'show_module': self.logging.console.format.show_module,
|
||||
'timestamp_format': self.logging.console.format.timestamp_format
|
||||
}
|
||||
"console": {
|
||||
"level": self.logging.console.level,
|
||||
"format": {
|
||||
"type": self.logging.console.format.type,
|
||||
"use_colors": self.logging.console.format.use_colors,
|
||||
"show_module": self.logging.console.format.show_module,
|
||||
"timestamp_format": self.logging.console.format.timestamp_format,
|
||||
},
|
||||
},
|
||||
'files': []
|
||||
"files": [],
|
||||
}
|
||||
|
||||
for file_config in self.logging.files:
|
||||
file_dict = {
|
||||
'path': file_config.path,
|
||||
'level': file_config.level,
|
||||
'loggers': file_config.loggers,
|
||||
'max_bytes': file_config.max_bytes,
|
||||
'backup_count': file_config.backup_count,
|
||||
'format': {
|
||||
'type': file_config.format.type,
|
||||
'use_colors': file_config.format.use_colors,
|
||||
'show_module': file_config.format.show_module,
|
||||
'timestamp_format': file_config.format.timestamp_format
|
||||
}
|
||||
"path": file_config.path,
|
||||
"level": file_config.level,
|
||||
"loggers": file_config.loggers,
|
||||
"max_bytes": file_config.max_bytes,
|
||||
"backup_count": file_config.backup_count,
|
||||
"format": {
|
||||
"type": file_config.format.type,
|
||||
"use_colors": file_config.format.use_colors,
|
||||
"show_module": file_config.format.show_module,
|
||||
"timestamp_format": file_config.format.timestamp_format,
|
||||
},
|
||||
}
|
||||
cast(List[Dict[str, Any]], config_dict['files']).append(file_dict)
|
||||
cast(List[Dict[str, Any]], config_dict["files"]).append(file_dict)
|
||||
|
||||
setup_logging(config_dict)
|
||||
|
||||
+51
-15
@@ -1,7 +1,10 @@
|
||||
import asyncio
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Any, List, Optional, Type
|
||||
from typing import Any, Dict, List, Optional, Type
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from .logging_utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -36,6 +39,7 @@ class RoutingExtension(Extension):
|
||||
default_proxy_timeout = config.get("default_proxy_timeout", 30.0)
|
||||
self.router = create_router_from_config(regex_locations)
|
||||
from .routing import RequestHandler
|
||||
|
||||
self.handler = RequestHandler(self.router, default_proxy_timeout=default_proxy_timeout)
|
||||
|
||||
async def process_request(self, request: Request) -> Optional[Response]:
|
||||
@@ -54,11 +58,9 @@ class SecurityExtension(Extension):
|
||||
super().__init__(config)
|
||||
self.allowed_ips = config.get("allowed_ips", [])
|
||||
self.blocked_ips = config.get("blocked_ips", [])
|
||||
self.security_headers = config.get("security_headers", {
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"X-XSS-Protection": "1; mode=block"
|
||||
})
|
||||
self.security_headers = config.get(
|
||||
"security_headers", {"X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", "X-XSS-Protection": "1; mode=block"}
|
||||
)
|
||||
|
||||
async def process_request(self, request: Request) -> Optional[Response]:
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
@@ -66,11 +68,13 @@ class SecurityExtension(Extension):
|
||||
if self.blocked_ips and client_ip in self.blocked_ips:
|
||||
logger.warning(f"Blocked request from IP: {client_ip}")
|
||||
from starlette.responses import PlainTextResponse
|
||||
|
||||
return PlainTextResponse("403 Forbidden", status_code=403)
|
||||
|
||||
if self.allowed_ips and client_ip not in self.allowed_ips:
|
||||
logger.warning(f"Access denied for IP: {client_ip}")
|
||||
from starlette.responses import PlainTextResponse
|
||||
|
||||
return PlainTextResponse("403 Forbidden", status_code=403)
|
||||
|
||||
return None
|
||||
@@ -108,33 +112,30 @@ class MonitoringExtension(Extension):
|
||||
async def process_request(self, request: Request) -> Optional[Response]:
|
||||
if self.enable_metrics:
|
||||
self.request_count += 1
|
||||
request.state.start_time = __import__('time').time()
|
||||
request.state.start_time = __import__("time").time()
|
||||
return None
|
||||
|
||||
async def process_response(self, request: Request, response: Response) -> Response:
|
||||
if self.enable_metrics and hasattr(request.state, 'start_time'):
|
||||
response_time = __import__('time').time() - request.state.start_time
|
||||
if self.enable_metrics and hasattr(request.state, "start_time"):
|
||||
response_time = __import__("time").time() - request.state.start_time
|
||||
self.response_times.append(response_time)
|
||||
|
||||
if response.status_code >= 400:
|
||||
self.error_count += 1
|
||||
|
||||
logger.info(f"Request: {request.method} {request.url.path} - "
|
||||
f"Status: {response.status_code} - "
|
||||
f"Time: {response_time:.3f}s")
|
||||
logger.info(f"Request: {request.method} {request.url.path} - " f"Status: {response.status_code} - " f"Time: {response_time:.3f}s")
|
||||
|
||||
return response
|
||||
|
||||
def get_metrics(self) -> Dict[str, Any]:
|
||||
avg_response_time = (sum(self.response_times) / len(self.response_times)
|
||||
if self.response_times else 0)
|
||||
avg_response_time = sum(self.response_times) / len(self.response_times) if self.response_times else 0
|
||||
|
||||
return {
|
||||
"request_count": self.request_count,
|
||||
"error_count": self.error_count,
|
||||
"error_rate": self.error_count / max(self.request_count, 1),
|
||||
"avg_response_time": avg_response_time,
|
||||
"total_response_times": len(self.response_times)
|
||||
"total_response_times": len(self.response_times),
|
||||
}
|
||||
|
||||
|
||||
@@ -216,6 +217,15 @@ class ExtensionManager:
|
||||
"monitoring": MonitoringExtension,
|
||||
"asgi": ASGIExtension,
|
||||
}
|
||||
self._register_process_orchestration()
|
||||
|
||||
def _register_process_orchestration(self) -> None:
|
||||
try:
|
||||
from .process_extension import ProcessOrchestrationExtension
|
||||
|
||||
self.extension_registry["process_orchestration"] = ProcessOrchestrationExtension
|
||||
except ImportError:
|
||||
pass # Optional dependency
|
||||
|
||||
def register_extension_type(self, name: str, extension_class: Type[Extension]) -> None:
|
||||
self.extension_registry[name] = extension_class
|
||||
@@ -234,6 +244,32 @@ class ExtensionManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading extension {extension_type}: {e}")
|
||||
|
||||
async def load_extension_async(self, extension_type: str, config: Dict[str, Any]) -> None:
|
||||
"""Load extension with async setup support (for ProcessOrchestration)."""
|
||||
if extension_type not in self.extension_registry:
|
||||
logger.error(f"Unknown extension type: {extension_type}")
|
||||
return
|
||||
|
||||
try:
|
||||
extension_class = self.extension_registry[extension_type]
|
||||
extension = extension_class(config)
|
||||
|
||||
setup_method = getattr(extension, "setup", None)
|
||||
if setup_method is not None and asyncio.iscoroutinefunction(setup_method):
|
||||
await setup_method(config)
|
||||
else:
|
||||
extension.initialize()
|
||||
|
||||
start_method = getattr(extension, "start", None)
|
||||
if start_method is not None and asyncio.iscoroutinefunction(start_method):
|
||||
await start_method()
|
||||
|
||||
# Insert at the beginning so process_orchestration is checked first
|
||||
self.extensions.insert(0, extension)
|
||||
logger.info(f"Loaded extension (async): {extension_type}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading extension {extension_type}: {e}")
|
||||
|
||||
async def process_request(self, request: Request) -> Optional[Response]:
|
||||
for extension in self.extensions:
|
||||
if not extension.enabled:
|
||||
|
||||
+93
-129
@@ -3,9 +3,10 @@ import logging.handlers
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, cast, Callable
|
||||
from typing import Any, Callable, Dict, List, cast
|
||||
|
||||
import structlog
|
||||
from structlog.types import FilteringBoundLogger, EventDict
|
||||
from structlog.types import EventDict, FilteringBoundLogger
|
||||
|
||||
from . import __version__
|
||||
|
||||
@@ -21,15 +22,15 @@ class StructlogFilter(logging.Filter):
|
||||
return True
|
||||
|
||||
for logger_name in self.logger_names:
|
||||
if record.name == logger_name or record.name.startswith(logger_name + '.'):
|
||||
if record.name == logger_name or record.name.startswith(logger_name + "."):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class UvicornStructlogFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
if hasattr(record, 'name') and 'uvicorn.access' in record.name:
|
||||
if hasattr(record, 'getMessage'):
|
||||
if hasattr(record, "name") and "uvicorn.access" in record.name:
|
||||
if hasattr(record, "getMessage"):
|
||||
msg = record.getMessage()
|
||||
if ' - "' in msg and '" ' in msg:
|
||||
parts = msg.split(' - "')
|
||||
@@ -56,14 +57,14 @@ def add_log_level(logger: FilteringBoundLogger, method_name: str, event_dict: Ev
|
||||
|
||||
|
||||
def add_module_info(logger: FilteringBoundLogger, method_name: str, event_dict: EventDict) -> EventDict:
|
||||
if hasattr(logger, '_context') and 'logger_name' in logger._context:
|
||||
logger_name = logger._context['logger_name']
|
||||
if logger_name.startswith('pyserve'):
|
||||
if hasattr(logger, "_context") and "logger_name" in logger._context:
|
||||
logger_name = logger._context["logger_name"]
|
||||
if logger_name.startswith("pyserve"):
|
||||
event_dict["module"] = logger_name
|
||||
elif logger_name.startswith('uvicorn'):
|
||||
event_dict["module"] = 'uvicorn'
|
||||
elif logger_name.startswith('starlette'):
|
||||
event_dict["module"] = 'starlette'
|
||||
elif logger_name.startswith("uvicorn"):
|
||||
event_dict["module"] = "uvicorn"
|
||||
elif logger_name.startswith("starlette"):
|
||||
event_dict["module"] = "starlette"
|
||||
else:
|
||||
event_dict["module"] = logger_name
|
||||
return event_dict
|
||||
@@ -74,18 +75,19 @@ def filter_module_info(show_module: bool) -> Callable[[FilteringBoundLogger, str
|
||||
if not show_module and "module" in event_dict:
|
||||
del event_dict["module"]
|
||||
return event_dict
|
||||
|
||||
return processor
|
||||
|
||||
|
||||
def colored_console_renderer(use_colors: bool = True, show_module: bool = True) -> structlog.dev.ConsoleRenderer:
|
||||
return structlog.dev.ConsoleRenderer(
|
||||
colors=use_colors and hasattr(sys.stderr, 'isatty') and sys.stderr.isatty(),
|
||||
colors=use_colors and hasattr(sys.stderr, "isatty") and sys.stderr.isatty(),
|
||||
level_styles={
|
||||
"critical": "\033[35m", # Magenta
|
||||
"error": "\033[31m", # Red
|
||||
"warning": "\033[33m", # Yellow
|
||||
"info": "\033[32m", # Green
|
||||
"debug": "\033[36m", # Cyan
|
||||
"error": "\033[31m", # Red
|
||||
"warning": "\033[33m", # Yellow
|
||||
"info": "\033[32m", # Green
|
||||
"debug": "\033[36m", # Cyan
|
||||
},
|
||||
pad_event=25,
|
||||
)
|
||||
@@ -113,43 +115,35 @@ class PyServeLogManager:
|
||||
if self.configured:
|
||||
return
|
||||
|
||||
if 'format' not in config and 'console' not in config and 'files' not in config:
|
||||
level = config.get('level', 'INFO').upper()
|
||||
console_output = config.get('console_output', True)
|
||||
log_file = config.get('log_file', './logs/pyserve.log')
|
||||
if "format" not in config and "console" not in config and "files" not in config:
|
||||
level = config.get("level", "INFO").upper()
|
||||
console_output = config.get("console_output", True)
|
||||
log_file = config.get("log_file", "./logs/pyserve.log")
|
||||
config = {
|
||||
'level': level,
|
||||
'console_output': console_output,
|
||||
'format': {
|
||||
'type': 'standard',
|
||||
'use_colors': True,
|
||||
'show_module': True,
|
||||
'timestamp_format': '%Y-%m-%d %H:%M:%S'
|
||||
},
|
||||
'files': [{
|
||||
'path': log_file,
|
||||
'level': level,
|
||||
'loggers': [],
|
||||
'max_bytes': 10 * 1024 * 1024,
|
||||
'backup_count': 5,
|
||||
'format': {
|
||||
'type': 'standard',
|
||||
'use_colors': False,
|
||||
'show_module': True,
|
||||
'timestamp_format': '%Y-%m-%d %H:%M:%S'
|
||||
"level": level,
|
||||
"console_output": console_output,
|
||||
"format": {"type": "standard", "use_colors": True, "show_module": True, "timestamp_format": "%Y-%m-%d %H:%M:%S"},
|
||||
"files": [
|
||||
{
|
||||
"path": log_file,
|
||||
"level": level,
|
||||
"loggers": [],
|
||||
"max_bytes": 10 * 1024 * 1024,
|
||||
"backup_count": 5,
|
||||
"format": {"type": "standard", "use_colors": False, "show_module": True, "timestamp_format": "%Y-%m-%d %H:%M:%S"},
|
||||
}
|
||||
}]
|
||||
],
|
||||
}
|
||||
|
||||
main_level = config.get('level', 'INFO').upper()
|
||||
console_output = config.get('console_output', True)
|
||||
main_level = config.get("level", "INFO").upper()
|
||||
console_output = config.get("console_output", True)
|
||||
|
||||
global_format = config.get('format', {})
|
||||
console_config = config.get('console', {})
|
||||
files_config = config.get('files', [])
|
||||
global_format = config.get("format", {})
|
||||
console_config = config.get("console", {})
|
||||
files_config = config.get("files", [])
|
||||
|
||||
console_format = {**global_format, **console_config.get('format', {})}
|
||||
console_level = console_config.get('level', main_level)
|
||||
console_format = {**global_format, **console_config.get("format", {})}
|
||||
console_level = console_config.get("level", main_level)
|
||||
|
||||
self._save_original_handlers()
|
||||
self._clear_all_handlers()
|
||||
@@ -159,38 +153,33 @@ class PyServeLogManager:
|
||||
console_output=console_output,
|
||||
console_format=console_format,
|
||||
console_level=console_level,
|
||||
files_config=files_config
|
||||
files_config=files_config,
|
||||
)
|
||||
|
||||
self._configure_stdlib_loggers(main_level)
|
||||
|
||||
logger = self.get_logger('pyserve')
|
||||
logger = self.get_logger("pyserve")
|
||||
logger.info(
|
||||
"PyServe logger initialized",
|
||||
version=__version__,
|
||||
level=main_level,
|
||||
console_output=console_output,
|
||||
console_format=console_format.get('type', 'standard')
|
||||
console_format=console_format.get("type", "standard"),
|
||||
)
|
||||
|
||||
for i, file_config in enumerate(files_config):
|
||||
logger.info(
|
||||
"File logging configured",
|
||||
file_index=i,
|
||||
path=file_config.get('path'),
|
||||
level=file_config.get('level', main_level),
|
||||
format_type=file_config.get('format', {}).get('type', 'standard')
|
||||
path=file_config.get("path"),
|
||||
level=file_config.get("level", main_level),
|
||||
format_type=file_config.get("format", {}).get("type", "standard"),
|
||||
)
|
||||
|
||||
self.configured = True
|
||||
|
||||
def _configure_structlog(
|
||||
self,
|
||||
main_level: str,
|
||||
console_output: bool,
|
||||
console_format: Dict[str, Any],
|
||||
console_level: str,
|
||||
files_config: List[Dict[str, Any]]
|
||||
self, main_level: str, console_output: bool, console_format: Dict[str, Any], console_level: str, files_config: List[Dict[str, Any]]
|
||||
) -> None:
|
||||
shared_processors = [
|
||||
structlog.stdlib.filter_by_level,
|
||||
@@ -202,57 +191,46 @@ class PyServeLogManager:
|
||||
]
|
||||
|
||||
if console_output:
|
||||
console_show_module = console_format.get('show_module', True)
|
||||
console_show_module = console_format.get("show_module", True)
|
||||
console_processors = shared_processors.copy()
|
||||
|
||||
console_processors.append(filter_module_info(console_show_module))
|
||||
|
||||
if console_format.get('type') == 'json':
|
||||
if console_format.get("type") == "json":
|
||||
console_processors.append(json_renderer())
|
||||
else:
|
||||
console_processors.append(
|
||||
colored_console_renderer(
|
||||
console_format.get('use_colors', True),
|
||||
console_show_module
|
||||
)
|
||||
)
|
||||
console_processors.append(colored_console_renderer(console_format.get("use_colors", True), console_show_module))
|
||||
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(getattr(logging, console_level))
|
||||
console_handler.addFilter(UvicornStructlogFilter())
|
||||
|
||||
console_formatter = structlog.stdlib.ProcessorFormatter(
|
||||
processor=colored_console_renderer(
|
||||
console_format.get('use_colors', True),
|
||||
console_show_module
|
||||
)
|
||||
if console_format.get('type') != 'json'
|
||||
else json_renderer(),
|
||||
processor=(
|
||||
colored_console_renderer(console_format.get("use_colors", True), console_show_module)
|
||||
if console_format.get("type") != "json"
|
||||
else json_renderer()
|
||||
),
|
||||
)
|
||||
console_handler.setFormatter(console_formatter)
|
||||
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(logging.DEBUG)
|
||||
root_logger.addHandler(console_handler)
|
||||
self.handlers['console'] = console_handler
|
||||
self.handlers["console"] = console_handler
|
||||
|
||||
for i, file_config in enumerate(files_config):
|
||||
file_path = file_config.get('path', './logs/pyserve.log')
|
||||
file_level = file_config.get('level', main_level)
|
||||
file_loggers = file_config.get('loggers', [])
|
||||
max_bytes = file_config.get('max_bytes', 10 * 1024 * 1024)
|
||||
backup_count = file_config.get('backup_count', 5)
|
||||
file_format = file_config.get('format', {})
|
||||
file_show_module = file_format.get('show_module', True)
|
||||
file_path = file_config.get("path", "./logs/pyserve.log")
|
||||
file_level = file_config.get("level", main_level)
|
||||
file_loggers = file_config.get("loggers", [])
|
||||
max_bytes = file_config.get("max_bytes", 10 * 1024 * 1024)
|
||||
backup_count = file_config.get("backup_count", 5)
|
||||
file_format = file_config.get("format", {})
|
||||
file_show_module = file_format.get("show_module", True)
|
||||
|
||||
self._ensure_log_directory(file_path)
|
||||
|
||||
file_handler = logging.handlers.RotatingFileHandler(
|
||||
file_path,
|
||||
maxBytes=max_bytes,
|
||||
backupCount=backup_count,
|
||||
encoding='utf-8'
|
||||
)
|
||||
file_handler = logging.handlers.RotatingFileHandler(file_path, maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8")
|
||||
file_handler.setLevel(getattr(logging, file_level))
|
||||
|
||||
if file_loggers:
|
||||
@@ -262,15 +240,13 @@ class PyServeLogManager:
|
||||
file_processors.append(filter_module_info(file_show_module))
|
||||
|
||||
file_formatter = structlog.stdlib.ProcessorFormatter(
|
||||
processor=json_renderer()
|
||||
if file_format.get('type') == 'json'
|
||||
else plain_console_renderer(file_show_module),
|
||||
processor=json_renderer() if file_format.get("type") == "json" else plain_console_renderer(file_show_module),
|
||||
)
|
||||
file_handler.setFormatter(file_formatter)
|
||||
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.addHandler(file_handler)
|
||||
self.handlers[f'file_{i}'] = file_handler
|
||||
self.handlers[f"file_{i}"] = file_handler
|
||||
|
||||
base_processors = [
|
||||
structlog.stdlib.filter_by_level,
|
||||
@@ -293,14 +269,14 @@ class PyServeLogManager:
|
||||
|
||||
def _configure_stdlib_loggers(self, main_level: str) -> None:
|
||||
library_configs = {
|
||||
'uvicorn': 'DEBUG' if main_level == 'DEBUG' else 'WARNING',
|
||||
'uvicorn.access': 'DEBUG' if main_level == 'DEBUG' else 'WARNING',
|
||||
'uvicorn.error': 'DEBUG' if main_level == 'DEBUG' else 'ERROR',
|
||||
'uvicorn.asgi': 'DEBUG' if main_level == 'DEBUG' else 'WARNING',
|
||||
'starlette': 'DEBUG' if main_level == 'DEBUG' else 'WARNING',
|
||||
'asyncio': 'WARNING',
|
||||
'concurrent.futures': 'WARNING',
|
||||
'multiprocessing': 'WARNING',
|
||||
"uvicorn": "DEBUG" if main_level == "DEBUG" else "WARNING",
|
||||
"uvicorn.access": "DEBUG" if main_level == "DEBUG" else "WARNING",
|
||||
"uvicorn.error": "DEBUG" if main_level == "DEBUG" else "ERROR",
|
||||
"uvicorn.asgi": "DEBUG" if main_level == "DEBUG" else "WARNING",
|
||||
"starlette": "DEBUG" if main_level == "DEBUG" else "WARNING",
|
||||
"asyncio": "WARNING",
|
||||
"concurrent.futures": "WARNING",
|
||||
"multiprocessing": "WARNING",
|
||||
}
|
||||
|
||||
for logger_name, level in library_configs.items():
|
||||
@@ -309,7 +285,7 @@ class PyServeLogManager:
|
||||
logger.propagate = True
|
||||
|
||||
def _save_original_handlers(self) -> None:
|
||||
logger_names = ['', 'uvicorn', 'uvicorn.access', 'uvicorn.error', 'starlette']
|
||||
logger_names = ["", "uvicorn", "uvicorn.access", "uvicorn.error", "starlette"]
|
||||
|
||||
for name in logger_names:
|
||||
logger = logging.getLogger(name)
|
||||
@@ -320,7 +296,7 @@ class PyServeLogManager:
|
||||
for handler in root_logger.handlers[:]:
|
||||
root_logger.removeHandler(handler)
|
||||
|
||||
logger_names = ['uvicorn', 'uvicorn.access', 'uvicorn.error', 'starlette']
|
||||
logger_names = ["uvicorn", "uvicorn.access", "uvicorn.error", "starlette"]
|
||||
for name in logger_names:
|
||||
logger = logging.getLogger(name)
|
||||
for handler in logger.handlers[:]:
|
||||
@@ -335,14 +311,17 @@ class PyServeLogManager:
|
||||
def get_logger(self, name: str) -> structlog.stdlib.BoundLogger:
|
||||
if not self._structlog_configured:
|
||||
structlog.configure(
|
||||
processors=cast(Any, [
|
||||
structlog.stdlib.filter_by_level,
|
||||
add_timestamp,
|
||||
add_log_level,
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.format_exc_info,
|
||||
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
||||
]),
|
||||
processors=cast(
|
||||
Any,
|
||||
[
|
||||
structlog.stdlib.filter_by_level,
|
||||
add_timestamp,
|
||||
add_log_level,
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.format_exc_info,
|
||||
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
||||
],
|
||||
),
|
||||
context_class=dict,
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
@@ -370,16 +349,8 @@ class PyServeLogManager:
|
||||
handler.close()
|
||||
del self.handlers[name]
|
||||
|
||||
def create_access_log(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
status_code: int,
|
||||
response_time: float,
|
||||
client_ip: str,
|
||||
user_agent: str = ""
|
||||
) -> None:
|
||||
access_logger = self.get_logger('pyserve.access')
|
||||
def create_access_log(self, method: str, path: str, status_code: int, response_time: float, client_ip: str, user_agent: str = "") -> None:
|
||||
access_logger = self.get_logger("pyserve.access")
|
||||
access_logger.info(
|
||||
"HTTP access",
|
||||
method=method,
|
||||
@@ -388,7 +359,7 @@ class PyServeLogManager:
|
||||
response_time_ms=round(response_time * 1000, 2),
|
||||
client_ip=client_ip,
|
||||
user_agent=user_agent,
|
||||
timestamp_format="access"
|
||||
timestamp_format="access",
|
||||
)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
@@ -416,14 +387,7 @@ def get_logger(name: str) -> structlog.stdlib.BoundLogger:
|
||||
return log_manager.get_logger(name)
|
||||
|
||||
|
||||
def create_access_log(
|
||||
method: str,
|
||||
path: str,
|
||||
status_code: int,
|
||||
response_time: float,
|
||||
client_ip: str,
|
||||
user_agent: str = ""
|
||||
) -> None:
|
||||
def create_access_log(method: str, path: str, status_code: int, response_time: float, client_ip: str, user_agent: str = "") -> None:
|
||||
log_manager.create_access_log(method, path, status_code, response_time, client_ip, user_agent)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
Path matcher module - uses Cython implementation if available, falls back to pure Python.
|
||||
"""
|
||||
|
||||
try:
|
||||
from pyserve._path_matcher import (
|
||||
FastMountedPath,
|
||||
FastMountManager,
|
||||
match_and_modify_path,
|
||||
path_matches_prefix,
|
||||
strip_path_prefix,
|
||||
)
|
||||
|
||||
CYTHON_AVAILABLE = True
|
||||
except ImportError:
|
||||
from pyserve._path_matcher_py import (
|
||||
FastMountedPath,
|
||||
FastMountManager,
|
||||
match_and_modify_path,
|
||||
path_matches_prefix,
|
||||
strip_path_prefix,
|
||||
)
|
||||
|
||||
CYTHON_AVAILABLE = False
|
||||
|
||||
__all__ = [
|
||||
"FastMountedPath",
|
||||
"FastMountManager",
|
||||
"path_matches_prefix",
|
||||
"strip_path_prefix",
|
||||
"match_and_modify_path",
|
||||
"CYTHON_AVAILABLE",
|
||||
]
|
||||
@@ -0,0 +1,365 @@
|
||||
"""Process Orchestration Extension
|
||||
|
||||
Extension that manages ASGI/WSGI applications as isolated processes
|
||||
and routes requests to them via reverse proxy.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import httpx
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from .extensions import Extension
|
||||
from .logging_utils import get_logger
|
||||
from .process_manager import ProcessConfig, ProcessManager
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ProcessOrchestrationExtension(Extension):
|
||||
"""
|
||||
Extension that orchestrates ASGI/WSGI applications as separate processes.
|
||||
|
||||
Unlike ASGIExtension which runs apps in-process, this extension:
|
||||
- Runs each app in its own isolated process
|
||||
- Provides health monitoring and auto-restart
|
||||
- Routes requests via HTTP reverse proxy
|
||||
- Supports multiple workers per app
|
||||
|
||||
Configuration example:
|
||||
```yaml
|
||||
extensions:
|
||||
- type: process_orchestration
|
||||
config:
|
||||
port_range: [9000, 9999]
|
||||
health_check_enabled: true
|
||||
apps:
|
||||
- name: api
|
||||
path: /api
|
||||
app_path: myapp.api:app
|
||||
workers: 4
|
||||
health_check_path: /health
|
||||
|
||||
- name: admin
|
||||
path: /admin
|
||||
app_path: myapp.admin:create_app
|
||||
factory: true
|
||||
workers: 2
|
||||
```
|
||||
"""
|
||||
|
||||
name = "process_orchestration"
|
||||
|
||||
def __init__(self, config: Dict[str, Any]) -> None:
|
||||
super().__init__(config)
|
||||
self._manager: Optional[ProcessManager] = None
|
||||
self._mounts: Dict[str, MountConfig] = {} # path -> config
|
||||
self._http_client: Optional[httpx.AsyncClient] = None
|
||||
self._started = False
|
||||
self._proxy_timeout: float = config.get("proxy_timeout", 60.0)
|
||||
self._pending_config = config # Store for async setup
|
||||
|
||||
logging_config = config.get("logging", {})
|
||||
self._log_proxy_requests: bool = logging_config.get("proxy_logs", True)
|
||||
self._log_health_checks: bool = logging_config.get("health_check_logs", False)
|
||||
|
||||
httpx_level = logging_config.get("httpx_level", "warning").upper()
|
||||
logging.getLogger("httpx").setLevel(getattr(logging, httpx_level, logging.WARNING))
|
||||
logging.getLogger("httpcore").setLevel(getattr(logging, httpx_level, logging.WARNING))
|
||||
|
||||
async def setup(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||
if config is None:
|
||||
config = self._pending_config
|
||||
|
||||
port_range = tuple(config.get("port_range", [9000, 9999]))
|
||||
health_check_enabled = config.get("health_check_enabled", True)
|
||||
self._proxy_timeout = config.get("proxy_timeout", 60.0)
|
||||
|
||||
self._manager = ProcessManager(
|
||||
port_range=port_range,
|
||||
health_check_enabled=health_check_enabled,
|
||||
)
|
||||
|
||||
self._http_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(self._proxy_timeout),
|
||||
follow_redirects=False,
|
||||
limits=httpx.Limits(
|
||||
max_keepalive_connections=100,
|
||||
max_connections=200,
|
||||
),
|
||||
)
|
||||
|
||||
apps_config = config.get("apps", [])
|
||||
for app_config in apps_config:
|
||||
await self._register_app(app_config)
|
||||
|
||||
logger.info(
|
||||
"Process orchestration extension initialized",
|
||||
app_count=len(self._mounts),
|
||||
)
|
||||
|
||||
async def _register_app(self, app_config: Dict[str, Any]) -> None:
|
||||
if not self._manager:
|
||||
return
|
||||
|
||||
name = app_config.get("name")
|
||||
path = app_config.get("path", "").rstrip("/")
|
||||
app_path = app_config.get("app_path")
|
||||
|
||||
if not name or not app_path:
|
||||
logger.error("App config missing 'name' or 'app_path'")
|
||||
return
|
||||
|
||||
process_config = ProcessConfig(
|
||||
name=name,
|
||||
app_path=app_path,
|
||||
app_type=app_config.get("app_type", "asgi"),
|
||||
workers=app_config.get("workers", 1),
|
||||
module_path=app_config.get("module_path"),
|
||||
factory=app_config.get("factory", False),
|
||||
factory_args=app_config.get("factory_args"),
|
||||
env=app_config.get("env", {}),
|
||||
health_check_enabled=app_config.get("health_check_enabled", True),
|
||||
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_memory_mb=app_config.get("max_memory_mb"),
|
||||
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),
|
||||
)
|
||||
|
||||
await self._manager.register(process_config)
|
||||
|
||||
self._mounts[path] = MountConfig(
|
||||
path=path,
|
||||
process_name=name,
|
||||
strip_path=app_config.get("strip_path", True),
|
||||
)
|
||||
|
||||
logger.info(f"Registered app '{name}' at path '{path}'")
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._started or not self._manager:
|
||||
return
|
||||
|
||||
await self._manager.start()
|
||||
results = await self._manager.start_all()
|
||||
|
||||
self._started = True
|
||||
|
||||
success = sum(1 for v in results.values() if v)
|
||||
failed = len(results) - success
|
||||
|
||||
logger.info(
|
||||
"Process orchestration started",
|
||||
success=success,
|
||||
failed=failed,
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
if not self._started:
|
||||
return
|
||||
|
||||
if self._http_client:
|
||||
await self._http_client.aclose()
|
||||
self._http_client = None
|
||||
|
||||
if self._manager:
|
||||
await self._manager.stop()
|
||||
|
||||
self._started = False
|
||||
logger.info("Process orchestration stopped")
|
||||
|
||||
def cleanup(self) -> None:
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.create_task(self.stop())
|
||||
except RuntimeError:
|
||||
asyncio.run(self.stop())
|
||||
|
||||
async def process_request(self, request: Request) -> Optional[Response]:
|
||||
if not self._started or not self._manager:
|
||||
logger.debug(
|
||||
"Process orchestration not ready",
|
||||
started=self._started,
|
||||
has_manager=self._manager is not None,
|
||||
)
|
||||
return None
|
||||
|
||||
mount = self._get_mount(request.url.path)
|
||||
if not mount:
|
||||
logger.debug(
|
||||
"No mount found for path",
|
||||
path=request.url.path,
|
||||
available_mounts=list(self._mounts.keys()),
|
||||
)
|
||||
return None
|
||||
|
||||
upstream_url = self._manager.get_upstream_url(mount.process_name)
|
||||
if not upstream_url:
|
||||
logger.warning(
|
||||
f"Process '{mount.process_name}' not running",
|
||||
path=request.url.path,
|
||||
)
|
||||
return Response("Service Unavailable", status_code=503)
|
||||
|
||||
request_id = request.headers.get("X-Request-ID", str(uuid.uuid4())[:8])
|
||||
|
||||
start_time = time.perf_counter()
|
||||
response = await self._proxy_request(request, upstream_url, mount, request_id)
|
||||
latency_ms = (time.perf_counter() - start_time) * 1000
|
||||
|
||||
if self._log_proxy_requests:
|
||||
logger.info(
|
||||
"Proxy request completed",
|
||||
request_id=request_id,
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
process=mount.process_name,
|
||||
upstream=upstream_url,
|
||||
status=response.status_code,
|
||||
latency_ms=round(latency_ms, 2),
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
def _get_mount(self, path: str) -> Optional["MountConfig"]:
|
||||
for mount_path in sorted(self._mounts.keys(), key=len, reverse=True):
|
||||
if mount_path == "":
|
||||
return self._mounts[mount_path]
|
||||
if path == mount_path or path.startswith(f"{mount_path}/"):
|
||||
return self._mounts[mount_path]
|
||||
return None
|
||||
|
||||
async def _proxy_request(
|
||||
self,
|
||||
request: Request,
|
||||
upstream_url: str,
|
||||
mount: "MountConfig",
|
||||
request_id: str = "",
|
||||
) -> Response:
|
||||
path = request.url.path
|
||||
if mount.strip_path and mount.path:
|
||||
path = path[len(mount.path) :] or "/"
|
||||
|
||||
target_url = f"{upstream_url}{path}"
|
||||
if request.url.query:
|
||||
target_url += f"?{request.url.query}"
|
||||
|
||||
headers = dict(request.headers)
|
||||
headers.pop("host", None)
|
||||
headers["X-Forwarded-For"] = request.client.host if request.client else "unknown"
|
||||
headers["X-Forwarded-Proto"] = request.url.scheme
|
||||
headers["X-Forwarded-Host"] = request.headers.get("host", "")
|
||||
if request_id:
|
||||
headers["X-Request-ID"] = request_id
|
||||
|
||||
try:
|
||||
if not self._http_client:
|
||||
return Response("Service Unavailable", status_code=503)
|
||||
|
||||
body = await request.body()
|
||||
|
||||
response = await self._http_client.request(
|
||||
method=request.method,
|
||||
url=target_url,
|
||||
headers=headers,
|
||||
content=body,
|
||||
)
|
||||
|
||||
response_headers = dict(response.headers)
|
||||
for header in ["transfer-encoding", "connection", "keep-alive"]:
|
||||
response_headers.pop(header, None)
|
||||
|
||||
return Response(
|
||||
content=response.content,
|
||||
status_code=response.status_code,
|
||||
headers=response_headers,
|
||||
)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error(f"Proxy timeout to {upstream_url}")
|
||||
return Response("Gateway Timeout", status_code=504)
|
||||
except httpx.ConnectError as e:
|
||||
logger.error(f"Proxy connection error to {upstream_url}: {e}")
|
||||
return Response("Bad Gateway", status_code=502)
|
||||
except Exception as e:
|
||||
logger.error(f"Proxy error to {upstream_url}: {e}")
|
||||
return Response("Internal Server Error", status_code=500)
|
||||
|
||||
async def process_response(
|
||||
self,
|
||||
request: Request,
|
||||
response: Response,
|
||||
) -> Response:
|
||||
return response
|
||||
|
||||
def get_metrics(self) -> Dict[str, Any]:
|
||||
metrics = {
|
||||
"process_orchestration": {
|
||||
"enabled": self._started,
|
||||
"mounts": len(self._mounts),
|
||||
}
|
||||
}
|
||||
|
||||
if self._manager:
|
||||
metrics["process_orchestration"].update(self._manager.get_metrics())
|
||||
|
||||
return metrics
|
||||
|
||||
async def get_process_status(self, name: str) -> Optional[Dict[str, Any]]:
|
||||
if not self._manager:
|
||||
return None
|
||||
info = self._manager.get_process(name)
|
||||
return info.to_dict() if info else None
|
||||
|
||||
async def get_all_status(self) -> Dict[str, Any]:
|
||||
if not self._manager:
|
||||
return {}
|
||||
return {name: info.to_dict() for name, info in self._manager.get_all_processes().items()}
|
||||
|
||||
async def restart_process(self, name: str) -> bool:
|
||||
if not self._manager:
|
||||
return False
|
||||
return await self._manager.restart_process(name)
|
||||
|
||||
async def scale_process(self, name: str, workers: int) -> bool:
|
||||
if not self._manager:
|
||||
return False
|
||||
|
||||
info = self._manager.get_process(name)
|
||||
if not info:
|
||||
return False
|
||||
|
||||
info.config.workers = workers
|
||||
return await self._manager.restart_process(name)
|
||||
|
||||
|
||||
class MountConfig:
|
||||
def __init__(
|
||||
self,
|
||||
path: str,
|
||||
process_name: str,
|
||||
strip_path: bool = True,
|
||||
):
|
||||
self.path = path
|
||||
self.process_name = process_name
|
||||
self.strip_path = strip_path
|
||||
|
||||
|
||||
async def setup_process_orchestration(config: Dict[str, Any]) -> ProcessOrchestrationExtension:
|
||||
ext = ProcessOrchestrationExtension(config)
|
||||
await ext.setup(config)
|
||||
await ext.start()
|
||||
return ext
|
||||
|
||||
|
||||
async def shutdown_process_orchestration(ext: ProcessOrchestrationExtension) -> None:
|
||||
await ext.stop()
|
||||
@@ -0,0 +1,553 @@
|
||||
"""Process Manager Module
|
||||
|
||||
Orchestrates ASGI/WSGI applications as separate processes
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .logging_utils import get_logger
|
||||
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ProcessState(Enum):
|
||||
PENDING = "pending"
|
||||
STARTING = "starting"
|
||||
RUNNING = "running"
|
||||
STOPPING = "stopping"
|
||||
STOPPED = "stopped"
|
||||
FAILED = "failed"
|
||||
RESTARTING = "restarting"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessConfig:
|
||||
name: str
|
||||
app_path: str
|
||||
app_type: str = "asgi" # asgi, wsgi
|
||||
host: str = "127.0.0.1"
|
||||
port: int = 0 # 0 = auto-assign
|
||||
workers: int = 1
|
||||
module_path: Optional[str] = None
|
||||
factory: bool = False
|
||||
factory_args: Optional[Dict[str, Any]] = None
|
||||
env: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
health_check_enabled: bool = True
|
||||
health_check_path: str = "/health"
|
||||
health_check_interval: float = 10.0
|
||||
health_check_timeout: float = 5.0
|
||||
health_check_retries: int = 3
|
||||
|
||||
max_memory_mb: Optional[int] = None
|
||||
max_restart_count: int = 5
|
||||
restart_delay: float = 1.0 # seconds
|
||||
|
||||
shutdown_timeout: float = 30.0 # seconds
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessInfo:
|
||||
config: ProcessConfig
|
||||
state: ProcessState = ProcessState.PENDING
|
||||
pid: Optional[int] = None
|
||||
port: int = 0
|
||||
start_time: Optional[float] = None
|
||||
restart_count: int = 0
|
||||
last_health_check: Optional[float] = None
|
||||
health_check_failures: int = 0
|
||||
process: Optional[subprocess.Popen] = None
|
||||
|
||||
@property
|
||||
def uptime(self) -> float:
|
||||
if self.start_time is None:
|
||||
return 0.0
|
||||
return time.time() - self.start_time
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self.state == ProcessState.RUNNING and self.process is not None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": self.config.name,
|
||||
"state": self.state.value,
|
||||
"pid": self.pid,
|
||||
"port": self.port,
|
||||
"uptime": round(self.uptime, 2),
|
||||
"restart_count": self.restart_count,
|
||||
"health_check_failures": self.health_check_failures,
|
||||
"workers": self.config.workers,
|
||||
}
|
||||
|
||||
|
||||
class PortAllocator:
|
||||
def __init__(self, start_port: int = 9000, end_port: int = 9999):
|
||||
self.start_port = start_port
|
||||
self.end_port = end_port
|
||||
self._allocated: set[int] = set()
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def allocate(self) -> int:
|
||||
async with self._lock:
|
||||
for port in range(self.start_port, self.end_port + 1):
|
||||
if port in self._allocated:
|
||||
continue
|
||||
if self._is_port_available(port):
|
||||
self._allocated.add(port)
|
||||
return port
|
||||
raise RuntimeError(f"No available ports in range {self.start_port}-{self.end_port}")
|
||||
|
||||
async def release(self, port: int) -> None:
|
||||
async with self._lock:
|
||||
self._allocated.discard(port)
|
||||
|
||||
def _is_port_available(self, port: int) -> bool:
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
s.bind(("127.0.0.1", port))
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
class ProcessManager:
|
||||
def __init__(
|
||||
self,
|
||||
port_range: tuple[int, int] = (9000, 9999),
|
||||
health_check_enabled: bool = True,
|
||||
):
|
||||
self._processes: Dict[str, ProcessInfo] = {}
|
||||
self._port_allocator = PortAllocator(*port_range)
|
||||
self._health_check_enabled = health_check_enabled
|
||||
self._health_check_task: Optional[asyncio.Task] = None
|
||||
self._shutdown_event = asyncio.Event()
|
||||
self._started = False
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._started:
|
||||
return
|
||||
|
||||
self._started = True
|
||||
self._shutdown_event.clear()
|
||||
|
||||
if self._health_check_enabled:
|
||||
self._health_check_task = asyncio.create_task(self._health_check_loop(), name="process_manager_health_check")
|
||||
|
||||
logger.info("Process manager started")
|
||||
|
||||
async def stop(self) -> None:
|
||||
if not self._started:
|
||||
return
|
||||
|
||||
logger.info("Stopping process manager...")
|
||||
self._shutdown_event.set()
|
||||
|
||||
if self._health_check_task:
|
||||
self._health_check_task.cancel()
|
||||
try:
|
||||
await self._health_check_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
await self.stop_all()
|
||||
|
||||
self._started = False
|
||||
logger.info("Process manager stopped")
|
||||
|
||||
async def register(self, config: ProcessConfig) -> ProcessInfo:
|
||||
async with self._lock:
|
||||
if config.name in self._processes:
|
||||
raise ValueError(f"Process '{config.name}' already registered")
|
||||
|
||||
info = ProcessInfo(config=config)
|
||||
self._processes[config.name] = info
|
||||
|
||||
logger.info(f"Registered process '{config.name}'", app_path=config.app_path)
|
||||
return info
|
||||
|
||||
async def unregister(self, name: str) -> None:
|
||||
async with self._lock:
|
||||
if name not in self._processes:
|
||||
return
|
||||
|
||||
info = self._processes[name]
|
||||
if info.is_running:
|
||||
await self._stop_process(info)
|
||||
|
||||
if info.port:
|
||||
await self._port_allocator.release(info.port)
|
||||
|
||||
del self._processes[name]
|
||||
logger.info(f"Unregistered process '{name}'")
|
||||
|
||||
async def start_process(self, name: str) -> bool:
|
||||
info = self._processes.get(name)
|
||||
if not info:
|
||||
logger.error(f"Process '{name}' not found")
|
||||
return False
|
||||
|
||||
if info.is_running:
|
||||
logger.warning(f"Process '{name}' is already running")
|
||||
return True
|
||||
|
||||
return await self._start_process(info)
|
||||
|
||||
async def stop_process(self, name: str) -> bool:
|
||||
info = self._processes.get(name)
|
||||
if not info:
|
||||
logger.error(f"Process '{name}' not found")
|
||||
return False
|
||||
|
||||
return await self._stop_process(info)
|
||||
|
||||
async def restart_process(self, name: str) -> bool:
|
||||
info = self._processes.get(name)
|
||||
if not info:
|
||||
logger.error(f"Process '{name}' not found")
|
||||
return False
|
||||
|
||||
info.state = ProcessState.RESTARTING
|
||||
|
||||
if info.is_running:
|
||||
await self._stop_process(info)
|
||||
|
||||
await asyncio.sleep(info.config.restart_delay)
|
||||
return await self._start_process(info)
|
||||
|
||||
async def start_all(self) -> Dict[str, bool]:
|
||||
results = {}
|
||||
for name in self._processes:
|
||||
results[name] = await self.start_process(name)
|
||||
return results
|
||||
|
||||
async def stop_all(self) -> None:
|
||||
tasks = []
|
||||
for info in self._processes.values():
|
||||
if info.is_running:
|
||||
tasks.append(self._stop_process(info))
|
||||
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
def get_process(self, name: str) -> Optional[ProcessInfo]:
|
||||
return self._processes.get(name)
|
||||
|
||||
def get_all_processes(self) -> Dict[str, ProcessInfo]:
|
||||
return self._processes.copy()
|
||||
|
||||
def get_process_by_port(self, port: int) -> Optional[ProcessInfo]:
|
||||
for info in self._processes.values():
|
||||
if info.port == port:
|
||||
return info
|
||||
return None
|
||||
|
||||
def get_upstream_url(self, name: str) -> Optional[str]:
|
||||
info = self._processes.get(name)
|
||||
if not info or not info.is_running:
|
||||
return None
|
||||
return f"http://{info.config.host}:{info.port}"
|
||||
|
||||
async def _start_process(self, info: ProcessInfo) -> bool:
|
||||
config = info.config
|
||||
|
||||
try:
|
||||
info.state = ProcessState.STARTING
|
||||
|
||||
if info.port == 0:
|
||||
info.port = await self._port_allocator.allocate()
|
||||
|
||||
cmd = self._build_command(config, info.port)
|
||||
|
||||
env = os.environ.copy()
|
||||
env.update(config.env)
|
||||
|
||||
if config.module_path:
|
||||
python_path = env.get("PYTHONPATH", "")
|
||||
module_dir = str(Path(config.module_path).resolve())
|
||||
env["PYTHONPATH"] = f"{module_dir}:{python_path}" if python_path else module_dir
|
||||
|
||||
# For WSGI apps, pass configuration via environment variables
|
||||
if config.app_type == "wsgi":
|
||||
env["PYSERVE_WSGI_APP"] = config.app_path
|
||||
env["PYSERVE_WSGI_FACTORY"] = "1" if config.factory else "0"
|
||||
|
||||
logger.info(
|
||||
f"Starting process '{config.name}'",
|
||||
command=" ".join(cmd),
|
||||
port=info.port,
|
||||
)
|
||||
|
||||
info.process = subprocess.Popen(
|
||||
cmd,
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
preexec_fn=os.setsid if hasattr(os, "setsid") else None,
|
||||
)
|
||||
|
||||
info.pid = info.process.pid
|
||||
info.start_time = time.time()
|
||||
|
||||
if not await self._wait_for_ready(info):
|
||||
raise RuntimeError(f"Process '{config.name}' failed to start")
|
||||
|
||||
info.state = ProcessState.RUNNING
|
||||
logger.info(
|
||||
f"Process '{config.name}' started successfully",
|
||||
pid=info.pid,
|
||||
port=info.port,
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start process '{config.name}': {e}")
|
||||
info.state = ProcessState.FAILED
|
||||
if info.port:
|
||||
await self._port_allocator.release(info.port)
|
||||
info.port = 0
|
||||
return False
|
||||
|
||||
async def _stop_process(self, info: ProcessInfo) -> bool:
|
||||
if not info.process:
|
||||
info.state = ProcessState.STOPPED
|
||||
return True
|
||||
|
||||
config = info.config
|
||||
info.state = ProcessState.STOPPING
|
||||
|
||||
try:
|
||||
if hasattr(os, "killpg"):
|
||||
try:
|
||||
os.killpg(os.getpgid(info.process.pid), signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
else:
|
||||
info.process.terminate()
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.get_event_loop().run_in_executor(None, info.process.wait), timeout=config.shutdown_timeout)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(f"Process '{config.name}' did not stop gracefully, forcing kill")
|
||||
if hasattr(os, "killpg"):
|
||||
try:
|
||||
os.killpg(os.getpgid(info.process.pid), signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
else:
|
||||
info.process.kill()
|
||||
info.process.wait()
|
||||
|
||||
if info.port:
|
||||
await self._port_allocator.release(info.port)
|
||||
|
||||
info.state = ProcessState.STOPPED
|
||||
info.process = None
|
||||
info.pid = None
|
||||
|
||||
logger.info(f"Process '{config.name}' stopped")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping process '{config.name}': {e}")
|
||||
info.state = ProcessState.FAILED
|
||||
return False
|
||||
|
||||
async def _wait_for_ready(self, info: ProcessInfo, timeout: float = 30.0) -> bool:
|
||||
import httpx
|
||||
|
||||
start_time = time.time()
|
||||
url = f"http://{info.config.host}:{info.port}{info.config.health_check_path}"
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
if info.process and info.process.poll() is not None:
|
||||
stdout, stderr = info.process.communicate()
|
||||
logger.error(
|
||||
f"Process '{info.config.name}' exited during startup",
|
||||
returncode=info.process.returncode,
|
||||
stderr=stderr.decode() if stderr else "",
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=2.0) as client:
|
||||
resp = await client.get(url)
|
||||
if resp.status_code < 500:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
return False
|
||||
|
||||
async def _health_check_loop(self) -> None:
|
||||
while not self._shutdown_event.is_set():
|
||||
try:
|
||||
for info in list(self._processes.values()):
|
||||
if not info.is_running or not info.config.health_check_enabled:
|
||||
continue
|
||||
|
||||
await self._check_process_health(info)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self._shutdown_event.wait(),
|
||||
timeout=(
|
||||
min(p.config.health_check_interval for p in self._processes.values() if p.config.health_check_enabled)
|
||||
if self._processes
|
||||
else 10.0
|
||||
),
|
||||
)
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in health check loop: {e}")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def _check_process_health(self, info: ProcessInfo) -> bool:
|
||||
import httpx
|
||||
|
||||
config = info.config
|
||||
url = f"http://{config.host}:{info.port}{config.health_check_path}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=config.health_check_timeout) as client:
|
||||
resp = await client.get(url)
|
||||
if resp.status_code < 500:
|
||||
info.health_check_failures = 0
|
||||
info.last_health_check = time.time()
|
||||
return True
|
||||
else:
|
||||
raise Exception(f"Health check returned status {resp.status_code}")
|
||||
|
||||
except Exception as e:
|
||||
info.health_check_failures += 1
|
||||
logger.warning(
|
||||
f"Health check failed for '{config.name}'",
|
||||
failures=info.health_check_failures,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
if info.health_check_failures >= config.health_check_retries:
|
||||
logger.error(f"Process '{config.name}' is unhealthy, restarting...")
|
||||
await self._handle_unhealthy_process(info)
|
||||
|
||||
return False
|
||||
|
||||
async def _handle_unhealthy_process(self, info: ProcessInfo) -> None:
|
||||
config = info.config
|
||||
|
||||
if info.restart_count >= config.max_restart_count:
|
||||
logger.error(f"Process '{config.name}' exceeded max restart count, marking as failed")
|
||||
info.state = ProcessState.FAILED
|
||||
return
|
||||
|
||||
info.restart_count += 1
|
||||
info.health_check_failures = 0
|
||||
|
||||
delay = config.restart_delay * (2 ** (info.restart_count - 1))
|
||||
delay = min(delay, 60.0)
|
||||
|
||||
logger.info(
|
||||
f"Restarting process '{config.name}'",
|
||||
restart_count=info.restart_count,
|
||||
delay=delay,
|
||||
)
|
||||
|
||||
await self._stop_process(info)
|
||||
await asyncio.sleep(delay)
|
||||
await self._start_process(info)
|
||||
|
||||
def _build_command(self, config: ProcessConfig, port: int) -> List[str]:
|
||||
if config.app_type == "wsgi":
|
||||
wrapper_app = self._create_wsgi_wrapper_path(config)
|
||||
app_path = wrapper_app
|
||||
else:
|
||||
app_path = config.app_path
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"uvicorn",
|
||||
app_path,
|
||||
"--host",
|
||||
config.host,
|
||||
"--port",
|
||||
str(port),
|
||||
"--workers",
|
||||
str(config.workers),
|
||||
"--log-level",
|
||||
"warning",
|
||||
"--no-access-log",
|
||||
]
|
||||
|
||||
if config.factory and config.app_type != "wsgi":
|
||||
cmd.append("--factory")
|
||||
|
||||
return cmd
|
||||
|
||||
def _create_wsgi_wrapper_path(self, config: ProcessConfig) -> str:
|
||||
"""
|
||||
Since uvicorn can't directly run WSGI apps, we create a wrapper
|
||||
that imports the WSGI app and wraps it with a2wsgi.
|
||||
"""
|
||||
# For WSGI apps, we'll use a special wrapper module
|
||||
# The wrapper is: pyserve._wsgi_wrapper:create_app
|
||||
# It will be called with app_path as environment variable
|
||||
return "pyserve._wsgi_wrapper:app"
|
||||
|
||||
def get_metrics(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"managed_processes": len(self._processes),
|
||||
"running_processes": sum(1 for p in self._processes.values() if p.is_running),
|
||||
"processes": {name: info.to_dict() for name, info in self._processes.items()},
|
||||
}
|
||||
|
||||
|
||||
_process_manager: Optional[ProcessManager] = None
|
||||
|
||||
|
||||
def get_process_manager() -> ProcessManager:
|
||||
global _process_manager
|
||||
if _process_manager is None:
|
||||
_process_manager = ProcessManager()
|
||||
return _process_manager
|
||||
|
||||
|
||||
async def init_process_manager(
|
||||
port_range: tuple[int, int] = (9000, 9999),
|
||||
health_check_enabled: bool = True,
|
||||
) -> ProcessManager:
|
||||
global _process_manager
|
||||
_process_manager = ProcessManager(
|
||||
port_range=port_range,
|
||||
health_check_enabled=health_check_enabled,
|
||||
)
|
||||
await _process_manager.start()
|
||||
return _process_manager
|
||||
|
||||
|
||||
async def shutdown_process_manager() -> None:
|
||||
global _process_manager
|
||||
if _process_manager:
|
||||
await _process_manager.stop()
|
||||
_process_manager = None
|
||||
+16
-10
@@ -1,11 +1,13 @@
|
||||
import re
|
||||
import mimetypes
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, Pattern
|
||||
from typing import Any, Dict, Optional, Pattern
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response, FileResponse, PlainTextResponse
|
||||
from starlette.responses import FileResponse, PlainTextResponse, Response
|
||||
|
||||
from .logging_utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -100,8 +102,7 @@ class RequestHandler:
|
||||
text = ""
|
||||
|
||||
content_type = config.get("content_type", "text/plain")
|
||||
return PlainTextResponse(text, status_code=status_code,
|
||||
media_type=content_type)
|
||||
return PlainTextResponse(text, status_code=status_code, media_type=content_type)
|
||||
|
||||
if "proxy_pass" in config:
|
||||
return await self._handle_proxy(request, config, route_match.params)
|
||||
@@ -171,8 +172,7 @@ class RequestHandler:
|
||||
|
||||
return PlainTextResponse("404 Not Found", status_code=404)
|
||||
|
||||
async def _handle_proxy(self, request: Request, config: Dict[str, Any],
|
||||
params: Dict[str, str]) -> Response:
|
||||
async def _handle_proxy(self, request: Request, config: Dict[str, Any], params: Dict[str, str]) -> Response:
|
||||
proxy_url = config["proxy_pass"]
|
||||
|
||||
for key, value in params.items():
|
||||
@@ -197,9 +197,15 @@ class RequestHandler:
|
||||
proxy_headers = dict(request.headers)
|
||||
|
||||
hop_by_hop_headers = [
|
||||
"connection", "keep-alive", "proxy-authenticate",
|
||||
"proxy-authorization", "te", "trailers", "transfer-encoding",
|
||||
"upgrade", "host"
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailers",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"host",
|
||||
]
|
||||
for header in hop_by_hop_headers:
|
||||
proxy_headers.pop(header, None)
|
||||
|
||||
+58
-69
@@ -1,18 +1,19 @@
|
||||
import ssl
|
||||
import uvicorn
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import uvicorn
|
||||
from starlette.applications import Starlette
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response, PlainTextResponse
|
||||
from starlette.responses import PlainTextResponse, Response
|
||||
from starlette.routing import Route
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from .config import Config
|
||||
from .extensions import ExtensionManager, ASGIExtension
|
||||
from .logging_utils import get_logger
|
||||
from . import __version__
|
||||
from .config import Config
|
||||
from .extensions import ASGIExtension, ExtensionManager
|
||||
from .logging_utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -21,7 +22,7 @@ class PyServeMiddleware:
|
||||
def __init__(self, app: ASGIApp, extension_manager: ExtensionManager):
|
||||
self.app = app
|
||||
self.extension_manager = extension_manager
|
||||
self.access_logger = get_logger('pyserve.access')
|
||||
self.access_logger = get_logger("pyserve.access")
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
@@ -48,14 +49,7 @@ class PyServeMiddleware:
|
||||
|
||||
await response(scope, receive, send)
|
||||
|
||||
async def _try_asgi_mount(
|
||||
self,
|
||||
scope: Scope,
|
||||
receive: Receive,
|
||||
send: Send,
|
||||
request: Request,
|
||||
start_time: float
|
||||
) -> bool:
|
||||
async def _try_asgi_mount(self, scope: Scope, receive: Receive, send: Send, request: Request, start_time: float) -> bool:
|
||||
for extension in self.extension_manager.extensions:
|
||||
if isinstance(extension, ASGIExtension):
|
||||
mount = extension.get_asgi_handler(request)
|
||||
@@ -65,10 +59,7 @@ class PyServeMiddleware:
|
||||
modified_scope["path"] = mount.get_modified_path(request.url.path)
|
||||
modified_scope["root_path"] = scope.get("root_path", "") + mount.path
|
||||
|
||||
logger.debug(
|
||||
f"Routing to ASGI mount '{mount.name}': "
|
||||
f"{request.url.path} -> {modified_scope['path']}"
|
||||
)
|
||||
logger.debug(f"Routing to ASGI mount '{mount.name}': " f"{request.url.path} -> {modified_scope['path']}")
|
||||
|
||||
try:
|
||||
response_started = False
|
||||
@@ -92,15 +83,12 @@ class PyServeMiddleware:
|
||||
mount=mount.name,
|
||||
status_code=status_code,
|
||||
process_time_ms=process_time,
|
||||
user_agent=request.headers.get("user-agent", "")
|
||||
user_agent=request.headers.get("user-agent", ""),
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error in ASGI mount '{mount.name}': {e}")
|
||||
error_response = PlainTextResponse(
|
||||
"500 Internal Server Error",
|
||||
status_code=500
|
||||
)
|
||||
error_response = PlainTextResponse("500 Internal Server Error", status_code=500)
|
||||
await error_response(scope, receive, send)
|
||||
return True
|
||||
return False
|
||||
@@ -122,7 +110,7 @@ class PyServeMiddleware:
|
||||
path=path,
|
||||
status_code=status_code,
|
||||
process_time_ms=process_time,
|
||||
user_agent=request.headers.get("user-agent", "")
|
||||
user_agent=request.headers.get("user-agent", ""),
|
||||
)
|
||||
|
||||
|
||||
@@ -131,6 +119,7 @@ class PyServeServer:
|
||||
self.config = config
|
||||
self.extension_manager = ExtensionManager()
|
||||
self.app: Optional[Starlette] = None
|
||||
self._async_extensions_loaded = False
|
||||
self._setup_logging()
|
||||
self._load_extensions()
|
||||
self._create_app()
|
||||
@@ -145,30 +134,39 @@ class PyServeServer:
|
||||
if ext_config.type == "routing":
|
||||
config.setdefault("default_proxy_timeout", self.config.server.proxy_timeout)
|
||||
|
||||
self.extension_manager.load_extension(
|
||||
ext_config.type,
|
||||
config
|
||||
)
|
||||
if ext_config.type == "process_orchestration":
|
||||
continue
|
||||
|
||||
self.extension_manager.load_extension(ext_config.type, config)
|
||||
|
||||
async def _load_async_extensions(self) -> None:
|
||||
if self._async_extensions_loaded:
|
||||
return
|
||||
|
||||
for ext_config in self.config.extensions:
|
||||
if ext_config.type == "process_orchestration":
|
||||
config = ext_config.config.copy()
|
||||
await self.extension_manager.load_extension_async(ext_config.type, config)
|
||||
|
||||
self._async_extensions_loaded = True
|
||||
|
||||
def _create_app(self) -> None:
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncIterator
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: Starlette) -> AsyncIterator[None]:
|
||||
await self._load_async_extensions()
|
||||
logger.info("Async extensions loaded")
|
||||
yield
|
||||
|
||||
routes = [
|
||||
Route("/health", self._health_check, methods=["GET"]),
|
||||
Route("/metrics", self._metrics, methods=["GET"]),
|
||||
Route(
|
||||
"/{path:path}",
|
||||
self._catch_all,
|
||||
methods=[
|
||||
"GET",
|
||||
"POST",
|
||||
"PUT",
|
||||
"DELETE",
|
||||
"PATCH",
|
||||
"OPTIONS"
|
||||
]
|
||||
),
|
||||
Route("/{path:path}", self._catch_all, methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]),
|
||||
]
|
||||
|
||||
self.app = Starlette(routes=routes)
|
||||
self.app = Starlette(routes=routes, lifespan=lifespan)
|
||||
self.app.add_middleware(PyServeMiddleware, extension_manager=self.extension_manager)
|
||||
|
||||
async def _health_check(self, request: Request) -> Response:
|
||||
@@ -178,19 +176,16 @@ class PyServeServer:
|
||||
metrics = {}
|
||||
|
||||
for extension in self.extension_manager.extensions:
|
||||
if hasattr(extension, 'get_metrics'):
|
||||
if hasattr(extension, "get_metrics"):
|
||||
try:
|
||||
ext_metrics = getattr(extension, 'get_metrics')()
|
||||
ext_metrics = getattr(extension, "get_metrics")()
|
||||
metrics.update(ext_metrics)
|
||||
except Exception as e:
|
||||
logger.error("Error getting metrics from extension",
|
||||
extension=type(extension).__name__, error=str(e))
|
||||
logger.error("Error getting metrics from extension", extension=type(extension).__name__, error=str(e))
|
||||
|
||||
import json
|
||||
return Response(
|
||||
json.dumps(metrics, ensure_ascii=False, indent=2),
|
||||
media_type="application/json"
|
||||
)
|
||||
|
||||
return Response(json.dumps(metrics, ensure_ascii=False, indent=2), media_type="application/json")
|
||||
|
||||
async def _catch_all(self, request: Request) -> Response:
|
||||
return PlainTextResponse("404 Not Found", status_code=404)
|
||||
@@ -209,10 +204,7 @@ class PyServeServer:
|
||||
|
||||
try:
|
||||
context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
|
||||
context.load_cert_chain(
|
||||
self.config.ssl.cert_file,
|
||||
self.config.ssl.key_file
|
||||
)
|
||||
context.load_cert_chain(self.config.ssl.cert_file, self.config.ssl.key_file)
|
||||
logger.info("SSL context created successfully")
|
||||
return context
|
||||
except Exception as e:
|
||||
@@ -237,20 +229,17 @@ class PyServeServer:
|
||||
}
|
||||
|
||||
if ssl_context:
|
||||
uvicorn_config.update({
|
||||
"ssl_keyfile": self.config.ssl.key_file,
|
||||
"ssl_certfile": self.config.ssl.cert_file,
|
||||
})
|
||||
uvicorn_config.update(
|
||||
{
|
||||
"ssl_keyfile": self.config.ssl.key_file,
|
||||
"ssl_certfile": self.config.ssl.cert_file,
|
||||
}
|
||||
)
|
||||
protocol = "https"
|
||||
else:
|
||||
protocol = "http"
|
||||
|
||||
logger.info(
|
||||
"Starting PyServe server",
|
||||
protocol=protocol,
|
||||
host=self.config.server.host,
|
||||
port=self.config.server.port
|
||||
)
|
||||
logger.info("Starting PyServe server", protocol=protocol, host=self.config.server.host, port=self.config.server.port)
|
||||
|
||||
try:
|
||||
assert self.app is not None, "App not initialized"
|
||||
@@ -306,6 +295,7 @@ class PyServeServer:
|
||||
self.extension_manager.cleanup()
|
||||
|
||||
from .logging_utils import shutdown_logging
|
||||
|
||||
shutdown_logging()
|
||||
|
||||
logger.info("Server stopped")
|
||||
@@ -317,13 +307,12 @@ class PyServeServer:
|
||||
metrics = {"server_status": "running"}
|
||||
|
||||
for extension in self.extension_manager.extensions:
|
||||
if hasattr(extension, 'get_metrics'):
|
||||
if hasattr(extension, "get_metrics"):
|
||||
try:
|
||||
ext_metrics = getattr(extension, 'get_metrics')()
|
||||
ext_metrics = getattr(extension, "get_metrics")()
|
||||
metrics.update(ext_metrics)
|
||||
except Exception as e:
|
||||
logger.error("Error getting metrics from extension",
|
||||
extension=type(extension).__name__, error=str(e))
|
||||
logger.error("Error getting metrics from extension", extension=type(extension).__name__, error=str(e))
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
Build script for Cython extensions.
|
||||
|
||||
Usage:
|
||||
python scripts/build_cython.py build_ext --inplace
|
||||
|
||||
Or via make:
|
||||
make build-cython
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def build_extensions():
|
||||
try:
|
||||
from Cython.Build import cythonize
|
||||
except ImportError:
|
||||
print("Cython not installed. Skipping Cython build.")
|
||||
print("Install with: pip install cython")
|
||||
return False
|
||||
|
||||
try:
|
||||
from setuptools import Extension
|
||||
from setuptools.dist import Distribution
|
||||
from setuptools.command.build_ext import build_ext
|
||||
except ImportError:
|
||||
print("setuptools not installed. Skipping Cython build.")
|
||||
print("Install with: pip install setuptools")
|
||||
return False
|
||||
|
||||
extensions = [
|
||||
Extension(
|
||||
"pyserve._path_matcher",
|
||||
sources=["pyserve/_path_matcher.pyx"],
|
||||
extra_compile_args=["-O3", "-ffast-math"],
|
||||
define_macros=[("NPY_NO_DEPRECATED_API", "NPY_1_7_API_VERSION")],
|
||||
),
|
||||
]
|
||||
|
||||
ext_modules = cythonize(
|
||||
extensions,
|
||||
compiler_directives={
|
||||
"language_level": "3",
|
||||
"boundscheck": False,
|
||||
"wraparound": False,
|
||||
"cdivision": True,
|
||||
"embedsignature": True,
|
||||
},
|
||||
annotate=True,
|
||||
)
|
||||
|
||||
dist = Distribution({"ext_modules": ext_modules})
|
||||
dist.package_dir = {"": "."}
|
||||
|
||||
cmd = build_ext(dist)
|
||||
cmd.ensure_finalized()
|
||||
cmd.inplace = True
|
||||
cmd.run()
|
||||
|
||||
print("\nCython extensions built successfully!")
|
||||
print(" - pyserve/_path_matcher" + (".pyd" if sys.platform == "win32" else ".so"))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
project_root = Path(__file__).parent.parent
|
||||
os.chdir(project_root)
|
||||
|
||||
success = build_extensions()
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
Tests for path_matcher module.
|
||||
|
||||
Run with: pytest tests/test_path_matcher.py -v
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pyserve.path_matcher import (
|
||||
FastMountedPath,
|
||||
FastMountManager,
|
||||
path_matches_prefix,
|
||||
strip_path_prefix,
|
||||
match_and_modify_path,
|
||||
CYTHON_AVAILABLE,
|
||||
)
|
||||
|
||||
|
||||
class TestFastMountedPath:
|
||||
def test_root_mount_matches_everything(self):
|
||||
"""Root mount should match all paths."""
|
||||
mount = FastMountedPath("")
|
||||
|
||||
assert mount.matches("/") is True
|
||||
assert mount.matches("/api") is True
|
||||
assert mount.matches("/api/users") is True
|
||||
assert mount.matches("/anything/at/all") is True
|
||||
|
||||
def test_slash_root_mount_matches_everything(self):
|
||||
"""'/' mount should match all paths."""
|
||||
mount = FastMountedPath("/")
|
||||
|
||||
assert mount.matches("/") is True
|
||||
assert mount.matches("/api") is True
|
||||
assert mount.matches("/api/users") is True
|
||||
|
||||
def test_exact_path_match(self):
|
||||
"""Exact path should match."""
|
||||
mount = FastMountedPath("/api")
|
||||
|
||||
assert mount.matches("/api") is True
|
||||
assert mount.matches("/api/") is True
|
||||
assert mount.matches("/api/users") is True
|
||||
|
||||
def test_no_false_prefix_match(self):
|
||||
"""/api should not match /api-v2."""
|
||||
mount = FastMountedPath("/api")
|
||||
|
||||
assert mount.matches("/api-v2") is False
|
||||
assert mount.matches("/api2") is False
|
||||
assert mount.matches("/apiv2") is False
|
||||
|
||||
def test_shorter_path_no_match(self):
|
||||
"""Request path shorter than mount path should not match."""
|
||||
mount = FastMountedPath("/api/v1")
|
||||
|
||||
assert mount.matches("/api") is False
|
||||
assert mount.matches("/ap") is False
|
||||
assert mount.matches("/") is False
|
||||
|
||||
def test_trailing_slash_normalized(self):
|
||||
"""Trailing slashes should be normalized."""
|
||||
mount1 = FastMountedPath("/api/")
|
||||
mount2 = FastMountedPath("/api")
|
||||
|
||||
assert mount1.path == "/api"
|
||||
assert mount2.path == "/api"
|
||||
assert mount1.matches("/api/users") is True
|
||||
assert mount2.matches("/api/users") is True
|
||||
|
||||
def test_get_modified_path_strips_prefix(self):
|
||||
"""Modified path should have prefix stripped."""
|
||||
mount = FastMountedPath("/api")
|
||||
|
||||
assert mount.get_modified_path("/api") == "/"
|
||||
assert mount.get_modified_path("/api/") == "/"
|
||||
assert mount.get_modified_path("/api/users") == "/users"
|
||||
assert mount.get_modified_path("/api/users/123") == "/users/123"
|
||||
|
||||
def test_get_modified_path_no_strip(self):
|
||||
"""With strip_path=False, path should not be modified."""
|
||||
mount = FastMountedPath("/api", strip_path=False)
|
||||
|
||||
assert mount.get_modified_path("/api/users") == "/api/users"
|
||||
assert mount.get_modified_path("/api") == "/api"
|
||||
|
||||
def test_root_mount_modified_path(self):
|
||||
"""Root mount should return original path."""
|
||||
mount = FastMountedPath("")
|
||||
|
||||
assert mount.get_modified_path("/api/users") == "/api/users"
|
||||
assert mount.get_modified_path("/") == "/"
|
||||
|
||||
def test_name_property(self):
|
||||
"""Name should be set correctly."""
|
||||
mount1 = FastMountedPath("/api")
|
||||
mount2 = FastMountedPath("/api", name="API Mount")
|
||||
|
||||
assert mount1.name == "/api"
|
||||
assert mount2.name == "API Mount"
|
||||
|
||||
def test_repr(self):
|
||||
"""Repr should be informative."""
|
||||
mount = FastMountedPath("/api", name="API")
|
||||
assert "FastMountedPath" in repr(mount)
|
||||
assert "/api" in repr(mount)
|
||||
|
||||
|
||||
class TestFastMountManager:
|
||||
def test_empty_manager(self):
|
||||
"""Empty manager should return None."""
|
||||
manager = FastMountManager()
|
||||
|
||||
assert manager.get_mount("/api") is None
|
||||
assert manager.mount_count == 0
|
||||
|
||||
def test_add_mount(self):
|
||||
"""Adding mounts should work."""
|
||||
manager = FastMountManager()
|
||||
mount = FastMountedPath("/api")
|
||||
|
||||
manager.add_mount(mount)
|
||||
|
||||
assert manager.mount_count == 1
|
||||
assert manager.get_mount("/api/users") is mount
|
||||
|
||||
def test_longest_prefix_matching(self):
|
||||
"""Longer prefixes should match first."""
|
||||
manager = FastMountManager()
|
||||
|
||||
api_mount = FastMountedPath("/api", name="api")
|
||||
api_v1_mount = FastMountedPath("/api/v1", name="api_v1")
|
||||
api_v2_mount = FastMountedPath("/api/v2", name="api_v2")
|
||||
|
||||
manager.add_mount(api_mount)
|
||||
manager.add_mount(api_v2_mount)
|
||||
manager.add_mount(api_v1_mount)
|
||||
|
||||
assert manager.get_mount("/api/v1/users").name == "api_v1"
|
||||
assert manager.get_mount("/api/v2/items").name == "api_v2"
|
||||
assert manager.get_mount("/api/v3/other").name == "api"
|
||||
assert manager.get_mount("/api").name == "api"
|
||||
|
||||
def test_remove_mount(self):
|
||||
"""Removing mounts should work."""
|
||||
manager = FastMountManager()
|
||||
|
||||
manager.add_mount(FastMountedPath("/api"))
|
||||
manager.add_mount(FastMountedPath("/admin"))
|
||||
|
||||
assert manager.mount_count == 2
|
||||
|
||||
result = manager.remove_mount("/api")
|
||||
|
||||
assert result is True
|
||||
assert manager.mount_count == 1
|
||||
assert manager.get_mount("/api/users") is None
|
||||
assert manager.get_mount("/admin/users") is not None
|
||||
|
||||
def test_remove_nonexistent_mount(self):
|
||||
"""Removing nonexistent mount should return False."""
|
||||
manager = FastMountManager()
|
||||
|
||||
result = manager.remove_mount("/api")
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_list_mounts(self):
|
||||
"""list_mounts should return mount info."""
|
||||
manager = FastMountManager()
|
||||
|
||||
manager.add_mount(FastMountedPath("/api", name="API"))
|
||||
manager.add_mount(FastMountedPath("/admin", name="Admin"))
|
||||
|
||||
mounts = manager.list_mounts()
|
||||
|
||||
assert len(mounts) == 2
|
||||
assert all("path" in m and "name" in m and "strip_path" in m for m in mounts)
|
||||
|
||||
def test_mounts_property_returns_copy(self):
|
||||
"""mounts property should return a copy."""
|
||||
manager = FastMountManager()
|
||||
manager.add_mount(FastMountedPath("/api"))
|
||||
|
||||
mounts1 = manager.mounts
|
||||
mounts2 = manager.mounts
|
||||
|
||||
assert mounts1 is not mounts2
|
||||
assert mounts1 == mounts2
|
||||
|
||||
|
||||
class TestUtilityFunctions:
|
||||
"""Tests for standalone utility functions."""
|
||||
|
||||
def test_path_matches_prefix_basic(self):
|
||||
"""Basic prefix matching."""
|
||||
assert path_matches_prefix("/api/users", "/api") is True
|
||||
assert path_matches_prefix("/api", "/api") is True
|
||||
assert path_matches_prefix("/api-v2", "/api") is False
|
||||
assert path_matches_prefix("/ap", "/api") is False
|
||||
|
||||
def test_path_matches_prefix_root(self):
|
||||
"""Root prefix matches everything."""
|
||||
assert path_matches_prefix("/anything", "") is True
|
||||
assert path_matches_prefix("/anything", "/") is True
|
||||
|
||||
def test_strip_path_prefix_basic(self):
|
||||
"""Basic path stripping."""
|
||||
assert strip_path_prefix("/api/users", "/api") == "/users"
|
||||
assert strip_path_prefix("/api", "/api") == "/"
|
||||
assert strip_path_prefix("/api/", "/api") == "/"
|
||||
|
||||
def test_strip_path_prefix_root(self):
|
||||
"""Root prefix doesn't strip anything."""
|
||||
assert strip_path_prefix("/api/users", "") == "/api/users"
|
||||
assert strip_path_prefix("/api/users", "/") == "/api/users"
|
||||
|
||||
def test_match_and_modify_combined(self):
|
||||
"""Combined match and modify operation."""
|
||||
matches, path = match_and_modify_path("/api/users", "/api")
|
||||
assert matches is True
|
||||
assert path == "/users"
|
||||
|
||||
matches, path = match_and_modify_path("/api", "/api")
|
||||
assert matches is True
|
||||
assert path == "/"
|
||||
|
||||
matches, path = match_and_modify_path("/other", "/api")
|
||||
assert matches is False
|
||||
assert path is None
|
||||
|
||||
def test_match_and_modify_no_strip(self):
|
||||
"""Combined operation with strip_path=False."""
|
||||
matches, path = match_and_modify_path("/api/users", "/api", strip_path=False)
|
||||
assert matches is True
|
||||
assert path == "/api/users"
|
||||
|
||||
|
||||
class TestCythonAvailability:
|
||||
def test_cython_available_is_bool(self):
|
||||
"""CYTHON_AVAILABLE should be a boolean."""
|
||||
assert isinstance(CYTHON_AVAILABLE, bool)
|
||||
|
||||
def test_module_works_regardless(self):
|
||||
"""Module should work whether Cython is available or not."""
|
||||
mount = FastMountedPath("/test")
|
||||
assert mount.matches("/test/path") is True
|
||||
|
||||
|
||||
class TestPerformance:
|
||||
def test_many_matches(self):
|
||||
"""Should handle many match operations."""
|
||||
mount = FastMountedPath("/api/v1/users")
|
||||
|
||||
for _ in range(10000):
|
||||
assert mount.matches("/api/v1/users/123/posts") is True
|
||||
assert mount.matches("/other/path") is False
|
||||
|
||||
def test_many_mounts(self):
|
||||
"""Should handle many mounts."""
|
||||
manager = FastMountManager()
|
||||
|
||||
for i in range(100):
|
||||
manager.add_mount(FastMountedPath(f"/api/v{i}"))
|
||||
|
||||
assert manager.mount_count == 100
|
||||
|
||||
mount = manager.get_mount("/api/v50/users")
|
||||
assert mount is not None
|
||||
assert mount.path == "/api/v50"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user