12 Commits
Author SHA1 Message Date
Илья Глазунов 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
28 changed files with 5253 additions and 365 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
+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"
+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"
+23 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "pyserve"
version = "0.7.0"
version = "0.9.1"
description = "Simple HTTP Web server written in Python"
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"
+24 -3
View File
@@ -2,10 +2,31 @@
PyServe - HTTP web server written on Python
"""
__version__ = "0.6.0"
__version__ = "0.9.0"
__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 .server import PyServeServer
__all__ = ["PyServeServer", "Config", "__version__"]
__all__ = [
"PyServeServer",
"Config",
"__version__",
# ASGI mounting
"ASGIAppLoader",
"ASGIMountManager",
"MountedApp",
"create_fastapi_app",
"create_flask_app",
"create_django_app",
"create_starlette_app",
]
+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)
+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)
+85 -16
View File
@@ -1,7 +1,9 @@
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 +38,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 +57,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 +67,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 +111,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,7 +213,8 @@ class ExtensionManager:
"routing": RoutingExtension,
"security": SecurityExtension,
"caching": CachingExtension,
"monitoring": MonitoringExtension
"monitoring": MonitoringExtension,
"asgi": ASGIExtension,
}
def register_extension_type(self, name: str, extension_class: Type[Extension]) -> None:
+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",
]
+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)
+83 -53
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,11 +110,9 @@ 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):
@@ -80,27 +133,13 @@ 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
)
self.extension_manager.load_extension(ext_config.type, config)
def _create_app(self) -> None:
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)
@@ -113,19 +152,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 +180,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 +205,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 +271,7 @@ class PyServeServer:
self.extension_manager.cleanup()
from .logging_utils import shutdown_logging
shutdown_logging()
logger.info("Server stopped")
@@ -252,13 +283,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