forked from Shifty/pyserveX
asgi/wsgi mounting implemented
This commit is contained in:
parent
831eee5d01
commit
0d0d1aec80
1
examples/apps/__init__.py
Normal file
1
examples/apps/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Example applications package."""
|
||||||
194
examples/apps/custom_asgi.py
Normal file
194
examples/apps/custom_asgi.py
Normal 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
examples/apps/fastapi_app.py
Normal file
118
examples/apps/fastapi_app.py
Normal 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
examples/apps/flask_app.py
Normal file
112
examples/apps/flask_app.py
Normal 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
examples/apps/starlette_app.py
Normal file
112
examples/apps/starlette_app.py
Normal 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
examples/config.example.asgi.yaml
Normal file
103
examples/config.example.asgi.yaml
Normal 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"
|
||||||
501
poetry.lock
generated
501
poetry.lock
generated
@ -1,5 +1,44 @@
|
|||||||
# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand.
|
# 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]]
|
[[package]]
|
||||||
name = "anyio"
|
name = "anyio"
|
||||||
version = "4.10.0"
|
version = "4.10.0"
|
||||||
@ -20,6 +59,22 @@ typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""}
|
|||||||
[package.extras]
|
[package.extras]
|
||||||
trio = ["trio (>=0.26.1)"]
|
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]]
|
[[package]]
|
||||||
name = "black"
|
name = "black"
|
||||||
version = "25.1.0"
|
version = "25.1.0"
|
||||||
@ -65,6 +120,19 @@ d = ["aiohttp (>=3.10)"]
|
|||||||
jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
|
jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
|
||||||
uvloop = ["uvloop (>=0.15.2)"]
|
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]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2025.11.12"
|
version = "2025.11.12"
|
||||||
@ -206,6 +274,52 @@ files = [
|
|||||||
[package.extras]
|
[package.extras]
|
||||||
toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
|
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]]
|
[[package]]
|
||||||
name = "flake8"
|
name = "flake8"
|
||||||
version = "7.3.0"
|
version = "7.3.0"
|
||||||
@ -223,6 +337,31 @@ mccabe = ">=0.7.0,<0.8.0"
|
|||||||
pycodestyle = ">=2.14.0,<2.15.0"
|
pycodestyle = ">=2.14.0,<2.15.0"
|
||||||
pyflakes = ">=3.4.0,<3.5.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]]
|
[[package]]
|
||||||
name = "h11"
|
name = "h11"
|
||||||
version = "0.16.0"
|
version = "0.16.0"
|
||||||
@ -382,6 +521,138 @@ files = [
|
|||||||
colors = ["colorama"]
|
colors = ["colorama"]
|
||||||
plugins = ["setuptools"]
|
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]]
|
[[package]]
|
||||||
name = "mccabe"
|
name = "mccabe"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@ -535,6 +806,164 @@ files = [
|
|||||||
{file = "pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783"},
|
{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]]
|
[[package]]
|
||||||
name = "pyflakes"
|
name = "pyflakes"
|
||||||
version = "3.4.0"
|
version = "3.4.0"
|
||||||
@ -714,6 +1143,23 @@ files = [
|
|||||||
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
|
{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]]
|
[[package]]
|
||||||
name = "starlette"
|
name = "starlette"
|
||||||
version = "0.47.3"
|
version = "0.47.3"
|
||||||
@ -769,6 +1215,35 @@ files = [
|
|||||||
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
|
{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]]
|
[[package]]
|
||||||
name = "uvicorn"
|
name = "uvicorn"
|
||||||
version = "0.35.0"
|
version = "0.35.0"
|
||||||
@ -1046,10 +1521,34 @@ files = [
|
|||||||
{file = "websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee"},
|
{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]
|
[extras]
|
||||||
|
all-frameworks = ["a2wsgi", "django", "fastapi", "flask"]
|
||||||
dev = ["black", "flake8", "isort", "mypy", "pytest", "pytest-asyncio", "pytest-cov"]
|
dev = ["black", "flake8", "isort", "mypy", "pytest", "pytest-asyncio", "pytest-cov"]
|
||||||
|
django = ["django"]
|
||||||
|
fastapi = ["fastapi"]
|
||||||
|
flask = ["a2wsgi", "flask"]
|
||||||
|
wsgi = ["a2wsgi"]
|
||||||
|
|
||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.1"
|
lock-version = "2.1"
|
||||||
python-versions = ">=3.12"
|
python-versions = ">=3.12"
|
||||||
content-hash = "e68108657ddfdc07ac0c4f5dbd9c5d2950e78b8b0053e4487ebf2327bbf4e020"
|
content-hash = "a08668c23222843b27b3977933c93b261328f43e90f22f35212c6c6f6030e3dc"
|
||||||
|
|||||||
@ -30,6 +30,25 @@ dev = [
|
|||||||
"mypy",
|
"mypy",
|
||||||
"flake8"
|
"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]
|
[build-system]
|
||||||
|
|||||||
@ -2,10 +2,31 @@
|
|||||||
PyServe - HTTP web server written on Python
|
PyServe - HTTP web server written on Python
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.7.0"
|
__version__ = "0.8.0"
|
||||||
__author__ = "Ilya Glazunov"
|
__author__ = "Ilya Glazunov"
|
||||||
|
|
||||||
from .server import PyServeServer
|
from .server import PyServeServer
|
||||||
from .config import Config
|
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
pyserve/asgi_mount.py
Normal file
311
pyserve/asgi_mount.py
Normal 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):
|
||||||
|
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 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):
|
||||||
|
self._mounts: list[MountedApp] = []
|
||||||
|
self._loader = ASGIAppLoader()
|
||||||
|
|
||||||
|
def mount(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
app: Optional[ASGIApp] = None,
|
||||||
|
app_path: Optional[str] = None,
|
||||||
|
app_type: str = "asgi",
|
||||||
|
module_path: Optional[str] = None,
|
||||||
|
factory: bool = False,
|
||||||
|
factory_args: Optional[Dict[str, Any]] = None,
|
||||||
|
name: str = "",
|
||||||
|
strip_path: bool = True,
|
||||||
|
) -> bool:
|
||||||
|
if app is None and app_path is None:
|
||||||
|
logger.error("Either 'app' or 'app_path' must be provided")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if app is None:
|
||||||
|
app = self._loader.load_app(
|
||||||
|
app_path=app_path, # type: ignore
|
||||||
|
app_type=app_type,
|
||||||
|
module_path=module_path,
|
||||||
|
factory=factory,
|
||||||
|
factory_args=factory_args,
|
||||||
|
)
|
||||||
|
if app is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
mounted = MountedApp(
|
||||||
|
path=path,
|
||||||
|
app=app,
|
||||||
|
name=name or app_path or "unnamed",
|
||||||
|
strip_path=strip_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._mounts.append(mounted)
|
||||||
|
self._mounts.sort(key=lambda m: len(m.path), reverse=True)
|
||||||
|
|
||||||
|
logger.info(f"Mounted application '{mounted.name}' at path '{path}'")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def unmount(self, path: str) -> bool:
|
||||||
|
for i, mount in enumerate(self._mounts):
|
||||||
|
if mount.path == path.rstrip("/"):
|
||||||
|
del self._mounts[i]
|
||||||
|
logger.info(f"Unmounted application at path '{path}'")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_mount(self, request_path: str) -> Optional[MountedApp]:
|
||||||
|
for mount in self._mounts:
|
||||||
|
if mount.matches(request_path):
|
||||||
|
return mount
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def handle_request(
|
||||||
|
self,
|
||||||
|
scope: Scope,
|
||||||
|
receive: Receive,
|
||||||
|
send: Send,
|
||||||
|
) -> bool:
|
||||||
|
if scope["type"] != "http":
|
||||||
|
return False
|
||||||
|
|
||||||
|
path = scope.get("path", "/")
|
||||||
|
mount = self.get_mount(path)
|
||||||
|
|
||||||
|
if mount is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
modified_scope = dict(scope)
|
||||||
|
if mount.strip_path:
|
||||||
|
modified_scope["path"] = mount.get_modified_path(path)
|
||||||
|
modified_scope["root_path"] = scope.get("root_path", "") + mount.path
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Routing request to mounted app '{mount.name}': "
|
||||||
|
f"{path} -> {modified_scope['path']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await mount.app(modified_scope, receive, send)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in mounted app '{mount.name}': {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mounts(self) -> list[MountedApp]:
|
||||||
|
return self._mounts.copy()
|
||||||
|
|
||||||
|
def list_mounts(self) -> list[Dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"path": mount.path,
|
||||||
|
"name": mount.name,
|
||||||
|
"strip_path": mount.strip_path,
|
||||||
|
}
|
||||||
|
for mount in self._mounts
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def create_fastapi_app(
|
||||||
|
app_path: str,
|
||||||
|
module_path: Optional[str] = None,
|
||||||
|
factory: bool = False,
|
||||||
|
factory_args: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> Optional[ASGIApp]:
|
||||||
|
loader = ASGIAppLoader()
|
||||||
|
return loader.load_app(
|
||||||
|
app_path=app_path,
|
||||||
|
app_type="asgi",
|
||||||
|
module_path=module_path,
|
||||||
|
factory=factory,
|
||||||
|
factory_args=factory_args,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_flask_app(
|
||||||
|
app_path: str,
|
||||||
|
module_path: Optional[str] = None,
|
||||||
|
factory: bool = False,
|
||||||
|
factory_args: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> Optional[ASGIApp]:
|
||||||
|
loader = ASGIAppLoader()
|
||||||
|
return loader.load_app(
|
||||||
|
app_path=app_path,
|
||||||
|
app_type="wsgi",
|
||||||
|
module_path=module_path,
|
||||||
|
factory=factory,
|
||||||
|
factory_args=factory_args,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_django_app(
|
||||||
|
settings_module: str,
|
||||||
|
module_path: Optional[str] = None,
|
||||||
|
) -> Optional[ASGIApp]:
|
||||||
|
import os
|
||||||
|
|
||||||
|
if module_path:
|
||||||
|
module_dir = Path(module_path).resolve()
|
||||||
|
if str(module_dir) not in sys.path:
|
||||||
|
sys.path.insert(0, str(module_dir))
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", settings_module)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from django.core.asgi import get_asgi_application
|
||||||
|
return 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,
|
||||||
|
)
|
||||||
@ -138,6 +138,74 @@ class MonitoringExtension(Extension):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ASGIExtension(Extension):
|
||||||
|
def __init__(self, config: Dict[str, Any]):
|
||||||
|
super().__init__(config)
|
||||||
|
from .asgi_mount import ASGIMountManager, create_django_app
|
||||||
|
|
||||||
|
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:
|
class ExtensionManager:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.extensions: List[Extension] = []
|
self.extensions: List[Extension] = []
|
||||||
@ -145,7 +213,8 @@ class ExtensionManager:
|
|||||||
"routing": RoutingExtension,
|
"routing": RoutingExtension,
|
||||||
"security": SecurityExtension,
|
"security": SecurityExtension,
|
||||||
"caching": CachingExtension,
|
"caching": CachingExtension,
|
||||||
"monitoring": MonitoringExtension
|
"monitoring": MonitoringExtension,
|
||||||
|
"asgi": ASGIExtension,
|
||||||
}
|
}
|
||||||
|
|
||||||
def register_extension_type(self, name: str, extension_class: Type[Extension]) -> None:
|
def register_extension_type(self, name: str, extension_class: Type[Extension]) -> None:
|
||||||
|
|||||||
@ -10,7 +10,7 @@ from pathlib import Path
|
|||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .extensions import ExtensionManager
|
from .extensions import ExtensionManager, ASGIExtension
|
||||||
from .logging_utils import get_logger
|
from .logging_utils import get_logger
|
||||||
from . import __version__
|
from . import __version__
|
||||||
|
|
||||||
@ -30,6 +30,11 @@ class PyServeMiddleware:
|
|||||||
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
request = Request(scope, receive)
|
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)
|
response = await self.extension_manager.process_request(request)
|
||||||
|
|
||||||
if response is None:
|
if response is None:
|
||||||
@ -39,6 +44,68 @@ class PyServeMiddleware:
|
|||||||
response = await self.extension_manager.process_response(request, response)
|
response = await self.extension_manager.process_response(request, response)
|
||||||
response.headers["Server"] = f"pyserve/{__version__}"
|
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"
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
method = request.method
|
method = request.method
|
||||||
path = str(request.url.path)
|
path = str(request.url.path)
|
||||||
@ -58,8 +125,6 @@ class PyServeMiddleware:
|
|||||||
user_agent=request.headers.get("user-agent", "")
|
user_agent=request.headers.get("user-agent", "")
|
||||||
)
|
)
|
||||||
|
|
||||||
await response(scope, receive, send)
|
|
||||||
|
|
||||||
|
|
||||||
class PyServeServer:
|
class PyServeServer:
|
||||||
def __init__(self, config: Config):
|
def __init__(self, config: Config):
|
||||||
|
|||||||
897
tests/test_asgi_mount.py
Normal file
897
tests/test_asgi_mount.py
Normal 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()
|
||||||
Loading…
x
Reference in New Issue
Block a user