diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 935e04b..981276d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,16 @@ jobs: python -m pip install --upgrade pip pip install -e ".[dev]" + - name: Ruff (lint + format check) + if: matrix.python-version == '3.13' + run: | + ruff format --check FasterAPI tests benchmarks + ruff check FasterAPI tests benchmarks + + - name: Mypy (strict on library) + if: matrix.python-version == '3.13' + run: mypy FasterAPI tests + - name: Run tests with coverage run: | pytest --cov=FasterAPI --cov-report=xml --cov-report=term-missing --cov-fail-under=85 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 7036773..2a009d2 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,15 +1,26 @@ +# Deploy static site to GitHub Pages (repo Settings → Pages → Source: GitHub Actions). name: Docs on: push: branches: [master] + workflow_dispatch: permissions: - contents: write + contents: read + pages: write + id-token: write + +concurrency: + group: github-pages + cancel-in-progress: false jobs: deploy: runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} steps: - uses: actions/checkout@v4 @@ -25,5 +36,14 @@ jobs: - name: Build MkDocs (strict) run: mkdocs build --strict - - name: Deploy to GitHub Pages - run: mkdocs gh-deploy --force + - name: Disable Jekyll (avoid 404 on paths with _) + run: touch site/.nojekyll + + - uses: actions/configure-pages@v4 + + - uses: actions/upload-pages-artifact@v3 + with: + path: site + + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 418b08e..f8959ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,7 +48,15 @@ source .venv/bin/activate pip install -e ".[dev]" # Run tests -pytest --cov=FasterAPI +pytest --cov=FasterAPI --cov-fail-under=85 + +# Lint and types (matches CI on Python 3.13) +ruff format --check FasterAPI tests benchmarks +ruff check FasterAPI tests benchmarks +mypy FasterAPI tests + +# Multi-version tests (requires Python 3.11–3.13 on PATH) +tox # Run benchmarks locally pip install -e ".[benchmark]" diff --git a/FasterAPI/__init__.py b/FasterAPI/__init__.py index 570dcb5..da3cbc4 100644 --- a/FasterAPI/__init__.py +++ b/FasterAPI/__init__.py @@ -6,7 +6,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from ._version import get_version @@ -94,7 +94,7 @@ ] -def __getattr__(name: str): +def __getattr__(name: str) -> Any: if name == "TestClient": try: from .testclient import TestClient as _TestClient diff --git a/FasterAPI/app.py b/FasterAPI/app.py index b253e4a..028917f 100644 --- a/FasterAPI/app.py +++ b/FasterAPI/app.py @@ -10,7 +10,8 @@ from __future__ import annotations import asyncio -from typing import Any, Callable, Sequence +from collections.abc import Callable, Sequence +from typing import Any, cast import msgspec.json @@ -26,8 +27,9 @@ from .openapi.generator import generate_openapi from .openapi.ui import redoc_html, swagger_ui_html from .request import Request -from .response import HTMLResponse, JSONResponse, Response +from .response import HTMLResponse, JSONResponse from .router import RadixRouter +from .types import ASGIApp from .websocket import WebSocket __all__ = ["Faster"] @@ -46,11 +48,21 @@ class Faster: """The main FasterAPI application class, implementing the ASGI interface.""" __slots__ = ( - "title", "version", "description", - "openapi_url", "docs_url", "redoc_url", - "routes", "startup_handlers", "shutdown_handlers", - "middleware", "exception_handlers", - "_router", "_openapi_cache", "_middleware_app", "_ws_routes", + "title", + "version", + "description", + "openapi_url", + "docs_url", + "redoc_url", + "routes", + "startup_handlers", + "shutdown_handlers", + "middleware", + "exception_handlers", + "_router", + "_openapi_cache", + "_middleware_app", + "_ws_routes", ) def __init__( @@ -70,14 +82,14 @@ def __init__( self.docs_url = docs_url self.redoc_url = redoc_url self.routes: list[dict[str, Any]] = [] - self.startup_handlers: list[Callable] = [] - self.shutdown_handlers: list[Callable] = [] + self.startup_handlers: list[ASGIApp] = [] + self.shutdown_handlers: list[ASGIApp] = [] self.middleware: list[dict[str, Any]] = [] - self.exception_handlers: dict[type, Callable] = {} + self.exception_handlers: dict[type, ASGIApp] = {} self._router = RadixRouter() self._openapi_cache: dict[str, Any] | None = None - self._middleware_app: Callable | None = None - self._ws_routes: dict[str, Callable] = {} + self._middleware_app: ASGIApp | None = None + self._ws_routes: dict[str, ASGIApp] = {} self._setup_openapi_routes() def __repr__(self) -> str: @@ -94,15 +106,22 @@ def _setup_openapi_routes(self) -> None: async def openapi_schema() -> JSONResponse: spec = generate_openapi( - app_ref, title=app_ref.title, - version=app_ref.version, description=app_ref.description, + app_ref, + title=app_ref.title, + version=app_ref.version, + description=app_ref.description, ) return JSONResponse(spec) self._add_route( - "GET", api_url, openapi_schema, - tags=["openapi"], summary="OpenAPI Schema", - response_model=None, status_code=200, deprecated=False, + "GET", + api_url, + openapi_schema, + tags=["openapi"], + summary="OpenAPI Schema", + response_model=None, + status_code=200, + deprecated=False, ) if self.docs_url is not None and self.openapi_url is not None: @@ -112,9 +131,14 @@ async def swagger_docs() -> HTMLResponse: return HTMLResponse(swagger_ui_html(ourl, title=f"{t} - Swagger UI")) self._add_route( - "GET", self.docs_url, swagger_docs, - tags=["openapi"], summary="Swagger UI", - response_model=None, status_code=200, deprecated=False, + "GET", + self.docs_url, + swagger_docs, + tags=["openapi"], + summary="Swagger UI", + response_model=None, + status_code=200, + deprecated=False, ) if self.redoc_url is not None and self.openapi_url is not None: @@ -124,16 +148,26 @@ async def redoc_docs() -> HTMLResponse: return HTMLResponse(redoc_html(ourl, title=f"{t} - ReDoc")) self._add_route( - "GET", self.redoc_url, redoc_docs, - tags=["openapi"], summary="ReDoc", - response_model=None, status_code=200, deprecated=False, + "GET", + self.redoc_url, + redoc_docs, + tags=["openapi"], + summary="ReDoc", + response_model=None, + status_code=200, + deprecated=False, ) # ------------------------------------------------------------------ # ASGI interface # ------------------------------------------------------------------ - async def __call__(self, scope: dict, receive: Callable, send: Callable) -> None: + async def __call__( + self, + scope: dict[str, Any], + receive: ASGIApp, + send: ASGIApp, + ) -> None: if self.middleware: if self._middleware_app is None: self._middleware_app = self._build_middleware_chain() @@ -141,7 +175,12 @@ async def __call__(self, scope: dict, receive: Callable, send: Callable) -> None else: await self._asgi_app(scope, receive, send) - async def _asgi_app(self, scope: dict, receive: Callable, send: Callable) -> None: + async def _asgi_app( + self, + scope: dict[str, Any], + receive: ASGIApp, + send: ASGIApp, + ) -> None: scope_type = scope["type"] if scope_type == "http": await self._handle_http(scope, receive, send) @@ -150,7 +189,7 @@ async def _asgi_app(self, scope: dict, receive: Callable, send: Callable) -> Non elif scope_type == "lifespan": await self._handle_lifespan(scope, receive, send) - def _build_middleware_chain(self) -> Callable: + def _build_middleware_chain(self) -> ASGIApp: app = self._asgi_app for entry in reversed(self.middleware): app = entry["class"](app, **entry["kwargs"]) @@ -160,7 +199,12 @@ def _build_middleware_chain(self) -> Callable: # HTTP dispatch — hot path # ------------------------------------------------------------------ - async def _handle_http(self, scope: dict, receive: Callable, send: Callable) -> None: + async def _handle_http( + self, + scope: dict[str, Any], + receive: ASGIApp, + send: ASGIApp, + ) -> None: result = self._router.resolve(scope["method"], scope["path"]) if result is None: await _send_error(send, 404, "Not Found") @@ -175,14 +219,18 @@ async def _handle_http(self, scope: dict, receive: Callable, send: Callable) -> response, bg_tasks = await _resolve_handler(handler, request, path_params) except RequestValidationError as exc: status, body, headers = await self._handle_exc( - request, exc, RequestValidationError, + request, + exc, + RequestValidationError, _default_validation_exception_handler, ) await _send_raw(send, status, body, headers) return except HTTPException as exc: status, body, headers = await self._handle_exc( - request, exc, HTTPException, + request, + exc, + HTTPException, _default_http_exception_handler, ) await _send_raw(send, status, body, headers) @@ -204,21 +252,27 @@ async def _handle_http(self, scope: dict, receive: Callable, send: Callable) -> await bg_tasks.run() async def _handle_exc( - self, request: Request, exc: Exception, exc_class: type, - default_handler: Callable, + self, + request: Request, + exc: Exception, + exc_class: type, + default_handler: ASGIApp, ) -> tuple[int, bytes, list[tuple[bytes, bytes]]]: handler = self.exception_handlers.get(exc_class, default_handler) result = handler(request, exc) if asyncio.iscoroutine(result): result = await result - return result # type: ignore[return-value] + return cast(tuple[int, bytes, list[tuple[bytes, bytes]]], result) # ------------------------------------------------------------------ # WebSocket dispatch # ------------------------------------------------------------------ async def _handle_websocket( - self, scope: dict, receive: Callable, send: Callable, + self, + scope: dict[str, Any], + receive: ASGIApp, + send: ASGIApp, ) -> None: path = scope.get("path", "/") handler = self._ws_routes.get(path.rstrip("/") or "/") @@ -233,7 +287,10 @@ async def _handle_websocket( # ------------------------------------------------------------------ async def _handle_lifespan( - self, scope: dict, receive: Callable, send: Callable, + self, + scope: dict[str, Any], + receive: ASGIApp, + send: ASGIApp, ) -> None: while True: message = await receive() @@ -263,23 +320,34 @@ async def _handle_lifespan( # ------------------------------------------------------------------ def _add_route( - self, method: str, path: str, handler: Callable, *, - tags: list[str], summary: str, response_model: Any, - status_code: int, deprecated: bool, + self, + method: str, + path: str, + handler: ASGIApp, + *, + tags: list[str], + summary: str, + response_model: Any, + status_code: int, + deprecated: bool, ) -> None: metadata = { - "tags": tags, "summary": summary, + "tags": tags, + "summary": summary, "response_model": response_model, - "status_code": status_code, "deprecated": deprecated, + "status_code": status_code, + "deprecated": deprecated, } self.routes.append({"method": method, "path": path, "handler": handler, **metadata}) self._router.add_route(method, path, handler, metadata) compile_handler(handler) # pre-compile at registration time - def _route_decorator(self, method: str, path: str, **kw: Any) -> Callable: - def decorator(handler: Callable) -> Callable: + def _route_decorator(self, method: str, path: str, **kw: Any) -> Callable[[ASGIApp], ASGIApp]: + def decorator(handler: ASGIApp) -> ASGIApp: self._add_route( - method, path, handler, + method, + path, + handler, tags=kw.get("tags") or [], summary=kw.get("summary", ""), response_model=kw.get("response_model"), @@ -287,38 +355,40 @@ def decorator(handler: Callable) -> Callable: deprecated=kw.get("deprecated", False), ) return handler + return decorator - def get(self, path: str, **kw: Any) -> Callable: + def get(self, path: str, **kw: Any) -> Callable[[ASGIApp], ASGIApp]: return self._route_decorator("GET", path, **kw) - def post(self, path: str, **kw: Any) -> Callable: + def post(self, path: str, **kw: Any) -> Callable[[ASGIApp], ASGIApp]: return self._route_decorator("POST", path, **kw) - def put(self, path: str, **kw: Any) -> Callable: + def put(self, path: str, **kw: Any) -> Callable[[ASGIApp], ASGIApp]: return self._route_decorator("PUT", path, **kw) - def delete(self, path: str, **kw: Any) -> Callable: + def delete(self, path: str, **kw: Any) -> Callable[[ASGIApp], ASGIApp]: return self._route_decorator("DELETE", path, **kw) - def patch(self, path: str, **kw: Any) -> Callable: + def patch(self, path: str, **kw: Any) -> Callable[[ASGIApp], ASGIApp]: return self._route_decorator("PATCH", path, **kw) - def websocket(self, path: str) -> Callable: - def decorator(handler: Callable) -> Callable: + def websocket(self, path: str) -> Callable[[ASGIApp], ASGIApp]: + def decorator(handler: ASGIApp) -> ASGIApp: self._ws_routes[path.rstrip("/") or "/"] = handler return handler + return decorator # ------------------------------------------------------------------ # Lifecycle hooks # ------------------------------------------------------------------ - def on_startup(self, handler: Callable) -> Callable: + def on_startup(self, handler: ASGIApp) -> ASGIApp: self.startup_handlers.append(handler) return handler - def on_shutdown(self, handler: Callable) -> Callable: + def on_shutdown(self, handler: ASGIApp) -> ASGIApp: self.shutdown_handlers.append(handler) return handler @@ -330,7 +400,7 @@ def add_middleware(self, middleware_class: type, **kwargs: Any) -> None: self.middleware.append({"class": middleware_class, "kwargs": kwargs}) self._middleware_app = None # invalidate cached chain - def add_exception_handler(self, exc_class: type, handler: Callable) -> None: + def add_exception_handler(self, exc_class: type, handler: ASGIApp) -> None: self.exception_handlers[exc_class] = handler # ------------------------------------------------------------------ @@ -338,7 +408,11 @@ def add_exception_handler(self, exc_class: type, handler: Callable) -> None: # ------------------------------------------------------------------ def include_router( - self, router: Any, *, prefix: str = "", tags: Sequence[str] = (), + self, + router: Any, + *, + prefix: str = "", + tags: Sequence[str] = (), ) -> None: pfx = prefix.rstrip("/") for route in router.routes: @@ -355,7 +429,8 @@ def include_router( # Module-level send helpers (avoid method lookup on self) # ------------------------------------------------------------------ -async def _send_response(send: Callable, status_code: int, body: Any) -> None: + +async def _send_response(send: ASGIApp, status_code: int, body: Any) -> None: if hasattr(body, "to_asgi"): await body.to_asgi(send) return @@ -375,13 +450,16 @@ async def _send_response(send: Callable, status_code: int, body: Any) -> None: async def _send_raw( - send: Callable, status: int, body: bytes, headers: list[tuple[bytes, bytes]], + send: ASGIApp, + status: int, + body: bytes, + headers: list[tuple[bytes, bytes]], ) -> None: await send({"type": "http.response.start", "status": status, "headers": headers}) await send({"type": "http.response.body", "body": body}) -async def _send_error(send: Callable, status: int, message: str) -> None: +async def _send_error(send: ASGIApp, status: int, message: str) -> None: body = msgspec.json.encode({"detail": message}) await send({"type": "http.response.start", "status": status, "headers": [(_HEADER_CT, _CT_JSON)]}) await send({"type": "http.response.body", "body": body}) diff --git a/FasterAPI/background.py b/FasterAPI/background.py index 63aeeb5..da2b280 100644 --- a/FasterAPI/background.py +++ b/FasterAPI/background.py @@ -1,7 +1,8 @@ from __future__ import annotations import asyncio -from typing import Any, Callable +from collections.abc import Callable +from typing import Any from .concurrency import is_coroutine @@ -11,7 +12,7 @@ class BackgroundTask: __slots__ = ("func", "args", "kwargs") - def __init__(self, func: Callable, *args: Any, **kwargs: Any) -> None: + def __init__(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> None: self.func = func self.args = args self.kwargs = kwargs @@ -34,7 +35,7 @@ class BackgroundTasks: def __init__(self) -> None: self._tasks: list[BackgroundTask] = [] - def add_task(self, func: Callable, *args: Any, **kwargs: Any) -> None: + def add_task(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> None: """Add a new background task to the collection.""" self._tasks.append(BackgroundTask(func, *args, **kwargs)) diff --git a/FasterAPI/concurrency.py b/FasterAPI/concurrency.py index 7b2b777..f32fa4e 100644 --- a/FasterAPI/concurrency.py +++ b/FasterAPI/concurrency.py @@ -27,12 +27,14 @@ from __future__ import annotations import asyncio +import contextlib import inspect import os import sys +from collections.abc import Callable from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor from functools import partial -from typing import Any, Callable +from typing import Any # ─────────────────────────────────────────────────────────────────────── # Version detection @@ -75,6 +77,7 @@ def _get_thread_pool() -> ThreadPoolExecutor: # Core helpers # ─────────────────────────────────────────────────────────────────────── + def is_coroutine(func: Callable) -> bool: # type: ignore[type-arg] """Return True if *func* is a coroutine function.""" return inspect.iscoroutinefunction(func) @@ -121,8 +124,9 @@ async def run_in_threadpool(func: Callable, *args: Any) -> Any: # type: ignore[ _HAS_INTERPRETERS = False if _PY313_PLUS: try: - import interpreters # type: ignore[import-not-found] - import interpreters.channels # type: ignore[import-not-found] + import interpreters + import interpreters.channels + _HAS_INTERPRETERS = True except ImportError: pass @@ -171,9 +175,7 @@ async def run(self, func: Callable, *args: Any) -> Any: # type: ignore[type-arg assert self._semaphore is not None async with self._semaphore: # Round-robin: pick the next free interpreter - interp = self._interpreters[ - id(asyncio.current_task()) % len(self._interpreters) - ] + interp = self._interpreters[id(asyncio.current_task()) % len(self._interpreters)] loop = asyncio.get_running_loop() return await loop.run_in_executor( self._thread_pool, @@ -184,10 +186,8 @@ def shutdown(self) -> None: """Destroy all sub-interpreters and the backing thread pool.""" self._thread_pool.shutdown(wait=False) for interp in self._interpreters: - try: + with contextlib.suppress(Exception): interp.close() - except Exception: - pass self._interpreters.clear() self._initialized = False @@ -219,7 +219,8 @@ async def run(self, func: Callable, *args: Any) -> Any: # type: ignore[type-arg """Execute *func* in a worker process (pickle-based).""" loop = asyncio.get_running_loop() return await loop.run_in_executor( - self._executor, partial(func, *args), + self._executor, + partial(func, *args), ) def shutdown(self) -> None: @@ -276,6 +277,7 @@ async def run_in_subinterpreter(func: Callable, *args: Any) -> Any: # type: ign # older → uvloop if available, else stdlib # ─────────────────────────────────────────────────────────────────────── + def install_event_loop() -> str: """Install the fastest available event loop and return its name. @@ -283,9 +285,11 @@ def install_event_loop() -> str: """ try: import uvloop + if _PY312_PLUS: # uvloop.install() is deprecated on 3.12+; set the policy instead import asyncio + asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) else: uvloop.install() diff --git a/FasterAPI/datastructures.py b/FasterAPI/datastructures.py index 9dcb017..0706158 100644 --- a/FasterAPI/datastructures.py +++ b/FasterAPI/datastructures.py @@ -49,13 +49,10 @@ def size(self) -> int | None: return self._size def __repr__(self) -> str: - return ( - f"UploadFile(filename={self.filename!r}, " - f"content_type={self.content_type!r})" - ) + return f"UploadFile(filename={self.filename!r}, content_type={self.content_type!r})" -class FormData(dict): +class FormData(dict[str, Any]): """Dict subclass for form data that may contain UploadFile values.""" async def close(self) -> None: diff --git a/FasterAPI/dependencies.py b/FasterAPI/dependencies.py index 63bbcd8..3c9bdbb 100644 --- a/FasterAPI/dependencies.py +++ b/FasterAPI/dependencies.py @@ -10,8 +10,9 @@ import inspect import typing +from collections.abc import Callable from functools import lru_cache -from typing import Any, Callable +from typing import Any import msgspec @@ -19,7 +20,7 @@ from .concurrency import is_coroutine from .datastructures import UploadFile from .exceptions import RequestValidationError -from .params import Body, Cookie, File, Form, Header, Path, Query, _MISSING +from .params import _MISSING, Body, Cookie, File, Form, Header, Path, Query from .request import Request __all__ = ["Depends", "compile_handler", "_resolve_handler"] @@ -28,12 +29,13 @@ # Depends marker # --------------------------------------------------------------------------- + class Depends: """Declare a dependency to be resolved and injected into a route handler.""" __slots__ = ("dependency", "use_cache") - def __init__(self, dependency: Callable, *, use_cache: bool = True) -> None: + def __init__(self, dependency: Callable[..., Any], *, use_cache: bool = True) -> None: self.dependency = dependency self.use_cache = use_cache @@ -65,7 +67,12 @@ class _ParamSpec: __slots__ = ("name", "kind", "annotation", "default", "marker") def __init__( - self, name: str, kind: int, annotation: Any, default: Any, marker: Any, + self, + name: str, + kind: int, + annotation: Any, + default: Any, + marker: Any, ) -> None: self.name = name self.kind = kind @@ -78,8 +85,9 @@ def __init__( # Compile handler (called once at route registration) # --------------------------------------------------------------------------- + @lru_cache(maxsize=512) -def compile_handler(func: Callable) -> tuple[tuple[_ParamSpec, ...], bool]: +def compile_handler(func: Callable[..., Any]) -> tuple[tuple[_ParamSpec, ...], bool]: """Introspect *func* once and return a tuple of _ParamSpec plus is-async flag. This replaces per-request inspect.signature + get_type_hints calls. @@ -127,14 +135,15 @@ def compile_handler(func: Callable) -> tuple[tuple[_ParamSpec, ...], bool]: # Hot-path resolver (called on every request) # --------------------------------------------------------------------------- + async def _resolve_handler( - handler: Callable, + handler: Callable[..., Any], request: Request, path_params: dict[str, str], ) -> tuple[Any, BackgroundTasks | None]: """Resolve dependencies, call handler, return (result, bg_tasks|None).""" specs, is_async = compile_handler(handler) - cache: dict[Callable, Any] = {} + cache: dict[Callable[..., Any], Any] = {} bg_tasks = BackgroundTasks() kwargs = await _resolve_from_specs(specs, request, path_params, cache, bg_tasks) @@ -146,7 +155,7 @@ async def _resolve_from_specs( specs: tuple[_ParamSpec, ...], request: Request, path_params: dict[str, str], - cache: dict[Callable, Any], + cache: dict[Callable[..., Any], Any], bg_tasks: BackgroundTasks, ) -> dict[str, Any]: """Build kwargs dict from pre-compiled param specs — no introspection.""" @@ -161,11 +170,17 @@ async def _resolve_from_specs( kwargs[spec.name] = bg_tasks elif kind == _KIND_DEPENDS: kwargs[spec.name] = await _resolve_dependency( - spec.marker, request, path_params, cache, bg_tasks, + spec.marker, + request, + path_params, + cache, + bg_tasks, ) elif kind == _KIND_STRUCT: kwargs[spec.name] = await _resolve_struct( - spec.annotation, request, spec.default, + spec.annotation, + request, + spec.default, ) elif kind == _KIND_PATH: kwargs[spec.name] = _resolve_path(spec.name, path_params, spec.marker) @@ -179,7 +194,9 @@ async def _resolve_from_specs( kwargs[spec.name] = await _resolve_file(spec.name, request) elif kind == _KIND_FORM: kwargs[spec.name] = await _resolve_form_field( - spec.name, request, spec.marker, + spec.name, + request, + spec.marker, ) elif kind == _KIND_BODY: kwargs[spec.name] = await _resolve_body(request, spec.marker) @@ -196,11 +213,12 @@ async def _resolve_from_specs( # Dependency resolution # --------------------------------------------------------------------------- + async def _resolve_dependency( dep: Depends, request: Request, path_params: dict[str, str], - cache: dict[Callable, Any], + cache: dict[Callable[..., Any], Any], bg_tasks: BackgroundTasks, ) -> Any: func = dep.dependency @@ -220,6 +238,7 @@ async def _resolve_dependency( # Individual param resolvers (kept lean) # --------------------------------------------------------------------------- + def _is_struct_type(annotation: Any) -> bool: return ( annotation is not inspect.Parameter.empty @@ -237,7 +256,9 @@ def _is_upload_file_type(annotation: Any) -> bool: async def _resolve_struct( - struct_type: type, request: Request, default: Any, + struct_type: type, + request: Request, + default: Any, ) -> Any: try: raw = await request._read_body() diff --git a/FasterAPI/exceptions.py b/FasterAPI/exceptions.py index bc70933..aa000b6 100644 --- a/FasterAPI/exceptions.py +++ b/FasterAPI/exceptions.py @@ -34,29 +34,31 @@ def __repr__(self) -> str: # --- Default exception handlers --- + async def _default_http_exception_handler( - request: Any, exc: HTTPException, + request: Any, + exc: HTTPException, ) -> tuple[int, bytes, list[tuple[bytes, bytes]]]: body = msgspec.json.encode({"detail": exc.detail}) headers: list[tuple[bytes, bytes]] = [(b"content-type", b"application/json")] if exc.headers: - headers.extend( - (k.lower().encode("latin-1"), v.encode("latin-1")) - for k, v in exc.headers.items() - ) + headers.extend((k.lower().encode("latin-1"), v.encode("latin-1")) for k, v in exc.headers.items()) return exc.status_code, body, headers async def _default_validation_exception_handler( - request: Any, exc: RequestValidationError, + request: Any, + exc: RequestValidationError, ) -> tuple[int, bytes, list[tuple[bytes, bytes]]]: detail = [] for err in exc.errors: - detail.append({ - "loc": err.get("loc", []), - "msg": err.get("msg", ""), - "type": err.get("type", "value_error"), - }) + detail.append( + { + "loc": err.get("loc", []), + "msg": err.get("msg", ""), + "type": err.get("type", "value_error"), + } + ) body = msgspec.json.encode({"detail": detail}) headers: list[tuple[bytes, bytes]] = [(b"content-type", b"application/json")] return 422, body, headers diff --git a/FasterAPI/middleware.py b/FasterAPI/middleware.py index 0dc850b..ba8fd6b 100644 --- a/FasterAPI/middleware.py +++ b/FasterAPI/middleware.py @@ -1,26 +1,32 @@ from __future__ import annotations -import asyncio import gzip -from typing import Any, Callable, Sequence +from collections.abc import Sequence +from typing import Any + +from .types import ASGIApp class BaseHTTPMiddleware: """Base class for HTTP middleware that wraps an ASGI application.""" - def __init__(self, app: Callable) -> None: + def __init__(self, app: ASGIApp) -> None: self.app = app - async def __call__(self, scope: dict, receive: Callable, send: Callable) -> None: + async def __call__(self, scope: dict[str, Any], receive: ASGIApp, send: ASGIApp) -> None: if scope["type"] != "http": await self.app(scope, receive, send) return await self.dispatch(scope, receive, send) async def dispatch( - self, scope: dict, receive: Callable, send: Callable, + self, + scope: dict[str, Any], + receive: ASGIApp, + send: ASGIApp, ) -> None: """Process the request. Override this method in subclasses.""" + async def call_next() -> None: await self.app(scope, receive, send) @@ -32,7 +38,7 @@ class CORSMiddleware(BaseHTTPMiddleware): def __init__( self, - app: Callable, + app: ASGIApp, *, allow_origins: Sequence[str] = ("*",), allow_methods: Sequence[str] = ("*",), @@ -53,7 +59,10 @@ def __init__( self.allow_all_headers = "*" in self.allow_headers async def dispatch( - self, scope: dict, receive: Callable, send: Callable, + self, + scope: dict[str, Any], + receive: ASGIApp, + send: ASGIApp, ) -> None: """Handle CORS preflight requests and inject CORS headers into responses.""" headers_raw: list[tuple[bytes, bytes]] = scope.get("headers", []) @@ -69,7 +78,7 @@ async def dispatch( # Normal request — intercept send to inject CORS headers cors_headers = self._build_cors_headers(origin) - async def send_with_cors(message: dict) -> None: + async def send_with_cors(message: dict[str, Any]) -> None: if message["type"] == "http.response.start": existing = list(message.get("headers", [])) existing.extend(cors_headers) @@ -100,15 +109,17 @@ def _build_cors_headers(self, origin: str | None) -> list[tuple[bytes, bytes]]: headers.append((b"access-control-allow-credentials", b"true")) if self.expose_headers: - headers.append(( - b"access-control-expose-headers", - ", ".join(self.expose_headers).encode("latin-1"), - )) + headers.append( + ( + b"access-control-expose-headers", + ", ".join(self.expose_headers).encode("latin-1"), + ) + ) return headers async def _preflight_response( self, - send: Callable, + send: ASGIApp, origin: str | None, request_headers: dict[str, str], ) -> None: @@ -126,43 +137,52 @@ async def _preflight_response( req_method = request_headers.get("access-control-request-method", "") headers.append((b"access-control-allow-methods", req_method.encode("latin-1"))) else: - headers.append(( - b"access-control-allow-methods", - ", ".join(self.allow_methods).encode("latin-1"), - )) + headers.append( + ( + b"access-control-allow-methods", + ", ".join(self.allow_methods).encode("latin-1"), + ) + ) # Headers if self.allow_all_headers: req_headers = request_headers.get("access-control-request-headers", "") headers.append((b"access-control-allow-headers", req_headers.encode("latin-1"))) else: - headers.append(( - b"access-control-allow-headers", - ", ".join(self.allow_headers).encode("latin-1"), - )) + headers.append( + ( + b"access-control-allow-headers", + ", ".join(self.allow_headers).encode("latin-1"), + ) + ) if self.allow_credentials: headers.append((b"access-control-allow-credentials", b"true")) headers.append((b"access-control-max-age", str(self.max_age).encode())) - await send({ - "type": "http.response.start", - "status": 200, - "headers": headers, - }) + await send( + { + "type": "http.response.start", + "status": 200, + "headers": headers, + } + ) await send({"type": "http.response.body", "body": b""}) class GZipMiddleware(BaseHTTPMiddleware): """Middleware that compresses responses using gzip when the client supports it.""" - def __init__(self, app: Callable, *, minimum_size: int = 1000) -> None: + def __init__(self, app: ASGIApp, *, minimum_size: int = 1000) -> None: super().__init__(app) self.minimum_size = minimum_size async def dispatch( - self, scope: dict, receive: Callable, send: Callable, + self, + scope: dict[str, Any], + receive: ASGIApp, + send: ASGIApp, ) -> None: """Compress the response body with gzip if it exceeds the minimum size.""" headers_raw: list[tuple[bytes, bytes]] = scope.get("headers", []) @@ -177,10 +197,10 @@ async def dispatch( return # Collect response to potentially compress - initial_message: dict | None = None + initial_message: dict[str, Any] | None = None body_parts: list[bytes] = [] - async def buffered_send(message: dict) -> None: + async def buffered_send(message: dict[str, Any]) -> None: nonlocal initial_message if message["type"] == "http.response.start": initial_message = message @@ -195,10 +215,7 @@ async def buffered_send(message: dict) -> None: headers.append((b"content-encoding", b"gzip")) headers.append((b"vary", b"Accept-Encoding")) # Update content-length - headers = [ - (k, v) for k, v in headers - if k.lower() != b"content-length" - ] + headers = [(k, v) for k, v in headers if k.lower() != b"content-length"] headers.append((b"content-length", str(len(compressed)).encode())) await send({**initial_message, "headers": headers}) await send({"type": "http.response.body", "body": compressed}) @@ -214,14 +231,20 @@ class TrustedHostMiddleware(BaseHTTPMiddleware): """Middleware that validates the Host header against a list of allowed hosts.""" def __init__( - self, app: Callable, *, allowed_hosts: Sequence[str] = ("*",), + self, + app: ASGIApp, + *, + allowed_hosts: Sequence[str] = ("*",), ) -> None: super().__init__(app) self.allowed_hosts = set(allowed_hosts) self.allow_all = "*" in self.allowed_hosts async def dispatch( - self, scope: dict, receive: Callable, send: Callable, + self, + scope: dict[str, Any], + receive: ASGIApp, + send: ASGIApp, ) -> None: if self.allow_all: await self.app(scope, receive, send) @@ -235,15 +258,19 @@ async def dispatch( break if host not in self.allowed_hosts: - await send({ - "type": "http.response.start", - "status": 400, - "headers": [(b"content-type", b"text/plain")], - }) - await send({ - "type": "http.response.body", - "body": b"Invalid host header", - }) + await send( + { + "type": "http.response.start", + "status": 400, + "headers": [(b"content-type", b"text/plain")], + } + ) + await send( + { + "type": "http.response.body", + "body": b"Invalid host header", + } + ) return await self.app(scope, receive, send) @@ -253,7 +280,10 @@ class HTTPSRedirectMiddleware(BaseHTTPMiddleware): """Middleware that redirects all HTTP requests to HTTPS.""" async def dispatch( - self, scope: dict, receive: Callable, send: Callable, + self, + scope: dict[str, Any], + receive: ASGIApp, + send: ASGIApp, ) -> None: if scope.get("scheme", "http") == "https": await self.app(scope, receive, send) @@ -273,12 +303,14 @@ async def dispatch( if qs: url += f"?{qs.decode('latin-1')}" - await send({ - "type": "http.response.start", - "status": 301, - "headers": [ - (b"location", url.encode("latin-1")), - (b"content-type", b"text/plain"), - ], - }) + await send( + { + "type": "http.response.start", + "status": 301, + "headers": [ + (b"location", url.encode("latin-1")), + (b"content-type", b"text/plain"), + ], + } + ) await send({"type": "http.response.body", "body": b"Redirecting to HTTPS"}) diff --git a/FasterAPI/openapi/generator.py b/FasterAPI/openapi/generator.py index 68a36d7..df23e4b 100644 --- a/FasterAPI/openapi/generator.py +++ b/FasterAPI/openapi/generator.py @@ -4,7 +4,8 @@ import re import types import typing -from typing import Any, Callable, Union, get_args, get_origin +from collections.abc import Callable +from typing import Any, Union, get_args, get_origin import msgspec @@ -59,7 +60,7 @@ def generate_openapi( def _build_operation( route: dict[str, Any], - handler: Callable, + handler: Callable[..., Any], schemas: dict[str, Any], ) -> dict[str, Any]: operation: dict[str, Any] = {} @@ -144,7 +145,7 @@ def _build_operation( def _extract_params( route: dict[str, Any], - handler: Callable, + handler: Callable[..., Any], schemas: dict[str, Any], ) -> tuple[list[dict[str, Any]], dict[str, Any] | None]: parameters: list[dict[str, Any]] = [] @@ -172,11 +173,13 @@ def _extract_params( # Skip Request injection from ..request import Request as RequestClass + if annotation is RequestClass: continue # Skip Depends from ..dependencies import Depends + if isinstance(default, Depends): continue @@ -211,9 +214,7 @@ def _extract_params( # Header parameter if isinstance(default, Header): - header_name = default.alias or ( - name.replace("_", "-") if default.convert_underscores else name - ) + header_name = default.alias or (name.replace("_", "-") if default.convert_underscores else name) p = { "name": header_name, "in": "header", @@ -267,7 +268,8 @@ def _is_optional(annotation: Any) -> bool: def _annotation_to_schema( - annotation: Any, schemas: dict[str, Any] | None = None, + annotation: Any, + schemas: dict[str, Any] | None = None, ) -> dict[str, Any]: if annotation is inspect.Parameter.empty or annotation is Any: return {"type": "string"} @@ -275,7 +277,8 @@ def _annotation_to_schema( def _python_type_to_schema( - tp: Any, schemas: dict[str, Any] | None = None, + tp: Any, + schemas: dict[str, Any] | None = None, ) -> dict[str, Any]: if tp is str: return {"type": "string"} @@ -321,7 +324,8 @@ def _python_type_to_schema( def _type_to_schema( - tp: Any, schemas: dict[str, Any], + tp: Any, + schemas: dict[str, Any], ) -> dict[str, Any]: if tp is None or tp is inspect.Parameter.empty: return {"type": "string"} @@ -333,7 +337,8 @@ def _type_to_schema( def _struct_to_ref( - struct_type: type, schemas: dict[str, Any], + struct_type: type, + schemas: dict[str, Any], ) -> dict[str, Any]: name = struct_type.__name__ @@ -344,7 +349,8 @@ def _struct_to_ref( def _struct_to_schema( - struct_type: type, schemas: dict[str, Any], + struct_type: type, + schemas: dict[str, Any], ) -> dict[str, Any]: properties: dict[str, Any] = {} required: list[str] = [] diff --git a/FasterAPI/request.py b/FasterAPI/request.py index ca27bcb..ab395a2 100644 --- a/FasterAPI/request.py +++ b/FasterAPI/request.py @@ -10,13 +10,14 @@ from __future__ import annotations from http.cookies import SimpleCookie -from typing import Any +from typing import Any, cast from urllib.parse import parse_qs import msgspec.json -from python_multipart.multipart import parse_options_header, MultipartParser +from python_multipart.multipart import MultipartParser, parse_options_header from .datastructures import FormData, UploadFile +from .types import ASGIApp __all__ = ["Request"] @@ -25,12 +26,20 @@ class Request: """Represents an incoming HTTP request with lazy attribute parsing.""" __slots__ = ( - "_scope", "_receive", "_body", "_body_read", "_form_cache", - "_headers", "_query_params", "_cookies", - "method", "path", "path_params", + "_scope", + "_receive", + "_body", + "_body_read", + "_form_cache", + "_headers", + "_query_params", + "_cookies", + "method", + "path", + "path_params", ) - def __init__(self, scope: dict, receive: Any) -> None: + def __init__(self, scope: dict[str, Any], receive: ASGIApp) -> None: self._scope = scope self._receive = receive self._body: bytes = b"" @@ -136,6 +145,7 @@ async def form(self) -> FormData: # Form parsing helpers # ------------------------------------------------------------------ + def _parse_urlencoded(raw: bytes) -> FormData: text = raw.decode("latin-1") parsed = parse_qs(text, keep_blank_values=True) @@ -168,9 +178,7 @@ def on_header_value(data: bytes, start: int, end: int) -> None: header_value.extend(data[start:end]) def on_header_end() -> None: - current_headers[bytes(header_field).decode("latin-1").lower()] = ( - bytes(header_value).decode("latin-1") - ) + current_headers[bytes(header_field).decode("latin-1").lower()] = bytes(header_value).decode("latin-1") header_field.clear() header_value.clear() @@ -182,7 +190,8 @@ def on_headers_finished() -> None: if filename is not None: part_info["filename"] = filename.decode("utf-8") part_info["content_type"] = current_headers.get( - "content-type", "application/octet-stream", + "content-type", + "application/octet-stream", ) part_info["headers"] = dict(current_headers) @@ -207,15 +216,21 @@ def on_part_end() -> None: else: fields[name] = bytes(current_data).decode("utf-8") - parser = MultipartParser(boundary, { - "on_part_begin": on_part_begin, - "on_header_field": on_header_field, - "on_header_value": on_header_value, - "on_header_end": on_header_end, - "on_headers_finished": on_headers_finished, - "on_part_data": on_part_data, - "on_part_end": on_part_end, - }) # type: ignore[arg-type] + parser = MultipartParser( + boundary, + cast( + Any, + { + "on_part_begin": on_part_begin, + "on_header_field": on_header_field, + "on_header_value": on_header_value, + "on_header_end": on_header_end, + "on_headers_finished": on_headers_finished, + "on_part_data": on_part_data, + "on_part_end": on_part_end, + }, + ), + ) parser.write(raw) parser.finalize() return FormData(fields) diff --git a/FasterAPI/response.py b/FasterAPI/response.py index e3b92c5..b87fbd5 100644 --- a/FasterAPI/response.py +++ b/FasterAPI/response.py @@ -2,11 +2,14 @@ import asyncio import mimetypes +from collections.abc import AsyncIterator, Iterator from pathlib import Path -from typing import Any, AsyncIterator, Callable, Iterator +from typing import Any import msgspec.json +from .types import ASGIApp + class Response: """Base HTTP response class.""" @@ -45,17 +48,21 @@ def _build_headers(self) -> list[tuple[bytes, bytes]]: raw.append((key.lower().encode("latin-1"), value.encode("latin-1"))) return raw - async def to_asgi(self, send: Callable) -> None: + async def to_asgi(self, send: ASGIApp) -> None: """Send the response through the ASGI interface.""" - await send({ - "type": "http.response.start", - "status": self.status_code, - "headers": self._build_headers(), - }) - await send({ - "type": "http.response.body", - "body": self.body, - }) + await send( + { + "type": "http.response.start", + "status": self.status_code, + "headers": self._build_headers(), + } + ) + await send( + { + "type": "http.response.body", + "body": self.body, + } + ) class JSONResponse(Response): @@ -136,27 +143,33 @@ def _build_headers(self) -> list[tuple[bytes, bytes]]: raw.append((key.lower().encode("latin-1"), value.encode("latin-1"))) return raw - async def to_asgi(self, send: Callable) -> None: + async def to_asgi(self, send: ASGIApp) -> None: """Stream the response body through the ASGI interface.""" - await send({ - "type": "http.response.start", - "status": self.status_code, - "headers": self._build_headers(), - }) + await send( + { + "type": "http.response.start", + "status": self.status_code, + "headers": self._build_headers(), + } + ) if hasattr(self.content, "__aiter__"): async for chunk in self.content: - await send({ - "type": "http.response.body", - "body": chunk if isinstance(chunk, bytes) else chunk.encode(), - "more_body": True, - }) + await send( + { + "type": "http.response.body", + "body": chunk if isinstance(chunk, bytes) else chunk.encode(), + "more_body": True, + } + ) else: for chunk in self.content: - await send({ - "type": "http.response.body", - "body": chunk if isinstance(chunk, bytes) else chunk.encode(), - "more_body": True, - }) + await send( + { + "type": "http.response.body", + "body": chunk if isinstance(chunk, bytes) else chunk.encode(), + "more_body": True, + } + ) await send({"type": "http.response.body", "body": b"", "more_body": False}) @@ -193,14 +206,17 @@ def _build_headers(self) -> list[tuple[bytes, bytes]]: raw.append((key.lower().encode("latin-1"), value.encode("latin-1"))) return raw - async def to_asgi(self, send: Callable) -> None: + async def to_asgi(self, send: ASGIApp) -> None: """Read the file and send it through the ASGI interface.""" content = await asyncio.get_running_loop().run_in_executor( - None, self.path.read_bytes, + None, + self.path.read_bytes, + ) + await send( + { + "type": "http.response.start", + "status": self.status_code, + "headers": self._build_headers(), + } ) - await send({ - "type": "http.response.start", - "status": self.status_code, - "headers": self._build_headers(), - }) await send({"type": "http.response.body", "body": content}) diff --git a/FasterAPI/router.py b/FasterAPI/router.py index 03fdbd3..fd8f69e 100644 --- a/FasterAPI/router.py +++ b/FasterAPI/router.py @@ -9,7 +9,10 @@ from __future__ import annotations -from typing import Any, Callable +from collections.abc import Callable +from typing import Any + +from .types import ASGIApp __all__ = ["RadixRouter", "FasterRouter"] @@ -21,7 +24,7 @@ class RadixNode: def __init__(self) -> None: self.children: dict[str, RadixNode] = {} - self.handlers: dict[str, tuple[Callable, dict[str, Any]]] = {} + self.handlers: dict[str, tuple[ASGIApp, dict[str, Any]]] = {} self.param_name: str | None = None self.is_param: bool = False @@ -42,7 +45,7 @@ def add_route( self, method: str, path: str, - handler: Callable, + handler: ASGIApp, metadata: dict[str, Any] | None = None, ) -> None: """Register a handler for the given HTTP method and path pattern.""" @@ -71,8 +74,10 @@ def add_route( # ------------------------------------------------------------------ def resolve( - self, method: str, path: str, - ) -> tuple[Callable, dict[str, str], dict[str, Any]] | None: + self, + method: str, + path: str, + ) -> tuple[ASGIApp, dict[str, str], dict[str, Any]] | None: """Resolve a path to (handler, path_params, metadata) or None.""" segments = _split(path) params: dict[str, str] = {} @@ -107,7 +112,8 @@ def _walk( # Try param child param_child = node.children.get("*") if param_child is not None: - params[param_child.param_name] = seg # type: ignore[index] + assert param_child.param_name is not None + params[param_child.param_name] = seg node = param_child idx += 1 continue @@ -120,6 +126,7 @@ def _walk( # Shared helpers # ------------------------------------------------------------------ + def _split(path: str) -> list[str]: """Split a URL path into non-empty segments, stripping trailing slashes.""" return [s for s in path.split("/") if s] @@ -129,6 +136,7 @@ def _split(path: str) -> list[str]: # FasterRouter (sub-router / blueprint) # ------------------------------------------------------------------ + class FasterRouter: """API router for grouping routes with a common prefix and tags.""" @@ -143,7 +151,7 @@ def _add_route( self, method: str, path: str, - handler: Callable, + handler: ASGIApp, *, tags: list[str], summary: str, @@ -152,47 +160,54 @@ def _add_route( deprecated: bool, ) -> None: full_path = self.prefix + path - self.routes.append({ - "method": method, - "path": full_path, - "handler": handler, - "tags": self.tags + tags, - "summary": summary, - "response_model": response_model, - "status_code": status_code, - "deprecated": deprecated, - }) + self.routes.append( + { + "method": method, + "path": full_path, + "handler": handler, + "tags": self.tags + tags, + "summary": summary, + "response_model": response_model, + "status_code": status_code, + "deprecated": deprecated, + } + ) # Decorator factories — identical API to Faster app - def get(self, path: str, **kw: Any) -> Callable: - def decorator(handler: Callable) -> Callable: + def get(self, path: str, **kw: Any) -> Callable[[ASGIApp], ASGIApp]: + def decorator(handler: ASGIApp) -> ASGIApp: self._add_route("GET", path, handler, **_route_kw(kw)) return handler + return decorator - def post(self, path: str, **kw: Any) -> Callable: - def decorator(handler: Callable) -> Callable: + def post(self, path: str, **kw: Any) -> Callable[[ASGIApp], ASGIApp]: + def decorator(handler: ASGIApp) -> ASGIApp: self._add_route("POST", path, handler, **_route_kw(kw)) return handler + return decorator - def put(self, path: str, **kw: Any) -> Callable: - def decorator(handler: Callable) -> Callable: + def put(self, path: str, **kw: Any) -> Callable[[ASGIApp], ASGIApp]: + def decorator(handler: ASGIApp) -> ASGIApp: self._add_route("PUT", path, handler, **_route_kw(kw)) return handler + return decorator - def delete(self, path: str, **kw: Any) -> Callable: - def decorator(handler: Callable) -> Callable: + def delete(self, path: str, **kw: Any) -> Callable[[ASGIApp], ASGIApp]: + def decorator(handler: ASGIApp) -> ASGIApp: self._add_route("DELETE", path, handler, **_route_kw(kw)) return handler + return decorator - def patch(self, path: str, **kw: Any) -> Callable: - def decorator(handler: Callable) -> Callable: + def patch(self, path: str, **kw: Any) -> Callable[[ASGIApp], ASGIApp]: + def decorator(handler: ASGIApp) -> ASGIApp: self._add_route("PATCH", path, handler, **_route_kw(kw)) return handler + return decorator diff --git a/FasterAPI/testclient.py b/FasterAPI/testclient.py index 9225d9e..7c61278 100644 --- a/FasterAPI/testclient.py +++ b/FasterAPI/testclient.py @@ -1,27 +1,29 @@ from __future__ import annotations import asyncio +from collections.abc import Generator from contextlib import contextmanager -from typing import Any, Callable, Generator +from typing import Any import httpx -from .websocket import WebSocket, WebSocketDisconnect +from .types import ASGIApp +from .websocket import WebSocketDisconnect class _WebSocketSession: """Test WebSocket session that communicates through in-memory queues.""" def __init__(self) -> None: - self._send_queue: asyncio.Queue[dict] = asyncio.Queue() - self._receive_queue: asyncio.Queue[dict] = asyncio.Queue() + self._send_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue() + self._receive_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue() self._accepted = False self._closed = False - async def _asgi_receive(self) -> dict: + async def _asgi_receive(self) -> dict[str, Any]: return await self._receive_queue.get() - async def _asgi_send(self, message: dict) -> None: + async def _asgi_send(self, message: dict[str, Any]) -> None: await self._send_queue.put(message) def send_text(self, data: str) -> None: @@ -32,6 +34,7 @@ def send_bytes(self, data: bytes) -> None: def send_json(self, data: Any) -> None: import msgspec.json + self.send_text(msgspec.json.encode(data).decode()) def receive_text(self) -> str: @@ -44,6 +47,7 @@ def receive_bytes(self) -> bytes: def receive_json(self) -> Any: import msgspec.json + text = self.receive_text() return msgspec.json.decode(text.encode()) @@ -51,12 +55,12 @@ def close(self, code: int = 1000) -> None: self._receive_queue.put_nowait({"type": "websocket.disconnect", "code": code}) self._closed = True - def _drain_one(self) -> dict: + def _drain_one(self) -> dict[str, Any]: """Get the next message from the send queue (blocks briefly).""" try: msg = self._send_queue.get_nowait() except asyncio.QueueEmpty: - raise RuntimeError("No message available from server") + raise RuntimeError("No message available from server") from None if msg.get("type") == "websocket.accept": self._accepted = True return self._drain_one() @@ -77,7 +81,7 @@ class TestClient: def __init__( self, - app: Callable, + app: ASGIApp, base_url: str = "http://testserver", ) -> None: self.app = app @@ -102,6 +106,7 @@ def _run(self, coro: Any) -> Any: if loop and loop.is_running(): import concurrent.futures + with concurrent.futures.ThreadPoolExecutor() as pool: return pool.submit(asyncio.run, coro).result() return asyncio.run(coro) @@ -158,10 +163,7 @@ def websocket_connect( scope = { "type": "websocket", "path": path, - "headers": [ - (k.lower().encode(), v.encode()) - for k, v in (headers or {}).items() - ], + "headers": [(k.lower().encode(), v.encode()) for k, v in (headers or {}).items()], "query_string": query_string.encode() if query_string else b"", "client": ("testclient", 0), } diff --git a/FasterAPI/types.py b/FasterAPI/types.py new file mode 100644 index 0000000..9025c7b --- /dev/null +++ b/FasterAPI/types.py @@ -0,0 +1,11 @@ +"""Shared typing aliases for ASGI callables.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +# ASGI application / handler (scope, receive, send) or route endpoint +ASGIApp = Callable[..., Any] + +__all__ = ["ASGIApp"] diff --git a/FasterAPI/websocket.py b/FasterAPI/websocket.py index 265e09f..0ac08d5 100644 --- a/FasterAPI/websocket.py +++ b/FasterAPI/websocket.py @@ -1,10 +1,12 @@ from __future__ import annotations -from typing import Any, Callable +from typing import Any from urllib.parse import parse_qs import msgspec.json +from .types import ASGIApp + class WebSocketState: """Enumeration of WebSocket connection states.""" @@ -25,11 +27,18 @@ class WebSocket: """Represents a WebSocket connection.""" __slots__ = ( - "_scope", "_receive", "_send", "path", "path_params", - "client", "headers", "query_params", "_state", + "_scope", + "_receive", + "_send", + "path", + "path_params", + "client", + "headers", + "query_params", + "_state", ) - def __init__(self, scope: dict, receive: Callable, send: Callable) -> None: + def __init__(self, scope: dict[str, Any], receive: ASGIApp, send: ASGIApp) -> None: self._scope = scope self._receive = receive self._send = send @@ -39,17 +48,11 @@ def __init__(self, scope: dict, receive: Callable, send: Callable) -> None: self.client: tuple[str, int] | None = scope.get("client") raw_headers: list[tuple[bytes, bytes]] = scope.get("headers", []) - self.headers: dict[str, str] = { - k.decode("latin-1").lower(): v.decode("latin-1") - for k, v in raw_headers - } + self.headers: dict[str, str] = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in raw_headers} qs = scope.get("query_string", b"") parsed = parse_qs(qs.decode("latin-1") if isinstance(qs, bytes) else qs) - self.query_params: dict[str, Any] = { - k: v[0] if len(v) == 1 else v - for k, v in parsed.items() - } + self.query_params: dict[str, Any] = {k: v[0] if len(v) == 1 else v for k, v in parsed.items()} async def accept(self, subprotocol: str | None = None) -> None: """Accept the WebSocket connection, optionally selecting a subprotocol.""" @@ -82,7 +85,7 @@ async def receive_bytes(self) -> bytes: async def receive_json(self) -> Any: """Receive a message from the WebSocket and parse it as JSON.""" text = await self.receive_text() - return msgspec.json.decode(text.encode()) # type: ignore[return-value] + return msgspec.json.decode(text.encode()) async def send_text(self, data: str) -> None: """Send a text message through the WebSocket.""" diff --git a/README.md b/README.md index 8bcd817..93d2ead 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,14 @@ # FasterAPI -[![PyPI version](https://img.shields.io/pypi/v/faster-api-web.svg)](https://pypi.org/project/faster-api-web/) +[![PyPI version](https://img.shields.io/pypi/v/faster-api-web.svg?logo=pypi&logoColor=white)](https://pypi.org/project/faster-api-web/) +[![GitHub release](https://img.shields.io/github/v/release/FasterApiWeb/FasterAPI?include_prereleases&sort=semver&logo=github&label=release)](https://github.com/FasterApiWeb/FasterAPI/releases) [![PyPI - Python](https://img.shields.io/pypi/pyversions/faster-api-web.svg)](https://pypi.org/project/faster-api-web/) [![PyPI - Downloads](https://img.shields.io/pypi/dm/faster-api-web.svg)](https://pypi.org/project/faster-api-web/) -[![CI](https://github.com/FasterApiWeb/FasterAPI/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/FasterApiWeb/FasterAPI/actions/workflows/ci.yml) -[![Benchmark](https://github.com/FasterApiWeb/FasterAPI/actions/workflows/benchmark.yml/badge.svg)](https://github.com/FasterApiWeb/FasterAPI/actions/workflows/benchmark.yml) -[![Docs](https://github.com/FasterApiWeb/FasterAPI/actions/workflows/docs.yml/badge.svg)](https://github.com/FasterApiWeb/FasterAPI/actions/workflows/docs.yml) -[![Documentation site](https://img.shields.io/badge/docs-GitHub%20Pages-5c6bc0)](https://fasterapiweb.github.io/FasterAPI/) +[![CI](https://github.com/FasterApiWeb/FasterAPI/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/FasterApiWeb/FasterAPI/actions/workflows/ci.yml?query=branch%3Amaster) +[![Benchmark](https://github.com/FasterApiWeb/FasterAPI/actions/workflows/benchmark.yml/badge.svg?branch=master)](https://github.com/FasterApiWeb/FasterAPI/actions/workflows/benchmark.yml?query=branch%3Amaster) +[![Docs workflow](https://github.com/FasterApiWeb/FasterAPI/actions/workflows/docs.yml/badge.svg?branch=master)](https://github.com/FasterApiWeb/FasterAPI/actions/workflows/docs.yml?query=branch%3Amaster) +[![Docs site live](https://img.shields.io/website?url=https%3A%2F%2Ffasterapiweb.github.io%2FFasterAPI%2F&up_message=online&down_message=offline&label=docs%20site)](https://fasterapiweb.github.io/FasterAPI/) +[![Documentation](https://img.shields.io/badge/docs-GitHub%20Pages-5c6bc0?logo=githubpages)](https://fasterapiweb.github.io/FasterAPI/) [![codecov](https://codecov.io/gh/FasterApiWeb/FasterAPI/branch/master/graph/badge.svg)](https://codecov.io/gh/FasterApiWeb/FasterAPI) [![License: MIT](https://img.shields.io/github/license/FasterApiWeb/FasterAPI)](LICENSE) [![Docker](https://img.shields.io/badge/docker-ghcr.io-blue?logo=docker)](https://ghcr.io/fasterapiweb/fasterapi) @@ -155,6 +157,8 @@ pip install -e ".[dev]" Tutorials and reference are published from the `docs/` folder with **MkDocs** — same topics as in this README, with **Python 3.13** as the primary target and a dedicated **[compatibility](https://fasterapiweb.github.io/FasterAPI/python-313/)** page for 3.10–3.12. +The live site is deployed by the **[Docs workflow](.github/workflows/docs.yml)** to **GitHub Pages**. In the repository **Settings → Pages → Build and deployment**, the **source must be “GitHub Actions”** (not the legacy `gh-pages` branch); otherwise the published URL can 404 even when the workflow succeeds. + --- ## Releases and PyPI versions diff --git a/benchmarks/compare.py b/benchmarks/compare.py index 7c17d29..7fe1f70 100644 --- a/benchmarks/compare.py +++ b/benchmarks/compare.py @@ -17,7 +17,7 @@ import subprocess import sys import time -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: import httpx @@ -33,13 +33,14 @@ # App factories (each runs in its own process) # ─────────────────────────────────────────────── + def _find_free_port() -> int: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("", 0)) return s.getsockname()[1] -def _fiber_binary_path() -> Optional[str]: +def _fiber_binary_path() -> str | None: name = "fiberbench.exe" if os.name == "nt" else "fiberbench" p = os.path.join(_PROJECT_ROOT, "benchmarks", "fiber", name) return p if os.path.isfile(p) else None @@ -47,9 +48,8 @@ def _fiber_binary_path() -> Optional[str]: def _run_fasterapi(port: int, ready: multiprocessing.Event) -> None: """Launch a FasterAPI (Faster) server in this process.""" - import uvicorn import msgspec - + import uvicorn from FasterAPI.app import Faster class User(msgspec.Struct): @@ -106,6 +106,7 @@ async def create_user(user: User): # Benchmark runner # ─────────────────────────────────────────────── + async def _wait_for_server(url: str, timeout: float = 10.0) -> None: import httpx @@ -128,9 +129,8 @@ async def _benchmark_endpoint( path: str, total: int, concurrency: int, - json_body: Optional[dict] = None, + json_body: dict | None = None, ) -> dict[str, Any]: - import httpx latencies: list[float] = [] errors = 0 @@ -175,8 +175,8 @@ async def _fire() -> None: async def measure_http_rps_three_way( total: int, concurrency: int, - fiber_executable: Optional[str] = None, -) -> tuple[dict[str, dict[str, float]], Optional[str]]: + fiber_executable: str | None = None, +) -> tuple[dict[str, dict[str, float]], str | None]: """Run the same HTTP load against FasterAPI, FastAPI, and (optional) Go Fiber.""" fiber_exe = fiber_executable or _fiber_binary_path() port_faster = _find_free_port() @@ -189,12 +189,16 @@ async def measure_http_rps_three_way( ready_fastapi = multiprocessing.Event() proc_faster = multiprocessing.Process( - target=_run_fasterapi, args=(port_faster, ready_faster), daemon=True, + target=_run_fasterapi, + args=(port_faster, ready_faster), + daemon=True, ) proc_fastapi = multiprocessing.Process( - target=_run_fastapi, args=(port_fastapi, ready_fastapi), daemon=True, + target=_run_fastapi, + args=(port_fastapi, ready_fastapi), + daemon=True, ) - proc_fiber: Optional[subprocess.Popen] = None + proc_fiber: subprocess.Popen | None = None if fiber_exe: env = os.environ.copy() env["PORT"] = str(port_fiber) @@ -209,7 +213,7 @@ async def measure_http_rps_three_way( proc_faster.start() proc_fastapi.start() - fiber_err: Optional[str] = None + fiber_err: str | None = None try: ready_faster.wait(timeout=15) ready_fastapi.wait(timeout=15) @@ -233,7 +237,9 @@ async def measure_http_rps_three_way( fiber_res: dict[str, dict[str, Any]] = {} if proc_fiber: fiber_res = await _run_all_benchmarks( - f"http://127.0.0.1:{port_fiber}", total, concurrency, + f"http://127.0.0.1:{port_fiber}", + total, + concurrency, ) out: dict[str, dict[str, float]] = {} @@ -264,7 +270,9 @@ async def measure_http_rps_three_way( async def _run_all_benchmarks( - base_url: str, total: int, concurrency: int, + base_url: str, + total: int, + concurrency: int, ) -> dict[str, dict[str, Any]]: import httpx @@ -286,6 +294,7 @@ async def _run_all_benchmarks( # Comparison table # ─────────────────────────────────────────────── + def _print_header(total: int, concurrency: int) -> None: print() print("=" * 78) @@ -318,8 +327,7 @@ def _print_summary(faster_results: dict, fastapi_results: dict) -> None: f = faster_results[endpoint] fa = fastapi_results[endpoint] speedup = f["rps"] / fa["rps"] if fa["rps"] > 0 else float("inf") - print(f" {label:<30} {speedup:>6.2f}x faster " - f"({f['rps']:,.0f} vs {fa['rps']:,.0f} req/s)") + print(f" {label:<30} {speedup:>6.2f}x faster ({f['rps']:,.0f} vs {fa['rps']:,.0f} req/s)") print() print(" Note: For Fiber (Go) comparison, use wrk/bombardier against") print(" a Fiber app on the same machine. Typical Fiber numbers are") @@ -334,6 +342,7 @@ def _print_summary(faster_results: dict, fastapi_results: dict) -> None: # Main # ─────────────────────────────────────────────── + def main(total: int = 10_000, concurrency: int = 100) -> None: port_faster = _find_free_port() @@ -343,10 +352,14 @@ def main(total: int = 10_000, concurrency: int = 100) -> None: ready_fastapi = multiprocessing.Event() proc_faster = multiprocessing.Process( - target=_run_fasterapi, args=(port_faster, ready_faster), daemon=True, + target=_run_fasterapi, + args=(port_faster, ready_faster), + daemon=True, ) proc_fastapi = multiprocessing.Process( - target=_run_fastapi, args=(port_fastapi, ready_fastapi), daemon=True, + target=_run_fastapi, + args=(port_fastapi, ready_fastapi), + daemon=True, ) proc_faster.start() @@ -371,8 +384,12 @@ def main(total: int = 10_000, concurrency: int = 100) -> None: fastapi_results = asyncio.run(_run_all_benchmarks(base_fastapi, total, concurrency)) _print_table("GET /health — simple JSON response", faster_results["health"], fastapi_results["health"]) - _print_table("GET /users/{id} — path parameter extraction", faster_results["users_get"], fastapi_results["users_get"]) - _print_table("POST /users — JSON body parsing & validation", faster_results["users_post"], fastapi_results["users_post"]) + _print_table( + "GET /users/{id} — path parameter extraction", faster_results["users_get"], fastapi_results["users_get"] + ) + _print_table( + "POST /users — JSON body parsing & validation", faster_results["users_post"], fastapi_results["users_post"] + ) _print_summary(faster_results, fastapi_results) @@ -385,9 +402,7 @@ def main(total: int = 10_000, concurrency: int = 100) -> None: def _build_asgi_pair(): """Return (faster_app, fastapi_app) for micro-benchmarks.""" - import json as _json import msgspec as _msgspec - from FasterAPI.app import Faster class UserF(_msgspec.Struct): @@ -444,9 +459,13 @@ def measure_direct_asgi_rps(iterations: int = 50_000) -> dict[str, dict[str, flo async def _make_scope(method: str, path: str, body: dict | None = None): scope = { - "type": "http", "method": method, "path": path, - "query_string": b"", "headers": [ - (b"content-type", b"application/json"), (b"host", b"localhost"), + "type": "http", + "method": method, + "path": path, + "query_string": b"", + "headers": [ + (b"content-type", b"application/json"), + (b"host", b"localhost"), ], "client": ("127.0.0.1", 9999), } @@ -504,7 +523,10 @@ def measure_routing_ops() -> dict[str, float]: router.add_route("GET", f"/users/{{id}}/action{i}", lambda: None, {}) for i in range(20): router.add_route( - "GET", f"/org/{{org_id}}/team/{{team_id}}/member{i}", lambda: None, {}, + "GET", + f"/org/{{org_id}}/team/{{team_id}}/member{i}", + lambda: None, + {}, ) paths = ["/static/route25", "/users/42/action15", "/org/abc/team/xyz/member10"] @@ -561,9 +583,13 @@ def direct_benchmark() -> None: async def _make_scope(method: str, path: str, body: dict | None = None): scope = { - "type": "http", "method": method, "path": path, - "query_string": b"", "headers": [ - (b"content-type", b"application/json"), (b"host", b"localhost"), + "type": "http", + "method": method, + "path": path, + "query_string": b"", + "headers": [ + (b"content-type", b"application/json"), + (b"host", b"localhost"), ], "client": ("127.0.0.1", 9999), } @@ -603,8 +629,7 @@ async def _run(): ]: f_rps = await _bench(fapp, m, p, b) fa_rps = await _bench(faapp, m, p, b) - print(f" {label:<28} {f_rps:>10,.0f}/s {fa_rps:>10,.0f}/s" - f" {f_rps / fa_rps:>9.2f}x") + print(f" {label:<28} {f_rps:>10,.0f}/s {fa_rps:>10,.0f}/s {f_rps / fa_rps:>9.2f}x") print() print("=" * 72) print() @@ -616,8 +641,7 @@ async def _run(): parser = argparse.ArgumentParser(description="FasterAPI vs FastAPI benchmark") parser.add_argument("--requests", type=int, default=10_000) parser.add_argument("--concurrency", type=int, default=100) - parser.add_argument("--direct", action="store_true", - help="Run direct ASGI benchmark (no HTTP server)") + parser.add_argument("--direct", action="store_true", help="Run direct ASGI benchmark (no HTTP server)") args = parser.parse_args() if args.direct: diff --git a/mkdocs.yml b/mkdocs.yml index 26b2fb0..6d21d50 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -4,9 +4,15 @@ site_url: https://fasterapiweb.github.io/FasterAPI/ repo_url: https://github.com/FasterApiWeb/FasterAPI repo_name: FasterApiWeb/FasterAPI edit_uri: edit/master/docs/ +site_author: Eshwar Chandra Vidhyasagar Thedla + +# Project pages live under /FasterAPI/; site_url must match Settings → Pages URL. +use_directory_urls: true theme: name: material + icon: + repo: fontawesome/brands/github palette: - media: "(prefers-color-scheme: light)" scheme: default @@ -46,6 +52,15 @@ markdown_extensions: - toc: permalink: true +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/FasterApiWeb/FasterAPI + name: Source on GitHub + - icon: fontawesome/brands/python + link: https://pypi.org/project/faster-api-web/ + name: PyPI faster-api-web + plugins: - search - mkdocstrings: diff --git a/pyproject.toml b/pyproject.toml index c8a6008..d0af1e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,8 @@ dev = [ "pytest-asyncio>=0.23.0", "pytest-cov>=5.0.0", "mypy>=1.10.0", + "ruff>=0.8.0", + "tox>=4.0.0", ] benchmark = [ "httpx>=0.27.0", @@ -74,12 +76,33 @@ testpaths = ["tests"] [tool.mypy] python_version = "3.13" -warn_return_any = true +strict = true warn_unused_configs = true -disallow_untyped_defs = false -check_untyped_defs = true ignore_missing_imports = true +# Test modules: strict typing every test would be noisy; library stays strict above. +[[tool.mypy.overrides]] +module = "tests.*" +ignore_errors = true + +[tool.ruff] +target-version = "py310" +line-length = 120 +src = ["FasterAPI", "tests", "benchmarks"] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM"] +ignore = [ + "E501", + "B008", + "B023", + "SIM115", +] + +[tool.ruff.lint.per-file-ignores] +"benchmarks/check_regressions.py" = ["E402"] +"benchmarks/export_pr_benchmarks.py" = ["E402"] + [tool.hatch.build.targets.wheel] packages = ["FasterAPI"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..76eca71 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test package for FasterAPI.""" diff --git a/tests/test_app_lifecycle.py b/tests/test_app_lifecycle.py index 0f139a7..748802f 100644 --- a/tests/test_app_lifecycle.py +++ b/tests/test_app_lifecycle.py @@ -1,7 +1,6 @@ """Lifespan, errors, and middleware wiring.""" import pytest - from FasterAPI.app import Faster from FasterAPI.exceptions import HTTPException @@ -78,6 +77,7 @@ async def x(): def handle(request, exc: Boom): from FasterAPI.response import PlainTextResponse + return PlainTextResponse("handled", 418) app.add_exception_handler(Boom, handle) diff --git a/tests/test_background.py b/tests/test_background.py index 978a526..e1e6dd2 100644 --- a/tests/test_background.py +++ b/tests/test_background.py @@ -1,9 +1,6 @@ """Background task execution.""" -import asyncio - import pytest - from FasterAPI.background import BackgroundTask, BackgroundTasks diff --git a/tests/test_concurrency.py b/tests/test_concurrency.py index 6397d77..627b588 100644 --- a/tests/test_concurrency.py +++ b/tests/test_concurrency.py @@ -1,7 +1,6 @@ """Concurrency helpers (thread pool, process pool fallbacks).""" import pytest - from FasterAPI import concurrency as c diff --git a/tests/test_datastructures.py b/tests/test_datastructures.py index b810bd9..0aca23f 100644 --- a/tests/test_datastructures.py +++ b/tests/test_datastructures.py @@ -1,7 +1,6 @@ """UploadFile and FormData helpers.""" import pytest - from FasterAPI.datastructures import FormData, UploadFile diff --git a/tests/test_deps.py b/tests/test_deps.py index c8e0847..76f6a80 100644 --- a/tests/test_deps.py +++ b/tests/test_deps.py @@ -1,16 +1,13 @@ -import asyncio - import msgspec import pytest - from FasterAPI.dependencies import Depends, _resolve_handler -from FasterAPI.exceptions import HTTPException, RequestValidationError +from FasterAPI.exceptions import RequestValidationError from FasterAPI.params import Body, Cookie, Header, Path, Query from FasterAPI.request import Request - # --------------- helpers --------------- + def _make_request( *, method: str = "GET", @@ -45,6 +42,7 @@ async def receive(): # Request injection # ============================== + class TestRequestInjection: @pytest.mark.asyncio async def test_inject_request(self): @@ -60,6 +58,7 @@ async def handler(request: Request): # Path params # ============================== + class TestPathParams: @pytest.mark.asyncio async def test_path_param(self): @@ -94,6 +93,7 @@ async def handler(user_id: str = Path("default_id")): # Query params # ============================== + class TestQueryParams: @pytest.mark.asyncio async def test_query_param(self): @@ -136,6 +136,7 @@ async def handler(q: str = Query(alias="search_query")): # Header params # ============================== + class TestHeaderParams: @pytest.mark.asyncio async def test_header(self): @@ -178,6 +179,7 @@ async def handler(x_token: str = Header("fallback")): # Cookie params # ============================== + class TestCookieParams: @pytest.mark.asyncio async def test_cookie(self): @@ -202,6 +204,7 @@ async def handler(session: str = Cookie("none")): # Body / msgspec.Struct # ============================== + class Item(msgspec.Struct): name: str price: float @@ -252,6 +255,7 @@ async def handler(data: dict = Body({"fallback": True})): # Depends() # ============================== + class TestDepends: @pytest.mark.asyncio async def test_simple_dependency(self): @@ -355,6 +359,7 @@ async def handler(version=Depends(get_version)): # Sync handlers # ============================== + class TestSyncHandlers: @pytest.mark.asyncio async def test_sync_handler(self): @@ -370,6 +375,7 @@ def handler(q: str = Query("hi")): # Combined params # ============================== + class TestCombinedParams: @pytest.mark.asyncio async def test_multiple_param_types(self): diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index cb4998b..122fab9 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -1,7 +1,6 @@ """HTTP and validation exception types and default handlers.""" import pytest - from FasterAPI.exceptions import ( HTTPException, RequestValidationError, @@ -35,10 +34,12 @@ async def test_default_http_handler_with_headers(): @pytest.mark.asyncio async def test_validation_handler_shapes_errors(): - exc = RequestValidationError([ - {"loc": ["query", "q"], "msg": "missing", "type": "value_error"}, - {"loc": [], "msg": "x"}, - ]) + exc = RequestValidationError( + [ + {"loc": ["query", "q"], "msg": "missing", "type": "value_error"}, + {"loc": [], "msg": "x"}, + ] + ) status, body, hdrs = await _default_validation_exception_handler(None, exc) assert status == 422 assert b"query" in body diff --git a/tests/test_middleware.py b/tests/test_middleware.py index e01d12d..caf5370 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -2,7 +2,6 @@ import msgspec import pytest - from FasterAPI.app import Faster from FasterAPI.middleware import ( CORSMiddleware, @@ -11,9 +10,9 @@ TrustedHostMiddleware, ) - # --------------- helpers --------------- + class MockSend: def __init__(self): self.messages: list[dict] = [] @@ -91,6 +90,7 @@ async def small_handler(): # CORS Middleware # ============================== + class TestCORSBasic: @pytest.mark.asyncio async def test_cors_allow_all_origins(self): @@ -210,6 +210,7 @@ async def test_credentials(self): # GZip Middleware # ============================== + class TestGZipMiddleware: @pytest.mark.asyncio async def test_gzip_compresses_large_response(self): @@ -276,6 +277,7 @@ async def test_gzip_vary_header(self): # TrustedHost Middleware # ============================== + class TestTrustedHostMiddleware: @pytest.mark.asyncio async def test_allowed_host(self): @@ -326,6 +328,7 @@ async def test_host_with_port(self): # HTTPS Redirect Middleware # ============================== + class TestHTTPSRedirectMiddleware: @pytest.mark.asyncio async def test_http_redirects(self): @@ -358,6 +361,7 @@ async def test_https_passes_through(self): # Middleware chain # ============================== + class TestMiddlewareChain: @pytest.mark.asyncio async def test_chain_is_cached(self): @@ -383,10 +387,12 @@ async def test_multiple_middleware(self): app.add_middleware(TrustedHostMiddleware, allowed_hosts=["example.com"]) send = MockSend() - scope = _make_scope(headers=[ - (b"origin", b"http://example.com"), - (b"host", b"example.com"), - ]) + scope = _make_scope( + headers=[ + (b"origin", b"http://example.com"), + (b"host", b"example.com"), + ] + ) await app(scope, _receive, send) assert send.status == 200 diff --git a/tests/test_openapi.py b/tests/test_openapi.py index 7dedf43..a3ddf73 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -1,10 +1,7 @@ from __future__ import annotations -from typing import Optional - import msgspec import pytest - from FasterAPI.app import Faster from FasterAPI.dependencies import Depends from FasterAPI.openapi.generator import generate_openapi @@ -12,11 +9,12 @@ from FasterAPI.params import Body, Cookie, Header, Path, Query from FasterAPI.request import Request - # --------------- models --------------- + class Item(msgspec.Struct): """An item in the store.""" + name: str price: float in_stock: bool = True @@ -35,6 +33,7 @@ class User(msgspec.Struct): # --------------- helpers --------------- + def _make_app(**kw) -> Faster: return Faster(**kw) @@ -43,6 +42,7 @@ def _make_app(**kw) -> Faster: # OpenAPI spec structure # ============================== + class TestSpecStructure: def test_basic_structure(self): app = _make_app(title="TestApp", version="2.0.0", description="A test app") @@ -85,6 +85,7 @@ async def create_user(): # Path parameters # ============================== + class TestPathParams: def test_path_param_in_spec(self): app = _make_app() @@ -130,6 +131,7 @@ async def get_user(user_id: str = Path(description="The user ID")): # Query parameters # ============================== + class TestQueryParams: def test_query_param(self): app = _make_app() @@ -178,6 +180,7 @@ async def list_items(q: str = Query(alias="search_query")): # Header parameters # ============================== + class TestHeaderParams: def test_header_param(self): app = _make_app() @@ -209,6 +212,7 @@ async def auth(token: str = Header(alias="Authorization")): # Cookie parameters # ============================== + class TestCookieParams: def test_cookie_param(self): app = _make_app() @@ -229,6 +233,7 @@ async def me(session: str = Cookie()): # Request body (structs) # ============================== + class TestRequestBody: def test_struct_body(self): app = _make_app() @@ -315,6 +320,7 @@ async def update(id: str = Path(), item: Item = Body()): # Response model # ============================== + class TestResponseModel: def test_response_model_ref(self): app = _make_app() @@ -344,6 +350,7 @@ async def ping(): # Tags, summary, deprecated # ============================== + class TestMetadata: def test_tags(self): app = _make_app() @@ -412,6 +419,7 @@ async def list_items(): # 422 validation error response # ============================== + class TestValidationResponse: def test_422_added_when_params_exist(self): app = _make_app() @@ -440,6 +448,7 @@ async def ping(): # Caching # ============================== + class TestCaching: def test_spec_is_cached(self): app = _make_app() @@ -457,6 +466,7 @@ async def ping(): # Type mapping # ============================== + class ListModel(msgspec.Struct): tags: list[str] @@ -507,6 +517,7 @@ async def create(model: DictModel): # UI HTML # ============================== + class TestUI: def test_swagger_html_contains_url(self): html = swagger_ui_html("/openapi.json", title="My App") @@ -525,6 +536,7 @@ def test_redoc_html_contains_url(self): # Auto-registered routes in app # ============================== + class MockSend: def __init__(self): self.messages: list[dict] = [] @@ -552,8 +564,11 @@ async def items(): send = MockSend() scope = { - "type": "http", "method": "GET", "path": "/openapi.json", - "headers": [], "query_string": b"", + "type": "http", + "method": "GET", + "path": "/openapi.json", + "headers": [], + "query_string": b"", } async def receive(): @@ -571,8 +586,11 @@ async def test_docs_route(self): app = _make_app() send = MockSend() scope = { - "type": "http", "method": "GET", "path": "/docs", - "headers": [], "query_string": b"", + "type": "http", + "method": "GET", + "path": "/docs", + "headers": [], + "query_string": b"", } async def receive(): @@ -587,8 +605,11 @@ async def test_redoc_route(self): app = _make_app() send = MockSend() scope = { - "type": "http", "method": "GET", "path": "/redoc", - "headers": [], "query_string": b"", + "type": "http", + "method": "GET", + "path": "/redoc", + "headers": [], + "query_string": b"", } async def receive(): @@ -603,8 +624,11 @@ async def test_disabled_openapi(self): app = Faster(openapi_url=None) send = MockSend() scope = { - "type": "http", "method": "GET", "path": "/openapi.json", - "headers": [], "query_string": b"", + "type": "http", + "method": "GET", + "path": "/openapi.json", + "headers": [], + "query_string": b"", } async def receive(): @@ -624,7 +648,8 @@ async def receive(): # /api/schema should work await app( {"type": "http", "method": "GET", "path": "/api/schema", "headers": [], "query_string": b""}, - receive, send, + receive, + send, ) assert send.status == 200 @@ -633,6 +658,7 @@ async def receive(): # Combined: complex app spec # ============================== + class TestComplexSpec: def test_full_crud_spec(self): app = _make_app(title="ItemStore", version="1.0.0") diff --git a/tests/test_params.py b/tests/test_params.py index 2a3be4b..3a3a3b0 100644 --- a/tests/test_params.py +++ b/tests/test_params.py @@ -1,8 +1,6 @@ -import asyncio import inspect import pytest - from FasterAPI.params import ( MISSING, Body, @@ -15,9 +13,9 @@ ) from FasterAPI.request import Request - # --------------- helpers --------------- + def _make_scope( *, method: str = "GET", @@ -55,6 +53,7 @@ async def receive(): # Request tests # ============================== + class TestRequestBasics: def test_method_and_path(self): scope = _make_scope(method="POST", path="/users") @@ -63,10 +62,12 @@ def test_method_and_path(self): assert req.path == "/users" def test_headers_lowercase(self): - scope = _make_scope(headers=[ - (b"Content-Type", b"application/json"), - (b"X-Custom", b"value"), - ]) + scope = _make_scope( + headers=[ + (b"Content-Type", b"application/json"), + (b"X-Custom", b"value"), + ] + ) req = Request(scope, None) assert req.headers["content-type"] == "application/json" assert req.headers["x-custom"] == "value" @@ -101,9 +102,11 @@ def test_client_absent(self): class TestRequestCookies: def test_cookies_parsed(self): - scope = _make_scope(headers=[ - (b"cookie", b"session=abc123; theme=dark"), - ]) + scope = _make_scope( + headers=[ + (b"cookie", b"session=abc123; theme=dark"), + ] + ) req = Request(scope, None) assert req.cookies == {"session": "abc123", "theme": "dark"} @@ -115,9 +118,11 @@ def test_no_cookies(self): class TestRequestContentType: def test_content_type(self): - scope = _make_scope(headers=[ - (b"content-type", b"application/json"), - ]) + scope = _make_scope( + headers=[ + (b"content-type", b"application/json"), + ] + ) req = Request(scope, None) assert req.content_type == "application/json" @@ -165,14 +170,14 @@ async def test_urlencoded_form(self): async def test_multipart_form(self): boundary = "----boundary" body = ( - f"------boundary\r\n" - f'Content-Disposition: form-data; name="field1"\r\n\r\n' - f"value1\r\n" - f"------boundary\r\n" - f'Content-Disposition: form-data; name="field2"\r\n\r\n' - f"value2\r\n" - f"------boundary--\r\n" - ).encode() + b"------boundary\r\n" + b'Content-Disposition: form-data; name="field1"\r\n\r\n' + b"value1\r\n" + b"------boundary\r\n" + b'Content-Disposition: form-data; name="field2"\r\n\r\n' + b"value2\r\n" + b"------boundary--\r\n" + ) receive = await _receive_body(body) scope = _make_scope( method="POST", @@ -188,6 +193,7 @@ async def test_multipart_form(self): # Param descriptor tests # ============================== + class TestPathParam: def test_defaults(self): p = Path() @@ -278,6 +284,7 @@ def test_repr(self): # Signature-level usage # ============================== + class TestSignatureUsage: def test_params_as_defaults_in_signature(self): """Verify param descriptors work as default values in function signatures.""" diff --git a/tests/test_response.py b/tests/test_response.py index 77a3af0..a33b8bc 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -1,10 +1,8 @@ """Tests for response classes and ASGI emitters.""" -import asyncio from pathlib import Path import pytest - from FasterAPI.response import ( FileResponse, HTMLResponse, @@ -123,4 +121,3 @@ async def send(msg: dict) -> None: assert sent[1]["body"] == b"file-content" hdrs = dict(sent[0]["headers"]) assert b"attachment" in hdrs[b"content-disposition"] - diff --git a/tests/test_routing.py b/tests/test_routing.py index 79c8afd..171c45b 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -1,8 +1,8 @@ -from FasterAPI.router import RadixRouter, FasterRouter - +from FasterAPI.router import FasterRouter, RadixRouter # --- Helpers --- + def _handler(): pass @@ -13,6 +13,7 @@ def _other(): # --- RadixRouter: static routes --- + class TestStaticRoutes: def test_root(self): r = RadixRouter() @@ -53,6 +54,7 @@ def test_multiple_static_routes(self): # --- RadixRouter: param routes --- + class TestParamRoutes: def test_single_param(self): r = RadixRouter() @@ -94,6 +96,7 @@ def test_static_preferred_over_param(self): # --- RadixRouter: method handling --- + class TestMethodHandling: def test_method_mismatch(self): r = RadixRouter() @@ -115,6 +118,7 @@ def test_method_case_insensitive(self): # --- RadixRouter: trailing slash tolerance --- + class TestTrailingSlash: def test_registered_without_resolved_with(self): r = RadixRouter() @@ -138,6 +142,7 @@ def test_root_with_trailing_slash(self): # --- RadixRouter: metadata --- + class TestMetadata: def test_metadata_returned(self): r = RadixRouter() @@ -154,6 +159,7 @@ def test_default_metadata_empty(self): # --- FasterRouter --- + class TestFasterRouter: def test_prefix_applied(self): router = FasterRouter(prefix="/api/v1") @@ -179,19 +185,24 @@ def test_all_methods(self): router = FasterRouter() @router.get("/a") - def a(): pass + def a(): + pass @router.post("/b") - def b(): pass + def b(): + pass @router.put("/c") - def c(): pass + def c(): + pass @router.delete("/d") - def d(): pass + def d(): + pass @router.patch("/e") - def e(): pass + def e(): + pass methods = [r["method"] for r in router.routes] assert methods == ["GET", "POST", "PUT", "DELETE", "PATCH"] diff --git a/tests/test_testclient.py b/tests/test_testclient.py index aa574fa..fe76f81 100644 --- a/tests/test_testclient.py +++ b/tests/test_testclient.py @@ -1,8 +1,6 @@ """TestClient HTTP and WebSocket paths.""" -import msgspec import pytest - from FasterAPI import Faster from FasterAPI.testclient import TestClient diff --git a/tests/test_websocket.py b/tests/test_websocket.py index 8fb787a..dcf0e40 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -1,14 +1,11 @@ -import asyncio - import msgspec import pytest - from FasterAPI.app import Faster from FasterAPI.websocket import WebSocket, WebSocketDisconnect, WebSocketState - # --------------- helpers --------------- + class MockWebSocketTransport: """Simulates the ASGI websocket protocol for testing.""" @@ -58,6 +55,7 @@ def _ws_scope( # WebSocket constructor & props # ============================== + class TestWebSocketProperties: def test_path_and_params(self): scope = _ws_scope(path="/chat") @@ -101,6 +99,7 @@ def test_initial_state_is_connecting(self): # accept # ============================== + class TestWebSocketAccept: @pytest.mark.asyncio async def test_accept(self): @@ -130,12 +129,15 @@ async def test_double_accept_raises(self): # send / receive text # ============================== + class TestWebSocketText: @pytest.mark.asyncio async def test_send_receive_text(self): - transport = MockWebSocketTransport(to_receive=[ - {"type": "websocket.receive", "text": "hello"}, - ]) + transport = MockWebSocketTransport( + to_receive=[ + {"type": "websocket.receive", "text": "hello"}, + ] + ) ws = WebSocket(_ws_scope(), transport.receive, transport.send) text = await ws.receive_text() assert text == "hello" @@ -145,9 +147,11 @@ async def test_send_receive_text(self): @pytest.mark.asyncio async def test_receive_empty_text(self): - transport = MockWebSocketTransport(to_receive=[ - {"type": "websocket.receive"}, - ]) + transport = MockWebSocketTransport( + to_receive=[ + {"type": "websocket.receive"}, + ] + ) ws = WebSocket(_ws_scope(), transport.receive, transport.send) text = await ws.receive_text() assert text == "" @@ -157,12 +161,15 @@ async def test_receive_empty_text(self): # send / receive bytes # ============================== + class TestWebSocketBytes: @pytest.mark.asyncio async def test_send_receive_bytes(self): - transport = MockWebSocketTransport(to_receive=[ - {"type": "websocket.receive", "bytes": b"\x00\x01"}, - ]) + transport = MockWebSocketTransport( + to_receive=[ + {"type": "websocket.receive", "bytes": b"\x00\x01"}, + ] + ) ws = WebSocket(_ws_scope(), transport.receive, transport.send) data = await ws.receive_bytes() assert data == b"\x00\x01" @@ -175,13 +182,16 @@ async def test_send_receive_bytes(self): # send / receive json # ============================== + class TestWebSocketJson: @pytest.mark.asyncio async def test_send_receive_json(self): payload = msgspec.json.encode({"key": "value"}).decode() - transport = MockWebSocketTransport(to_receive=[ - {"type": "websocket.receive", "text": payload}, - ]) + transport = MockWebSocketTransport( + to_receive=[ + {"type": "websocket.receive", "text": payload}, + ] + ) ws = WebSocket(_ws_scope(), transport.receive, transport.send) data = await ws.receive_json() assert data == {"key": "value"} @@ -203,6 +213,7 @@ async def test_send_json_list(self): # close & disconnect # ============================== + class TestWebSocketCloseDisconnect: @pytest.mark.asyncio async def test_close(self): @@ -227,9 +238,11 @@ async def test_close_default_code(self): @pytest.mark.asyncio async def test_disconnect_raises(self): - transport = MockWebSocketTransport(to_receive=[ - {"type": "websocket.disconnect", "code": 1001}, - ]) + transport = MockWebSocketTransport( + to_receive=[ + {"type": "websocket.disconnect", "code": 1001}, + ] + ) ws = WebSocket(_ws_scope(), transport.receive, transport.send) with pytest.raises(WebSocketDisconnect) as exc_info: await ws.receive_text() @@ -238,9 +251,11 @@ async def test_disconnect_raises(self): @pytest.mark.asyncio async def test_disconnect_default_code(self): - transport = MockWebSocketTransport(to_receive=[ - {"type": "websocket.disconnect"}, - ]) + transport = MockWebSocketTransport( + to_receive=[ + {"type": "websocket.disconnect"}, + ] + ) ws = WebSocket(_ws_scope(), transport.receive, transport.send) with pytest.raises(WebSocketDisconnect) as exc_info: await ws.receive_bytes() @@ -251,6 +266,7 @@ async def test_disconnect_default_code(self): # App integration # ============================== + class TestWebSocketApp: @pytest.mark.asyncio async def test_echo_handler(self): @@ -263,9 +279,11 @@ async def echo(ws: WebSocket): await ws.send_text(f"echo: {text}") await ws.close() - transport = MockWebSocketTransport(to_receive=[ - {"type": "websocket.receive", "text": "ping"}, - ]) + transport = MockWebSocketTransport( + to_receive=[ + {"type": "websocket.receive", "text": "ping"}, + ] + ) await app(_ws_scope("/ws"), transport.receive, transport.send) assert transport.accepted() @@ -309,9 +327,11 @@ async def api(ws: WebSocket): await ws.close() payload = msgspec.json.encode({"msg": "hi"}).decode() - transport = MockWebSocketTransport(to_receive=[ - {"type": "websocket.receive", "text": payload}, - ]) + transport = MockWebSocketTransport( + to_receive=[ + {"type": "websocket.receive", "text": payload}, + ] + ) await app(_ws_scope("/api"), transport.receive, transport.send) sent = msgspec.json.decode(transport.sent_texts()[0].encode()) @@ -355,12 +375,14 @@ async def chat(ws: WebSocket): except WebSocketDisconnect: pass - transport = MockWebSocketTransport(to_receive=[ - {"type": "websocket.receive", "text": "first"}, - {"type": "websocket.receive", "text": "second"}, - {"type": "websocket.receive", "text": "third"}, - {"type": "websocket.disconnect", "code": 1000}, - ]) + transport = MockWebSocketTransport( + to_receive=[ + {"type": "websocket.receive", "text": "first"}, + {"type": "websocket.receive", "text": "second"}, + {"type": "websocket.receive", "text": "third"}, + {"type": "websocket.disconnect", "code": 1000}, + ] + ) await app(_ws_scope("/chat"), transport.receive, transport.send) assert transport.sent_texts() == ["reply: first", "reply: second", "reply: third"] diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..4e87ec8 --- /dev/null +++ b/tox.ini @@ -0,0 +1,10 @@ +[tox] +env_list = py311, py312, py313 +isolated_build = true +skip_missing_interpreters = true + +[testenv] +extras = dev +commands = + python -m pytest {posargs} --cov=FasterAPI --cov-report=term-missing --cov-fail-under=85 + python -m mypy FasterAPI