diff --git a/FasterAPI/app.py b/FasterAPI/app.py index b9f8d54..093f7ec 100644 --- a/FasterAPI/app.py +++ b/FasterAPI/app.py @@ -409,6 +409,7 @@ def _add_route( dependencies: list[Depends] | None, response_model_include: set[str] | None = None, response_model_exclude: set[str] | None = None, + openapi_extra: dict[str, Any] | None = None, ) -> None: metadata: dict[str, Any] = { "tags": tags, @@ -420,6 +421,7 @@ def _add_route( "deprecated": deprecated, "responses": responses, "dependencies": dependencies, + "openapi_extra": openapi_extra, } self.routes.append({"method": method, "path": path, "handler": handler, **metadata}) self._router.add_route(method, path, handler, metadata) @@ -440,6 +442,7 @@ def decorator(handler: ASGIApp) -> ASGIApp: deprecated=kw.get("deprecated", False), responses=kw.get("responses"), dependencies=kw.get("dependencies"), + openapi_extra=kw.get("openapi_extra"), ) return handler diff --git a/FasterAPI/cli.py b/FasterAPI/cli.py new file mode 100644 index 0000000..6f2682b --- /dev/null +++ b/FasterAPI/cli.py @@ -0,0 +1,401 @@ +"""FasterAPI command-line interface. + +Commands +-------- +fasterapi run Run with uvicorn (production mode). +fasterapi dev Run with auto-reload (development mode). +fasterapi new Scaffold a new FasterAPI project. +fasterapi migrate-from-fastapi + Rewrite fastapi imports to FasterAPI in a file or directory. +""" + +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys +import textwrap +from pathlib import Path + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def main(argv: list[str] | None = None) -> int: + parser = _build_parser() + args = parser.parse_args(argv) + if not hasattr(args, "func"): + parser.print_help() + return 1 + result: int = args.func(args) + return result + + +# --------------------------------------------------------------------------- +# Sub-command: run +# --------------------------------------------------------------------------- + + +def _cmd_run(args: argparse.Namespace) -> int: + """Run the app with uvicorn in production mode.""" + cmd = _build_uvicorn_cmd(args, reload=False) + return subprocess.call(cmd) + + +# --------------------------------------------------------------------------- +# Sub-command: dev +# --------------------------------------------------------------------------- + + +def _cmd_dev(args: argparse.Namespace) -> int: + """Run the app with uvicorn in development (auto-reload) mode.""" + cmd = _build_uvicorn_cmd(args, reload=True) + return subprocess.call(cmd) + + +def _build_uvicorn_cmd(args: argparse.Namespace, *, reload: bool) -> list[str]: + app_ref = args.app + # Auto-detect if bare module name given (no colon) — look for 'app' object + if ":" not in app_ref: + app_ref = f"{app_ref}:app" + + cmd: list[str] = [ + sys.executable, + "-m", + "uvicorn", + app_ref, + "--host", + args.host, + "--port", + str(args.port), + ] + if reload: + cmd.append("--reload") + else: + cmd += ["--workers", str(args.workers)] + + if args.log_level: + cmd += ["--log-level", args.log_level] + + return cmd + + +# --------------------------------------------------------------------------- +# Sub-command: new +# --------------------------------------------------------------------------- + + +def _cmd_new(args: argparse.Namespace) -> int: + """Scaffold a new FasterAPI project.""" + name: str = args.name + dest = Path(name) + + if dest.exists(): + print(f"error: directory '{name}' already exists", file=sys.stderr) + return 1 + + dest.mkdir(parents=True) + (dest / "app").mkdir() + + _write(dest / "app" / "__init__.py", "") + _write(dest / "app" / "main.py", _MAIN_PY.format(name=name)) + _write(dest / "app" / "routers" / "__init__.py", "") + _write(dest / "app" / "routers" / "items.py", _ITEMS_ROUTER_PY) + _write(dest / "app" / "models.py", _MODELS_PY) + _write(dest / "pyproject.toml", _PYPROJECT_TOML.format(name=name)) + _write( + dest / "README.md", + f"# {name}\n\nA FasterAPI application.\n\n## Run\n\n```bash\npip install -e .\nfasterapi dev app.main\n```\n", + ) + _write(dest / ".gitignore", _GITIGNORE) + _write(dest / ".env", "# Environment variables\nDEBUG=true\n") + _write(dest / "Dockerfile", _DOCKERFILE.format(name=name)) + + print(f"Created project '{name}'. Get started:\n") + print(f" cd {name}") + print(" pip install -e .") + print(" fasterapi dev app.main\n") + return 0 + + +def _write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(textwrap.dedent(content).lstrip()) + + +# --------------------------------------------------------------------------- +# Sub-command: migrate-from-fastapi +# --------------------------------------------------------------------------- + + +def _cmd_migrate(args: argparse.Namespace) -> int: + """Rewrite fastapi imports to FasterAPI in a file or directory tree.""" + target = Path(args.path) + if not target.exists(): + print(f"error: '{args.path}' does not exist", file=sys.stderr) + return 1 + + files = list(target.rglob("*.py")) if target.is_dir() else [target] + changed = 0 + for f in files: + if _migrate_file(f, dry_run=args.dry_run): + changed += 1 + verb = "would rewrite" if args.dry_run else "rewritten" + print(f" {verb}: {f}") + + if args.dry_run: + print(f"\nDry run: {changed} file(s) would be changed. Re-run without --dry-run to apply.") + else: + print(f"\nDone: {changed} file(s) rewritten.") + return 0 + + +# Substitution rules applied in order +_MIGRATION_RULES: list[tuple[str, str]] = [ + # Import rewriting — most specific first + (r"from fastapi\.testclient import TestClient", "from FasterAPI.testclient import TestClient"), + (r"from fastapi\.security import", "from FasterAPI.security import"), + (r"from fastapi\.middleware\.cors import CORSMiddleware", "from FasterAPI.middleware import CORSMiddleware"), + (r"from fastapi\.middleware\.gzip import GZipMiddleware", "from FasterAPI.middleware import GZipMiddleware"), + (r"from fastapi\.middleware import", "from FasterAPI.middleware import"), + (r"from fastapi\.staticfiles import StaticFiles", "from FasterAPI.staticfiles import StaticFiles"), + (r"from fastapi\.templating import Jinja2Templates", "from FasterAPI.templating import Jinja2Templates"), + (r"from fastapi\.responses import", "from FasterAPI.response import"), + (r"from fastapi\.background import", "from FasterAPI.background import"), + (r"from fastapi\.websockets import", "from FasterAPI.websocket import"), + (r"from fastapi import APIRouter", "from FasterAPI import FasterRouter as APIRouter"), + (r"from fastapi import FastAPI", "from FasterAPI import Faster as FastAPI"), + (r"from fastapi import", "from FasterAPI import"), + (r"import fastapi\b", "import FasterAPI"), + # Class name rewriting + (r"\bFastAPI\(\b", "Faster("), + (r"\bAPIRouter\(\b", "FasterRouter("), +] + + +def _migrate_file(path: Path, *, dry_run: bool) -> bool: + original = path.read_text(encoding="utf-8") + result = original + for pattern, replacement in _MIGRATION_RULES: + result = re.sub(pattern, replacement, result) + if result == original: + return False + if not dry_run: + path.write_text(result, encoding="utf-8") + return True + + +# --------------------------------------------------------------------------- +# Parser +# --------------------------------------------------------------------------- + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="fasterapi", + description="FasterAPI command-line interface", + ) + sub = parser.add_subparsers(title="commands", metavar="") + + # -- run -- + p_run = sub.add_parser("run", help="Run app with uvicorn (production)") + _add_server_args(p_run) + p_run.add_argument( + "--workers", + type=int, + default=_default_workers(), + metavar="N", + help="Number of uvicorn worker processes (default: %(default)s)", + ) + p_run.set_defaults(func=_cmd_run) + + # -- dev -- + p_dev = sub.add_parser("dev", help="Run app with auto-reload (development)") + _add_server_args(p_dev) + p_dev.set_defaults(func=_cmd_dev) + + # -- new -- + p_new = sub.add_parser("new", help="Scaffold a new FasterAPI project") + p_new.add_argument("name", help="Project directory name") + p_new.set_defaults(func=_cmd_new) + + # -- migrate-from-fastapi -- + p_mig = sub.add_parser("migrate-from-fastapi", help="Rewrite fastapi imports to FasterAPI") + p_mig.add_argument("path", help="File or directory to migrate") + p_mig.add_argument("--dry-run", action="store_true", help="Show what would change without writing files") + p_mig.set_defaults(func=_cmd_migrate) + + return parser + + +def _add_server_args(p: argparse.ArgumentParser) -> None: + p.add_argument( + "app", + nargs="?", + default="main:app", + help="App import string, e.g. main:app or mypackage.main (default: main:app)", + ) + p.add_argument("--host", default="127.0.0.1", help="Bind host (default: 127.0.0.1)") + p.add_argument("--port", type=int, default=8000, help="Bind port (default: 8000)") + p.add_argument( + "--log-level", + default="info", + choices=["critical", "error", "warning", "info", "debug", "trace"], + metavar="LEVEL", + help="Log level (default: info)", + ) + + +def _default_workers() -> int: + try: + return (os.cpu_count() or 1) * 2 + 1 + except Exception: + return 1 + + +# --------------------------------------------------------------------------- +# Project scaffold templates +# --------------------------------------------------------------------------- + +_MAIN_PY = """\ +from contextlib import asynccontextmanager + +from FasterAPI import Faster + +from .routers import items + + +@asynccontextmanager +async def lifespan(app: Faster): + # Startup: initialise DB connections, caches, etc. + yield + # Shutdown: release resources + + +app = Faster( + title="{name}", + description="A FasterAPI application.", + version="0.1.0", + lifespan=lifespan, +) + +app.include_router(items.router, prefix="/items", tags=["items"]) + + +@app.get("/health", tags=["health"]) +async def health(): + return {{"status": "ok"}} +""" + +_ITEMS_ROUTER_PY = """\ +import msgspec +from FasterAPI import FasterRouter, HTTPException + +router = FasterRouter() + + +class Item(msgspec.Struct): + id: int + name: str + price: float = 0.0 + + +_DB: dict[int, Item] = {} +_NEXT_ID = 1 + + +@router.get("/", response_model=list[Item]) +async def list_items(): + return list(_DB.values()) + + +@router.get("/{item_id}", response_model=Item) +async def get_item(item_id: int): + item = _DB.get(item_id) + if item is None: + raise HTTPException(status_code=404, detail="Item not found") + return item + + +@router.post("/", response_model=Item, status_code=201) +async def create_item(body: Item): + global _NEXT_ID + item = Item(id=_NEXT_ID, name=body.name, price=body.price) + _DB[_NEXT_ID] = item + _NEXT_ID += 1 + return item + + +@router.delete("/{item_id}", status_code=204) +async def delete_item(item_id: int): + if item_id not in _DB: + raise HTTPException(status_code=404, detail="Item not found") + del _DB[item_id] +""" + +_MODELS_PY = '''\ +"""Shared data models for the application.""" +import msgspec + + +class ErrorDetail(msgspec.Struct): + detail: str +''' + +_PYPROJECT_TOML = """\ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{name}" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = [ + "faster-api-web[all]>=0.1.0", +] + +[project.optional-dependencies] +dev = [ + "httpx", + "pytest", + "pytest-asyncio", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] + +[tool.hatch.build.targets.wheel] +packages = ["app"] +""" + +_GITIGNORE = """\ +__pycache__/ +*.pyc +*.pyo +.venv/ +.env +dist/ +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +""" + +_DOCKERFILE = """\ +FROM python:3.13-slim + +WORKDIR /app +COPY pyproject.toml ./ +RUN pip install --no-cache-dir ".[all]" +COPY app/ app/ + +RUN useradd -r -u 1001 appuser +USER appuser +EXPOSE 8000 +CMD ["fasterapi", "run", "app.main", "--host", "0.0.0.0"] +""" diff --git a/FasterAPI/dependencies.py b/FasterAPI/dependencies.py index 6780432..d32695c 100644 --- a/FasterAPI/dependencies.py +++ b/FasterAPI/dependencies.py @@ -9,6 +9,7 @@ from __future__ import annotations import dataclasses +import enum import inspect import typing from collections.abc import Callable @@ -247,7 +248,7 @@ async def _resolve_from_specs( spec.default, ) elif kind == _KIND_PATH: - kwargs[spec.name] = _resolve_path(spec.name, path_params, spec.marker) + kwargs[spec.name] = _resolve_path(spec.name, path_params, spec.marker, spec.annotation) elif kind == _KIND_QUERY: kwargs[spec.name] = _resolve_query(spec.name, request, spec.marker) elif kind == _KIND_HEADER: @@ -266,7 +267,7 @@ async def _resolve_from_specs( kwargs[spec.name] = await _resolve_body(request, spec.marker) else: if spec.name in path_params: - kwargs[spec.name] = path_params[spec.name] + kwargs[spec.name] = _coerce_path_value(path_params[spec.name], spec.annotation, spec.name) elif spec.default is not inspect.Parameter.empty: kwargs[spec.name] = spec.default @@ -380,9 +381,9 @@ async def _resolve_dataclass( ) from exc -def _resolve_path(name: str, path_params: dict[str, str], marker: Path) -> Any: +def _resolve_path(name: str, path_params: dict[str, str], marker: Path, annotation: Any = None) -> Any: if name in path_params: - return path_params[name] + return _coerce_path_value(path_params[name], annotation, name) if marker.default is not _MISSING: return marker.default raise RequestValidationError( @@ -390,6 +391,34 @@ def _resolve_path(name: str, path_params: dict[str, str], marker: Path) -> Any: ) +def _coerce_path_value(value: str, annotation: Any, name: str) -> Any: + """Coerce a raw path-param string to *annotation* when it is an Enum type.""" + if annotation is None or not (inspect.isclass(annotation) and issubclass(annotation, enum.Enum)): + return value + # For numeric enums (IntEnum, etc.) cast the string to the value type first + members = list(annotation) + if members: + value_type = type(members[0].value) + if value_type is not str: + try: + return annotation(value_type(value)) + except (ValueError, TypeError): + pass + try: + return annotation(value) + except ValueError: + valid = [m.value for m in annotation] + raise RequestValidationError( + [ + { + "loc": ["path", name], + "msg": f"value is not a valid enum member; permitted: {valid}", + "type": "type_error.enum", + } + ], + ) from None + + def _resolve_query(name: str, request: Request, marker: Query) -> Any: key = marker.alias or name value = request.query_params.get(key) diff --git a/FasterAPI/openapi/generator.py b/FasterAPI/openapi/generator.py index 408f769..a60c06e 100644 --- a/FasterAPI/openapi/generator.py +++ b/FasterAPI/openapi/generator.py @@ -161,6 +161,17 @@ def _build_operation( ) operation["responses"] = responses + + # Merge caller-supplied extra fields (e.g. x-internal, externalDocs, servers) + openapi_extra: dict[str, Any] | None = route.get("openapi_extra") + if openapi_extra: + for key, value in openapi_extra.items(): + if key == "responses" and isinstance(value, dict): + # Deep-merge additional responses rather than clobber + operation["responses"].update(value) + else: + operation[key] = value + return operation diff --git a/FasterAPI/router.py b/FasterAPI/router.py index c50c906..a156fda 100644 --- a/FasterAPI/router.py +++ b/FasterAPI/router.py @@ -166,6 +166,7 @@ def _add_route( dependencies: list[Any] | None, response_model_include: set[str] | None = None, response_model_exclude: set[str] | None = None, + openapi_extra: dict[str, Any] | None = None, ) -> None: full_path = self.prefix + path self.routes.append( @@ -182,6 +183,7 @@ def _add_route( "deprecated": deprecated, "responses": responses, "dependencies": dependencies, + "openapi_extra": openapi_extra, } ) @@ -235,4 +237,5 @@ def _route_kw(kw: dict[str, Any]) -> dict[str, Any]: "deprecated": kw.get("deprecated", False), "responses": kw.get("responses"), "dependencies": kw.get("dependencies"), + "openapi_extra": kw.get("openapi_extra"), } diff --git a/docs/advanced/additional-responses.md b/docs/advanced/additional-responses.md new file mode 100644 index 0000000..9667d76 --- /dev/null +++ b/docs/advanced/additional-responses.md @@ -0,0 +1,157 @@ +# Additional Responses in OpenAPI + +Route decorators accept a `responses` parameter that lets you document extra HTTP +status codes beyond the primary one. Swagger UI renders each code with its schema +and description. + +## Declaring additional responses + +```python +import msgspec +from FasterAPI import Faster, HTTPException + +app = Faster() + + +class Item(msgspec.Struct): + id: int + name: str + + +class ErrorDetail(msgspec.Struct): + detail: str + + +@app.get( + "/items/{item_id}", + response_model=Item, + responses={ + 404: {"model": ErrorDetail, "description": "Item not found"}, + 422: {"description": "Validation error — item_id must be a positive integer"}, + }, +) +async def get_item(item_id: int): + if item_id <= 0: + raise HTTPException(status_code=422, detail="item_id must be positive") + if item_id > 1000: + raise HTTPException(status_code=404, detail="Item not found") + return Item(id=item_id, name="Widget") +``` + +The `response_model` on the decorator describes the **200 OK** body. The `responses` +dict adds the other codes; each value can contain: + +| Key | Type | Purpose | +|---|---|---| +| `model` | msgspec Struct class | Schema to generate for this status code | +| `description` | `str` | Human-readable explanation | +| `content` | `dict` | Explicit media-type map (overrides `model`) | +| `headers` | `dict` | Response header schemas | + +## Sharing an error model across routes + +Define a reusable error struct and reference it everywhere: + +```python +class ProblemDetail(msgspec.Struct): + """RFC 9457-style error body.""" + type: str + title: str + status: int + detail: str | None = None + + +COMMON_ERRORS = { + 400: {"model": ProblemDetail, "description": "Bad request"}, + 401: {"model": ProblemDetail, "description": "Unauthenticated"}, + 403: {"model": ProblemDetail, "description": "Forbidden"}, + 500: {"model": ProblemDetail, "description": "Internal server error"}, +} + + +@app.get("/users/{user_id}", response_model=User, responses=COMMON_ERRORS) +async def get_user(user_id: int): + ... + + +@app.post("/users", response_model=User, status_code=201, responses=COMMON_ERRORS) +async def create_user(body: CreateUser): + ... +``` + +## Documenting response headers + +```python +@app.post( + "/items", + status_code=201, + responses={ + 201: { + "description": "Item created", + "headers": { + "Location": { + "schema": {"type": "string"}, + "description": "URL of the newly created item", + }, + "X-Request-ID": { + "schema": {"type": "string", "format": "uuid"}, + "description": "Idempotency key echoed back", + }, + }, + } + }, +) +async def create_item(body: CreateItem): + item_id = await db.insert(body) + from FasterAPI import Response + return Response( + status_code=201, + headers={"Location": f"/items/{item_id}"}, + ) +``` + +## Multiple content types + +Use `content` when a route can return different media types depending on the +`Accept` header: + +```python +@app.get( + "/report", + responses={ + 200: { + "description": "Report data", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/Report"}}, + "text/csv": {"schema": {"type": "string"}}, + }, + } + }, +) +async def get_report(accept: str = "application/json"): + ... +``` + +## Default response — covering all undocumented codes + +OpenAPI allows a special `"default"` key that represents any status code not +explicitly listed: + +```python +@app.delete( + "/items/{item_id}", + status_code=204, + responses={ + 204: {"description": "Deleted successfully"}, + "default": {"model": ProblemDetail, "description": "Unexpected error"}, + }, +) +async def delete_item(item_id: int): + ... +``` + +## Next steps + +- [OpenAPI Customisation](openapi-customization.md) — extend or override the full schema. +- [Custom Response Classes](custom-response.md) — return non-JSON responses. +- [Error Handling](../tutorial/error-handling.md) — raise `HTTPException` with detail bodies. diff --git a/docs/advanced/index.md b/docs/advanced/index.md index 47fb190..b3dd877 100644 --- a/docs/advanced/index.md +++ b/docs/advanced/index.md @@ -12,6 +12,7 @@ OpenAPI customisation, real-time transports, and testing strategies. | [Using the Request Directly](using-request.md) | Access raw request data, headers, client IP | | [Settings & Environment Variables](settings.md) | Twelve-factor config with `os.environ` / `python-dotenv` | | [OpenAPI Customisation](openapi-customization.md) | Conditional docs, extending the schema | +| [Additional Responses](additional-responses.md) | Document extra status codes and response schemas | | [Templates (Jinja2)](templates.md) | Server-side HTML rendering | | [Lifespan Events](lifespan.md) | Startup/shutdown hooks for connections and caches | | [Behind a Proxy](behind-proxy.md) | Root path, forwarded headers, Nginx/Traefik | diff --git a/docs/concepts/msgspec-vs-pydantic.md b/docs/concepts/msgspec-vs-pydantic.md new file mode 100644 index 0000000..b790748 --- /dev/null +++ b/docs/concepts/msgspec-vs-pydantic.md @@ -0,0 +1,360 @@ +# msgspec vs Pydantic + +FasterAPI uses **msgspec** for validation and serialization instead of Pydantic. +This page explains the design philosophy behind each library, where they differ, +when each one shines, and how to migrate existing Pydantic code. + +## Philosophy + +| | msgspec | Pydantic v2 | +|---|---|---| +| **Primary goal** | Zero-copy serialization + validation in C | Developer-friendly validation with rich error messages | +| **Schema definition** | `msgspec.Struct` (immutable, `__slots__`-based) | `pydantic.BaseModel` (mutable, supports `__init__` customisation) | +| **Validation timing** | At decode/encode boundary only | On attribute assignment (with `model_validate`) | +| **Type coercion** | Strict by default (no silent coercion) | Lenient by default (`"42"` → `42`) | +| **Error detail** | Compact, path-based | Verbose, human-readable with loc/msg/type | +| **Speed** | ~5–10× faster than Pydantic v2 for encode/decode | Fast (Rust core), slower than msgspec | +| **Memory** | Lower (Structs use `__slots__`, no instance `__dict__`) | Higher (BaseModel has more metadata overhead) | +| **Ecosystem** | Self-contained | Rich ecosystem (validators, serializers, settings) | + +## Performance comparison + +Benchmark on Apple Silicon (Python 3.13, 1 M iterations): + +| Operation | msgspec | Pydantic v2 | Speedup | +|---|---|---|---| +| JSON encode | ~1,400,000 ops/s | ~280,000 ops/s | ~5× | +| JSON decode + validate | ~950,000 ops/s | ~180,000 ops/s | ~5× | +| Object construction | ~8,000,000 ops/s | ~1,500,000 ops/s | ~5× | + +These numbers are from `benchmarks/compare.py`. See the +[Benchmark Methodology](../benchmark-methodology.md) page for reproduction steps. + +## API comparison + +### Defining schemas + +=== "msgspec" + + ```python + import msgspec + + class Address(msgspec.Struct): + street: str + city: str + zip_code: str + + class User(msgspec.Struct): + id: int + name: str + email: str + age: int | None = None + address: Address | None = None + tags: list[str] = [] + ``` + +=== "Pydantic" + + ```python + from pydantic import BaseModel + + class Address(BaseModel): + street: str + city: str + zip_code: str + + class User(BaseModel): + id: int + name: str + email: str + age: int | None = None + address: Address | None = None + tags: list[str] = [] + ``` + +### Decoding / validating + +=== "msgspec" + + ```python + import msgspec + + json_bytes = b'{"id": 1, "name": "Alice", "email": "alice@example.com"}' + + # Decode JSON bytes directly into a typed Struct + user = msgspec.json.decode(json_bytes, type=User) + print(user.name) # Alice + + # Validate from a dict (Python object) + user = msgspec.convert({"id": 1, "name": "Alice", "email": "a@e.com"}, User) + ``` + +=== "Pydantic" + + ```python + from pydantic import TypeAdapter + + json_bytes = b'{"id": 1, "name": "Alice", "email": "alice@example.com"}' + + # Validate from JSON bytes + adapter = TypeAdapter(User) + user = adapter.validate_json(json_bytes) + + # Validate from dict + user = User.model_validate({"id": 1, "name": "Alice", "email": "a@e.com"}) + ``` + +### Encoding / serializing + +=== "msgspec" + + ```python + user = User(id=1, name="Alice", email="alice@example.com") + + # To JSON bytes (fastest path) + json_bytes = msgspec.json.encode(user) + + # To dict + data = msgspec.structs.asdict(user) + ``` + +=== "Pydantic" + + ```python + user = User(id=1, name="Alice", email="alice@example.com") + + # To JSON string + json_str = user.model_dump_json() + + # To dict + data = user.model_dump() + ``` + +### Field customisation + +=== "msgspec" + + ```python + import msgspec + + class Product(msgspec.Struct): + id: int + # Rename field in JSON + internal_name: str = msgspec.field(name="name") + # Default factory + tags: list[str] = msgspec.field(default_factory=list) + # Optional with default + price: float = 0.0 + ``` + +=== "Pydantic" + + ```python + from pydantic import BaseModel, Field + + class Product(BaseModel): + id: int + internal_name: str = Field(alias="name") + tags: list[str] = Field(default_factory=list) + price: float = 0.0 + ``` + +### Custom validators + +=== "msgspec" + + ```python + import msgspec + + class Email(str): + """Custom type that validates email format.""" + + # msgspec uses Python's __get_validators__ protocol for custom types + # For complex validation, use post-decode processing or a custom Encoder/Decoder + class SignupRequest(msgspec.Struct): + email: str + password: str + + def __post_init__(self): + if "@" not in self.email: + raise ValueError(f"Invalid email: {self.email}") + if len(self.password) < 8: + raise ValueError("Password must be at least 8 characters") + ``` + +=== "Pydantic" + + ```python + from pydantic import BaseModel, field_validator, EmailStr + + class SignupRequest(BaseModel): + email: EmailStr # built-in email validator + password: str + + @field_validator("password") + @classmethod + def password_length(cls, v: str) -> str: + if len(v) < 8: + raise ValueError("Password must be at least 8 characters") + return v + ``` + +## Key behavioural differences + +### Type coercion + +msgspec is **strict** — it raises on type mismatches; Pydantic v2 is **lenient** +by default: + +```python +import msgspec, json + +class Payload(msgspec.Struct): + count: int + +# OK — integer +msgspec.json.decode(b'{"count": 42}', type=Payload) + +# Raises msgspec.ValidationError — string not accepted for int +msgspec.json.decode(b'{"count": "42"}', type=Payload) +``` + +Pydantic would silently coerce `"42"` → `42`. + +### Mutability + +msgspec Structs are **immutable by default**: + +```python +class Point(msgspec.Struct): + x: float + y: float + +p = Point(1.0, 2.0) +p.x = 3.0 # AttributeError — Struct is frozen + +# For a mutable Struct: +class MutablePoint(msgspec.Struct, frozen=False): + x: float + y: float +``` + +Pydantic BaseModel instances are mutable by default. + +### Inheritance + +```python +# msgspec — single inheritance only, no field override +class Base(msgspec.Struct): + id: int + +class Child(Base): + name: str # adds a field + +# Pydantic — supports multiple inheritance and field override +class Child(Base): + name: str + id: int = 0 # overrides parent field +``` + +### JSON `null` vs missing field + +```python +class Item(msgspec.Struct): + name: str + tag: str | None = None + +# Both parse correctly in msgspec: +msgspec.json.decode(b'{"name": "x"}', type=Item) # tag = None +msgspec.json.decode(b'{"name": "x", "tag": null}', type=Item) # tag = None + +# To distinguish missing from null, use msgspec.NODEFAULT sentinel: +import msgspec +MISSING = msgspec.NODEFAULT + +class Item(msgspec.Struct): + name: str + tag: str | msgspec.UnsetType = msgspec.NODEFAULT +``` + +## When to choose each + +### Choose msgspec when: + +- **Maximum throughput** is a priority (high-req/s APIs, real-time systems) +- Schemas are relatively simple (CRUD entities, event payloads) +- You want zero external dependencies beyond msgspec itself +- You're already using FasterAPI (it's the native validation layer) + +### Choose Pydantic when: + +- You need **complex validators** (`@field_validator`, `@model_validator`) +- You rely on **Pydantic-ecosystem libraries** (pydantic-settings, SQLModel, FastAPI) +- Your team is already experienced with Pydantic +- You need **strict/lenient mode control** per-field +- You need `model_dump(exclude_unset=True)` semantics + +### Using both in the same project + +FasterAPI uses msgspec for route handler validation. You can still use Pydantic +for settings or internal domain models: + +```python +# settings.py — Pydantic-settings for type-safe env vars +from pydantic_settings import BaseSettings + +class Settings(BaseSettings): + database_url: str + secret_key: str + debug: bool = False + + class Config: + env_file = ".env" + +settings = Settings() + +# routes.py — msgspec for request/response schemas +import msgspec +from FasterAPI import Faster + +app = Faster() + +class CreateUser(msgspec.Struct): + username: str + password: str + +@app.post("/users") +async def create_user(body: CreateUser): + ... +``` + +## Migration patterns + +### From Pydantic BaseModel to msgspec Struct + +| Pydantic | msgspec | +|---|---| +| `class M(BaseModel)` | `class M(msgspec.Struct)` | +| `Field(alias="x")` | `msgspec.field(name="x")` | +| `Field(default_factory=list)` | `msgspec.field(default_factory=list)` | +| `model_validate(data)` | `msgspec.convert(data, M)` | +| `model_dump()` | `msgspec.structs.asdict(obj)` | +| `model_dump_json()` | `msgspec.json.encode(obj)` | +| `@field_validator` | `__post_init__` method | +| `Optional[X]` / `X \| None` | `X \| None` (same) | +| `list[X]` | `list[X]` (same) | + +### Step-by-step migration + +1. Replace `BaseModel` with `msgspec.Struct` in schema files. +2. Replace `Field(...)` with `msgspec.field(...)` where needed. +3. Replace `model_validate` calls with `msgspec.convert`. +4. Replace `model_dump` / `model_dump_json` with `msgspec.structs.asdict` / `msgspec.json.encode`. +5. Move complex validators into `__post_init__` or a separate validation function. +6. Run your tests — msgspec is stricter, so some test inputs may need adjustment. + +## Next steps + +- [Python Type Hints](types-intro.md) — the type annotation foundations msgspec builds on. +- [Benchmarks Deep Dive](../benchmark-methodology.md) — how the performance numbers are measured. +- [Request Body](../tutorial/request-body.md) — using msgspec Structs in route handlers. diff --git a/docs/concepts/radix-tree-routing.md b/docs/concepts/radix-tree-routing.md new file mode 100644 index 0000000..52c83ca --- /dev/null +++ b/docs/concepts/radix-tree-routing.md @@ -0,0 +1,230 @@ +# Radix Tree Routing + +FasterAPI's router is built on a **radix tree** (also called a Patricia trie or +compressed prefix tree). This data structure enables **O(k)** route resolution — +where *k* is the number of path segments — regardless of how many routes are +registered. + +## The problem with alternative approaches + +### Linear scan (O(n)) + +The simplest router iterates through a list of route patterns and returns the first +match: + +```python +for pattern, handler in routes: + match = pattern.match(path) + if match: + return handler, match.groupdict() +``` + +This is O(n) in the number of registered routes. With 200 routes the 200th route +is 200× slower to resolve than the first. + +### Compiled regex (O(n) with lower constant) + +Frameworks like older versions of Flask/Werkzeug compile routes into a single +large regex with named groups. Still O(n) — a big regex must be re-evaluated +against every alternative until one matches. + +### Radix tree (O(k)) + +Resolution time depends only on the **length of the URL path**, not on the number +of registered routes. 1 route or 10,000 routes — same speed. + +## How a radix tree works + +A radix tree stores strings in a **trie** (character-by-character tree), but +**compresses** paths that have only one child into a single node. + +### Building the tree + +Given these routes: + +``` +GET /users +GET /users/{id} +GET /users/{id}/posts +POST /users +GET /items +GET /items/{id} +``` + +The tree looks like: + +``` +root +├── "users" [GET, POST] +│ └── "*" (param: id) [GET] +│ └── "posts" [GET] +└── "items" [GET] + └── "*" (param: id) [GET] +``` + +Each node stores: +- `children`: a `dict[str, RadixNode]` — fast O(1) lookup per segment +- `handlers`: a `dict[str, (handler, metadata)]` — keyed by HTTP method +- `param_name`: name of the path parameter if this node is a wildcard (`*`) + +### Resolving a path + +To resolve `GET /users/42/posts`: + +1. Split into segments: `["users", "42", "posts"]` +2. Start at root, index = 0 +3. Look up `"users"` in root's children → found, move to `users` node, index = 1 +4. Look up `"42"` in `users.children` → not found; try `"*"` → found (param node), capture `id = "42"`, index = 2 +5. Look up `"posts"` in `param.children` → found, move to `posts` node, index = 3 +6. Index == len(segments), check `posts.handlers["GET"]` → return handler + `{"id": "42"}` + +**Total operations**: 3 dict lookups — one per path segment. For a path with k +segments, always k lookups regardless of the route count. + +## FasterAPI's implementation + +The source is in `FasterAPI/router.py`. Key design choices: + +### Iterative traversal + +```python +def _walk(self, node, segments, idx, params): + n = len(segments) + while idx < n: + seg = segments[idx] + child = node.children.get(seg) # O(1) dict lookup + if child is not None: + node = child + idx += 1 + continue + param_child = node.children.get("*") # O(1) + if param_child is not None: + params[param_child.param_name] = seg + node = param_child + idx += 1 + continue + return None # no match + return node if node.handlers else None +``` + +**Why iterative?** Recursive calls add stack frames. At O(k) depth, a route +with 10 segments would create 10 stack frames per request. The iterative `while` +loop avoids this overhead entirely. + +### `__slots__` on every node + +```python +class RadixNode: + __slots__ = ("children", "handlers", "param_name", "is_param") +``` + +`__slots__` eliminates the per-instance `__dict__`, reducing memory per node by +~50 bytes and speeding up attribute access (no hash lookup through `__dict__`). +With thousands of nodes in a large application, this is significant. + +### Static routes checked before parameters + +```python +child = node.children.get(seg) # try exact match first +if child is not None: + ... +param_child = node.children.get("*") # fall back to param wildcard +``` + +The priority order — static segments before wildcard parameters — ensures that +`/users/me` resolves to its dedicated handler rather than the `{id}` wildcard +when both are registered: + +```python +@app.get("/users/me") # resolves first for /users/me +async def get_current_user(): ... + +@app.get("/users/{id}") # resolves for /users/42, /users/123, etc. +async def get_user(id: int): ... +``` + +### Path splitting + +```python +def _split(path: str) -> list[str]: + return [s for s in path.split("/") if s] +``` + +`str.split("/")` runs in C and returns a list in a single pass. The list +comprehension filters empty strings (from leading/trailing slashes). This is +meaningfully faster than repeated `str.partition("/")` or regex splitting. + +## Complexity summary + +| Operation | Complexity | Notes | +|---|---|---| +| Route registration | O(k) | k = path segments; done once at startup | +| Route resolution | O(k) | k = segments in the incoming path | +| Memory per route | O(k) | Shared prefix nodes reduce total memory | +| Static route lookup | O(k) dictionary lookups | Python `dict.get` is O(1) average | +| Parametric route lookup | O(k) | Same; falls back to `"*"` key | + +Contrast with regex-based routers where both registration and resolution are O(n) +in the number of routes. + +## Benchmark results + +Routing benchmark from `benchmarks/compare.py` with 100 routes registered +(50 static, 30 single-param, 20 multi-param): + +| Path | FasterAPI RadixRouter | Regex router | Speedup | +|---|---|---|---| +| `/health` (static) | ~4,200,000 lookups/s | ~850,000 lookups/s | ~5× | +| `/users/{id}` (1 param) | ~3,800,000 lookups/s | ~620,000 lookups/s | ~6× | +| `/users/{id}/posts/{pid}` (2 params) | ~3,100,000 lookups/s | ~490,000 lookups/s | ~6× | + +## Limitations + +### No regex constraints in path parameters + +FasterAPI parameters use `{name}` syntax only. There is no built-in constraint +like `{id:\d+}`. Validate the value inside the handler or using the type +annotation: + +```python +@app.get("/users/{user_id}") +async def get_user(user_id: int): # msgspec validates it is an integer + ... +``` + +### No optional path segments + +Path segments cannot be optional in the URL pattern. Use query parameters for +optional values: + +```python +# Instead of /items/{id?} (not supported), use: +@app.get("/items/{item_id}") +async def get_item(item_id: int, version: int = 1): # version is a query param + ... +``` + +### No catch-all wildcards + +There is no `{path:path}` glob matching for arbitrary sub-paths. Mount a +sub-application for dynamic path prefixes (see [Sub-applications](../advanced/sub-applications.md)). + +## Extending the router + +`RadixRouter` is exposed as a public class. You can inspect it: + +```python +from FasterAPI.router import RadixRouter + +router = RadixRouter() +router.add_route("GET", "/users/{id}", handler, metadata={}) + +result = router.resolve("GET", "/users/42") +# (handler, {"id": "42"}, {}) +``` + +## Next steps + +- [Architecture](../architecture.md) — how the router fits into the full request lifecycle. +- [Benchmarks Deep Dive](../benchmark-methodology.md) — how routing benchmarks are run. +- [Bigger Applications](../advanced/bigger-apps.md) — organising routes with `FasterRouter`. diff --git a/docs/deployment/gunicorn.md b/docs/deployment/gunicorn.md new file mode 100644 index 0000000..c7e1f5d --- /dev/null +++ b/docs/deployment/gunicorn.md @@ -0,0 +1,235 @@ +# Gunicorn + Uvicorn Workers + +**Gunicorn** is a battle-tested WSGI/ASGI process manager. **Uvicorn** provides the +ASGI worker class. Together they give you: + +- Pre-fork worker pool managed by a supervisor process +- Automatic worker restart on crash +- Graceful rolling restarts with zero downtime +- OS signal-based configuration reload + +!!! note "When to use this pattern" + A single `uvicorn` process already handles thousands of concurrent connections + via async I/O. Add Gunicorn when you need **multi-core CPU utilisation** without + Kubernetes/Docker orchestration, or when your infrastructure team requires Gunicorn + for consistency with other Python services. + +## Install + +```bash +pip install gunicorn uvicorn[standard] +``` + +## Quickstart + +```bash +gunicorn main:app \ + --worker-class uvicorn.workers.UvicornWorker \ + --workers 4 \ + --bind 0.0.0.0:8000 +``` + +## Worker count guidelines + +``` +workers = (2 × CPU cores) + 1 +``` + +| vCPUs | Recommended workers | +|---|---| +| 1 | 3 | +| 2 | 5 | +| 4 | 9 | +| 8 | 17 | + +Each Uvicorn worker is an async event loop, so it handles many concurrent +connections. More workers = more parallelism for CPU-bound code but also more +memory. + +Check your core count: + +```bash +nproc # Linux +``` + +## gunicorn.conf.py + +Keep all configuration in a file rather than long command-line flags: + +```python +# gunicorn.conf.py +import multiprocessing + +# Server socket +bind = "0.0.0.0:8000" +backlog = 2048 + +# Worker processes +worker_class = "uvicorn.workers.UvicornWorker" +workers = multiprocessing.cpu_count() * 2 + 1 +worker_connections = 1000 +max_requests = 10_000 # restart worker after N requests (prevents memory leaks) +max_requests_jitter = 1_000 # randomise to prevent thundering-herd restarts + +# Timeouts +timeout = 30 # worker killed if no response within 30 s +graceful_timeout = 30 # time allowed for in-flight requests on SIGTERM +keepalive = 5 # seconds to keep idle connections open + +# Logging +accesslog = "-" # stdout +errorlog = "-" # stderr +loglevel = "info" +access_log_format = '%(h)s "%(r)s" %(s)s %(b)s %(D)sμs' + +# Process naming +proc_name = "fasterapi" + +# Security: drop privileges after binding +user = "fasterapi" +group = "fasterapi" + +# Preload app for faster worker fork and shared memory +preload_app = True +``` + +Run with the config file: + +```bash +gunicorn main:app -c gunicorn.conf.py +``` + +## UvicornH11Worker vs UvicornWorker + +| Worker class | HTTP/2 | WebSocket | When to use | +|---|---|---|---| +| `uvicorn.workers.UvicornWorker` | No (HTTP/1.1 only) | Yes | Behind a reverse proxy that terminates HTTP/2 (recommended) | +| `uvicorn.workers.UvicornH11Worker` | No | Yes | Explicit h11 parser; slightly more strict | + +Always terminate HTTP/2 at Nginx/Traefik and use plain HTTP/1.1 between the +proxy and Gunicorn. + +## systemd integration + +Combine with the [systemd guide](systemd.md): + +```ini +[Unit] +Description=FasterAPI via Gunicorn +After=network.target + +[Service] +Type=notify +User=fasterapi +Group=fasterapi +WorkingDirectory=/opt/myapp/app +EnvironmentFile=-/etc/fasterapi.env +Environment="PATH=/opt/myapp/.venv/bin:/usr/bin:/bin" + +ExecStart=/opt/myapp/.venv/bin/gunicorn main:app \ + --config /opt/myapp/gunicorn.conf.py + +ExecReload=/bin/kill -s HUP $MAINPID + +TimeoutStopSec=30 +KillMode=mixed +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +`KillMode=mixed` sends SIGTERM to the master (triggers graceful drain) and +SIGKILL to any remaining workers after `TimeoutStopSec`. + +## Zero-downtime rolling restart + +```bash +# Signal master to spawn new workers then kill old ones gracefully +kill -USR2 $(cat /var/run/gunicorn.pid) +# Old master exits after new master is healthy +kill -WINCH $(cat /var/run/gunicorn.pid.2) +``` + +Or via systemd: + +```bash +sudo systemctl reload fasterapi # sends SIGHUP → graceful reload +``` + +## Monitoring worker health + +```bash +# List all gunicorn processes +ps aux | grep gunicorn + +# Check master PID +cat /var/run/gunicorn.pid + +# Worker restarts (high count → memory leak or crash loop) +journalctl -u fasterapi | grep "Worker exiting" +``` + +## Pre-loading and shared state + +`preload_app = True` imports your app **once** in the master before forking. +Workers share the code segment (copy-on-write), reducing total memory use. + +!!! warning + Do **not** open database connections or asyncio event loops at import time + when `preload_app = True`. Each forked worker needs its own connection pool. + Use a [lifespan handler](../advanced/lifespan.md) instead. + +```python +# main.py — safe with preload_app +from contextlib import asynccontextmanager +from FasterAPI import Faster +import databases + +DATABASE_URL = "postgresql+asyncpg://..." + +@asynccontextmanager +async def lifespan(app): + # Opens AFTER fork — each worker gets its own pool + app.state.db = databases.Database(DATABASE_URL) + await app.state.db.connect() + yield + await app.state.db.disconnect() + +app = Faster(lifespan=lifespan) +``` + +## Performance tuning + +| Option | Value | Effect | +|---|---|---| +| `worker_connections` | 1000–4000 | Max simultaneous connections per worker | +| `max_requests` | 5000–20000 | Restart worker after N requests (memory hygiene) | +| `keepalive` | 2–75 | Reuse TCP connections; match your load balancer timeout | +| `timeout` | 30–120 | Kill unresponsive workers; set > your slowest endpoint | +| `preload_app` | `True` | Share code pages across workers | + +## Docker + Gunicorn + +```dockerfile +FROM python:3.13-slim + +WORKDIR /app +COPY pyproject.toml gunicorn.conf.py ./ +RUN pip install --no-cache-dir ".[all]" gunicorn + +COPY app/ app/ + +RUN useradd -r -u 1001 fasterapi +USER fasterapi + +EXPOSE 8000 +CMD ["gunicorn", "app.main:app", "--config", "gunicorn.conf.py"] +``` + +## Next steps + +- [systemd Service](systemd.md) — process supervision on Linux. +- [HTTPS — Let's Encrypt](https.md) — TLS in front of Gunicorn. +- [Docker](docker.md) — containerising the app. diff --git a/docs/deployment/https.md b/docs/deployment/https.md new file mode 100644 index 0000000..54fdcfc --- /dev/null +++ b/docs/deployment/https.md @@ -0,0 +1,286 @@ +# HTTPS — Let's Encrypt & Nginx + +This guide sets up **free, auto-renewing TLS** for a FasterAPI application using +[Let's Encrypt](https://letsencrypt.org/) certificates and Nginx as the TLS-terminating +reverse proxy. + +## Architecture + +``` +Internet → Nginx (port 443, TLS) → FasterAPI / uvicorn (127.0.0.1:8000, plain HTTP) + ↓ (port 80, HTTP → 301 redirect) +``` + +Nginx handles encryption; FasterAPI never sees raw TLS. This is the standard +pattern for production Python web applications. + +## Prerequisites + +- A VPS or dedicated server with a **public IP address** +- A **domain name** pointed at that IP (A record, propagated) +- Ubuntu / Debian (commands use `apt`; adapt for RHEL/CentOS) +- FasterAPI running locally on `127.0.0.1:8000` (systemd guide: [systemd](systemd.md)) + +## 1. Install Nginx and Certbot + +```bash +sudo apt update +sudo apt install -y nginx certbot python3-certbot-nginx +``` + +## 2. Initial Nginx configuration (HTTP only) + +Certbot needs to verify domain ownership over HTTP before it can issue a certificate. +Create a minimal server block first: + +```nginx +# /etc/nginx/sites-available/fasterapi +server { + listen 80; + server_name api.example.com; + + # Let's Encrypt ACME challenge + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # Temporary: serve a test response + location / { + return 200 "OK"; + add_header Content-Type text/plain; + } +} +``` + +```bash +sudo ln -s /etc/nginx/sites-available/fasterapi /etc/nginx/sites-enabled/ +sudo nginx -t && sudo systemctl reload nginx +``` + +## 3. Obtain a certificate + +```bash +sudo certbot --nginx -d api.example.com --email admin@example.com --agree-tos --no-eff-email +``` + +Certbot modifies your Nginx config automatically, adding TLS directives and an +HTTP → HTTPS redirect. For wildcard certificates use the DNS challenge: + +```bash +sudo certbot certonly --manual --preferred-challenges dns -d "*.example.com" +``` + +Certificates are stored in `/etc/letsencrypt/live/api.example.com/`. + +## 4. Production Nginx configuration + +Replace the site config with a hardened production version: + +```nginx +# /etc/nginx/sites-available/fasterapi + +# ── HTTP → HTTPS redirect ──────────────────────────────────────────── +server { + listen 80; + listen [::]:80; + server_name api.example.com; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +# ── HTTPS ──────────────────────────────────────────────────────────── +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name api.example.com; + + # TLS certificates (managed by Certbot) + ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + # Modern TLS settings + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256; + ssl_prefer_server_ciphers off; + + # HSTS — tell browsers to always use HTTPS (1 year) + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + + # Security headers + add_header X-Frame-Options DENY always; + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Proxy settings + client_max_body_size 10m; + + # ── Proxy to FasterAPI ─────────────────────────────────────────── + location / { + proxy_pass http://127.0.0.1:8000; + proxy_http_version 1.1; + + # WebSocket / SSE support + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # Forward client information + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Timeouts + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + + # Buffer tuning + proxy_buffering on; + proxy_buffer_size 4k; + proxy_buffers 8 4k; + } + + # Static files (if any) — serve directly from Nginx + location /static/ { + alias /opt/myapp/static/; + expires 30d; + add_header Cache-Control "public, immutable"; + } +} +``` + +```bash +sudo nginx -t && sudo systemctl reload nginx +``` + +## 5. Tell FasterAPI it is behind a proxy + +Set `root_path` so OpenAPI URLs and redirects work correctly: + +```python +app = Faster(root_path="/") +``` + +And run uvicorn with the proxy headers trusted: + +```bash +uvicorn main:app --host 127.0.0.1 --port 8000 --proxy-headers --forwarded-allow-ips="127.0.0.1" +``` + +See [Behind a Proxy](../advanced/behind-proxy.md) for details. + +## 6. Automatic certificate renewal + +Certbot installs a systemd timer that runs twice daily: + +```bash +sudo systemctl status certbot.timer +# Active: active (waiting) + +# Dry-run to verify renewal works +sudo certbot renew --dry-run +``` + +After renewal, Nginx must reload to pick up the new certificate. Add a deploy hook: + +```bash +# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh +#!/bin/bash +systemctl reload nginx +``` + +```bash +sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh +``` + +## 7. Verify TLS + +```bash +# Check certificate details +openssl s_client -connect api.example.com:443 -servername api.example.com < /dev/null 2>&1 \ + | openssl x509 -noout -dates -issuer + +# Grade your TLS configuration (A+ target) +# Visit: https://www.ssllabs.com/ssltest/analyze.html?d=api.example.com + +# Check HSTS +curl -sI https://api.example.com | grep -i strict +``` + +## Nginx performance tuning + +```nginx +# /etc/nginx/nginx.conf — global section +worker_processes auto; +worker_rlimit_nofile 65535; + +events { + worker_connections 4096; + use epoll; + multi_accept on; +} + +http { + # Enable keepalive to the upstream + upstream fasterapi { + server 127.0.0.1:8000; + keepalive 32; + } + + # Gzip compression + gzip on; + gzip_comp_level 5; + gzip_min_length 256; + gzip_types application/json text/plain text/css application/javascript; + + # Connection caching for TLS sessions + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + ssl_session_tickets off; +} +``` + +## Multiple domains / virtual hosts + +Add a second `server` block for each additional domain: + +```nginx +server { + listen 443 ssl; + http2 on; + server_name internal.example.com; + + ssl_certificate /etc/letsencrypt/live/internal.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/internal.example.com/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:8001; # second FasterAPI instance + ... + } +} +``` + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| `502 Bad Gateway` | FasterAPI not running | `systemctl status fasterapi` | +| `ERR_SSL_PROTOCOL_ERROR` | Wrong port or Nginx not listening on 443 | `ss -tlnp \| grep nginx` | +| Certificate expired | Renewal hook not firing | `certbot renew --force-renewal` | +| `X-Forwarded-For` shows nginx IP | `proxy_set_header` missing | Check Nginx config | +| OpenAPI docs links broken | `root_path` not set | `Faster(root_path="/")` | + +## Next steps + +- [Behind a Proxy](../advanced/behind-proxy.md) — configure FasterAPI for proxy headers. +- [Nginx & Traefik](nginx-traefik.md) — advanced reverse proxy patterns. +- [Gunicorn + Uvicorn](gunicorn.md) — multi-worker production setup. diff --git a/docs/deployment/index.md b/docs/deployment/index.md index 7c30ebf..6c0a7db 100644 --- a/docs/deployment/index.md +++ b/docs/deployment/index.md @@ -8,6 +8,9 @@ to cloud platforms to bare-metal servers. | Topic | What you learn | |---|---| | [Docker](docker.md) | Dockerfile, multi-stage builds, docker-compose | +| [systemd Service](systemd.md) | Service unit file, auto-start, journald logging | +| [Gunicorn + Uvicorn](gunicorn.md) | Multi-process worker pooling, production config | +| [HTTPS — Let's Encrypt](https.md) | Free TLS certificates with Certbot and Nginx | | [Nginx & Traefik](nginx-traefik.md) | Reverse proxy, TLS termination, load balancing | | [Cloud Services](cloud.md) | AWS, GCP, Azure deployment options | | [Kubernetes](kubernetes.md) | Manifests, health checks, rolling updates | @@ -88,4 +91,7 @@ async def health(): ## Next steps - [Docker](docker.md) — containerise your app. -- [Nginx & Traefik](nginx-traefik.md) — proxy and TLS. +- [systemd Service](systemd.md) — run as a managed Linux service. +- [Gunicorn + Uvicorn](gunicorn.md) — multi-worker process pooling. +- [HTTPS — Let's Encrypt](https.md) — free TLS with Certbot and Nginx. +- [Nginx & Traefik](nginx-traefik.md) — reverse proxy and load balancing. diff --git a/docs/deployment/systemd.md b/docs/deployment/systemd.md new file mode 100644 index 0000000..b09441a --- /dev/null +++ b/docs/deployment/systemd.md @@ -0,0 +1,211 @@ +# systemd Service + +Running FasterAPI under **systemd** lets the OS manage your process lifecycle: +auto-start on boot, automatic restart on crash, and centralized log collection +through `journald`. + +## Prerequisites + +- A Linux host with systemd (Ubuntu 20.04+, Debian 11+, RHEL 8+, etc.) +- FasterAPI installed in a virtualenv at a known path +- A non-root system user to own the process + +## Create a system user + +```bash +sudo useradd --system --no-create-home --shell /usr/sbin/nologin fasterapi +``` + +## Install the application + +```bash +# Example: install into /opt/myapp +sudo mkdir -p /opt/myapp +sudo python3 -m venv /opt/myapp/.venv +sudo /opt/myapp/.venv/bin/pip install "faster-api-web[all]" +sudo cp -r /path/to/your/app /opt/myapp/app +sudo chown -R fasterapi:fasterapi /opt/myapp +``` + +## Service unit file + +Create `/etc/systemd/system/fasterapi.service`: + +```ini +[Unit] +Description=FasterAPI application server +After=network.target +# Uncomment if the app needs PostgreSQL: +# After=network.target postgresql.service +# Requires=postgresql.service + +[Service] +Type=exec +User=fasterapi +Group=fasterapi +WorkingDirectory=/opt/myapp/app + +# Runtime environment — override in /etc/fasterapi.env +EnvironmentFile=-/etc/fasterapi.env +Environment="PATH=/opt/myapp/.venv/bin:/usr/local/bin:/usr/bin:/bin" + +ExecStart=/opt/myapp/.venv/bin/uvicorn main:app \ + --host 127.0.0.1 \ + --port 8000 \ + --workers 4 \ + --log-level info \ + --access-log + +# Graceful shutdown: send SIGTERM, wait up to 30 s, then SIGKILL +TimeoutStopSec=30 + +# Restart policy +Restart=on-failure +RestartSec=5 +StartLimitIntervalSec=60 +StartLimitBurst=3 + +# Security hardening +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=yes +ReadWritePaths=/opt/myapp + +# Resource limits +LimitNOFILE=65535 + +[Install] +WantedBy=multi-user.target +``` + +## Environment file + +Store secrets outside the unit file so they don't appear in `systemctl status`: + +```bash +# /etc/fasterapi.env +DATABASE_URL=postgresql+asyncpg://user:pass@localhost/mydb +SECRET_KEY=a-long-random-string-here +ENV=production +LOG_LEVEL=info +``` + +```bash +sudo chmod 640 /etc/fasterapi.env +sudo chown root:fasterapi /etc/fasterapi.env +``` + +## Enable and start + +```bash +# Reload systemd to pick up the new unit file +sudo systemctl daemon-reload + +# Enable auto-start on boot +sudo systemctl enable fasterapi + +# Start now +sudo systemctl start fasterapi + +# Check status +sudo systemctl status fasterapi +``` + +Expected output: + +``` +● fasterapi.service - FasterAPI application server + Loaded: loaded (/etc/systemd/system/fasterapi.service; enabled) + Active: active (running) since Mon 2025-01-01 12:00:00 UTC; 3s ago + Main PID: 12345 (uvicorn) + Tasks: 5 (limit: 4915) + Memory: 72.0M + CPU: 1.234s +``` + +## Common management commands + +| Command | Purpose | +|---|---| +| `sudo systemctl start fasterapi` | Start the service | +| `sudo systemctl stop fasterapi` | Stop gracefully | +| `sudo systemctl restart fasterapi` | Stop then start | +| `sudo systemctl reload fasterapi` | Send SIGHUP (uvicorn: graceful reload) | +| `sudo systemctl status fasterapi` | Current state + last log lines | +| `sudo systemctl enable fasterapi` | Enable on boot | +| `sudo systemctl disable fasterapi` | Disable on boot | +| `sudo journalctl -u fasterapi -f` | Follow live logs | +| `sudo journalctl -u fasterapi --since "1h ago"` | Last hour of logs | + +## Zero-downtime reload + +Uvicorn supports graceful reload via `--reload` (dev only) or by signaling the +master process. For production zero-downtime restarts: + +```bash +# Send SIGHUP to reload workers without dropping connections +sudo kill -HUP $(systemctl show -p MainPID --value fasterapi) +``` + +Or use `systemctl reload` if `ExecReload` is configured: + +```ini +[Service] +ExecReload=/bin/kill -HUP $MAINPID +``` + +## Multiple instances (socket activation) + +For A/B deployments, use systemd socket activation so the OS holds the socket +while you swap service instances: + +```ini +# /etc/systemd/system/fasterapi.socket +[Unit] +Description=FasterAPI socket + +[Socket] +ListenStream=0.0.0.0:8000 +BindIPv6Only=both + +[Install] +WantedBy=sockets.target +``` + +```ini +# /etc/systemd/system/fasterapi.service (socket-activated variant) +[Unit] +Description=FasterAPI application server +Requires=fasterapi.socket + +[Service] +Type=exec +User=fasterapi +ExecStart=/opt/myapp/.venv/bin/uvicorn main:app --fd 0 --workers 4 +StandardInput=socket +``` + +```bash +sudo systemctl enable --now fasterapi.socket +sudo systemctl start fasterapi +``` + +## Watching logs + +```bash +# Real-time structured logs +sudo journalctl -u fasterapi -f -o json-pretty + +# Errors only +sudo journalctl -u fasterapi -p err + +# Export to file +sudo journalctl -u fasterapi > fasterapi.log +``` + +## Next steps + +- [Gunicorn + Uvicorn](gunicorn.md) — multi-process worker pooling. +- [HTTPS — Let's Encrypt](https.md) — TLS termination with Nginx. +- [Nginx & Traefik](nginx-traefik.md) — reverse proxy setup. diff --git a/docs/overrides/main.html b/docs/overrides/main.html new file mode 100644 index 0000000..24a35bb --- /dev/null +++ b/docs/overrides/main.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} + +{% block extrahead %} + {%- set page_title = page.title ~ " - " ~ config.site_name if page.title else config.site_name %} + {%- set page_desc = page.meta.description if page.meta and page.meta.description else config.site_description %} + {%- set page_url = page.canonical_url if page.canonical_url else config.site_url %} + {%- set og_image = config.site_url ~ "assets/images/og-banner.png" %} + + + + + + + + + + + + + + + + + + + + +{% endblock %} diff --git a/mkdocs.yml b/mkdocs.yml index d2c6934..d3f4a56 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -11,8 +11,11 @@ use_directory_urls: true theme: name: material + custom_dir: docs/overrides icon: repo: fontawesome/brands/github + edit: material/pencil + view: material/eye palette: - media: "(prefers-color-scheme: light)" scheme: default @@ -34,8 +37,16 @@ theme: - navigation.expand - navigation.sections - navigation.top + - navigation.footer + - navigation.indexes - content.code.copy + - content.code.annotate + - content.action.edit + - content.action.view - toc.follow + - search.highlight + - search.suggest + - search.share nav: - Home: index.md @@ -60,6 +71,7 @@ nav: - Using the Request: advanced/using-request.md - Settings & Env Vars: advanced/settings.md - OpenAPI Customisation: advanced/openapi-customization.md + - Additional Responses: advanced/additional-responses.md - Templates (Jinja2): advanced/templates.md - Lifespan Events: advanced/lifespan.md - Behind a Proxy: advanced/behind-proxy.md @@ -81,6 +93,9 @@ nav: - Deployment: - Overview: deployment/index.md - Docker: deployment/docker.md + - systemd Service: deployment/systemd.md + - Gunicorn + Uvicorn: deployment/gunicorn.md + - HTTPS — Let's Encrypt: deployment/https.md - Nginx & Traefik: deployment/nginx-traefik.md - Cloud Services: deployment/cloud.md - Kubernetes: deployment/kubernetes.md @@ -88,6 +103,8 @@ nav: - Async / Await: concepts/async-await.md - Concurrency & Parallelism: concepts/concurrency.md - Python Type Hints: concepts/types-intro.md + - msgspec vs Pydantic: concepts/msgspec-vs-pydantic.md + - Radix Tree Routing: concepts/radix-tree-routing.md - Sub-Interpreters Guide: concepts/sub-interpreters.md - How-To Recipes: how-to/index.md - Reference: @@ -104,10 +121,16 @@ nav: markdown_extensions: - pymdownx.highlight: anchor_linenums: true + line_spans: __span + pygments_lang_class: true - pymdownx.superfences - pymdownx.inlinehilite - pymdownx.tabbed: alternate_style: true + - pymdownx.snippets + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg - admonition - pymdownx.details - toc: @@ -115,8 +138,12 @@ markdown_extensions: - attr_list - md_in_html - tables + - footnotes extra: + version: + provider: mike + default: stable social: - icon: fontawesome/brands/github link: https://github.com/FasterApiWeb/FasterAPI @@ -126,7 +153,11 @@ extra: name: PyPI faster-api-web plugins: - - search + - search: + separator: '[\s\-,:!=\[\]()"`/]+|\.(?!\d)|&[lg]t;|(?!\b)(?=[A-Z][a-z])' + - mike: + alias_type: symlink + canonical_version: latest - mkdocstrings: handlers: python: diff --git a/pyproject.toml b/pyproject.toml index c618460..24a1069 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,11 +59,15 @@ benchmark = [ docs = [ "mkdocs-material>=9.5.0", "mkdocstrings[python]>=0.25.0", + "mike>=2.0.0", ] test = [ "httpx>=0.27.0", ] +[project.scripts] +fasterapi = "FasterAPI.cli:main" + [project.urls] Homepage = "https://github.com/FasterApiWeb/FasterAPI" Repository = "https://github.com/FasterApiWeb/FasterAPI" diff --git a/tests/test_phase3.py b/tests/test_phase3.py new file mode 100644 index 0000000..408d48a --- /dev/null +++ b/tests/test_phase3.py @@ -0,0 +1,331 @@ +"""Tests for Phase 3 feature-parity additions. + +Covers: +- openapi_extra on route decorators and FasterRouter +- Enum path parameter coercion (implicit and explicit Path marker) +- CLI argument parsing (fasterapi run / dev / new / migrate-from-fastapi) +""" + +from __future__ import annotations + +import enum +import textwrap +from unittest import mock + +from FasterAPI import Faster, FasterRouter +from FasterAPI.cli import _build_parser, _migrate_file, main +from FasterAPI.openapi.generator import generate_openapi +from FasterAPI.params import Path as PathParam +from FasterAPI.testclient import TestClient + +# =========================================================================== +# openapi_extra +# =========================================================================== + + +class TestOpenAPIExtra: + def test_extra_fields_appear_in_operation(self): + app = Faster() + + @app.get( + "/items", + openapi_extra={ + "x-internal": True, + "externalDocs": {"url": "https://example.com", "description": "Docs"}, + }, + ) + async def list_items(): + return [] + + spec = generate_openapi(app, title="T", version="0") + op = spec["paths"]["/items"]["get"] + assert op["x-internal"] is True + assert op["externalDocs"]["url"] == "https://example.com" + + def test_extra_does_not_clobber_existing_fields(self): + app = Faster() + + @app.get("/health", summary="Health check", openapi_extra={"x-stable": True}) + async def health(): + return {"ok": True} + + spec = generate_openapi(app, title="T", version="0") + op = spec["paths"]["/health"]["get"] + assert op["summary"] == "Health check" + assert op["x-stable"] is True + + def test_extra_responses_merged(self): + app = Faster() + + @app.get( + "/resource", + openapi_extra={ + "responses": {"503": {"description": "Service unavailable"}}, + }, + ) + async def resource(): + return {} + + spec = generate_openapi(app, title="T", version="0") + op = spec["paths"]["/resource"]["get"] + assert "503" in op["responses"] + assert op["responses"]["503"]["description"] == "Service unavailable" + # Primary 200 must still exist + assert "200" in op["responses"] + + def test_extra_on_router_route(self): + router = FasterRouter(prefix="/v1") + + @router.get("/ping", openapi_extra={"x-router-extra": "yes"}) + async def ping(): + return {} + + app = Faster() + app.include_router(router) + spec = generate_openapi(app, title="T", version="0") + op = spec["paths"]["/v1/ping"]["get"] + assert op["x-router-extra"] == "yes" + + def test_none_extra_is_noop(self): + app = Faster() + + @app.get("/noop", openapi_extra=None) + async def noop(): + return {} + + spec = generate_openapi(app, title="T", version="0") + op = spec["paths"]["/noop"]["get"] + # Should not raise; operation is still valid + assert "200" in op["responses"] + + +# =========================================================================== +# Enum path parameter coercion +# =========================================================================== + + +class Color(enum.Enum): + RED = "red" + GREEN = "green" + BLUE = "blue" + + +class Priority(enum.IntEnum): + LOW = 1 + MEDIUM = 2 + HIGH = 3 + + +class TestEnumPathCoercion: + def test_implicit_enum_path_param(self): + app = Faster() + + @app.get("/colors/{color}") + async def get_color(color: Color): + return {"value": color.value} + + client = TestClient(app) + resp = client.get("/colors/red") + assert resp.status_code == 200 + assert resp.json() == {"value": "red"} + + def test_implicit_enum_invalid_value(self): + app = Faster() + + @app.get("/colors/{color}") + async def get_color(color: Color): + return {"value": color.value} + + client = TestClient(app) + resp = client.get("/colors/purple") + assert resp.status_code == 422 + + def test_explicit_path_marker_enum(self): + app = Faster() + + @app.get("/prio/{level}") + async def get_priority(level: Priority = PathParam()): + return {"level": level.value} + + client = TestClient(app) + resp = client.get("/prio/2") + assert resp.status_code == 200 + assert resp.json() == {"level": 2} + + def test_explicit_path_marker_invalid_enum(self): + app = Faster() + + @app.get("/prio/{level}") + async def get_priority(level: Priority = PathParam()): + return {"level": level.value} + + client = TestClient(app) + resp = client.get("/prio/99") + assert resp.status_code == 422 + + def test_enum_coercion_does_not_affect_non_enum(self): + app = Faster() + + @app.get("/items/{item_id}") + async def get_item(item_id: str): + return {"id": item_id} + + client = TestClient(app) + resp = client.get("/items/hello") + assert resp.status_code == 200 + assert resp.json() == {"id": "hello"} + + def test_enum_appears_in_openapi_schema(self): + app = Faster() + + @app.get("/colors/{color}") + async def get_color(color: Color): + return {"value": color.value} + + spec = generate_openapi(app, title="T", version="0") + params = spec["paths"]["/colors/{color}"]["get"]["parameters"] + color_param = next(p for p in params if p["name"] == "color") + # Enum values should appear in schema + assert "enum" in color_param["schema"] or color_param["schema"].get("type") is not None + + +# =========================================================================== +# CLI — argument parsing +# =========================================================================== + + +class TestCLIParser: + def setup_method(self): + self.parser = _build_parser() + + def test_run_defaults(self): + args = self.parser.parse_args(["run"]) + assert args.app == "main:app" + assert args.host == "127.0.0.1" + assert args.port == 8000 + assert args.log_level == "info" + + def test_run_custom_args(self): + args = self.parser.parse_args(["run", "myapp:application", "--host", "0.0.0.0", "--port", "9000"]) + assert args.app == "myapp:application" + assert args.host == "0.0.0.0" + assert args.port == 9000 + + def test_dev_has_no_workers_arg(self): + args = self.parser.parse_args(["dev", "app.main"]) + assert args.app == "app.main" + assert not hasattr(args, "workers") + + def test_new_command(self): + args = self.parser.parse_args(["new", "myproject"]) + assert args.name == "myproject" + + def test_migrate_command(self): + args = self.parser.parse_args(["migrate-from-fastapi", "/some/path"]) + assert args.path == "/some/path" + assert args.dry_run is False + + def test_migrate_dry_run(self): + args = self.parser.parse_args(["migrate-from-fastapi", "/some/path", "--dry-run"]) + assert args.dry_run is True + + def test_no_subcommand_returns_1(self): + rc = main([]) + assert rc == 1 + + +class TestCLIRunDev: + def test_run_calls_uvicorn(self): + with mock.patch("subprocess.call", return_value=0) as m: + rc = main(["run", "app.main:app", "--port", "8001"]) + assert rc == 0 + cmd = m.call_args[0][0] + assert "uvicorn" in cmd + assert "app.main:app" in cmd + assert "--port" in cmd + assert "8001" in cmd + assert "--reload" not in cmd + + def test_dev_adds_reload(self): + with mock.patch("subprocess.call", return_value=0) as m: + main(["dev", "app.main:app"]) + cmd = m.call_args[0][0] + assert "--reload" in cmd + + def test_run_auto_expands_bare_module(self): + with mock.patch("subprocess.call", return_value=0) as m: + main(["run", "mymodule"]) + cmd = m.call_args[0][0] + assert "mymodule:app" in cmd + + +class TestCLINew: + def test_new_creates_files(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + rc = main(["new", "testproj"]) + assert rc == 0 + proj = tmp_path / "testproj" + assert (proj / "app" / "main.py").exists() + assert (proj / "app" / "routers" / "items.py").exists() + assert (proj / "pyproject.toml").exists() + assert (proj / "Dockerfile").exists() + assert (proj / ".gitignore").exists() + + def test_new_main_py_content(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + main(["new", "myapp"]) + content = (tmp_path / "myapp" / "app" / "main.py").read_text() + assert "from FasterAPI import Faster" in content + assert "myapp" in content + + def test_new_fails_if_dir_exists(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "existing").mkdir() + rc = main(["new", "existing"]) + assert rc == 1 + + +class TestCLIMigrate: + def test_migrate_rewrites_fastapi_imports(self, tmp_path): + src = tmp_path / "app.py" + src.write_text( + textwrap.dedent("""\ + from fastapi import FastAPI + from fastapi import APIRouter + from fastapi.responses import JSONResponse + from fastapi.middleware.cors import CORSMiddleware + + app = FastAPI() + """) + ) + rc = main(["migrate-from-fastapi", str(src)]) + assert rc == 0 + result = src.read_text() + assert "from FasterAPI import" in result + assert "from fastapi import FastAPI" not in result + + def test_migrate_dry_run_does_not_write(self, tmp_path): + src = tmp_path / "app.py" + original = "from fastapi import FastAPI\napp = FastAPI()\n" + src.write_text(original) + main(["migrate-from-fastapi", str(src), "--dry-run"]) + assert src.read_text() == original + + def test_migrate_directory(self, tmp_path): + (tmp_path / "a.py").write_text("from fastapi import FastAPI\n") + (tmp_path / "b.py").write_text("from fastapi import APIRouter\n") + rc = main(["migrate-from-fastapi", str(tmp_path)]) + assert rc == 0 + assert "FasterAPI" in (tmp_path / "a.py").read_text() + assert "FasterAPI" in (tmp_path / "b.py").read_text() + + def test_migrate_unchanged_file_not_reported(self, tmp_path): + src = tmp_path / "clean.py" + src.write_text("x = 1\n") + changed = _migrate_file(src, dry_run=False) + assert changed is False + assert src.read_text() == "x = 1\n" + + def test_migrate_missing_path_returns_1(self): + rc = main(["migrate-from-fastapi", "/nonexistent/path"]) + assert rc == 1