16 Commits
Author SHA1 Message Date
Илья Глазунов b4f63c6804 bump version to 0.9.10
Run Tests / test (3.12) (push) Successful in 2m39s
Run Tests / test (3.13) (push) Successful in 2m31s
CI/CD Pipeline / lint (push) Successful in 0s
Build and Release / build (push) Successful in 36s
CI/CD Pipeline / test (push) Has been skipped
CI/CD Pipeline / build-and-release (push) Has been skipped
Build and Release / release (push) Successful in 6s
CI/CD Pipeline / notify (push) Successful in 1s
Lint Code / lint (push) Failing after 40s
2025-12-04 01:31:19 +03:00
Илья Глазунов 59d6ae2fd2 fix: correct return type in _load_wsgi_app function 2025-12-04 01:30:18 +03:00
Илья Глазунов edaccb59bb lint fixes 2025-12-04 01:27:43 +03:00
Илья Глазунов 3454801be7 process_orchestration for asgi added 2025-12-04 01:25:13 +03:00
Илья Глазунов bb2c3aa357 bump version to 0.9.1
Build and Release / release (push) Successful in 5s
CI/CD Pipeline / build-and-release (push) Has been skipped
CI/CD Pipeline / notify (push) Successful in 1s
Lint Code / lint (push) Successful in 40s
Run Tests / test (3.12) (push) Successful in 1m3s
Run Tests / test (3.13) (push) Successful in 1m4s
CI/CD Pipeline / lint (push) Successful in 0s
Build and Release / build (push) Successful in 37s
CI/CD Pipeline / test (push) Has been skipped
2025-12-03 13:02:41 +03:00
Илья Глазунов 6761b791c3 version bump in __init__ 2025-12-03 13:02:10 +03:00
Илья Глазунов fb87445cbd bump version to 0.9.0
Run Tests / test (3.12) (push) Successful in 1m10s
Run Tests / test (3.13) (push) Successful in 1m9s
CI/CD Pipeline / lint (push) Successful in 0s
Build and Release / build (push) Successful in 41s
CI/CD Pipeline / test (push) Has been skipped
CI/CD Pipeline / build-and-release (push) Has been skipped
Build and Release / release (push) Successful in 5s
CI/CD Pipeline / notify (push) Successful in 1s
Lint Code / lint (push) Failing after 38s
2025-12-03 12:55:42 +03:00
Илья Глазунов 5d863bc97c cython path_matcher added to reduce time on hot operations 2025-12-03 12:54:45 +03:00
Илья Глазунов 6c50a35aa3 bump version to 0.8.0
Lint Code / lint (push) Failing after 42s
Run Tests / test (3.12) (push) Successful in 1m10s
Run Tests / test (3.13) (push) Successful in 1m10s
CI/CD Pipeline / lint (push) Successful in 0s
Build and Release / build (push) Successful in 35s
CI/CD Pipeline / test (push) Has been skipped
Build and Release / release (push) Successful in 5s
CI/CD Pipeline / build-and-release (push) Has been skipped
CI/CD Pipeline / notify (push) Successful in 1s
2025-12-03 12:24:57 +03:00
Илья Глазунов 3e2704f870 fix linter errors 2025-12-03 12:24:41 +03:00
Илья Глазунов 40e39efa37 lint fix 2025-12-03 12:13:48 +03:00
Илья Глазунов 0d0d1aec80 asgi/wsgi mounting implemented 2025-12-03 12:10:28 +03:00
Илья Глазунов 831eee5d01 bump version to 0.7.1
CI/CD Pipeline / lint (push) Successful in 0s
CI/CD Pipeline / test (push) Has been skipped
CI/CD Pipeline / build-and-release (push) Has been skipped
CI/CD Pipeline / notify (push) Successful in 0s
Lint Code / lint (push) Successful in 42s
Build and Release / build (push) Successful in 31s
Build and Release / release (push) Successful in 5s
Run Tests / test (3.12) (push) Successful in 1m0s
Run Tests / test (3.13) (push) Successful in 59s
2025-12-03 02:21:23 +03:00
Илья Глазунов 2f49ee576f test for routing module
Lint Code / lint (push) Failing after 11s
CI/CD Pipeline / lint (push) Successful in 0s
CI/CD Pipeline / test (push) Successful in 0s
CI/CD Pipeline / build-and-release (push) Has been skipped
CI/CD Pipeline / notify (push) Successful in 0s
Run Tests / test (3.12) (push) Failing after 2s
Run Tests / test (3.13) (push) Failing after 1s
2025-12-03 02:20:42 +03:00
Илья Глазунов 2d445462c2 fix in routing
Lint Code / lint (push) Successful in 39s
CI/CD Pipeline / lint (push) Successful in 0s
Run Tests / test (3.12) (push) Successful in 53s
Run Tests / test (3.13) (push) Successful in 51s
CI/CD Pipeline / test (push) Successful in 1s
CI/CD Pipeline / build-and-release (push) Has been skipped
CI/CD Pipeline / notify (push) Successful in 1s
2025-12-03 02:14:05 +03:00
Илья Глазунов 1b462bd5f0 bump version in __init__.py and some changes in .gitignore
Lint Code / lint (push) Successful in 40s
CI/CD Pipeline / lint (push) Successful in 0s
Run Tests / test (3.12) (push) Successful in 54s
Run Tests / test (3.13) (push) Successful in 52s
CI/CD Pipeline / test (push) Successful in 0s
CI/CD Pipeline / build-and-release (push) Has been skipped
CI/CD Pipeline / notify (push) Successful in 0s
2025-12-03 00:30:18 +03:00
34 changed files with 7558 additions and 477 deletions
+18 -1
View File
@@ -10,4 +10,21 @@ logs/*
static/*
.DS_Store
.coverage
.coverage
docs/
dist/
build/
# Cython generated files
*.c
*.so
*.pyd
*.html
*.egg-info/
# IDE
.idea/
.vscode/
*.swp
*.swo
+24 -9
View File
@@ -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
+62 -110
View File
@@ -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)
+215
View File
@@ -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()
+32
View File
@@ -0,0 +1,32 @@
# PyServe configuration for serving documentation
# Usage: pyserve -c config.docs.yaml
http:
static_dir: ./docs
templates_dir: ./templates
server:
host: 0.0.0.0
port: 8000
backlog: 5
default_root: false
ssl:
enabled: false
logging:
level: INFO
console_output: true
extensions:
- type: routing
config:
regex_locations:
"~*\\.(css)$":
root: "./docs"
cache_control: "public, max-age=3600"
"__default__":
root: "./docs"
index_file: "index.html"
cache_control: "no-cache"
+1
View File
@@ -0,0 +1 @@
"""Example applications package."""
+194
View File
@@ -0,0 +1,194 @@
"""
Example custom ASGI application for PyServe ASGI mounting.
This demonstrates how to create a raw ASGI application without
any framework - similar to Python's http.server but async.
"""
from typing import Dict, Any, List, Callable, Awaitable, Optional
import json
Scope = Dict[str, Any]
Receive = Callable[[], Awaitable[Dict[str, Any]]]
Send = Callable[[Dict[str, Any]], Awaitable[None]]
class SimpleASGIApp:
def __init__(self):
self.routes: Dict[str, Callable] = {}
self._setup_routes()
def _setup_routes(self) -> None:
self.routes = {
"/": self._handle_root,
"/health": self._handle_health,
"/echo": self._handle_echo,
"/info": self._handle_info,
"/headers": self._handle_headers,
}
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
return
path = scope.get("path", "/")
method = scope.get("method", "GET")
handler = self.routes.get(path)
if handler is None:
if path.startswith("/echo/"):
handler = self._handle_echo_path
else:
await self._send_response(
send,
status=404,
body={"error": "Not found", "path": path}
)
return
await handler(scope, receive, send)
async def _handle_root(self, scope: Scope, receive: Receive, send: Send) -> None:
await self._send_response(
send,
body={
"message": "Welcome to Custom ASGI App mounted in PyServe!",
"description": "This is a raw ASGI application without any framework",
"endpoints": list(self.routes.keys()) + ["/echo/{message}"],
}
)
async def _handle_health(self, scope: Scope, receive: Receive, send: Send) -> None:
await self._send_response(
send,
body={"status": "healthy", "app": "custom-asgi"}
)
async def _handle_echo(self, scope: Scope, receive: Receive, send: Send) -> None:
method = scope.get("method", "GET")
if method == "POST":
body = await self._read_body(receive)
await self._send_response(
send,
body={"echo": body.decode("utf-8") if body else ""}
)
else:
await self._send_response(
send,
body={"message": "Send a POST request to echo data"}
)
async def _handle_echo_path(self, scope: Scope, receive: Receive, send: Send) -> None:
path = scope.get("path", "")
message = path.replace("/echo/", "", 1)
await self._send_response(
send,
body={"echo": message}
)
async def _handle_info(self, scope: Scope, receive: Receive, send: Send) -> None:
await self._send_response(
send,
body={
"method": scope.get("method"),
"path": scope.get("path"),
"query_string": scope.get("query_string", b"").decode("utf-8"),
"root_path": scope.get("root_path", ""),
"scheme": scope.get("scheme", "http"),
"server": list(scope.get("server", ())),
"asgi": scope.get("asgi", {}),
}
)
async def _handle_headers(self, scope: Scope, receive: Receive, send: Send) -> None:
headers = {}
for name, value in scope.get("headers", []):
headers[name.decode("utf-8")] = value.decode("utf-8")
await self._send_response(
send,
body={"headers": headers}
)
async def _read_body(self, receive: Receive) -> bytes:
body = b""
more_body = True
while more_body:
message = await receive()
body += message.get("body", b"")
more_body = message.get("more_body", False)
return body
async def _send_response(
self,
send: Send,
status: int = 200,
body: Any = None,
content_type: str = "application/json",
headers: Optional[List[tuple]] = None,
) -> None:
response_headers = [
(b"content-type", content_type.encode("utf-8")),
]
if headers:
response_headers.extend(headers)
if body is not None:
if content_type == "application/json":
body_bytes = json.dumps(body, ensure_ascii=False).encode("utf-8")
elif isinstance(body, bytes):
body_bytes = body
else:
body_bytes = str(body).encode("utf-8")
else:
body_bytes = b""
response_headers.append(
(b"content-length", str(len(body_bytes)).encode("utf-8"))
)
await send({
"type": "http.response.start",
"status": status,
"headers": response_headers,
})
await send({
"type": "http.response.body",
"body": body_bytes,
})
app = SimpleASGIApp()
async def simple_asgi_app(scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
return
response_body = json.dumps({
"message": "Hello from minimal ASGI app!",
"path": scope.get("path", "/"),
}).encode("utf-8")
await send({
"type": "http.response.start",
"status": 200,
"headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(response_body)).encode("utf-8")),
],
})
await send({
"type": "http.response.body",
"body": response_body,
})
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8004)
+118
View File
@@ -0,0 +1,118 @@
"""
Example FastAPI application for PyServe ASGI mounting.
This demonstrates how to create a FastAPI application that can be
mounted at a specific path in PyServe.
"""
from typing import Optional, Dict, Any
try:
from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel
except ImportError:
raise ImportError(
"FastAPI is not installed. Install with: pip install fastapi"
)
app = FastAPI(
title="Example FastAPI App",
description="This is an example FastAPI application mounted in PyServe",
version="1.0.0",
)
class Item(BaseModel):
name: str
description: Optional[str] = None
price: float
tax: Optional[float] = None
class Message(BaseModel):
message: str
items_db: Dict[int, Dict[str, Any]] = {
1: {"name": "Item 1", "description": "First item", "price": 10.5, "tax": 1.05},
2: {"name": "Item 2", "description": "Second item", "price": 20.0, "tax": 2.0},
}
@app.get("/")
async def root():
return {"message": "Welcome to FastAPI mounted in PyServe!"}
@app.get("/health")
async def health_check():
return {"status": "healthy", "app": "fastapi"}
@app.get("/items")
async def list_items():
return {"items": list(items_db.values()), "count": len(items_db)}
@app.get("/items/{item_id}")
async def get_item(item_id: int):
if item_id not in items_db:
raise HTTPException(status_code=404, detail="Item not found")
return items_db[item_id]
@app.post("/items", response_model=Message)
async def create_item(item: Item):
new_id = max(items_db.keys()) + 1 if items_db else 1
items_db[new_id] = item.model_dump()
return {"message": f"Item created with ID {new_id}"}
@app.put("/items/{item_id}")
async def update_item(item_id: int, item: Item):
if item_id not in items_db:
raise HTTPException(status_code=404, detail="Item not found")
items_db[item_id] = item.model_dump()
return {"message": f"Item {item_id} updated"}
@app.delete("/items/{item_id}")
async def delete_item(item_id: int):
if item_id not in items_db:
raise HTTPException(status_code=404, detail="Item not found")
del items_db[item_id]
return {"message": f"Item {item_id} deleted"}
def create_app(debug: bool = False, **kwargs) -> FastAPI:
application = FastAPI(
title="Example FastAPI App (Factory)",
description="FastAPI application created via factory function",
version="2.0.0",
debug=debug,
)
@application.get("/")
async def factory_root():
return {
"message": "Welcome to FastAPI (factory) mounted in PyServe!",
"debug": debug,
"config": kwargs,
}
@application.get("/health")
async def factory_health():
return {"status": "healthy", "app": "fastapi-factory", "debug": debug}
@application.get("/echo/{message}")
async def echo(message: str):
return {"echo": message}
return application
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8001)
+112
View File
@@ -0,0 +1,112 @@
"""
Example Flask application for PyServe ASGI mounting.
This demonstrates how to create a Flask application that can be
mounted at a specific path in PyServe (via WSGI-to-ASGI adapter).
"""
from typing import Optional
try:
from flask import Flask, jsonify, request
except ImportError:
raise ImportError(
"Flask is not installed. Install with: pip install flask"
)
app = Flask(__name__)
users_db = {
1: {"id": 1, "name": "Alice", "email": "alice@example.com"},
2: {"id": 2, "name": "Bob", "email": "bob@example.com"},
}
@app.route("/")
def root():
return jsonify({"message": "Welcome to Flask mounted in PyServe!"})
@app.route("/health")
def health_check():
return jsonify({"status": "healthy", "app": "flask"})
@app.route("/users")
def list_users():
return jsonify({"users": list(users_db.values()), "count": len(users_db)})
@app.route("/users/<int:user_id>")
def get_user(user_id: int):
if user_id not in users_db:
return jsonify({"error": "User not found"}), 404
return jsonify(users_db[user_id])
@app.route("/users", methods=["POST"])
def create_user():
data = request.get_json()
if not data or "name" not in data:
return jsonify({"error": "Name is required"}), 400
new_id = max(users_db.keys()) + 1 if users_db else 1
users_db[new_id] = {
"id": new_id,
"name": data["name"],
"email": data.get("email", ""),
}
return jsonify({"message": f"User created with ID {new_id}", "user": users_db[new_id]}), 201
@app.route("/users/<int:user_id>", methods=["PUT"])
def update_user(user_id: int):
if user_id not in users_db:
return jsonify({"error": "User not found"}), 404
data = request.get_json()
if data:
if "name" in data:
users_db[user_id]["name"] = data["name"]
if "email" in data:
users_db[user_id]["email"] = data["email"]
return jsonify({"message": f"User {user_id} updated", "user": users_db[user_id]})
@app.route("/users/<int:user_id>", methods=["DELETE"])
def delete_user(user_id: int):
if user_id not in users_db:
return jsonify({"error": "User not found"}), 404
del users_db[user_id]
return jsonify({"message": f"User {user_id} deleted"})
def create_app(config: Optional[dict] = None) -> Flask:
application = Flask(__name__)
if config:
application.config.update(config)
@application.route("/")
def factory_root():
return jsonify({
"message": "Welcome to Flask (factory) mounted in PyServe!",
"config": config or {},
})
@application.route("/health")
def factory_health():
return jsonify({"status": "healthy", "app": "flask-factory"})
@application.route("/echo/<message>")
def echo(message: str):
return jsonify({"echo": message})
return application
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8002, debug=True)
+112
View File
@@ -0,0 +1,112 @@
"""
Example Starlette application for PyServe ASGI mounting.
This demonstrates how to create a Starlette application that can be
mounted at a specific path in PyServe.
"""
try:
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route
from starlette.requests import Request
except ImportError:
raise ImportError(
"Starlette is not installed. Install with: pip install starlette"
)
tasks_db = {
1: {"id": 1, "title": "Task 1", "completed": False},
2: {"id": 2, "title": "Task 2", "completed": True},
}
async def homepage(request: Request) -> JSONResponse:
return JSONResponse({
"message": "Welcome to Starlette mounted in PyServe!"
})
async def health_check(request: Request) -> JSONResponse:
return JSONResponse({"status": "healthy", "app": "starlette"})
async def list_tasks(request: Request) -> JSONResponse:
return JSONResponse({
"tasks": list(tasks_db.values()),
"count": len(tasks_db)
})
async def get_task(request: Request) -> JSONResponse:
task_id = int(request.path_params["task_id"])
if task_id not in tasks_db:
return JSONResponse({"error": "Task not found"}, status_code=404)
return JSONResponse(tasks_db[task_id])
async def create_task(request: Request) -> JSONResponse:
data = await request.json()
if not data or "title" not in data:
return JSONResponse({"error": "Title is required"}, status_code=400)
new_id = max(tasks_db.keys()) + 1 if tasks_db else 1
tasks_db[new_id] = {
"id": new_id,
"title": data["title"],
"completed": data.get("completed", False),
}
return JSONResponse(
{"message": f"Task created with ID {new_id}", "task": tasks_db[new_id]},
status_code=201
)
async def update_task(request: Request) -> JSONResponse:
task_id = int(request.path_params["task_id"])
if task_id not in tasks_db:
return JSONResponse({"error": "Task not found"}, status_code=404)
data = await request.json()
if data:
if "title" in data:
tasks_db[task_id]["title"] = data["title"]
if "completed" in data:
tasks_db[task_id]["completed"] = data["completed"]
return JSONResponse({
"message": f"Task {task_id} updated",
"task": tasks_db[task_id]
})
async def delete_task(request: Request) -> JSONResponse:
task_id = int(request.path_params["task_id"])
if task_id not in tasks_db:
return JSONResponse({"error": "Task not found"}, status_code=404)
del tasks_db[task_id]
return JSONResponse({"message": f"Task {task_id} deleted"})
routes = [
Route("/", homepage),
Route("/health", health_check),
Route("/tasks", list_tasks, methods=["GET"]),
Route("/tasks", create_task, methods=["POST"]),
Route("/tasks/{task_id:int}", get_task, methods=["GET"]),
Route("/tasks/{task_id:int}", update_task, methods=["PUT"]),
Route("/tasks/{task_id:int}", delete_task, methods=["DELETE"]),
]
app = Starlette(debug=True, routes=routes)
def create_app(debug: bool = False) -> Starlette:
return Starlette(debug=debug, routes=routes)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8003)
+103
View File
@@ -0,0 +1,103 @@
# Example configuration for ASGI application mounts
# This demonstrates how to mount various Python web frameworks
http:
static_dir: ./static
templates_dir: ./templates
server:
host: 0.0.0.0
port: 8080
backlog: 5
proxy_timeout: 30.0
logging:
level: DEBUG
console_output: true
format:
type: standard
use_colors: true
extensions:
# ASGI Application Mount Extension
- type: asgi
config:
mounts:
# FastAPI application
- path: "/api"
app_path: "examples.apps.fastapi_app:app"
app_type: asgi
name: "fastapi-api"
strip_path: true
# FastAPI with factory pattern
- path: "/api/v2"
app_path: "examples.apps.fastapi_app:create_app"
app_type: asgi
factory: true
factory_args:
debug: true
name: "fastapi-api-v2"
strip_path: true
# Flask application (WSGI wrapped to ASGI)
- path: "/flask"
app_path: "examples.apps.flask_app:app"
app_type: wsgi
name: "flask-app"
strip_path: true
# Flask with factory pattern
- path: "/flask-v2"
app_path: "examples.apps.flask_app:create_app"
app_type: wsgi
factory: true
name: "flask-app-factory"
strip_path: true
# Django application
# Uncomment and configure for your Django project
# - path: "/django"
# django_settings: "myproject.settings"
# module_path: "/path/to/django/project"
# name: "django-app"
# strip_path: true
# Starlette application
- path: "/starlette"
app_path: "examples.apps.starlette_app:app"
app_type: asgi
name: "starlette-app"
strip_path: true
# Custom ASGI application (http.server style)
- path: "/custom"
app_path: "examples.apps.custom_asgi:app"
app_type: asgi
name: "custom-asgi"
strip_path: true
# Standard routing for other paths
- type: routing
config:
regex_locations:
# Health check
"=/health":
return: "200 OK"
content_type: "text/plain"
# Static files
"~*\\.(js|css|png|jpg|gif|ico|svg|woff2?)$":
root: "./static"
cache_control: "public, max-age=31536000"
# Root path
"=/":
root: "./static"
index_file: "index.html"
# Default fallback
"__default__":
spa_fallback: true
root: "./static"
index_file: "index.html"
+113
View File
@@ -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
+16
View File
@@ -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
+570 -1
View File
@@ -1,5 +1,44 @@
# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand.
[[package]]
name = "a2wsgi"
version = "1.10.10"
description = "Convert WSGI app to ASGI app or ASGI app to WSGI app."
optional = true
python-versions = ">=3.8.0"
groups = ["main"]
markers = "extra == \"wsgi\" or extra == \"flask\" or extra == \"all-frameworks\""
files = [
{file = "a2wsgi-1.10.10-py3-none-any.whl", hash = "sha256:d2b21379479718539dc15fce53b876251a0efe7615352dfe49f6ad1bc507848d"},
{file = "a2wsgi-1.10.10.tar.gz", hash = "sha256:a5bcffb52081ba39df0d5e9a884fc6f819d92e3a42389343ba77cbf809fe1f45"},
]
[[package]]
name = "annotated-doc"
version = "0.0.4"
description = "Document parameters, class attributes, return types, and variables inline, with Annotated."
optional = true
python-versions = ">=3.8"
groups = ["main"]
markers = "extra == \"fastapi\" or extra == \"all-frameworks\""
files = [
{file = "annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320"},
{file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"},
]
[[package]]
name = "annotated-types"
version = "0.7.0"
description = "Reusable constraint types to use with typing.Annotated"
optional = true
python-versions = ">=3.8"
groups = ["main"]
markers = "extra == \"fastapi\" or extra == \"all-frameworks\""
files = [
{file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
{file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
]
[[package]]
name = "anyio"
version = "4.10.0"
@@ -20,6 +59,22 @@ typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""}
[package.extras]
trio = ["trio (>=0.26.1)"]
[[package]]
name = "asgiref"
version = "3.11.0"
description = "ASGI specs, helper code, and adapters"
optional = true
python-versions = ">=3.9"
groups = ["main"]
markers = "extra == \"django\" or extra == \"all-frameworks\""
files = [
{file = "asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d"},
{file = "asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4"},
]
[package.extras]
tests = ["mypy (>=1.14.0)", "pytest", "pytest-asyncio"]
[[package]]
name = "black"
version = "25.1.0"
@@ -65,6 +120,19 @@ d = ["aiohttp (>=3.10)"]
jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
uvloop = ["uvloop (>=0.15.2)"]
[[package]]
name = "blinker"
version = "1.9.0"
description = "Fast, simple object-to-object and broadcast signaling"
optional = true
python-versions = ">=3.9"
groups = ["main"]
markers = "extra == \"flask\" or extra == \"all-frameworks\""
files = [
{file = "blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc"},
{file = "blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf"},
]
[[package]]
name = "certifi"
version = "2025.11.12"
@@ -206,6 +274,101 @@ 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"
description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design."
optional = true
python-versions = ">=3.10"
groups = ["main"]
markers = "extra == \"django\" or extra == \"all-frameworks\""
files = [
{file = "django-5.2.9-py3-none-any.whl", hash = "sha256:3a4ea88a70370557ab1930b332fd2887a9f48654261cdffda663fef5976bb00a"},
{file = "django-5.2.9.tar.gz", hash = "sha256:16b5ccfc5e8c27e6c0561af551d2ea32852d7352c67d452ae3e76b4f6b2ca495"},
]
[package.dependencies]
asgiref = ">=3.8.1"
sqlparse = ">=0.3.1"
tzdata = {version = "*", markers = "sys_platform == \"win32\""}
[package.extras]
argon2 = ["argon2-cffi (>=19.1.0)"]
bcrypt = ["bcrypt"]
[[package]]
name = "fastapi"
version = "0.123.5"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
optional = true
python-versions = ">=3.8"
groups = ["main"]
markers = "extra == \"fastapi\" or extra == \"all-frameworks\""
files = [
{file = "fastapi-0.123.5-py3-none-any.whl", hash = "sha256:a9c708e47c0fa424139cddb8601d0f92d3111b77843c22e9c8d0164d65fe3c97"},
{file = "fastapi-0.123.5.tar.gz", hash = "sha256:54bbb660ca231d3985474498b51c621ddcf8888d9a4c1ecb10aa40ec217e4965"},
]
[package.dependencies]
annotated-doc = ">=0.0.2"
pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0"
starlette = ">=0.40.0,<0.51.0"
typing-extensions = ">=4.8.0"
[package.extras]
all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"]
standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"]
standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"]
[[package]]
name = "flake8"
version = "7.3.0"
@@ -223,6 +386,31 @@ mccabe = ">=0.7.0,<0.8.0"
pycodestyle = ">=2.14.0,<2.15.0"
pyflakes = ">=3.4.0,<3.5.0"
[[package]]
name = "flask"
version = "3.1.2"
description = "A simple framework for building complex web applications."
optional = true
python-versions = ">=3.9"
groups = ["main"]
markers = "extra == \"flask\" or extra == \"all-frameworks\""
files = [
{file = "flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c"},
{file = "flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87"},
]
[package.dependencies]
blinker = ">=1.9.0"
click = ">=8.1.3"
itsdangerous = ">=2.2.0"
jinja2 = ">=3.1.2"
markupsafe = ">=2.1.1"
werkzeug = ">=3.1.0"
[package.extras]
async = ["asgiref (>=3.2)"]
dotenv = ["python-dotenv"]
[[package]]
name = "h11"
version = "0.16.0"
@@ -382,6 +570,138 @@ files = [
colors = ["colorama"]
plugins = ["setuptools"]
[[package]]
name = "itsdangerous"
version = "2.2.0"
description = "Safely pass data to untrusted environments and back."
optional = true
python-versions = ">=3.8"
groups = ["main"]
markers = "extra == \"flask\" or extra == \"all-frameworks\""
files = [
{file = "itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"},
{file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"},
]
[[package]]
name = "jinja2"
version = "3.1.6"
description = "A very fast and expressive template engine."
optional = true
python-versions = ">=3.7"
groups = ["main"]
markers = "extra == \"flask\" or extra == \"all-frameworks\""
files = [
{file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"},
{file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"},
]
[package.dependencies]
MarkupSafe = ">=2.0"
[package.extras]
i18n = ["Babel (>=2.7)"]
[[package]]
name = "markupsafe"
version = "3.0.3"
description = "Safely add untrusted strings to HTML/XML markup."
optional = true
python-versions = ">=3.9"
groups = ["main"]
markers = "extra == \"flask\" or extra == \"all-frameworks\""
files = [
{file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"},
{file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"},
{file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"},
{file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"},
{file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"},
{file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"},
{file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"},
{file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"},
{file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"},
{file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"},
{file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"},
{file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"},
{file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"},
{file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"},
{file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"},
{file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"},
{file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"},
{file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"},
{file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"},
{file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"},
{file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"},
{file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"},
{file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"},
{file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"},
{file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"},
{file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"},
{file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"},
{file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"},
{file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"},
{file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"},
{file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"},
{file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"},
{file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"},
{file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"},
{file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"},
{file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"},
{file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"},
{file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"},
{file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"},
{file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"},
{file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"},
{file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"},
{file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"},
{file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"},
{file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"},
{file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"},
{file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"},
{file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"},
{file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"},
{file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"},
{file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"},
{file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"},
{file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"},
{file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"},
{file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"},
{file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"},
{file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"},
{file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"},
{file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"},
{file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"},
{file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"},
{file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"},
{file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"},
{file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"},
{file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"},
{file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"},
{file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"},
{file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"},
{file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"},
{file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"},
{file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"},
{file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"},
{file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"},
{file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"},
{file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"},
{file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"},
{file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"},
{file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"},
{file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"},
{file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"},
{file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"},
{file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"},
{file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"},
{file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"},
{file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"},
{file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"},
{file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"},
{file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"},
{file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"},
]
[[package]]
name = "mccabe"
version = "0.7.0"
@@ -535,6 +855,164 @@ files = [
{file = "pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783"},
]
[[package]]
name = "pydantic"
version = "2.12.5"
description = "Data validation using Python type hints"
optional = true
python-versions = ">=3.9"
groups = ["main"]
markers = "extra == \"fastapi\" or extra == \"all-frameworks\""
files = [
{file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"},
{file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"},
]
[package.dependencies]
annotated-types = ">=0.6.0"
pydantic-core = "2.41.5"
typing-extensions = ">=4.14.1"
typing-inspection = ">=0.4.2"
[package.extras]
email = ["email-validator (>=2.0.0)"]
timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""]
[[package]]
name = "pydantic-core"
version = "2.41.5"
description = "Core functionality for Pydantic validation and serialization"
optional = true
python-versions = ">=3.9"
groups = ["main"]
markers = "extra == \"fastapi\" or extra == \"all-frameworks\""
files = [
{file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"},
{file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"},
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"},
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"},
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"},
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"},
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"},
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"},
{file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"},
{file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"},
{file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"},
{file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"},
{file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"},
{file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"},
{file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"},
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"},
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"},
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"},
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"},
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"},
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"},
{file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"},
{file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"},
{file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"},
{file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"},
{file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"},
{file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"},
{file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"},
{file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"},
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"},
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"},
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"},
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"},
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"},
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"},
{file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"},
{file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"},
{file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"},
{file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"},
{file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"},
{file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"},
{file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"},
{file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"},
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"},
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"},
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"},
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"},
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"},
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"},
{file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"},
{file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"},
{file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"},
{file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"},
{file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"},
{file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"},
{file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"},
{file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"},
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"},
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"},
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"},
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"},
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"},
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"},
{file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"},
{file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"},
{file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"},
{file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"},
{file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"},
{file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"},
{file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"},
{file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"},
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"},
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"},
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"},
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"},
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"},
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"},
{file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"},
{file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"},
{file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"},
{file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"},
{file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"},
{file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"},
{file = "pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf"},
{file = "pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5"},
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d"},
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60"},
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82"},
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5"},
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3"},
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425"},
{file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504"},
{file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5"},
{file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3"},
{file = "pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460"},
{file = "pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b"},
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034"},
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c"},
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2"},
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad"},
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd"},
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc"},
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56"},
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b"},
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8"},
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a"},
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b"},
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2"},
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093"},
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a"},
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963"},
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a"},
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26"},
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808"},
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc"},
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1"},
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84"},
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770"},
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f"},
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"},
{file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"},
]
[package.dependencies]
typing-extensions = ">=4.14.1"
[[package]]
name = "pyflakes"
version = "3.4.0"
@@ -702,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"
@@ -714,6 +1213,23 @@ files = [
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
]
[[package]]
name = "sqlparse"
version = "0.5.4"
description = "A non-validating SQL parser."
optional = true
python-versions = ">=3.8"
groups = ["main"]
markers = "extra == \"django\" or extra == \"all-frameworks\""
files = [
{file = "sqlparse-0.5.4-py3-none-any.whl", hash = "sha256:99a9f0314977b76d776a0fcb8554de91b9bb8a18560631d6bc48721d07023dcb"},
{file = "sqlparse-0.5.4.tar.gz", hash = "sha256:4396a7d3cf1cd679c1be976cf3dc6e0a51d0111e87787e7a8d780e7d5a998f9e"},
]
[package.extras]
dev = ["build"]
doc = ["sphinx"]
[[package]]
name = "starlette"
version = "0.47.3"
@@ -769,6 +1285,35 @@ files = [
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
description = "Runtime typing introspection tools"
optional = true
python-versions = ">=3.9"
groups = ["main"]
markers = "extra == \"fastapi\" or extra == \"all-frameworks\""
files = [
{file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"},
{file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"},
]
[package.dependencies]
typing-extensions = ">=4.12.0"
[[package]]
name = "tzdata"
version = "2025.2"
description = "Provider of IANA time zone data"
optional = true
python-versions = ">=2"
groups = ["main"]
markers = "(extra == \"django\" or extra == \"all-frameworks\") and sys_platform == \"win32\""
files = [
{file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"},
{file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"},
]
[[package]]
name = "uvicorn"
version = "0.35.0"
@@ -1046,10 +1591,34 @@ files = [
{file = "websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee"},
]
[[package]]
name = "werkzeug"
version = "3.1.4"
description = "The comprehensive WSGI web application library."
optional = true
python-versions = ">=3.9"
groups = ["main"]
markers = "extra == \"flask\" or extra == \"all-frameworks\""
files = [
{file = "werkzeug-3.1.4-py3-none-any.whl", hash = "sha256:2ad50fb9ed09cc3af22c54698351027ace879a0b60a3b5edf5730b2f7d876905"},
{file = "werkzeug-3.1.4.tar.gz", hash = "sha256:cd3cd98b1b92dc3b7b3995038826c68097dcb16f9baa63abe35f20eafeb9fe5e"},
]
[package.dependencies]
markupsafe = ">=2.1.1"
[package.extras]
watchdog = ["watchdog (>=2.3)"]
[extras]
all-frameworks = ["a2wsgi", "django", "fastapi", "flask"]
dev = ["black", "flake8", "isort", "mypy", "pytest", "pytest-asyncio", "pytest-cov"]
django = ["django"]
fastapi = ["fastapi"]
flask = ["a2wsgi", "flask"]
wsgi = ["a2wsgi"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.12"
content-hash = "e68108657ddfdc07ac0c4f5dbd9c5d2950e78b8b0053e4487ebf2327bbf4e020"
content-hash = "32ebf260f6792987cb4236fe29ad3329374e063504d507b5a0319684e24a30a8"
+24 -3
View File
@@ -1,7 +1,7 @@
[project]
name = "pyserve"
version = "0.7.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"}
]
@@ -30,10 +30,29 @@ dev = [
"mypy",
"flake8"
]
wsgi = [
"a2wsgi>=1.10.0",
]
flask = [
"flask>=3.0.0",
"a2wsgi>=1.10.0",
]
fastapi = [
"fastapi>=0.115.0",
]
django = [
"django>=5.0",
]
all-frameworks = [
"fastapi>=0.115.0",
"flask>=3.0.0",
"django>=5.0",
"a2wsgi>=1.10.0",
]
[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]
@@ -76,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"
+41 -3
View File
@@ -2,10 +2,48 @@
PyServe - HTTP web server written on Python
"""
__version__ = "0.6.0"
__version__ = "0.9.10"
__author__ = "Ilya Glazunov"
from .server import PyServeServer
from .asgi_mount import (
ASGIAppLoader,
ASGIMountManager,
MountedApp,
create_django_app,
create_fastapi_app,
create_flask_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__"]
__all__ = [
"PyServeServer",
"Config",
"__version__",
# ASGI mounting (in-process)
"ASGIAppLoader",
"ASGIMountManager",
"MountedApp",
"create_fastapi_app",
"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",
]
+225
View File
@@ -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)
+168
View File
@@ -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)
+70
View File
@@ -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()
+307
View File
@@ -0,0 +1,307 @@
"""
ASGI Application Mount Module
This module provides functionality to mount external ASGI/WSGI applications
(FastAPI, Flask, Django, etc.) at specified paths within PyServe.
"""
import importlib
import sys
from pathlib import Path
from typing import Any, Callable, Dict, Optional, cast
from starlette.types import ASGIApp, Receive, Scope, Send
from .logging_utils import get_logger
logger = get_logger(__name__)
class ASGIAppLoader:
def __init__(self) -> None:
self._apps: Dict[str, ASGIApp] = {}
self._wsgi_adapters: Dict[str, ASGIApp] = {}
def load_app(
self,
app_path: str,
app_type: str = "asgi",
module_path: Optional[str] = None,
factory: bool = False,
factory_args: Optional[Dict[str, Any]] = None,
) -> Optional[ASGIApp]:
try:
if module_path:
module_dir = Path(module_path).resolve()
if str(module_dir) not in sys.path:
sys.path.insert(0, str(module_dir))
logger.debug(f"Added {module_dir} to sys.path")
if ":" in app_path:
module_name, attr_name = app_path.rsplit(":", 1)
else:
module_name = app_path
attr_name = "app"
module = importlib.import_module(module_name)
app_or_factory = getattr(module, attr_name)
if factory:
factory_args = factory_args or {}
app = app_or_factory(**factory_args)
logger.info(f"Created app from factory: {app_path}")
else:
app = app_or_factory
logger.info(f"Loaded app: {app_path}")
if app_type == "wsgi":
app = self._wrap_wsgi(app)
logger.info(f"Wrapped WSGI app: {app_path}")
self._apps[app_path] = app
return cast(ASGIApp, app)
except ImportError as e:
logger.error(f"Failed to import application {app_path}: {e}")
return None
except AttributeError as e:
logger.error(f"Failed to get attribute from {app_path}: {e}")
return None
except Exception as e:
logger.error(f"Failed to load application {app_path}: {e}")
return None
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.")
def get_app(self, app_path: str) -> Optional[ASGIApp]:
return self._apps.get(app_path)
def reload_app(self, app_path: str, **kwargs: Any) -> Optional[ASGIApp]:
if app_path in self._apps:
del self._apps[app_path]
if ":" in app_path:
module_name, _ = app_path.rsplit(":", 1)
else:
module_name = app_path
if module_name in sys.modules:
importlib.reload(sys.modules[module_name])
return self.load_app(app_path, **kwargs)
class MountedApp:
def __init__(
self,
path: str,
app: ASGIApp,
name: str = "",
strip_path: bool = True,
):
self.path = path.rstrip("/")
self.app = app
self.name = name or path
self.strip_path = strip_path
def matches(self, request_path: str) -> bool:
if self.path == "":
return True
return request_path == self.path or request_path.startswith(f"{self.path}/")
def get_modified_path(self, original_path: str) -> str:
if not self.strip_path:
return original_path
if self.path == "":
return original_path
new_path = original_path[len(self.path) :]
return new_path if new_path else "/"
class ASGIMountManager:
def __init__(self) -> None:
self._mounts: list[MountedApp] = []
self._loader = ASGIAppLoader()
def mount(
self,
path: str,
app: Optional[ASGIApp] = None,
app_path: Optional[str] = None,
app_type: str = "asgi",
module_path: Optional[str] = None,
factory: bool = False,
factory_args: Optional[Dict[str, Any]] = None,
name: str = "",
strip_path: bool = True,
) -> bool:
if app is None and app_path is None:
logger.error("Either 'app' or 'app_path' must be provided")
return False
if app is None:
app = self._loader.load_app(
app_path=app_path, # type: ignore
app_type=app_type,
module_path=module_path,
factory=factory,
factory_args=factory_args,
)
if app is None:
return False
mounted = MountedApp(
path=path,
app=app,
name=name or app_path or "unnamed",
strip_path=strip_path,
)
self._mounts.append(mounted)
self._mounts.sort(key=lambda m: len(m.path), reverse=True)
logger.info(f"Mounted application '{mounted.name}' at path '{path}'")
return True
def unmount(self, path: str) -> bool:
for i, mount in enumerate(self._mounts):
if mount.path == path.rstrip("/"):
del self._mounts[i]
logger.info(f"Unmounted application at path '{path}'")
return True
return False
def get_mount(self, request_path: str) -> Optional[MountedApp]:
for mount in self._mounts:
if mount.matches(request_path):
return mount
return None
async def handle_request(
self,
scope: Scope,
receive: Receive,
send: Send,
) -> bool:
if scope["type"] != "http":
return False
path = scope.get("path", "/")
mount = self.get_mount(path)
if mount is None:
return False
modified_scope = dict(scope)
if mount.strip_path:
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']}")
try:
await mount.app(modified_scope, receive, send)
return True
except Exception as e:
logger.error(f"Error in mounted app '{mount.name}': {e}")
raise
@property
def mounts(self) -> list[MountedApp]:
return self._mounts.copy()
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 create_fastapi_app(
app_path: str,
module_path: Optional[str] = None,
factory: bool = False,
factory_args: Optional[Dict[str, Any]] = None,
) -> Optional[ASGIApp]:
loader = ASGIAppLoader()
return loader.load_app(
app_path=app_path,
app_type="asgi",
module_path=module_path,
factory=factory,
factory_args=factory_args,
)
def create_flask_app(
app_path: str,
module_path: Optional[str] = None,
factory: bool = False,
factory_args: Optional[Dict[str, Any]] = None,
) -> Optional[ASGIApp]:
loader = ASGIAppLoader()
return loader.load_app(
app_path=app_path,
app_type="wsgi",
module_path=module_path,
factory=factory,
factory_args=factory_args,
)
def create_django_app(
settings_module: str,
module_path: Optional[str] = None,
) -> Optional[ASGIApp]:
import os
if module_path:
module_dir = Path(module_path).resolve()
if str(module_dir) not in sys.path:
sys.path.insert(0, str(module_dir))
os.environ.setdefault("DJANGO_SETTINGS_MODULE", settings_module)
try:
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}")
return None
def create_starlette_app(
app_path: str,
module_path: Optional[str] = None,
factory: bool = False,
factory_args: Optional[Dict[str, Any]] = None,
) -> Optional[ASGIApp]:
loader = ASGIAppLoader()
return loader.load_app(
app_path=app_path,
app_type="asgi",
module_path=module_path,
factory=factory,
factory_args=factory_args,
)
+7 -26
View File
@@ -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
View File
@@ -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)
+121 -16
View File
@@ -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,36 +112,101 @@ 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),
}
class ASGIExtension(Extension):
def __init__(self, config: Dict[str, Any]):
super().__init__(config)
from .asgi_mount import ASGIMountManager
self.mount_manager = ASGIMountManager()
self._load_mounts(config.get("mounts", []))
def _load_mounts(self, mounts: List[Dict[str, Any]]) -> None:
from .asgi_mount import create_django_app
for mount_config in mounts:
path = mount_config.get("path", "/")
if "django_settings" in mount_config:
app = create_django_app(
settings_module=mount_config["django_settings"],
module_path=mount_config.get("module_path"),
)
if app:
self.mount_manager.mount(
path=path,
app=app,
name=mount_config.get("name", f"django:{mount_config['django_settings']}"),
strip_path=mount_config.get("strip_path", True),
)
continue
self.mount_manager.mount(
path=path,
app_path=mount_config.get("app_path"),
app_type=mount_config.get("app_type", "asgi"),
module_path=mount_config.get("module_path"),
factory=mount_config.get("factory", False),
factory_args=mount_config.get("factory_args"),
name=mount_config.get("name", ""),
strip_path=mount_config.get("strip_path", True),
)
async def process_request(self, request: Request) -> Optional[Response]:
path = request.url.path
mount = self.mount_manager.get_mount(path)
if mount is not None:
# Store mount info in request state for middleware to use
request.state.asgi_mount = mount
# Return a special marker response that middleware will intercept
return None # Will be handled by get_asgi_handler
return None
async def process_response(self, request: Request, response: Response) -> Response:
return response
def get_asgi_handler(self, request: Request) -> Optional[Any]:
path = request.url.path
return self.mount_manager.get_mount(path)
def get_metrics(self) -> Dict[str, Any]:
return {
"asgi_mounts": self.mount_manager.list_mounts(),
"asgi_mount_count": len(self.mount_manager.mounts),
}
def cleanup(self) -> None:
logger.info("Cleaning up ASGI mounts")
class ExtensionManager:
def __init__(self) -> None:
self.extensions: List[Extension] = []
@@ -145,8 +214,18 @@ class ExtensionManager:
"routing": RoutingExtension,
"security": SecurityExtension,
"caching": CachingExtension,
"monitoring": MonitoringExtension
"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
@@ -165,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
View File
@@ -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)
+33
View File
@@ -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",
]
+365
View File
@@ -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()
+553
View File
@@ -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
+20 -10
View File
@@ -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)
@@ -123,6 +124,10 @@ class RequestHandler:
file_path = root / index_file
else:
file_path = root / path
# If path is a directory, look for index file
if file_path.is_dir():
index_file = config.get("index_file", "index.html")
file_path = file_path / index_file
try:
file_path = file_path.resolve()
@@ -167,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():
@@ -193,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)
+108 -54
View File
@@ -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
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":
@@ -30,6 +31,11 @@ class PyServeMiddleware:
start_time = time.time()
request = Request(scope, receive)
asgi_handled = await self._try_asgi_mount(scope, receive, send, request, start_time)
if asgi_handled:
return
response = await self.extension_manager.process_request(request)
if response is None:
@@ -39,6 +45,55 @@ class PyServeMiddleware:
response = await self.extension_manager.process_response(request, response)
response.headers["Server"] = f"pyserve/{__version__}"
self._log_access(request, response, start_time)
await response(scope, receive, send)
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)
if mount is not None:
modified_scope = dict(scope)
if mount.strip_path:
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']}")
try:
response_started = False
status_code = 0
async def send_wrapper(message: Dict[str, Any]) -> None:
nonlocal response_started, status_code
if message["type"] == "http.response.start":
response_started = True
status_code = message.get("status", 0)
await send(message)
await mount.app(modified_scope, receive, send_wrapper)
process_time = round((time.time() - start_time) * 1000, 2)
self.access_logger.info(
"ASGI request",
client_ip=request.client.host if request.client else "unknown",
method=request.method,
path=str(request.url.path),
mount=mount.name,
status_code=status_code,
process_time_ms=process_time,
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)
await error_response(scope, receive, send)
return True
return False
def _log_access(self, request: Request, response: Response, start_time: float) -> None:
client_ip = request.client.host if request.client else "unknown"
method = request.method
path = str(request.url.path)
@@ -55,17 +110,16 @@ 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", ""),
)
await response(scope, receive, send)
class PyServeServer:
def __init__(self, config: Config):
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()
@@ -80,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:
@@ -113,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)
@@ -144,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:
@@ -172,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"
@@ -241,6 +295,7 @@ class PyServeServer:
self.extension_manager.cleanup()
from .logging_utils import shutdown_logging
shutdown_logging()
logger.info("Server stopped")
@@ -252,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
+72
View File
@@ -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)
+897
View File
@@ -0,0 +1,897 @@
"""
Integration tests for ASGI mount functionality.
These tests start PyServe with mounted ASGI applications and verify
that requests are correctly routed to the mounted apps.
"""
import asyncio
import pytest
import httpx
import socket
from typing import Dict, Any
import uvicorn
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse, PlainTextResponse, Response
from starlette.routing import Route
from pyserve.config import Config, ServerConfig, HttpConfig, LoggingConfig, ExtensionConfig
from pyserve.server import PyServeServer
from pyserve.asgi_mount import (
ASGIAppLoader,
MountedApp,
ASGIMountManager,
)
def get_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(('', 0))
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
return s.getsockname()[1]
# ============== Test ASGI Applications ==============
def create_api_v1_app() -> Starlette:
"""Create a test API v1 application."""
async def root(request: Request) -> JSONResponse:
return JSONResponse({
"app": "api-v1",
"message": "Welcome to API v1",
"path": request.url.path,
"root_path": request.scope.get("root_path", ""),
})
async def health(request: Request) -> JSONResponse:
return JSONResponse({"status": "healthy", "app": "api-v1"})
async def users_list(request: Request) -> JSONResponse:
return JSONResponse({
"users": [
{"id": 1, "name": "Alice"},
{"id": 2, "name": "Bob"},
],
"app": "api-v1",
})
async def user_detail(request: Request) -> JSONResponse:
user_id = request.path_params.get("user_id")
return JSONResponse({
"user": {"id": user_id, "name": f"User {user_id}"},
"app": "api-v1",
})
async def create_user(request: Request) -> JSONResponse:
body = await request.json()
return JSONResponse({
"created": body,
"app": "api-v1",
}, status_code=201)
async def echo(request: Request) -> Response:
body = await request.body()
return Response(
content=body,
media_type=request.headers.get("content-type", "text/plain"),
)
routes = [
Route("/", root, methods=["GET"]),
Route("/health", health, methods=["GET"]),
Route("/users", users_list, methods=["GET"]),
Route("/users", create_user, methods=["POST"]),
Route("/users/{user_id:int}", user_detail, methods=["GET"]),
Route("/echo", echo, methods=["POST"]),
]
return Starlette(routes=routes)
def create_api_v2_app() -> Starlette:
"""Create a test API v2 application with different responses."""
async def root(request: Request) -> JSONResponse:
return JSONResponse({
"app": "api-v2",
"message": "Welcome to API v2 - Enhanced!",
"version": "2.0.0",
"path": request.url.path,
})
async def health(request: Request) -> JSONResponse:
return JSONResponse({
"status": "healthy",
"app": "api-v2",
"version": "2.0.0",
})
async def users_list(request: Request) -> JSONResponse:
return JSONResponse({
"data": {
"users": [
{"id": 1, "name": "Alice", "email": "alice@test.com"},
{"id": 2, "name": "Bob", "email": "bob@test.com"},
],
},
"meta": {"total": 2, "page": 1},
"app": "api-v2",
})
routes = [
Route("/", root, methods=["GET"]),
Route("/health", health, methods=["GET"]),
Route("/users", users_list, methods=["GET"]),
]
return Starlette(routes=routes)
def create_admin_app() -> Starlette:
"""Create a test admin application."""
async def dashboard(request: Request) -> JSONResponse:
return JSONResponse({
"app": "admin",
"page": "dashboard",
"path": request.url.path,
})
async def settings(request: Request) -> JSONResponse:
return JSONResponse({
"app": "admin",
"page": "settings",
"config": {"debug": True, "theme": "dark"},
})
async def stats(request: Request) -> JSONResponse:
return JSONResponse({
"app": "admin",
"stats": {
"requests": 1000,
"errors": 5,
"uptime": "24h",
},
})
routes = [
Route("/", dashboard, methods=["GET"]),
Route("/settings", settings, methods=["GET"]),
Route("/stats", stats, methods=["GET"]),
]
return Starlette(routes=routes)
def create_websocket_test_app() -> Starlette:
"""Create a test app that also has websocket endpoint info."""
async def root(request: Request) -> JSONResponse:
return JSONResponse({
"app": "ws-app",
"message": "WebSocket test app",
"ws_endpoint": "/ws",
})
async def info(request: Request) -> JSONResponse:
return JSONResponse({
"app": "ws-app",
"supports": ["http", "websocket"],
})
routes = [
Route("/", root, methods=["GET"]),
Route("/info", info, methods=["GET"]),
]
return Starlette(routes=routes)
# ============== PyServe Test Server ==============
class PyServeTestServer:
"""Test server wrapper for PyServe with ASGI mounts."""
def __init__(self, config: Config):
self.config = config
self.server = PyServeServer(config)
self._server_task = None
async def start(self) -> None:
assert self.server.app is not None, "Server app not initialized"
config = uvicorn.Config(
app=self.server.app,
host=self.config.server.host,
port=self.config.server.port,
log_level="critical",
access_log=False,
)
server = uvicorn.Server(config)
self._server_task = asyncio.create_task(server.serve())
# Wait for server to be ready
for _ in range(50):
try:
async with httpx.AsyncClient() as client:
await client.get(f"http://127.0.0.1:{self.config.server.port}/health")
return
except httpx.ConnectError:
await asyncio.sleep(0.1)
raise RuntimeError(f"PyServe server failed to start on port {self.config.server.port}")
async def stop(self) -> None:
if self._server_task:
self._server_task.cancel()
try:
await self._server_task
except asyncio.CancelledError:
pass
# ============== Fixtures ==============
@pytest.fixture
def pyserve_port() -> int:
"""Get a free port for PyServe."""
return get_free_port()
@pytest.fixture
def api_v1_app() -> Starlette:
"""Create API v1 test app."""
return create_api_v1_app()
@pytest.fixture
def api_v2_app() -> Starlette:
"""Create API v2 test app."""
return create_api_v2_app()
@pytest.fixture
def admin_app() -> Starlette:
"""Create admin test app."""
return create_admin_app()
# ============== Unit Tests ==============
class TestMountedApp:
"""Unit tests for MountedApp class."""
def test_matches_exact_path(self, api_v1_app):
"""Test exact path matching."""
mounted = MountedApp("/api", api_v1_app)
assert mounted.matches("/api") is True
assert mounted.matches("/api/users") is True
assert mounted.matches("/api/users/123") is True
assert mounted.matches("/other") is False
assert mounted.matches("/apiv2") is False
def test_matches_empty_path(self, api_v1_app):
"""Test root mount matching."""
mounted = MountedApp("", api_v1_app)
assert mounted.matches("/") is True
assert mounted.matches("/anything") is True
assert mounted.matches("/nested/path") is True
def test_get_modified_path_with_strip(self, api_v1_app):
"""Test path modification with strip_path=True."""
mounted = MountedApp("/api/v1", api_v1_app, strip_path=True)
assert mounted.get_modified_path("/api/v1") == "/"
assert mounted.get_modified_path("/api/v1/users") == "/users"
assert mounted.get_modified_path("/api/v1/users/123") == "/users/123"
def test_get_modified_path_without_strip(self, api_v1_app):
"""Test path modification with strip_path=False."""
mounted = MountedApp("/api/v1", api_v1_app, strip_path=False)
assert mounted.get_modified_path("/api/v1/users") == "/api/v1/users"
class TestASGIMountManager:
"""Unit tests for ASGIMountManager class."""
def test_mount_direct_app(self, api_v1_app):
"""Test mounting a direct ASGI app."""
manager = ASGIMountManager()
result = manager.mount(
path="/api",
app=api_v1_app,
name="api-v1"
)
assert result is True
assert len(manager.mounts) == 1
assert manager.mounts[0].name == "api-v1"
assert manager.mounts[0].path == "/api"
def test_mount_requires_app_or_path(self):
"""Test that mount requires either app or app_path."""
manager = ASGIMountManager()
result = manager.mount(path="/test")
assert result is False
assert len(manager.mounts) == 0
def test_mount_ordering_by_path_length(self, api_v1_app, api_v2_app, admin_app):
"""Test that mounts are ordered by path length (longest first)."""
manager = ASGIMountManager()
manager.mount(path="/api", app=api_v1_app, name="short")
manager.mount(path="/api/v1", app=api_v2_app, name="medium")
manager.mount(path="/api/v1/admin", app=admin_app, name="long")
# Verify ordering
assert manager.mounts[0].name == "long"
assert manager.mounts[1].name == "medium"
assert manager.mounts[2].name == "short"
# Should match the longest prefix first
mount = manager.get_mount("/api/v1/admin/dashboard")
assert mount is not None
assert mount.name == "long"
mount = manager.get_mount("/api/v1/users")
assert mount is not None
assert mount.name == "medium"
mount = manager.get_mount("/api/other")
assert mount is not None
assert mount.name == "short"
def test_unmount(self, api_v1_app):
"""Test unmounting an application."""
manager = ASGIMountManager()
manager.mount(path="/api", app=api_v1_app)
assert len(manager.mounts) == 1
result = manager.unmount("/api")
assert result is True
assert len(manager.mounts) == 0
def test_list_mounts(self, api_v1_app, api_v2_app):
"""Test listing all mounts."""
manager = ASGIMountManager()
manager.mount(path="/api/v1", app=api_v1_app, name="api-v1")
manager.mount(path="/api/v2", app=api_v2_app, name="api-v2")
mounts_info = manager.list_mounts()
assert len(mounts_info) == 2
names = {m["name"] for m in mounts_info}
assert "api-v1" in names
assert "api-v2" in names
class TestASGIAppLoader:
"""Unit tests for ASGIAppLoader class."""
def test_load_app_invalid_module(self):
"""Test loading app with invalid module path."""
loader = ASGIAppLoader()
app = loader.load_app("nonexistent.module:app")
assert app is None
def test_load_app_invalid_attribute(self):
"""Test loading app with invalid attribute."""
loader = ASGIAppLoader()
app = loader.load_app("starlette.applications:nonexistent")
assert app is None
def test_get_app_cached(self, api_v1_app):
"""Test getting a cached app."""
loader = ASGIAppLoader()
loader._apps["test:app"] = api_v1_app
app = loader.get_app("test:app")
assert app is api_v1_app
app = loader.get_app("nonexistent:app")
assert app is None
# ============== Integration Tests ==============
class TestASGIMountIntegration:
"""Integration tests for ASGIMountManager request handling."""
@pytest.mark.asyncio
async def test_handle_request_to_mounted_app(self, api_v1_app):
"""Test handling a request through mounted app."""
manager = ASGIMountManager()
manager.mount(path="/api", app=api_v1_app)
scope = {
"type": "http",
"asgi": {"version": "3.0"},
"http_version": "1.1",
"method": "GET",
"path": "/api/health",
"query_string": b"",
"headers": [],
"server": ("127.0.0.1", 8000),
}
received_messages = []
async def receive():
return {"type": "http.request", "body": b""}
async def send(message):
received_messages.append(message)
result = await manager.handle_request(scope, receive, send)
assert result is True
assert len(received_messages) == 2 # response.start + response.body
assert received_messages[0]["type"] == "http.response.start"
assert received_messages[0]["status"] == 200
@pytest.mark.asyncio
async def test_handle_request_no_match(self):
"""Test handling request with no matching mount."""
manager = ASGIMountManager()
scope = {
"type": "http",
"path": "/unmatched",
}
async def receive():
return {}
async def send(message):
pass
result = await manager.handle_request(scope, receive, send)
assert result is False
@pytest.mark.asyncio
async def test_handle_non_http_request(self, api_v1_app):
"""Test that non-HTTP requests are not handled."""
manager = ASGIMountManager()
manager.mount(path="/api", app=api_v1_app)
scope = {
"type": "websocket",
"path": "/api/ws",
}
async def receive():
return {}
async def send(message):
pass
result = await manager.handle_request(scope, receive, send)
assert result is False
@pytest.mark.asyncio
async def test_path_stripping(self, api_v1_app):
"""Test that mount path is correctly stripped from request."""
manager = ASGIMountManager()
manager.mount(path="/api/v1", app=api_v1_app, strip_path=True)
scope = {
"type": "http",
"asgi": {"version": "3.0"},
"http_version": "1.1",
"method": "GET",
"path": "/api/v1/users",
"query_string": b"",
"headers": [],
"server": ("127.0.0.1", 8000),
}
received_messages = []
async def receive():
return {"type": "http.request", "body": b""}
async def send(message):
received_messages.append(message)
result = await manager.handle_request(scope, receive, send)
assert result is True
assert received_messages[0]["status"] == 200
# ============== Full Server Integration Tests ==============
class TestPyServeWithASGIMounts:
"""Full integration tests with PyServe server and ASGI mounts."""
@pytest.mark.asyncio
async def test_basic_asgi_mount(self, pyserve_port, api_v1_app):
"""Test basic ASGI app mounting through PyServe."""
config = Config(
server=ServerConfig(host="127.0.0.1", port=pyserve_port),
http=HttpConfig(static_dir="./static", templates_dir="./templates"),
logging=LoggingConfig(level="ERROR", console_output=False),
extensions=[],
)
# Create server and manually add ASGI extension
server = PyServeServer(config)
# Add ASGI mount directly via extension manager
from pyserve.extensions import ASGIExtension
asgi_ext = ASGIExtension({"mounts": []})
asgi_ext.mount_manager.mount(path="/api", app=api_v1_app, name="api-v1")
server.extension_manager.extensions.insert(0, asgi_ext)
test_server = PyServeTestServer.__new__(PyServeTestServer)
test_server.config = config
test_server.server = server
test_server._server_task = None
try:
await test_server.start()
async with httpx.AsyncClient() as client:
# Test root endpoint of mounted app
response = await client.get(f"http://127.0.0.1:{pyserve_port}/api/")
assert response.status_code == 200
data = response.json()
assert data["app"] == "api-v1"
assert data["message"] == "Welcome to API v1"
# Test health endpoint
response = await client.get(f"http://127.0.0.1:{pyserve_port}/api/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
assert data["app"] == "api-v1"
# Test users list
response = await client.get(f"http://127.0.0.1:{pyserve_port}/api/users")
assert response.status_code == 200
data = response.json()
assert "users" in data
assert len(data["users"]) == 2
# Test user detail
response = await client.get(f"http://127.0.0.1:{pyserve_port}/api/users/1")
assert response.status_code == 200
data = response.json()
assert data["user"]["id"] == 1
finally:
await test_server.stop()
@pytest.mark.asyncio
async def test_multiple_asgi_mounts(self, pyserve_port, api_v1_app, api_v2_app, admin_app):
"""Test multiple ASGI apps mounted at different paths."""
config = Config(
server=ServerConfig(host="127.0.0.1", port=pyserve_port),
http=HttpConfig(static_dir="./static", templates_dir="./templates"),
logging=LoggingConfig(level="ERROR", console_output=False),
extensions=[],
)
server = PyServeServer(config)
from pyserve.extensions import ASGIExtension
asgi_ext = ASGIExtension({"mounts": []})
asgi_ext.mount_manager.mount(path="/api/v1", app=api_v1_app, name="api-v1")
asgi_ext.mount_manager.mount(path="/api/v2", app=api_v2_app, name="api-v2")
asgi_ext.mount_manager.mount(path="/admin", app=admin_app, name="admin")
server.extension_manager.extensions.insert(0, asgi_ext)
test_server = PyServeTestServer.__new__(PyServeTestServer)
test_server.config = config
test_server.server = server
test_server._server_task = None
try:
await test_server.start()
async with httpx.AsyncClient() as client:
# Test API v1
response = await client.get(f"http://127.0.0.1:{pyserve_port}/api/v1/")
assert response.status_code == 200
assert response.json()["app"] == "api-v1"
# Test API v2
response = await client.get(f"http://127.0.0.1:{pyserve_port}/api/v2/")
assert response.status_code == 200
data = response.json()
assert data["app"] == "api-v2"
assert data["version"] == "2.0.0"
# Test API v2 users (different response format)
response = await client.get(f"http://127.0.0.1:{pyserve_port}/api/v2/users")
assert response.status_code == 200
data = response.json()
assert "data" in data
assert "meta" in data
# Test Admin
response = await client.get(f"http://127.0.0.1:{pyserve_port}/admin/")
assert response.status_code == 200
assert response.json()["app"] == "admin"
assert response.json()["page"] == "dashboard"
# Test Admin settings
response = await client.get(f"http://127.0.0.1:{pyserve_port}/admin/settings")
assert response.status_code == 200
data = response.json()
assert data["config"]["theme"] == "dark"
finally:
await test_server.stop()
@pytest.mark.asyncio
async def test_asgi_mount_post_request(self, pyserve_port, api_v1_app):
"""Test POST requests to mounted ASGI app."""
config = Config(
server=ServerConfig(host="127.0.0.1", port=pyserve_port),
http=HttpConfig(static_dir="./static", templates_dir="./templates"),
logging=LoggingConfig(level="ERROR", console_output=False),
extensions=[],
)
server = PyServeServer(config)
from pyserve.extensions import ASGIExtension
asgi_ext = ASGIExtension({"mounts": []})
asgi_ext.mount_manager.mount(path="/api", app=api_v1_app, name="api")
server.extension_manager.extensions.insert(0, asgi_ext)
test_server = PyServeTestServer.__new__(PyServeTestServer)
test_server.config = config
test_server.server = server
test_server._server_task = None
try:
await test_server.start()
async with httpx.AsyncClient() as client:
# Test POST to create user
response = await client.post(
f"http://127.0.0.1:{pyserve_port}/api/users",
json={"name": "Charlie", "email": "charlie@test.com"}
)
assert response.status_code == 201
data = response.json()
assert data["created"]["name"] == "Charlie"
# Test echo endpoint
response = await client.post(
f"http://127.0.0.1:{pyserve_port}/api/echo",
content=b"Hello, World!",
headers={"Content-Type": "text/plain"}
)
assert response.status_code == 200
assert response.content == b"Hello, World!"
finally:
await test_server.stop()
@pytest.mark.asyncio
async def test_asgi_mount_with_routing_extension(self, pyserve_port, api_v1_app):
"""Test ASGI mounts working alongside routing extension."""
config = Config(
server=ServerConfig(host="127.0.0.1", port=pyserve_port),
http=HttpConfig(static_dir="./static", templates_dir="./templates"),
logging=LoggingConfig(level="ERROR", console_output=False),
extensions=[
ExtensionConfig(
type="routing",
config={
"regex_locations": {
"=/health": {"return": "200 PyServe OK"},
"=/status": {"return": "200 Server Running"},
}
}
)
],
)
server = PyServeServer(config)
# Add ASGI extension BEFORE routing extension
from pyserve.extensions import ASGIExtension
asgi_ext = ASGIExtension({"mounts": []})
asgi_ext.mount_manager.mount(path="/api", app=api_v1_app, name="api")
server.extension_manager.extensions.insert(0, asgi_ext)
test_server = PyServeTestServer.__new__(PyServeTestServer)
test_server.config = config
test_server.server = server
test_server._server_task = None
try:
await test_server.start()
async with httpx.AsyncClient() as client:
# Test ASGI mounted app
response = await client.get(f"http://127.0.0.1:{pyserve_port}/api/users")
assert response.status_code == 200
assert response.json()["app"] == "api-v1"
# Test routing extension endpoints
response = await client.get(f"http://127.0.0.1:{pyserve_port}/status")
assert response.status_code == 200
assert "Server Running" in response.text
finally:
await test_server.stop()
@pytest.mark.asyncio
async def test_asgi_mount_path_not_stripped(self, pyserve_port):
"""Test ASGI mount with strip_path=False."""
# Create an app that expects full path
async def handler(request: Request) -> JSONResponse:
return JSONResponse({
"full_path": request.url.path,
"received": True,
})
app = Starlette(routes=[
Route("/mounted/data", handler, methods=["GET"]),
])
config = Config(
server=ServerConfig(host="127.0.0.1", port=pyserve_port),
http=HttpConfig(static_dir="./static", templates_dir="./templates"),
logging=LoggingConfig(level="ERROR", console_output=False),
extensions=[],
)
server = PyServeServer(config)
from pyserve.extensions import ASGIExtension
asgi_ext = ASGIExtension({"mounts": []})
asgi_ext.mount_manager.mount(
path="/mounted",
app=app,
name="full-path-app",
strip_path=False
)
server.extension_manager.extensions.insert(0, asgi_ext)
test_server = PyServeTestServer.__new__(PyServeTestServer)
test_server.config = config
test_server.server = server
test_server._server_task = None
try:
await test_server.start()
async with httpx.AsyncClient() as client:
response = await client.get(f"http://127.0.0.1:{pyserve_port}/mounted/data")
assert response.status_code == 200
data = response.json()
assert data["full_path"] == "/mounted/data"
finally:
await test_server.stop()
@pytest.mark.asyncio
async def test_asgi_mount_metrics(self, pyserve_port, api_v1_app):
"""Test that ASGI extension reports metrics correctly."""
config = Config(
server=ServerConfig(host="127.0.0.1", port=pyserve_port),
http=HttpConfig(static_dir="./static", templates_dir="./templates"),
logging=LoggingConfig(level="ERROR", console_output=False),
extensions=[],
)
server = PyServeServer(config)
from pyserve.extensions import ASGIExtension
asgi_ext = ASGIExtension({"mounts": []})
asgi_ext.mount_manager.mount(path="/api/v1", app=api_v1_app, name="api-v1")
asgi_ext.mount_manager.mount(path="/api/v2", app=create_api_v2_app(), name="api-v2")
server.extension_manager.extensions.insert(0, asgi_ext)
# Check metrics
metrics = asgi_ext.get_metrics()
assert metrics["asgi_mount_count"] == 2
assert len(metrics["asgi_mounts"]) == 2
mount_names = {m["name"] for m in metrics["asgi_mounts"]}
assert "api-v1" in mount_names
assert "api-v2" in mount_names
@pytest.mark.asyncio
async def test_asgi_mount_error_handling(self, pyserve_port):
"""Test error handling when mounted app raises exception."""
async def failing_handler(request: Request) -> JSONResponse:
raise ValueError("Intentional error for testing")
async def working_handler(request: Request) -> JSONResponse:
return JSONResponse({"status": "ok"})
app = Starlette(routes=[
Route("/fail", failing_handler, methods=["GET"]),
Route("/ok", working_handler, methods=["GET"]),
])
config = Config(
server=ServerConfig(host="127.0.0.1", port=pyserve_port),
http=HttpConfig(static_dir="./static", templates_dir="./templates"),
logging=LoggingConfig(level="ERROR", console_output=False),
extensions=[],
)
server = PyServeServer(config)
from pyserve.extensions import ASGIExtension
asgi_ext = ASGIExtension({"mounts": []})
asgi_ext.mount_manager.mount(path="/test", app=app, name="test-app")
server.extension_manager.extensions.insert(0, asgi_ext)
test_server = PyServeTestServer.__new__(PyServeTestServer)
test_server.config = config
test_server.server = server
test_server._server_task = None
try:
await test_server.start()
async with httpx.AsyncClient() as client:
# Working endpoint should work
response = await client.get(f"http://127.0.0.1:{pyserve_port}/test/ok")
assert response.status_code == 200
# Failing endpoint should return 500
response = await client.get(f"http://127.0.0.1:{pyserve_port}/test/fail")
assert response.status_code == 500
finally:
await test_server.stop()
@pytest.mark.asyncio
async def test_concurrent_requests_to_mounted_apps(self, pyserve_port, api_v1_app, api_v2_app):
"""Test concurrent requests to different mounted apps."""
config = Config(
server=ServerConfig(host="127.0.0.1", port=pyserve_port),
http=HttpConfig(static_dir="./static", templates_dir="./templates"),
logging=LoggingConfig(level="ERROR", console_output=False),
extensions=[],
)
server = PyServeServer(config)
from pyserve.extensions import ASGIExtension
asgi_ext = ASGIExtension({"mounts": []})
asgi_ext.mount_manager.mount(path="/v1", app=api_v1_app, name="v1")
asgi_ext.mount_manager.mount(path="/v2", app=api_v2_app, name="v2")
server.extension_manager.extensions.insert(0, asgi_ext)
test_server = PyServeTestServer.__new__(PyServeTestServer)
test_server.config = config
test_server.server = server
test_server._server_task = None
try:
await test_server.start()
async with httpx.AsyncClient() as client:
# Send concurrent requests
tasks = [
client.get(f"http://127.0.0.1:{pyserve_port}/v1/health"),
client.get(f"http://127.0.0.1:{pyserve_port}/v2/health"),
client.get(f"http://127.0.0.1:{pyserve_port}/v1/users"),
client.get(f"http://127.0.0.1:{pyserve_port}/v2/users"),
client.get(f"http://127.0.0.1:{pyserve_port}/v1/"),
client.get(f"http://127.0.0.1:{pyserve_port}/v2/"),
]
responses = await asyncio.gather(*tasks)
# All requests should succeed
for response in responses:
assert response.status_code == 200
# Verify correct app responded
assert responses[0].json()["app"] == "api-v1"
assert responses[1].json()["app"] == "api-v2"
finally:
await test_server.stop()
+273
View File
@@ -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
File diff suppressed because it is too large Load Diff