10 Commits
Author SHA1 Message Date
Илья Глазунов 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
Илья Глазунов 600f71612b bump version to 0.7.0
Lint Code / lint (push) Successful in 49s
Run Tests / test (3.12) (push) Successful in 1m1s
Run Tests / test (3.13) (push) Successful in 58s
CI/CD Pipeline / lint (push) Successful in 0s
Build and Release / build (push) Successful in 32s
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 8s
CI/CD Pipeline / notify (push) Successful in 1s
2025-12-03 00:05:23 +03:00
Илья Глазунов 5262c5e1fb reverse proxy added
added tests for reverse proxy too
2025-12-03 00:05:11 +03:00
19 changed files with 4794 additions and 16 deletions
+4 -1
View File
@@ -10,4 +10,7 @@ logs/*
static/*
.DS_Store
.coverage
.coverage
docs/
dist/
+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"
Generated
+581 -2
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,31 @@ 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"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"},
{file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"},
]
[[package]]
name = "click"
version = "8.2.1"
@@ -194,6 +274,52 @@ files = [
[package.extras]
toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
[[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"
@@ -211,6 +337,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"
@@ -223,6 +374,28 @@ files = [
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
]
[[package]]
name = "httpcore"
version = "1.0.9"
description = "A minimal low-level HTTP client."
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
{file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
]
[package.dependencies]
certifi = "*"
h11 = ">=0.16"
[package.extras]
asyncio = ["anyio (>=4.0,<5.0)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
trio = ["trio (>=0.22.0,<1.0)"]
[[package]]
name = "httptools"
version = "0.6.4"
@@ -279,6 +452,32 @@ files = [
[package.extras]
test = ["Cython (>=0.29.24)"]
[[package]]
name = "httpx"
version = "0.27.2"
description = "The next generation HTTP client."
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0"},
{file = "httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2"},
]
[package.dependencies]
anyio = "*"
certifi = "*"
httpcore = "==1.*"
idna = "*"
sniffio = "*"
[package.extras]
brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""]
cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
zstd = ["zstandard (>=0.18.0)"]
[[package]]
name = "idna"
version = "3.10"
@@ -322,6 +521,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"
@@ -475,6 +806,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"
@@ -524,6 +1013,26 @@ pygments = ">=2.7.2"
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
[[package]]
name = "pytest-asyncio"
version = "1.3.0"
description = "Pytest support for asyncio"
optional = false
python-versions = ">=3.10"
groups = ["main", "dev"]
files = [
{file = "pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5"},
{file = "pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5"},
]
[package.dependencies]
pytest = ">=8.2,<10"
typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""}
[package.extras]
docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"]
testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"]
[[package]]
name = "pytest-cov"
version = "6.2.1"
@@ -634,6 +1143,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"
@@ -689,6 +1215,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"
@@ -966,10 +1521,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]
dev = ["black", "flake8", "isort", "mypy", "pytest", "pytest-cov"]
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 = "5eda39db8e3d119d03c8e6083d1f9cd14691669a7130fb17b1445a0dd7bb79e7"
content-hash = "a08668c23222843b27b3977933c93b261328f43e90f22f35212c6c6f6030e3dc"
+23 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "pyserve"
version = "0.6.0"
version = "0.8.0"
description = "Simple HTTP Web server written in Python"
authors = [
{name = "Илья Глазунов",email = "i.glazunov@sapiens.solutions"}
@@ -14,6 +14,7 @@ dependencies = [
"pyyaml (>=6.0,<7.0)",
"types-pyyaml (>=6.0.12.20250822,<7.0.0.0)",
"structlog (>=25.4.0,<26.0.0)",
"httpx (>=0.27.0,<0.28.0)",
]
[project.scripts]
@@ -23,11 +24,31 @@ pyserve = "pyserve.cli:main"
dev = [
"pytest",
"pytest-cov",
"pytest-asyncio",
"black",
"isort",
"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]
@@ -73,4 +94,5 @@ black = "^25.1.0"
isort = "^6.0.1"
mypy = "^1.17.1"
flake8 = "^7.3.0"
pytest-asyncio = "^1.3.0"
+23 -2
View File
@@ -2,10 +2,31 @@
PyServe - HTTP web server written on Python
"""
__version__ = "0.6.0"
__version__ = "0.8.0"
__author__ = "Ilya Glazunov"
from .server import PyServeServer
from .config import Config
from .asgi_mount import (
ASGIAppLoader,
ASGIMountManager,
MountedApp,
create_fastapi_app,
create_flask_app,
create_django_app,
create_starlette_app,
)
__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",
]
+311
View File
@@ -0,0 +1,311 @@
"""
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 Dict, Any, Optional, Callable, 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 # type: ignore[import-untyped]
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,
)
+2
View File
@@ -18,6 +18,7 @@ class ServerConfig:
port: int = 8080
backlog: int = 5
default_root: bool = False
proxy_timeout: float = 30.0
redirect_instructions: Dict[str, str] = field(default_factory=dict)
@@ -112,6 +113,7 @@ class Config:
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', {})
)
+72 -2
View File
@@ -33,9 +33,10 @@ class RoutingExtension(Extension):
from .routing import create_router_from_config
regex_locations = config.get("regex_locations", {})
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)
self.handler = RequestHandler(self.router, default_proxy_timeout=default_proxy_timeout)
async def process_request(self, request: Request) -> Optional[Response]:
try:
@@ -137,6 +138,74 @@ class MonitoringExtension(Extension):
}
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] = []
@@ -144,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:
+85 -4
View File
@@ -2,6 +2,8 @@ import re
import mimetypes
from pathlib import Path
from typing import Dict, Any, Optional, Pattern
from urllib.parse import urlparse
import httpx
from starlette.requests import Request
from starlette.responses import Response, FileResponse, PlainTextResponse
from .logging_utils import get_logger
@@ -63,9 +65,10 @@ class Router:
class RequestHandler:
def __init__(self, router: Router, static_dir: str = "./static"):
def __init__(self, router: Router, static_dir: str = "./static", default_proxy_timeout: float = 30.0):
self.router = router
self.static_dir = Path(static_dir)
self.default_proxy_timeout = default_proxy_timeout
async def handle(self, request: Request) -> Response:
path = request.url.path
@@ -120,6 +123,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()
@@ -166,15 +173,89 @@ class RequestHandler:
async def _handle_proxy(self, request: Request, config: Dict[str, Any],
params: Dict[str, str]) -> Response:
# TODO: implement real proxying
proxy_url = config["proxy_pass"]
for key, value in params.items():
proxy_url = proxy_url.replace(f"{{{key}}}", value)
logger.info(f"Proxying request to: {proxy_url}")
parsed_proxy = urlparse(proxy_url)
return PlainTextResponse(f"Proxy to: {proxy_url}", status_code=200)
original_path = request.url.path
if parsed_proxy.path and parsed_proxy.path not in ("/", ""):
target_url = proxy_url
else:
base_url = f"{parsed_proxy.scheme}://{parsed_proxy.netloc}"
target_url = f"{base_url}{original_path}"
if request.url.query:
separator = "&" if "?" in target_url else "?"
target_url = f"{target_url}{separator}{request.url.query}"
logger.info(f"Proxying request to: {target_url}")
proxy_headers = dict(request.headers)
hop_by_hop_headers = [
"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)
client_ip = request.client.host if request.client else "unknown"
proxy_headers["X-Forwarded-For"] = client_ip
proxy_headers["X-Forwarded-Proto"] = request.url.scheme
proxy_headers["X-Forwarded-Host"] = request.headers.get("host", "")
proxy_headers["X-Real-IP"] = client_ip
proxy_headers["Host"] = parsed_proxy.netloc
if "headers" in config:
for header in config["headers"]:
if ":" in header:
name, value = header.split(":", 1)
value = value.strip()
for key, param_value in params.items():
value = value.replace(f"{{{key}}}", param_value)
value = value.replace("$remote_addr", client_ip)
proxy_headers[name.strip()] = value
body = await request.body()
timeout = config.get("timeout", self.default_proxy_timeout)
try:
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client:
proxy_response = await client.request(
method=request.method,
url=target_url,
headers=proxy_headers,
content=body if body else None,
)
response_headers = dict(proxy_response.headers)
for header in hop_by_hop_headers:
response_headers.pop(header, None)
return Response(
content=proxy_response.content,
status_code=proxy_response.status_code,
headers=response_headers,
media_type=proxy_response.headers.get("content-type"),
)
except httpx.ConnectError as e:
logger.error(f"Proxy connection error to {target_url}: {e}")
return PlainTextResponse("502 Bad Gateway", status_code=502)
except httpx.TimeoutException as e:
logger.error(f"Proxy timeout to {target_url}: {e}")
return PlainTextResponse("504 Gateway Timeout", status_code=504)
except Exception as e:
logger.error(f"Proxy error to {target_url}: {e}")
return PlainTextResponse("502 Bad Gateway", status_code=502)
def create_router_from_config(regex_locations: Dict[str, Dict[str, Any]]) -> Router:
+73 -4
View File
@@ -10,7 +10,7 @@ from pathlib import Path
from typing import Optional, Dict, Any
from .config import Config
from .extensions import ExtensionManager
from .extensions import ExtensionManager, ASGIExtension
from .logging_utils import get_logger
from . import __version__
@@ -30,6 +30,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 +44,68 @@ 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)
@@ -58,8 +125,6 @@ class PyServeMiddleware:
user_agent=request.headers.get("user-agent", "")
)
await response(scope, receive, send)
class PyServeServer:
def __init__(self, config: Config):
@@ -76,9 +141,13 @@ class PyServeServer:
def _load_extensions(self) -> None:
for ext_config in self.config.extensions:
config = ext_config.config.copy()
if ext_config.type == "routing":
config.setdefault("default_proxy_timeout", self.config.server.proxy_timeout)
self.extension_manager.load_extension(
ext_config.type,
ext_config.config
config
)
def _create_app(self) -> None:
+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()
+724
View File
@@ -0,0 +1,724 @@
"""
Tests for reverse proxy functionality.
These tests start a backend test server and the main PyServe server,
then verify that requests are correctly proxied between them.
"""
import asyncio
import pytest
import httpx
import socket
import time
from contextlib import asynccontextmanager
from typing import AsyncGenerator, Dict, Any, Optional
from unittest.mock import patch
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
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]
# ============== Backend Test Application ==============
class BackendTestApp:
def __init__(self, port: int):
self.port = port
self.request_log: list[Dict[str, Any]] = []
self.app = self._create_app()
self._server_task = None
def _create_app(self) -> Starlette:
routes = [
Route("/", self._root, methods=["GET"]),
Route("/api/v{version}/users", self._users, methods=["GET", "POST"]),
Route("/api/v{version}/users/{user_id}", self._user_detail, methods=["GET", "PUT", "DELETE"]),
Route("/api/v{version}/data", self._data, methods=["GET", "POST"]),
Route("/echo", self._echo, methods=["GET", "POST", "PUT", "DELETE", "PATCH"]),
Route("/headers", self._headers, methods=["GET"]),
Route("/slow", self._slow, methods=["GET"]),
Route("/status/{code:int}", self._status, methods=["GET"]),
Route("/json", self._json, methods=["POST"]),
Route("/backend2/", self._backend2_root, methods=["GET"]),
Route("/{path:path}", self._catch_all, methods=["GET", "POST", "PUT", "DELETE", "PATCH"]),
]
return Starlette(routes=routes)
async def _root(self, request: Request) -> Response:
self._log_request(request)
return JSONResponse({"message": "Backend root", "port": self.port})
async def _users(self, request: Request) -> Response:
self._log_request(request)
version = request.path_params.get("version", "1")
if request.method == "POST":
body = await request.json()
return JSONResponse({"action": "create_user", "version": version, "data": body}, status_code=201)
return JSONResponse({"users": [{"id": 1, "name": "Test User"}], "version": version})
async def _user_detail(self, request: Request) -> Response:
self._log_request(request)
version = request.path_params.get("version", "1")
user_id = request.path_params.get("user_id", "0")
if request.method == "DELETE":
return JSONResponse({"action": "delete_user", "user_id": user_id, "version": version})
if request.method == "PUT":
body = await request.json()
return JSONResponse({"action": "update_user", "user_id": user_id, "version": version, "data": body})
return JSONResponse({"user": {"id": user_id, "name": f"User {user_id}"}, "version": version})
async def _data(self, request: Request) -> Response:
self._log_request(request)
version = request.path_params.get("version", "1")
return JSONResponse({"data": "test data", "version": version})
async def _echo(self, request: Request) -> Response:
self._log_request(request)
body = await request.body()
return Response(
content=body,
status_code=200,
media_type=request.headers.get("content-type", "text/plain")
)
async def _headers(self, request: Request) -> Response:
self._log_request(request)
headers = dict(request.headers)
return JSONResponse({
"received_headers": headers,
"client_ip": request.client.host if request.client else None,
})
async def _slow(self, request: Request) -> Response:
self._log_request(request)
delay = float(request.query_params.get("delay", "2"))
await asyncio.sleep(delay)
return JSONResponse({"message": "slow response", "delay": delay})
async def _status(self, request: Request) -> Response:
self._log_request(request)
code = request.path_params.get("code", 200)
return PlainTextResponse(f"Status: {code}", status_code=code)
async def _json(self, request: Request) -> Response:
self._log_request(request)
body = await request.json()
return JSONResponse({"received": body, "processed": True})
async def _backend2_root(self, request: Request) -> Response:
self._log_request(request)
return JSONResponse({"message": "Backend2 root", "port": self.port})
async def _catch_all(self, request: Request) -> Response:
"""Catch-all handler for debugging unmatched routes."""
self._log_request(request)
return JSONResponse({
"message": "Catch-all",
"path": str(request.url.path),
"method": request.method,
"port": self.port
})
def _log_request(self, request: Request) -> None:
self.request_log.append({
"method": request.method,
"path": str(request.url.path),
"query": str(request.url.query),
"headers": dict(request.headers),
})
async def start(self) -> None:
config = uvicorn.Config(
app=self.app,
host="127.0.0.1",
port=self.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): # 5 seconds max
try:
async with httpx.AsyncClient() as client:
await client.get(f"http://127.0.0.1:{self.port}/")
return
except httpx.ConnectError:
await asyncio.sleep(0.1)
raise RuntimeError(f"Backend server failed to start on port {self.port}")
async def stop(self) -> None:
if self._server_task:
self._server_task.cancel()
try:
await self._server_task
except asyncio.CancelledError:
pass
# ============== PyServe Test Server ==============
class PyServeTestServer:
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): # 5 seconds max
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 backend_port() -> int:
return get_free_port()
@pytest.fixture
def pyserve_port() -> int:
return get_free_port()
@pytest.fixture
def create_proxy_config():
def _create_config(pyserve_port: int, backend_port: int, extra_locations: Optional[Dict[str, Any]] = None) -> Config:
locations = {
# API proxy with version capture
"~^/api/v(?P<version>\\d+)/": {
"proxy_pass": f"http://127.0.0.1:{backend_port}",
"headers": [
"X-API-Version: {version}",
"X-Forwarded-For: $remote_addr"
]
},
# Echo endpoint proxy
"=/echo": {
"proxy_pass": f"http://127.0.0.1:{backend_port}/echo",
},
# Headers test proxy
"=/headers": {
"proxy_pass": f"http://127.0.0.1:{backend_port}/headers",
},
# Slow endpoint proxy with timeout
"~^/slow": {
"proxy_pass": f"http://127.0.0.1:{backend_port}/slow",
"timeout": 5.0,
},
# Status endpoint proxy
"~^/status/": {
"proxy_pass": f"http://127.0.0.1:{backend_port}",
},
# JSON endpoint proxy
"=/json": {
"proxy_pass": f"http://127.0.0.1:{backend_port}/json",
},
# Health check
"=/health": {
"return": "200 OK",
"content_type": "text/plain"
},
# Default fallback
"__default__": {
"return": "404 Not Found",
"content_type": "text/plain"
}
}
if extra_locations:
locations.update(extra_locations)
config = Config(
http=HttpConfig(static_dir="./static", templates_dir="./templates"),
server=ServerConfig(host="127.0.0.1", port=pyserve_port),
logging=LoggingConfig(level="ERROR", console_output=False),
extensions=[
ExtensionConfig(
type="routing",
config={"regex_locations": locations}
)
]
)
return config
return _create_config
@asynccontextmanager
async def running_servers(
pyserve_port: int,
backend_port: int,
config_factory,
extra_locations: Optional[Dict[str, Any]] = None
) -> AsyncGenerator[tuple[PyServeTestServer, BackendTestApp], None]:
backend = BackendTestApp(backend_port)
config = config_factory(pyserve_port, backend_port, extra_locations)
pyserve = PyServeTestServer(config)
await backend.start()
await pyserve.start()
try:
yield pyserve, backend
finally:
await pyserve.stop()
await backend.stop()
# ============== Tests ==============
@pytest.mark.asyncio
async def test_basic_proxy_get(backend_port, pyserve_port, create_proxy_config):
"""Test basic GET request proxying."""
async with running_servers(pyserve_port, backend_port, create_proxy_config):
async with httpx.AsyncClient() as client:
response = await client.get(f"http://127.0.0.1:{pyserve_port}/api/v1/users")
assert response.status_code == 200
data = response.json()
assert "users" in data
assert data["version"] == "1"
@pytest.mark.asyncio
async def test_proxy_api_versions(backend_port, pyserve_port, create_proxy_config):
"""Test that API version is correctly captured and passed."""
async with running_servers(pyserve_port, backend_port, create_proxy_config):
async with httpx.AsyncClient() as client:
# Test v1
response_v1 = await client.get(f"http://127.0.0.1:{pyserve_port}/api/v1/data")
assert response_v1.status_code == 200
assert response_v1.json()["version"] == "1"
# Test v2
response_v2 = await client.get(f"http://127.0.0.1:{pyserve_port}/api/v2/data")
assert response_v2.status_code == 200
assert response_v2.json()["version"] == "2"
@pytest.mark.asyncio
async def test_proxy_post_with_body(backend_port, pyserve_port, create_proxy_config):
"""Test POST request with JSON body."""
async with running_servers(pyserve_port, backend_port, create_proxy_config):
async with httpx.AsyncClient() as client:
payload = {"name": "New User", "email": "test@example.com"}
response = await client.post(
f"http://127.0.0.1:{pyserve_port}/api/v1/users",
json=payload
)
assert response.status_code == 201
data = response.json()
assert data["action"] == "create_user"
assert data["data"]["name"] == "New User"
@pytest.mark.asyncio
async def test_proxy_put_request(backend_port, pyserve_port, create_proxy_config):
"""Test PUT request proxying."""
async with running_servers(pyserve_port, backend_port, create_proxy_config):
async with httpx.AsyncClient() as client:
payload = {"name": "Updated User"}
response = await client.put(
f"http://127.0.0.1:{pyserve_port}/api/v1/users/123",
json=payload
)
assert response.status_code == 200
data = response.json()
assert data["action"] == "update_user"
assert data["user_id"] == "123"
@pytest.mark.asyncio
async def test_proxy_delete_request(backend_port, pyserve_port, create_proxy_config):
"""Test DELETE request proxying."""
async with running_servers(pyserve_port, backend_port, create_proxy_config):
async with httpx.AsyncClient() as client:
response = await client.delete(
f"http://127.0.0.1:{pyserve_port}/api/v1/users/456"
)
assert response.status_code == 200
data = response.json()
assert data["action"] == "delete_user"
assert data["user_id"] == "456"
@pytest.mark.asyncio
async def test_proxy_headers_forwarding(backend_port, pyserve_port, create_proxy_config):
"""Test that headers are correctly forwarded."""
async with running_servers(pyserve_port, backend_port, create_proxy_config):
async with httpx.AsyncClient() as client:
response = await client.get(
f"http://127.0.0.1:{pyserve_port}/headers",
headers={"X-Custom-Header": "test-value"}
)
assert response.status_code == 200
data = response.json()
# Check that custom header was forwarded
assert data["received_headers"].get("x-custom-header") == "test-value"
# Check that X-Forwarded headers were added
assert "x-forwarded-for" in data["received_headers"]
assert "x-forwarded-proto" in data["received_headers"]
@pytest.mark.asyncio
async def test_proxy_echo_endpoint(backend_port, pyserve_port, create_proxy_config):
"""Test echo endpoint that returns request body."""
async with running_servers(pyserve_port, backend_port, create_proxy_config):
async with httpx.AsyncClient() as client:
test_data = "Hello, Proxy!"
response = await client.post(
f"http://127.0.0.1:{pyserve_port}/echo",
content=test_data,
headers={"Content-Type": "text/plain"}
)
assert response.status_code == 200
assert response.text == test_data
@pytest.mark.asyncio
async def test_proxy_json_endpoint(backend_port, pyserve_port, create_proxy_config):
"""Test JSON processing through proxy."""
async with running_servers(pyserve_port, backend_port, create_proxy_config):
async with httpx.AsyncClient() as client:
payload = {"key": "value", "numbers": [1, 2, 3]}
response = await client.post(
f"http://127.0.0.1:{pyserve_port}/json",
json=payload
)
assert response.status_code == 200
data = response.json()
assert data["processed"] is True
assert data["received"]["key"] == "value"
@pytest.mark.asyncio
async def test_proxy_status_codes(backend_port, pyserve_port, create_proxy_config):
"""Test that various status codes are correctly proxied."""
async with running_servers(pyserve_port, backend_port, create_proxy_config):
async with httpx.AsyncClient() as client:
for status in [200, 201, 400, 404, 500]:
response = await client.get(
f"http://127.0.0.1:{pyserve_port}/status/{status}"
)
assert response.status_code == status
@pytest.mark.asyncio
async def test_proxy_query_params(backend_port, pyserve_port, create_proxy_config):
"""Test that query parameters are passed through."""
async with running_servers(pyserve_port, backend_port, create_proxy_config):
async with httpx.AsyncClient() as client:
response = await client.get(
f"http://127.0.0.1:{pyserve_port}/slow?delay=0.1"
)
assert response.status_code == 200
data = response.json()
assert data["delay"] == 0.1
@pytest.mark.asyncio
async def test_proxy_backend_unavailable(pyserve_port, create_proxy_config):
"""Test handling when backend is unavailable (502 Bad Gateway)."""
# Use a port where nothing is listening
unavailable_port = get_free_port()
config = create_proxy_config(pyserve_port, unavailable_port)
pyserve = PyServeTestServer(config)
await pyserve.start()
try:
async with httpx.AsyncClient() as client:
response = await client.get(
f"http://127.0.0.1:{pyserve_port}/api/v1/users"
)
assert response.status_code == 502
assert "Bad Gateway" in response.text
finally:
await pyserve.stop()
@pytest.mark.asyncio
async def test_proxy_timeout(backend_port, pyserve_port, create_proxy_config):
"""Test proxy timeout handling (504 Gateway Timeout)."""
# Create config with very short timeout
extra_locations = {
"=/timeout-test": {
"proxy_pass": f"http://127.0.0.1:{backend_port}/slow?delay=5",
"timeout": 0.5, # Very short timeout
}
}
async with running_servers(pyserve_port, backend_port, create_proxy_config, extra_locations):
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
f"http://127.0.0.1:{pyserve_port}/timeout-test"
)
assert response.status_code == 504
assert "Gateway Timeout" in response.text
@pytest.mark.asyncio
async def test_proxy_health_check_not_proxied(backend_port, pyserve_port, create_proxy_config):
"""Test that health check endpoint is handled locally, not proxied."""
async with running_servers(pyserve_port, backend_port, create_proxy_config):
async with httpx.AsyncClient() as client:
response = await client.get(
f"http://127.0.0.1:{pyserve_port}/health"
)
assert response.status_code == 200
assert response.text == "OK"
@pytest.mark.asyncio
async def test_proxy_default_fallback(backend_port, pyserve_port, create_proxy_config):
"""Test default fallback for unmatched routes."""
async with running_servers(pyserve_port, backend_port, create_proxy_config):
async with httpx.AsyncClient() as client:
response = await client.get(
f"http://127.0.0.1:{pyserve_port}/nonexistent/path"
)
assert response.status_code == 404
assert "Not Found" in response.text
@pytest.mark.asyncio
async def test_complex_config_multiple_proxies(backend_port, pyserve_port, create_proxy_config):
"""Test complex configuration with multiple proxy rules."""
# Create a second backend port for testing multiple backends
backend2_port = get_free_port()
backend2 = BackendTestApp(backend2_port)
extra_locations = {
"~^/backend2/": {
"proxy_pass": f"http://127.0.0.1:{backend2_port}",
}
}
async with running_servers(pyserve_port, backend_port, create_proxy_config, extra_locations):
await backend2.start()
try:
async with httpx.AsyncClient() as client:
# Request to first backend
response1 = await client.get(
f"http://127.0.0.1:{pyserve_port}/api/v1/users"
)
assert response1.status_code == 200
assert response1.json()["users"][0]["id"] == 1
# Request to second backend
response2 = await client.get(
f"http://127.0.0.1:{pyserve_port}/backend2/"
)
assert response2.status_code == 200
data2 = response2.json()
assert data2["port"] == backend2_port
finally:
await backend2.stop()
@pytest.mark.asyncio
async def test_proxy_large_request_body(backend_port, pyserve_port, create_proxy_config):
"""Test proxying large request bodies."""
async with running_servers(pyserve_port, backend_port, create_proxy_config):
async with httpx.AsyncClient() as client:
# Create a large payload
large_data = "x" * 100000 # 100KB of data
response = await client.post(
f"http://127.0.0.1:{pyserve_port}/echo",
content=large_data,
headers={"Content-Type": "text/plain"}
)
assert response.status_code == 200
assert response.text == large_data
@pytest.mark.asyncio
async def test_proxy_content_type_preservation(backend_port, pyserve_port, create_proxy_config):
"""Test that content-type is correctly preserved."""
async with running_servers(pyserve_port, backend_port, create_proxy_config):
async with httpx.AsyncClient() as client:
response = await client.get(
f"http://127.0.0.1:{pyserve_port}/api/v1/users"
)
assert response.status_code == 200
assert "application/json" in response.headers.get("content-type", "")
@pytest.mark.asyncio
async def test_proxy_concurrent_requests(backend_port, pyserve_port, create_proxy_config):
"""Test handling multiple concurrent proxy requests."""
async with running_servers(pyserve_port, backend_port, create_proxy_config):
async with httpx.AsyncClient() as client:
# Send multiple concurrent requests
tasks = [
client.get(f"http://127.0.0.1:{pyserve_port}/api/v{i % 3 + 1}/users")
for i in range(10)
]
responses = await asyncio.gather(*tasks)
# All should succeed
for response in responses:
assert response.status_code == 200
assert "users" in response.json()
@pytest.mark.asyncio
async def test_server_default_proxy_timeout(backend_port, pyserve_port):
"""Test that server-level proxy_timeout is used as default."""
locations = {
"~^/slow": {
"proxy_pass": f"http://127.0.0.1:{backend_port}/slow",
# No timeout specified - should use server default
},
"=/health": {
"return": "200 OK",
"content_type": "text/plain"
},
}
config = Config(
http=HttpConfig(static_dir="./static", templates_dir="./templates"),
server=ServerConfig(host="127.0.0.1", port=pyserve_port, proxy_timeout=0.5), # Very short timeout
logging=LoggingConfig(level="ERROR", console_output=False),
extensions=[
ExtensionConfig(
type="routing",
config={"regex_locations": locations}
)
]
)
backend = BackendTestApp(backend_port)
pyserve = PyServeTestServer(config)
await backend.start()
await pyserve.start()
try:
async with httpx.AsyncClient(timeout=10.0) as client:
# This should timeout because server default is 0.5s and slow endpoint takes 2s
response = await client.get(f"http://127.0.0.1:{pyserve_port}/slow?delay=2")
assert response.status_code == 504
assert "Gateway Timeout" in response.text
finally:
await pyserve.stop()
await backend.stop()
@pytest.mark.asyncio
async def test_route_timeout_overrides_server_default(backend_port, pyserve_port):
"""Test that route-level timeout overrides server default proxy_timeout."""
locations = {
"~^/slow": {
"proxy_pass": f"http://127.0.0.1:{backend_port}/slow",
"timeout": 5.0, # Route-level timeout overrides server default
},
"=/health": {
"return": "200 OK",
"content_type": "text/plain"
},
}
config = Config(
http=HttpConfig(static_dir="./static", templates_dir="./templates"),
server=ServerConfig(host="127.0.0.1", port=pyserve_port, proxy_timeout=0.1), # Very short server default
logging=LoggingConfig(level="ERROR", console_output=False),
extensions=[
ExtensionConfig(
type="routing",
config={"regex_locations": locations}
)
]
)
backend = BackendTestApp(backend_port)
pyserve = PyServeTestServer(config)
await backend.start()
await pyserve.start()
try:
async with httpx.AsyncClient(timeout=10.0) as client:
# This should succeed because route timeout (5s) > delay (0.5s), even though server default is 0.1s
response = await client.get(f"http://127.0.0.1:{pyserve_port}/slow?delay=0.5")
assert response.status_code == 200
data = response.json()
assert data["delay"] == 0.5
finally:
await pyserve.stop()
await backend.stop()
@pytest.mark.asyncio
async def test_proxy_timeout_config_parsing():
"""Test that proxy_timeout is correctly parsed from config."""
from pyserve.config import Config
# Test default value
config = Config()
assert config.server.proxy_timeout == 30.0
# Test custom value from dict
config_with_timeout = Config._from_dict({
"server": {
"host": "127.0.0.1",
"port": 8080,
"proxy_timeout": 60.0
}
})
assert config_with_timeout.server.proxy_timeout == 60.0
File diff suppressed because it is too large Load Diff