From 0d0d1aec80dc1a1a375af1e853ce88613e91b393 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=20=D0=93=D0=BB=D0=B0=D0=B7=D1=83?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2?= Date: Wed, 3 Dec 2025 12:10:28 +0300 Subject: [PATCH] asgi/wsgi mounting implemented --- examples/apps/__init__.py | 1 + examples/apps/custom_asgi.py | 194 +++++++ examples/apps/fastapi_app.py | 118 ++++ examples/apps/flask_app.py | 112 ++++ examples/apps/starlette_app.py | 112 ++++ examples/config.example.asgi.yaml | 103 ++++ poetry.lock | 501 ++++++++++++++++- pyproject.toml | 19 + pyserve/__init__.py | 25 +- pyserve/asgi_mount.py | 311 +++++++++++ pyserve/extensions.py | 71 ++- pyserve/server.py | 71 ++- tests/test_asgi_mount.py | 897 ++++++++++++++++++++++++++++++ 13 files changed, 2528 insertions(+), 7 deletions(-) create mode 100644 examples/apps/__init__.py create mode 100644 examples/apps/custom_asgi.py create mode 100644 examples/apps/fastapi_app.py create mode 100644 examples/apps/flask_app.py create mode 100644 examples/apps/starlette_app.py create mode 100644 examples/config.example.asgi.yaml create mode 100644 pyserve/asgi_mount.py create mode 100644 tests/test_asgi_mount.py diff --git a/examples/apps/__init__.py b/examples/apps/__init__.py new file mode 100644 index 0000000..da9317d --- /dev/null +++ b/examples/apps/__init__.py @@ -0,0 +1 @@ +"""Example applications package.""" diff --git a/examples/apps/custom_asgi.py b/examples/apps/custom_asgi.py new file mode 100644 index 0000000..7381220 --- /dev/null +++ b/examples/apps/custom_asgi.py @@ -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) diff --git a/examples/apps/fastapi_app.py b/examples/apps/fastapi_app.py new file mode 100644 index 0000000..af43d41 --- /dev/null +++ b/examples/apps/fastapi_app.py @@ -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) diff --git a/examples/apps/flask_app.py b/examples/apps/flask_app.py new file mode 100644 index 0000000..37268df --- /dev/null +++ b/examples/apps/flask_app.py @@ -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/") +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/", 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/", 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/") + def echo(message: str): + return jsonify({"echo": message}) + + return application + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=8002, debug=True) diff --git a/examples/apps/starlette_app.py b/examples/apps/starlette_app.py new file mode 100644 index 0000000..a10a870 --- /dev/null +++ b/examples/apps/starlette_app.py @@ -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) diff --git a/examples/config.example.asgi.yaml b/examples/config.example.asgi.yaml new file mode 100644 index 0000000..084b959 --- /dev/null +++ b/examples/config.example.asgi.yaml @@ -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" diff --git a/poetry.lock b/poetry.lock index 37ae383..196dc3e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,44 @@ # This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. +[[package]] +name = "a2wsgi" +version = "1.10.10" +description = "Convert WSGI app to ASGI app or ASGI app to WSGI app." +optional = true +python-versions = ">=3.8.0" +groups = ["main"] +markers = "extra == \"wsgi\" or extra == \"flask\" or extra == \"all-frameworks\"" +files = [ + {file = "a2wsgi-1.10.10-py3-none-any.whl", hash = "sha256:d2b21379479718539dc15fce53b876251a0efe7615352dfe49f6ad1bc507848d"}, + {file = "a2wsgi-1.10.10.tar.gz", hash = "sha256:a5bcffb52081ba39df0d5e9a884fc6f819d92e3a42389343ba77cbf809fe1f45"}, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +description = "Document parameters, class attributes, return types, and variables inline, with Annotated." +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"fastapi\" or extra == \"all-frameworks\"" +files = [ + {file = "annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320"}, + {file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"}, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"fastapi\" or extra == \"all-frameworks\"" +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + [[package]] name = "anyio" version = "4.10.0" @@ -20,6 +59,22 @@ typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] trio = ["trio (>=0.26.1)"] +[[package]] +name = "asgiref" +version = "3.11.0" +description = "ASGI specs, helper code, and adapters" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"django\" or extra == \"all-frameworks\"" +files = [ + {file = "asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d"}, + {file = "asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4"}, +] + +[package.extras] +tests = ["mypy (>=1.14.0)", "pytest", "pytest-asyncio"] + [[package]] name = "black" version = "25.1.0" @@ -65,6 +120,19 @@ d = ["aiohttp (>=3.10)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] +[[package]] +name = "blinker" +version = "1.9.0" +description = "Fast, simple object-to-object and broadcast signaling" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"flask\" or extra == \"all-frameworks\"" +files = [ + {file = "blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc"}, + {file = "blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf"}, +] + [[package]] name = "certifi" version = "2025.11.12" @@ -206,6 +274,52 @@ files = [ [package.extras] toml = ["tomli ; python_full_version <= \"3.11.0a6\""] +[[package]] +name = "django" +version = "5.2.9" +description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"django\" or extra == \"all-frameworks\"" +files = [ + {file = "django-5.2.9-py3-none-any.whl", hash = "sha256:3a4ea88a70370557ab1930b332fd2887a9f48654261cdffda663fef5976bb00a"}, + {file = "django-5.2.9.tar.gz", hash = "sha256:16b5ccfc5e8c27e6c0561af551d2ea32852d7352c67d452ae3e76b4f6b2ca495"}, +] + +[package.dependencies] +asgiref = ">=3.8.1" +sqlparse = ">=0.3.1" +tzdata = {version = "*", markers = "sys_platform == \"win32\""} + +[package.extras] +argon2 = ["argon2-cffi (>=19.1.0)"] +bcrypt = ["bcrypt"] + +[[package]] +name = "fastapi" +version = "0.123.5" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"fastapi\" or extra == \"all-frameworks\"" +files = [ + {file = "fastapi-0.123.5-py3-none-any.whl", hash = "sha256:a9c708e47c0fa424139cddb8601d0f92d3111b77843c22e9c8d0164d65fe3c97"}, + {file = "fastapi-0.123.5.tar.gz", hash = "sha256:54bbb660ca231d3985474498b51c621ddcf8888d9a4c1ecb10aa40ec217e4965"}, +] + +[package.dependencies] +annotated-doc = ">=0.0.2" +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" +starlette = ">=0.40.0,<0.51.0" +typing-extensions = ">=4.8.0" + +[package.extras] +all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] +standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] + [[package]] name = "flake8" version = "7.3.0" @@ -223,6 +337,31 @@ mccabe = ">=0.7.0,<0.8.0" pycodestyle = ">=2.14.0,<2.15.0" pyflakes = ">=3.4.0,<3.5.0" +[[package]] +name = "flask" +version = "3.1.2" +description = "A simple framework for building complex web applications." +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"flask\" or extra == \"all-frameworks\"" +files = [ + {file = "flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c"}, + {file = "flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87"}, +] + +[package.dependencies] +blinker = ">=1.9.0" +click = ">=8.1.3" +itsdangerous = ">=2.2.0" +jinja2 = ">=3.1.2" +markupsafe = ">=2.1.1" +werkzeug = ">=3.1.0" + +[package.extras] +async = ["asgiref (>=3.2)"] +dotenv = ["python-dotenv"] + [[package]] name = "h11" version = "0.16.0" @@ -382,6 +521,138 @@ files = [ colors = ["colorama"] plugins = ["setuptools"] +[[package]] +name = "itsdangerous" +version = "2.2.0" +description = "Safely pass data to untrusted environments and back." +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"flask\" or extra == \"all-frameworks\"" +files = [ + {file = "itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"}, + {file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"}, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +description = "A very fast and expressive template engine." +optional = true +python-versions = ">=3.7" +groups = ["main"] +markers = "extra == \"flask\" or extra == \"all-frameworks\"" +files = [ + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "markupsafe" +version = "3.0.3" +description = "Safely add untrusted strings to HTML/XML markup." +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"flask\" or extra == \"all-frameworks\"" +files = [ + {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, + {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, + {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, + {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, + {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, + {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, + {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, + {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, + {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, +] + [[package]] name = "mccabe" version = "0.7.0" @@ -535,6 +806,164 @@ files = [ {file = "pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783"}, ] +[[package]] +name = "pydantic" +version = "2.12.5" +description = "Data validation using Python type hints" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"fastapi\" or extra == \"all-frameworks\"" +files = [ + {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, + {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.41.5" +typing-extensions = ">=4.14.1" +typing-inspection = ">=0.4.2" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +description = "Core functionality for Pydantic validation and serialization" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"fastapi\" or extra == \"all-frameworks\"" +files = [ + {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, + {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"}, + {file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"}, + {file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"}, + {file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"}, + {file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"}, + {file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"}, + {file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"}, + {file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"}, + {file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"}, + {file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"}, + {file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"}, + {file = "pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf"}, + {file = "pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3"}, + {file = "pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460"}, + {file = "pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"}, + {file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"}, +] + +[package.dependencies] +typing-extensions = ">=4.14.1" + [[package]] name = "pyflakes" version = "3.4.0" @@ -714,6 +1143,23 @@ files = [ {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, ] +[[package]] +name = "sqlparse" +version = "0.5.4" +description = "A non-validating SQL parser." +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"django\" or extra == \"all-frameworks\"" +files = [ + {file = "sqlparse-0.5.4-py3-none-any.whl", hash = "sha256:99a9f0314977b76d776a0fcb8554de91b9bb8a18560631d6bc48721d07023dcb"}, + {file = "sqlparse-0.5.4.tar.gz", hash = "sha256:4396a7d3cf1cd679c1be976cf3dc6e0a51d0111e87787e7a8d780e7d5a998f9e"}, +] + +[package.extras] +dev = ["build"] +doc = ["sphinx"] + [[package]] name = "starlette" version = "0.47.3" @@ -769,6 +1215,35 @@ files = [ {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +description = "Runtime typing introspection tools" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"fastapi\" or extra == \"all-frameworks\"" +files = [ + {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, + {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" + +[[package]] +name = "tzdata" +version = "2025.2" +description = "Provider of IANA time zone data" +optional = true +python-versions = ">=2" +groups = ["main"] +markers = "(extra == \"django\" or extra == \"all-frameworks\") and sys_platform == \"win32\"" +files = [ + {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, + {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, +] + [[package]] name = "uvicorn" version = "0.35.0" @@ -1046,10 +1521,34 @@ files = [ {file = "websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee"}, ] +[[package]] +name = "werkzeug" +version = "3.1.4" +description = "The comprehensive WSGI web application library." +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"flask\" or extra == \"all-frameworks\"" +files = [ + {file = "werkzeug-3.1.4-py3-none-any.whl", hash = "sha256:2ad50fb9ed09cc3af22c54698351027ace879a0b60a3b5edf5730b2f7d876905"}, + {file = "werkzeug-3.1.4.tar.gz", hash = "sha256:cd3cd98b1b92dc3b7b3995038826c68097dcb16f9baa63abe35f20eafeb9fe5e"}, +] + +[package.dependencies] +markupsafe = ">=2.1.1" + +[package.extras] +watchdog = ["watchdog (>=2.3)"] + [extras] +all-frameworks = ["a2wsgi", "django", "fastapi", "flask"] dev = ["black", "flake8", "isort", "mypy", "pytest", "pytest-asyncio", "pytest-cov"] +django = ["django"] +fastapi = ["fastapi"] +flask = ["a2wsgi", "flask"] +wsgi = ["a2wsgi"] [metadata] lock-version = "2.1" python-versions = ">=3.12" -content-hash = "e68108657ddfdc07ac0c4f5dbd9c5d2950e78b8b0053e4487ebf2327bbf4e020" +content-hash = "a08668c23222843b27b3977933c93b261328f43e90f22f35212c6c6f6030e3dc" diff --git a/pyproject.toml b/pyproject.toml index 5b99005..1cdcc89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,25 @@ dev = [ "mypy", "flake8" ] +wsgi = [ + "a2wsgi>=1.10.0", +] +flask = [ + "flask>=3.0.0", + "a2wsgi>=1.10.0", +] +fastapi = [ + "fastapi>=0.115.0", +] +django = [ + "django>=5.0", +] +all-frameworks = [ + "fastapi>=0.115.0", + "flask>=3.0.0", + "django>=5.0", + "a2wsgi>=1.10.0", +] [build-system] diff --git a/pyserve/__init__.py b/pyserve/__init__.py index e9a87e2..6502672 100644 --- a/pyserve/__init__.py +++ b/pyserve/__init__.py @@ -2,10 +2,31 @@ PyServe - HTTP web server written on Python """ -__version__ = "0.7.0" +__version__ = "0.8.0" __author__ = "Ilya Glazunov" from .server import PyServeServer from .config import Config +from .asgi_mount import ( + ASGIAppLoader, + ASGIMountManager, + MountedApp, + create_fastapi_app, + create_flask_app, + create_django_app, + create_starlette_app, +) -__all__ = ["PyServeServer", "Config", "__version__"] +__all__ = [ + "PyServeServer", + "Config", + "__version__", + # ASGI mounting + "ASGIAppLoader", + "ASGIMountManager", + "MountedApp", + "create_fastapi_app", + "create_flask_app", + "create_django_app", + "create_starlette_app", +] diff --git a/pyserve/asgi_mount.py b/pyserve/asgi_mount.py new file mode 100644 index 0000000..84b6ac5 --- /dev/null +++ b/pyserve/asgi_mount.py @@ -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, + ) diff --git a/pyserve/extensions.py b/pyserve/extensions.py index b5d379b..7e666a1 100644 --- a/pyserve/extensions.py +++ b/pyserve/extensions.py @@ -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: def __init__(self) -> None: self.extensions: List[Extension] = [] @@ -145,7 +213,8 @@ class ExtensionManager: "routing": RoutingExtension, "security": SecurityExtension, "caching": CachingExtension, - "monitoring": MonitoringExtension + "monitoring": MonitoringExtension, + "asgi": ASGIExtension, } def register_extension_type(self, name: str, extension_class: Type[Extension]) -> None: diff --git a/pyserve/server.py b/pyserve/server.py index 294efe0..5c7a675 100644 --- a/pyserve/server.py +++ b/pyserve/server.py @@ -10,7 +10,7 @@ from pathlib import Path from typing import Optional, Dict, Any from .config import Config -from .extensions import ExtensionManager +from .extensions import ExtensionManager, ASGIExtension from .logging_utils import get_logger from . import __version__ @@ -30,6 +30,11 @@ class PyServeMiddleware: start_time = time.time() request = Request(scope, receive) + + asgi_handled = await self._try_asgi_mount(scope, receive, send, request, start_time) + if asgi_handled: + return + response = await self.extension_manager.process_request(request) if response is None: @@ -39,6 +44,68 @@ class PyServeMiddleware: response = await self.extension_manager.process_response(request, response) response.headers["Server"] = f"pyserve/{__version__}" + self._log_access(request, response, start_time) + + await response(scope, receive, send) + + async def _try_asgi_mount( + self, + scope: Scope, + receive: Receive, + send: Send, + request: Request, + start_time: float + ) -> bool: + for extension in self.extension_manager.extensions: + if isinstance(extension, ASGIExtension): + mount = extension.get_asgi_handler(request) + if mount is not None: + modified_scope = dict(scope) + if mount.strip_path: + modified_scope["path"] = mount.get_modified_path(request.url.path) + modified_scope["root_path"] = scope.get("root_path", "") + mount.path + + logger.debug( + f"Routing to ASGI mount '{mount.name}': " + f"{request.url.path} -> {modified_scope['path']}" + ) + + try: + response_started = False + status_code = 0 + + async def send_wrapper(message: Dict[str, Any]) -> None: + nonlocal response_started, status_code + if message["type"] == "http.response.start": + response_started = True + status_code = message.get("status", 0) + await send(message) + + await mount.app(modified_scope, receive, send_wrapper) + + process_time = round((time.time() - start_time) * 1000, 2) + self.access_logger.info( + "ASGI request", + client_ip=request.client.host if request.client else "unknown", + method=request.method, + path=str(request.url.path), + mount=mount.name, + status_code=status_code, + process_time_ms=process_time, + user_agent=request.headers.get("user-agent", "") + ) + return True + except Exception as e: + logger.error(f"Error in ASGI mount '{mount.name}': {e}") + error_response = PlainTextResponse( + "500 Internal Server Error", + status_code=500 + ) + await error_response(scope, receive, send) + return True + return False + + def _log_access(self, request: Request, response: Response, start_time: float) -> None: client_ip = request.client.host if request.client else "unknown" method = request.method path = str(request.url.path) @@ -58,8 +125,6 @@ class PyServeMiddleware: user_agent=request.headers.get("user-agent", "") ) - await response(scope, receive, send) - class PyServeServer: def __init__(self, config: Config): diff --git a/tests/test_asgi_mount.py b/tests/test_asgi_mount.py new file mode 100644 index 0000000..1fb30fa --- /dev/null +++ b/tests/test_asgi_mount.py @@ -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()