diff --git a/.gitignore b/.gitignore index 86958c842..6c1a554c7 100644 --- a/.gitignore +++ b/.gitignore @@ -135,3 +135,12 @@ tests/test_data/rapl/* credentials* .codecarbon.config* scripts/agent-vm.personal.config.sh + +# Added by ggshield +.cache_ggshield + +# Added by ggshield +.cache_ggshield + +# Added by ggshield +.cache_ggshield diff --git a/codecarbon/integrations/__init__.py b/codecarbon/integrations/__init__.py new file mode 100644 index 000000000..9c5777a96 --- /dev/null +++ b/codecarbon/integrations/__init__.py @@ -0,0 +1 @@ +"""Optional integrations for frameworks and platforms.""" diff --git a/codecarbon/integrations/fastapi/__init__.py b/codecarbon/integrations/fastapi/__init__.py new file mode 100644 index 000000000..466d24499 --- /dev/null +++ b/codecarbon/integrations/fastapi/__init__.py @@ -0,0 +1,13 @@ +"""FastAPI integration: middleware and lifespan helpers.""" + +from codecarbon.integrations.fastapi.lifespan import create_codecarbon_lifespan +from codecarbon.integrations.fastapi.middleware import ( + CodeCarbonMiddleware, + add_codecarbon_middleware, +) + +__all__ = [ + "CodeCarbonMiddleware", + "add_codecarbon_middleware", + "create_codecarbon_lifespan", +] diff --git a/codecarbon/integrations/fastapi/_headers.py b/codecarbon/integrations/fastapi/_headers.py new file mode 100644 index 000000000..5fffe539f --- /dev/null +++ b/codecarbon/integrations/fastapi/_headers.py @@ -0,0 +1,119 @@ +"""Configurable response headers from emissions measurements.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +from typing import Union + +from starlette.requests import Request +from starlette.responses import Response + +from codecarbon.output_methods.emissions_data import EmissionsData + +HeaderConfig = Union[bool, str, Sequence[str], Mapping[str, str], None] +HeaderFormatter = Callable[[EmissionsData, Request], Mapping[str, str]] + +FIELD_UNITS: dict[str, str] = { + "emissions": "kg", + "emissions_rate": "kg-per-s", + "duration": "s", + "energy_consumed": "kwh", + "cpu_energy": "kwh", + "gpu_energy": "kwh", + "ram_energy": "kwh", + "water_consumed": "l", + "cpu_power": "w", + "gpu_power": "w", + "ram_power": "w", + "cpu_utilization_percent": "percent", + "gpu_utilization_percent": "percent", + "ram_utilization_percent": "percent", + "ram_used_gb": "gb", + "pue": "ratio", + "wue": "l-per-kwh", +} + +HEADER_PRESETS: dict[str, dict[str, str]] = { + "emissions": {"emissions": "X-CodeCarbon-Emissions-kg"}, + "default": { + "emissions": "X-CodeCarbon-Emissions-kg", + "energy_consumed": "X-CodeCarbon-Energy-Consumed-kwh", + "duration": "X-CodeCarbon-Duration-s", + "emissions_rate": "X-CodeCarbon-Emissions-Rate-kg-per-s", + }, + "energy": { + "emissions": "X-CodeCarbon-Emissions-kg", + "energy_consumed": "X-CodeCarbon-Energy-Consumed-kwh", + "cpu_energy": "X-CodeCarbon-Cpu-Energy-kwh", + "gpu_energy": "X-CodeCarbon-Gpu-Energy-kwh", + "ram_energy": "X-CodeCarbon-Ram-Energy-kwh", + "duration": "X-CodeCarbon-Duration-s", + }, + "power": { + "emissions": "X-CodeCarbon-Emissions-kg", + "cpu_power": "X-CodeCarbon-Cpu-Power-w", + "gpu_power": "X-CodeCarbon-Gpu-Power-w", + "ram_power": "X-CodeCarbon-Ram-Power-w", + "duration": "X-CodeCarbon-Duration-s", + }, +} + +FULL_HEADER_FIELDS: tuple[str, ...] = tuple(FIELD_UNITS.keys()) + + +def _auto_header_name(field: str) -> str: + unit = FIELD_UNITS.get(field, "") + title = "-".join(part.capitalize() for part in field.split("_")) + suffix = f"-{unit}" if unit else "" + return f"X-CodeCarbon-{title}{suffix}" + + +def resolve_header_mapping(config: HeaderConfig) -> dict[str, str]: + """Normalize ``response_headers`` settings to ``{field_name: header_name}``. + + Args: + config: ``None`` or ``False`` for no headers; ``True`` for the emissions preset; + a preset name (``emissions``, ``default``, ``energy``, ``power``, ``full``); + a sequence of field names (auto header names); or an explicit mapping. + + Returns: + Mapping from :class:`~codecarbon.output_methods.emissions_data.EmissionsData` + attribute names to HTTP header names. + + Raises: + ValueError: If ``config`` is a string that is not a known preset (other than + ``full``). + """ + if config is None or config is False: + return {} + if config is True: + return dict(HEADER_PRESETS["emissions"]) + if isinstance(config, str): + preset = HEADER_PRESETS.get(config) + if preset is None: + if config == "full": + return {field: _auto_header_name(field) for field in FULL_HEADER_FIELDS} + raise ValueError(f"Unknown response_headers preset: {config!r}") + return dict(preset) + if isinstance(config, Mapping): + return dict(config) + return {field: _auto_header_name(field) for field in config} + + +def apply_response_headers( + response: Response, + emissions_data: EmissionsData, + header_mapping: Mapping[str, str], +) -> None: + """Write selected emission fields onto an HTTP response as headers. + + Args: + response: Outgoing Starlette response (headers are updated in place). + emissions_data: Values read via ``getattr`` for each key in ``header_mapping``. + header_mapping: Field name to HTTP header name; unknown fields are skipped. + """ + for field, header_name in header_mapping.items(): + if not hasattr(emissions_data, field): + continue + value = getattr(emissions_data, field) + response.headers[header_name] = str(value) diff --git a/codecarbon/integrations/fastapi/_routing.py b/codecarbon/integrations/fastapi/_routing.py new file mode 100644 index 000000000..a21a11bd1 --- /dev/null +++ b/codecarbon/integrations/fastapi/_routing.py @@ -0,0 +1,135 @@ +"""Route naming and endpoint filter helpers for FastAPI/Starlette.""" + +from collections.abc import Callable, Iterable +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from starlette.requests import Request + +DEFAULT_EXCLUDE: frozenset[str] = frozenset( + { + "/docs", + "/redoc", + "/openapi.json", + "/health", + "/healthz", + "/ready", + "/live", + } +) + +HTTP_METHODS = frozenset( + {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE", "CONNECT"} +) + + +def get_endpoint_path(request: "Request") -> str: + """Return the mounted route template or the raw URL path. + + Args: + request: Current Starlette/FastAPI request. + + Returns: + Route template such as ``/items/{item_id}``, or ``request.url.path``. + """ + route = request.scope.get("route") + if route is not None: + return route.path + return request.url.path + + +def build_endpoint_key(request: "Request") -> str: + """Build a stable endpoint identifier such as ``GET /predict``. + + Args: + request: Current Starlette/FastAPI request. + + Returns: + HTTP method plus route template or URL path. + """ + return f"{request.method} {get_endpoint_path(request)}" + + +def is_method_pattern(pattern: str) -> bool: + """Return True when ``pattern`` is ``METHOD /path``.""" + method, _, path = pattern.partition(" ") + return method in HTTP_METHODS and path.startswith("/") + + +def matches_exclude( + pattern: str, + url_path: str, + endpoint_key: str, + endpoint_path: str, +) -> bool: + """Return True when an exclude pattern matches the request.""" + if is_method_pattern(pattern): + return endpoint_key == pattern + if not pattern.startswith("/"): + return endpoint_key == pattern + return ( + url_path == pattern + or url_path.startswith(f"{pattern}/") + or endpoint_path == pattern + ) + + +def matches_include(pattern: str, endpoint_key: str, endpoint_path: str) -> bool: + """Return True when an include pattern matches the request.""" + if is_method_pattern(pattern): + return endpoint_key == pattern + if pattern.startswith("/"): + return endpoint_path == pattern + return endpoint_key == pattern + + +def should_track_request( + request: "Request", + include: Iterable[str] | None, + exclude: Iterable[str], +) -> bool: + """Return True when the request should be measured. + + Patterns use one of two forms: + + * ``METHOD /route/template`` — one HTTP method on one route (e.g. ``GET /predict``) + * ``/route/template`` — any method on that route, or a URL path prefix when excluding + + Args: + request: Current Starlette/FastAPI request. + include: When set, only matching endpoints are tracked. + exclude: Endpoints or URL prefixes to skip. + + Returns: + True when CodeCarbon should track this request. + """ + url_path = request.url.path + endpoint_key = build_endpoint_key(request) + endpoint_path = get_endpoint_path(request) + for pattern in exclude: + if matches_exclude(pattern, url_path, endpoint_key, endpoint_path): + return False + if include is None: + return True + return any( + matches_include(pattern, endpoint_key, endpoint_path) for pattern in include + ) + + +def build_task_name( + request: "Request", + formatter: Callable[["Request"], str] | None = None, +) -> str: + """Derive a stable label like ``GET /items/{item_id}`` for task-scoped tracking. + + Args: + request: Current Starlette/FastAPI request. + formatter: Optional function that returns the task name instead of the default. + + Returns: + Method plus route template when a route is mounted on the request scope, + otherwise method plus the raw URL path. + """ + if formatter is not None: + return formatter(request) + return build_endpoint_key(request) diff --git a/codecarbon/integrations/fastapi/lifespan.py b/codecarbon/integrations/fastapi/lifespan.py new file mode 100644 index 000000000..00dfb3746 --- /dev/null +++ b/codecarbon/integrations/fastapi/lifespan.py @@ -0,0 +1,38 @@ +"""Lifespan helpers for sharing one ``EmissionsTracker`` across requests.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Any + +from codecarbon import EmissionsTracker + + +@asynccontextmanager +async def create_codecarbon_lifespan( + app: Any, + *, + project_name: str = "codecarbon-fastapi", + **tracker_kwargs: Any, +) -> AsyncIterator[None]: + """Start a tracker for the app lifetime and expose it on ``app.state``. + + Args: + app: Starlette/FastAPI application with ``state`` namespace. + project_name: ``project_name`` for :class:`~codecarbon.EmissionsTracker`. + **tracker_kwargs: Extra constructor kwargs for the tracker. + + Yields: + ``None`` while the app runs. + """ + merged = dict(tracker_kwargs) + merged.setdefault("allow_multiple_runs", True) + tracker = EmissionsTracker(project_name=project_name, **merged) + tracker.start() + app.state.codecarbon_tracker = tracker + try: + yield + finally: + tracker.stop() + app.state.codecarbon_tracker = None diff --git a/codecarbon/integrations/fastapi/middleware.py b/codecarbon/integrations/fastapi/middleware.py new file mode 100644 index 000000000..8a9dbbd4b --- /dev/null +++ b/codecarbon/integrations/fastapi/middleware.py @@ -0,0 +1,235 @@ +"""FastAPI/Starlette middleware for per-request emissions tracking.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable, Iterable +from typing import Any + +try: + from starlette.middleware.base import BaseHTTPMiddleware + from starlette.requests import Request + from starlette.responses import Response +except ImportError as exc: + raise ImportError( + "CodeCarbon FastAPI integration requires Starlette (installed with FastAPI). " + "Install optional dependencies with: pip install 'codecarbon[fastapi]'" + ) from exc + +from codecarbon import EmissionsTracker +from codecarbon.integrations.fastapi._headers import ( + HeaderConfig, + HeaderFormatter, + apply_response_headers, + resolve_header_mapping, +) +from codecarbon.integrations.fastapi._routing import ( + DEFAULT_EXCLUDE, + build_task_name, + should_track_request, +) +from codecarbon.output_methods.emissions_data import EmissionsData + + +class CodeCarbonMiddleware(BaseHTTPMiddleware): + """Measure emissions per HTTP request or attach to a shared app-level tracker.""" + + def __init__( + self, + app: Any, + *, + project_name: str = "codecarbon-fastapi", + tracking_mode: str = "request", + include: Iterable[str] | None = None, + exclude: Iterable[str] | None = None, + response_headers: HeaderConfig | None = None, + include_emissions_header: bool = False, + header_formatter: HeaderFormatter | None = None, + task_name_formatter: Callable[[Request], str] | None = None, + on_request_complete: Callable[..., Any] | None = None, + tracker_kwargs: dict[str, Any] | None = None, + defer_measurement: bool = False, + **emissions_tracker_kwargs: Any, + ) -> None: + """Configure middleware. + + Args: + app: ASGI application wrapped by this middleware. + project_name: ``project_name`` passed to :class:`~codecarbon.EmissionsTracker`. + tracking_mode: ``\"request\"`` (new tracker per request) or ``\"app\"`` (shared tracker). + include: When set, only matching endpoints are tracked (e.g. ``GET /predict``). + exclude: Endpoints or URL prefixes to skip. Defaults to common docs and health routes. + response_headers: Preset name, field list, field-to-header mapping, or boolean. + include_emissions_header: Deprecated; equivalent to ``response_headers=True``. + header_formatter: If set, builds response headers instead of ``response_headers``. + task_name_formatter: Overrides default route-based task naming. + on_request_complete: Optional callback + ``(request, response, emissions_data | None, task_name)``. + tracker_kwargs: Baseline kwargs merged into the tracker constructor. + defer_measurement: Return the HTTP response before ``stop`` / ``stop_task``; + skips response headers and runs ``on_request_complete`` in a background task. + **emissions_tracker_kwargs: Additional :class:`~codecarbon.EmissionsTracker` kwargs. + """ + super().__init__(app) + self.project_name = project_name + self.tracking_mode = tracking_mode + self.defer_measurement = defer_measurement + self.include = set(include) if include is not None else None + self.exclude = set(exclude if exclude is not None else DEFAULT_EXCLUDE) + if response_headers is not None: + self.header_mapping = resolve_header_mapping(response_headers) + elif include_emissions_header: + self.header_mapping = resolve_header_mapping(True) + else: + self.header_mapping = {} + self.header_formatter = header_formatter + self.task_name_formatter = task_name_formatter + self.on_request_complete = on_request_complete + merged: dict[str, Any] = dict(tracker_kwargs or {}) + merged.update(emissions_tracker_kwargs) + merged.setdefault("allow_multiple_runs", True) + self.tracker_kwargs = merged + self._app_tracker: EmissionsTracker | None = None + self._measurement_lock = asyncio.Lock() + + async def dispatch( + self, + request: Request, + call_next: Callable[[Request], Awaitable[Response]], + ) -> Response: + """Handle an incoming request behind CodeCarbon measurement.""" + if not should_track_request(request, self.include, self.exclude): + return await call_next(request) + if self.tracking_mode == "app": + return await self._dispatch_app_mode(request, call_next) + return await self._dispatch_request_mode(request, call_next) + + def _apply_headers( + self, + response: Response | None, + emissions_data: EmissionsData | None, + request: Request, + ) -> None: + if response is None or emissions_data is None: + return + if self.header_formatter is not None: + for name, value in self.header_formatter(emissions_data, request).items(): + response.headers[name] = value + return + apply_response_headers(response, emissions_data, self.header_mapping) + + def _create_and_start_tracker(self) -> EmissionsTracker: + tracker = EmissionsTracker( + project_name=self.project_name, **self.tracker_kwargs + ) + tracker.start() + return tracker + + async def _start_request_tracker(self) -> EmissionsTracker: + return await asyncio.to_thread(self._create_and_start_tracker) + + async def _stop_request_tracker( + self, tracker: EmissionsTracker + ) -> EmissionsData | None: + await asyncio.to_thread(tracker.stop) + return getattr(tracker, "final_emissions_data", None) + + def _run_request_complete( + self, + request: Request, + response: Response | None, + emissions_data: EmissionsData | None, + ) -> None: + if self.on_request_complete is None or response is None: + return + task_name = build_task_name(request, self.task_name_formatter) + self.on_request_complete(request, response, emissions_data, task_name) + + async def _finalize_request_measurement( + self, + tracker: EmissionsTracker, + request: Request, + response: Response | None, + ) -> None: + emissions_data = await self._stop_request_tracker(tracker) + self._run_request_complete(request, response, emissions_data) + + async def _dispatch_request_mode( + self, + request: Request, + call_next: Callable[[Request], Awaitable[Response]], + ) -> Response: + tracker = await self._start_request_tracker() + response: Response | None = None + try: + response = await call_next(request) + finally: + if self.defer_measurement: + asyncio.create_task( + self._finalize_request_measurement(tracker, request, response) + ) + else: + emissions_data = await self._stop_request_tracker(tracker) + self._run_request_complete(request, response, emissions_data) + self._apply_headers(response, emissions_data, request) + return response + + async def _finalize_app_measurement( + self, + tracker: EmissionsTracker, + task_name: str, + request: Request, + response: Response | None, + ) -> None: + async with self._measurement_lock: + emissions_data = await asyncio.to_thread(tracker.stop_task, task_name) + self._run_request_complete(request, response, emissions_data) + + async def _dispatch_app_mode( + self, + request: Request, + call_next: Callable[[Request], Awaitable[Response]], + ) -> Response: + tracker = await self._get_app_tracker(request) + task_name = build_task_name(request, self.task_name_formatter) + response: Response | None = None + emissions_data: EmissionsData | None = None + if self.defer_measurement: + async with self._measurement_lock: + await asyncio.to_thread(tracker.start_task, task_name) + try: + response = await call_next(request) + finally: + asyncio.create_task( + self._finalize_app_measurement( + tracker, task_name, request, response + ) + ) + return response + async with self._measurement_lock: + await asyncio.to_thread(tracker.start_task, task_name) + try: + response = await call_next(request) + finally: + emissions_data = await asyncio.to_thread(tracker.stop_task, task_name) + self._run_request_complete(request, response, emissions_data) + self._apply_headers(response, emissions_data, request) + return response + + async def _get_app_tracker(self, request: Request) -> EmissionsTracker: + app_tracker = getattr(request.app.state, "codecarbon_tracker", None) + if app_tracker is not None: + return app_tracker + if self._app_tracker is None: + self._app_tracker = await asyncio.to_thread(self._create_and_start_tracker) + return self._app_tracker + + +def add_codecarbon_middleware(app: Any, **kwargs: Any) -> None: + """Register :class:`CodeCarbonMiddleware` on a FastAPI or Starlette app. + + Args: + app: Application instance with ``add_middleware``. + **kwargs: Forwarded to :class:`CodeCarbonMiddleware`. + """ + app.add_middleware(CodeCarbonMiddleware, **kwargs) diff --git a/docs/how-to/fastapi.md b/docs/how-to/fastapi.md new file mode 100644 index 000000000..2467f3fd5 --- /dev/null +++ b/docs/how-to/fastapi.md @@ -0,0 +1,169 @@ +# FastAPI middleware + +Track HTTP request carbon emissions for a [FastAPI](https://fastapi.tiangolo.com/) (or Starlette) app with optional response headers. Install the optional integration extra, register the middleware, and each route is measured without per-handler boilerplate. + +## Install + +```console +pip install "codecarbon[fastapi]" +``` + +With uv: + +```console +uv add "codecarbon[fastapi]" +``` + +## Basic usage + +```python +from fastapi import FastAPI +from codecarbon.integrations.fastapi import add_codecarbon_middleware + +app = FastAPI() +add_codecarbon_middleware(app, project_name="my-api", response_headers="default") +``` + +A minimal runnable app lives at [`examples/fastapi_middleware.py`](https://github.com/mlco2/codecarbon/blob/master/examples/fastapi_middleware.py). Run it with: + +```console +uv run --extra fastapi uvicorn examples.fastapi_middleware:app --reload +``` + +Then open or `curl` `http://127.0.0.1:8000/predict` and inspect response headers for CodeCarbon fields. + +## `tracking_mode`: `request` vs `app` + +| Mode | Behavior | +|------|-----------| +| **`request`** (default) | Creates a short-lived `EmissionsTracker` per HTTP request. Safe under concurrency; each request is isolated. | +| **`app`** | Reuses one tracker on `app.state` and uses `start_task` / `stop_task` per request (with an asyncio lock). Lower overhead; measurements for concurrent requests are serialized. | + +Use **`request`** unless you have measured a need for a shared tracker. For production APIs, prefer **`app`** mode with a lifespan handler and `save_to_file=False` to avoid per-request tracker startup cost. + +## Performance + +Per-request tracking runs hardware measurement in a thread pool so the event loop stays responsive. Response headers still require waiting for measurement to finish before the response is sent. + +| Option | Effect | +|--------|--------| +| `tracking_mode="app"` + `create_codecarbon_lifespan` | Amortizes tracker startup; best default for APIs | +| `tracker_kwargs={"save_to_file": False, "save_to_api": False}` | Skips I/O on every request | +| `defer_measurement=True` | Returns the HTTP response immediately; runs `stop` / `stop_task` in a background task. Skips response headers; use `on_request_complete` for logging or metrics | + +Example with deferred measurement: + +```python +add_codecarbon_middleware( + app, + tracking_mode="app", + defer_measurement=True, + on_request_complete=lambda request, response, data, task_name: logger.info( + "%s emissions=%s", task_name, getattr(data, "emissions", None) + ), +) +``` + +## Lifespan pattern for `tracking_mode="app"` + +When using **`app`** mode, start and stop the shared tracker with the application lifespan so totals flush on shutdown: + +```python +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from codecarbon.integrations.fastapi import add_codecarbon_middleware, create_codecarbon_lifespan + + +@asynccontextmanager +async def lifespan(app: FastAPI): + async with create_codecarbon_lifespan(app, project_name="my-api"): + yield + + +app = FastAPI(lifespan=lifespan) +add_codecarbon_middleware(app, tracking_mode="app", response_headers="default") +``` + +`create_codecarbon_lifespan` stores the tracker on `app.state.codecarbon_tracker` for the middleware to reuse. + +## Response headers + +### Presets + +| Preset | Typical use | +|--------|----------------| +| **`emissions`** | Single header for CO₂ (kg). | +| **`default`** | Emissions, energy consumed, duration, emissions rate. | +| **`energy`** | Emissions plus per-subsystem energy (`cpu_energy`, `gpu_energy`, `ram_energy`) and duration. | +| **`power`** | Emissions plus instantaneous power components and duration. | +| **`full`** | All supported numeric fields, each with an auto-generated `X-CodeCarbon-…` header name. | + +`True` is an alias for the **`emissions`** preset; `False` or `None` disables optional headers. + +### Field lists and custom maps + +Pass a **list of field names** to emit those metrics with auto-named headers (derived from the field and unit). + +Pass a **dict** mapping `EmissionsData` field names to exact header names for full control: + +```python +add_codecarbon_middleware( + app, + response_headers={ + "emissions": "X-MyApp-Carbon-kg", + "energy_consumed": "X-MyApp-Energy-kwh", + "duration": "X-MyApp-Duration-s", + }, +) +``` + +### `header_formatter` + +For JSON, extra headers, or non-standard formatting, pass **`header_formatter`** as a callable `(EmissionsData, Request) -> dict[str, str]`. When set, it replaces preset/list/dict mapping for response headers. + +## `include` and `exclude` + +Two filters control which requests are measured. Both accept the same pattern forms: + +| Pattern | Meaning | +|---------|---------| +| `GET /predict` | One HTTP method on one route | +| `/predict` | Any method on that route (`include`), or skip that route/URL prefix (`exclude`) | + +- **`exclude`** — skip matching requests. Defaults to docs and health paths (`/docs`, `/health`, …). Pass your own list to replace the default. +- **`include`** — when set, only matching endpoints are tracked (allowlist). + +```python +add_codecarbon_middleware( + app, + include=["GET /predict", "POST /train"], + exclude=["GET /admin", "/internal"], +) +``` + +## `task_name_formatter`, `on_request_complete` + +## CORS and `expose_headers` + +If the browser must read CodeCarbon headers (e.g. in JavaScript `fetch`), configure **`expose_headers`** on `CORSMiddleware` to list the header names you emit (browsers do not expose arbitrary response headers to frontend code by default). + +## Middleware order + +Per [FastAPI middleware order](https://fastapi.tiangolo.com/tutorial/middleware/), the **last** middleware added is **outermost** on the request path (runs first on the way in). Add CodeCarbon **after** other middleware so it wraps inner layers and includes work done by inner middleware and route handlers: + +```python +from starlette.middleware.cors import CORSMiddleware + +app.add_middleware(CORSMiddleware, ...) +add_codecarbon_middleware(app) # outermost on request → measures the full stack below +``` + +## Limitations (v1) + +- **WebSockets** are not instrumented by this middleware. +- **Background tasks** (`BackgroundTasks` and similar) run **after** the middleware has finished the request path; their CPU/GPU use may **not** be fully attributed to that request’s measurement window. + +## Per-endpoint tracking + +For a single route or fine-grained control without global middleware, use the [`@track_emissions` decorator](../reference/api.md#track_emissions-decorator) (same parameters as `EmissionsTracker`). diff --git a/examples/fastapi_middleware.py b/examples/fastapi_middleware.py new file mode 100644 index 000000000..91a89f805 --- /dev/null +++ b/examples/fastapi_middleware.py @@ -0,0 +1,44 @@ +"""Minimal FastAPI app with CodeCarbon middleware.""" + +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from codecarbon.integrations.fastapi import ( + add_codecarbon_middleware, + create_codecarbon_lifespan, +) + +_tracker_kwargs = { + "save_to_file": False, + "save_to_api": False, +} + + +@asynccontextmanager +async def lifespan(app: FastAPI): + async with create_codecarbon_lifespan( + app, + project_name="fastapi-demo", + **_tracker_kwargs, + ): + yield + + +app = FastAPI(title="CodeCarbon FastAPI demo", lifespan=lifespan) +add_codecarbon_middleware( + app, + project_name="fastapi-demo", + tracking_mode="app", + response_headers="default", + tracker_kwargs=_tracker_kwargs, +) + + +@app.get("/predict") +def predict(text: str = "hello"): + return {"text": text, "label": "demo"} + + +# Lowest latency (no response headers): defer_measurement=True and use on_request_complete. +# Run: uv run --extra fastapi --with uvicorn uvicorn examples.fastapi_middleware:app --reload diff --git a/mkdocs.yml b/mkdocs.yml index 11a0bed93..62e71cc05 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -148,6 +148,7 @@ nav: - LLMs and Agents: how-to/agents.md - How-to Guides: - Configure CodeCarbon: how-to/configuration.md + - FastAPI middleware: how-to/fastapi.md - Compare Model Efficiency: tutorials/comparing-model-efficiency.md - Dashboard & Visualization: - Use the Cloud API & Dashboard: how-to/cloud-api.md diff --git a/pyproject.toml b/pyproject.toml index 59d610015..8b04f0bc4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,6 +95,8 @@ dev = [ "jsonschema", # For BoAmps schema validation tests "mktestdocs", # For testing documentation code blocks "scikit-learn", # For documentation examples and tests + "fastapi>=0.100", + "httpx", ] doc = [ "requests", @@ -123,6 +125,10 @@ viz-legacy = [ amdsmi = [ "amdsmi>=6.0.0" ] +fastapi = [ + "fastapi>=0.100", + "httpx", +] [project.scripts] carbonboard = "codecarbon.viz.carbonboard:main" diff --git a/tests/integrations/test_fastapi_headers.py b/tests/integrations/test_fastapi_headers.py new file mode 100644 index 000000000..8f43d12fe --- /dev/null +++ b/tests/integrations/test_fastapi_headers.py @@ -0,0 +1,118 @@ +"""Tests for response header mapping from :class:`~codecarbon.output_methods.emissions_data.EmissionsData`.""" + +import pytest +from starlette.responses import Response + +from codecarbon.integrations.fastapi._headers import ( + HEADER_PRESETS, + apply_response_headers, + resolve_header_mapping, +) +from codecarbon.output_methods.emissions_data import EmissionsData + + +@pytest.fixture +def emissions_data() -> EmissionsData: + return EmissionsData( + timestamp="2026-05-19T12:00:00", + project_name="test", + run_id="run-1", + experiment_id="exp-1", + duration=1.5, + emissions=0.00042, + emissions_rate=0.00028, + cpu_power=12.0, + gpu_power=0.0, + ram_power=5.0, + cpu_energy=0.003, + gpu_energy=0.0, + ram_energy=0.001, + energy_consumed=0.004, + water_consumed=0.0, + country_name="France", + country_iso_code="FRA", + region="", + cloud_provider="", + cloud_region="", + os="Darwin", + python_version="3.12", + codecarbon_version="3.2.6", + cpu_count=8, + cpu_model="Apple M1", + gpu_count=0, + gpu_model="", + longitude=2.35, + latitude=48.85, + ram_total_size=16.0, + tracking_mode="machine", + ) + + +def test_resolve_header_mapping_preset_emissions() -> None: + mapping = resolve_header_mapping("emissions") + assert mapping == {"emissions": "X-CodeCarbon-Emissions-kg"} + + +def test_resolve_header_mapping_field_list() -> None: + mapping = resolve_header_mapping(["emissions", "duration"]) + assert mapping["emissions"] == "X-CodeCarbon-Emissions-kg" + assert mapping["duration"] == "X-CodeCarbon-Duration-s" + + +def test_resolve_header_mapping_custom_dict() -> None: + custom = {"emissions": "X-App-CO2", "duration": "X-App-Time"} + assert resolve_header_mapping(custom) == custom + + +def test_resolve_header_mapping_bool_true_aliases_emissions() -> None: + assert resolve_header_mapping(True) == HEADER_PRESETS["emissions"] + + +def test_resolve_header_mapping_none_or_false_returns_empty() -> None: + assert resolve_header_mapping(None) == {} + assert resolve_header_mapping(False) == {} + + +def test_resolve_header_mapping_full_preset() -> None: + mapping = resolve_header_mapping("full") + assert mapping["emissions"] == "X-CodeCarbon-Emissions-kg" + assert ( + mapping["cpu_utilization_percent"] + == "X-CodeCarbon-Cpu-Utilization-Percent-percent" + ) + + +def test_resolve_header_mapping_unknown_preset_raises() -> None: + with pytest.raises(ValueError, match="Unknown response_headers preset"): + resolve_header_mapping("not-a-preset") + + +def test_apply_response_headers_sets_values(emissions_data: EmissionsData) -> None: + response = Response(content=b"ok") + apply_response_headers( + response, + emissions_data, + { + "emissions": "X-CodeCarbon-Emissions-kg", + "duration": "X-CodeCarbon-Duration-s", + }, + ) + assert response.headers["X-CodeCarbon-Emissions-kg"] == "0.00042" + assert response.headers["X-CodeCarbon-Duration-s"] == "1.5" + + +def test_apply_response_headers_ignores_unknown_fields( + emissions_data: EmissionsData, +) -> None: + response = Response(content=b"ok") + apply_response_headers(response, emissions_data, {"not_a_field": "X-Bad"}) + assert "X-Bad" not in response.headers + + +def test_apply_response_headers_noop_when_mapping_empty( + emissions_data: EmissionsData, +) -> None: + response = Response(content=b"ok") + before = dict(response.headers) + apply_response_headers(response, emissions_data, {}) + assert dict(response.headers) == before diff --git a/tests/integrations/test_fastapi_import.py b/tests/integrations/test_fastapi_import.py new file mode 100644 index 000000000..f0b9e41a5 --- /dev/null +++ b/tests/integrations/test_fastapi_import.py @@ -0,0 +1,47 @@ +"""Import surface for the optional FastAPI integration package.""" + +import builtins +import importlib +import sys + +import pytest + + +def test_fastapi_integration_importable() -> None: + """Public helpers are importable without instantiating middleware.""" + from codecarbon.integrations.fastapi import ( + CodeCarbonMiddleware, + add_codecarbon_middleware, + create_codecarbon_lifespan, + ) + + assert CodeCarbonMiddleware is not None + assert callable(add_codecarbon_middleware) + assert callable(create_codecarbon_lifespan) + + +def test_missing_starlette_shows_helpful_error(monkeypatch: pytest.MonkeyPatch) -> None: + """Middleware import surfaces an actionable hint without Starlette/FastAPI.""" + for key in list(sys.modules): + if key.startswith("starlette") or key.startswith( + "codecarbon.integrations.fastapi" + ): + del sys.modules[key] + + real_import = builtins.__import__ + + def mock_import( + name: str, + globals: dict | None = None, + locals: dict | None = None, + fromlist: tuple[str, ...] = (), + level: int = 0, + ): + root = name.split(".", 1)[0] + if root in ("starlette", "fastapi"): + raise ImportError("no starlette") + return real_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", mock_import) + with pytest.raises(ImportError, match=r"pip install .*codecarbon\[fastapi\]"): + importlib.import_module("codecarbon.integrations.fastapi.middleware") diff --git a/tests/integrations/test_fastapi_lifespan.py b/tests/integrations/test_fastapi_lifespan.py new file mode 100644 index 000000000..eae624d28 --- /dev/null +++ b/tests/integrations/test_fastapi_lifespan.py @@ -0,0 +1,29 @@ +import asyncio +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import FastAPI + +import codecarbon.integrations.fastapi.lifespan as cc_fastapi_lifespan +from codecarbon.integrations.fastapi.lifespan import create_codecarbon_lifespan + + +@pytest.fixture +def app(): + return FastAPI() + + +@patch.object(cc_fastapi_lifespan, "EmissionsTracker") +def test_lifespan_stops_tracker_on_shutdown(MockTracker, app): + tracker = MagicMock() + MockTracker.return_value = tracker + + async def run(): + async with create_codecarbon_lifespan(app, project_name="api"): + assert app.state.codecarbon_tracker is tracker + tracker.start.assert_called_once() + + asyncio.run(run()) + + tracker.stop.assert_called_once() + assert app.state.codecarbon_tracker is None diff --git a/tests/integrations/test_fastapi_middleware.py b/tests/integrations/test_fastapi_middleware.py new file mode 100644 index 000000000..848bfb59d --- /dev/null +++ b/tests/integrations/test_fastapi_middleware.py @@ -0,0 +1,407 @@ +import asyncio +from concurrent import futures +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +import codecarbon.integrations.fastapi.middleware as cc_fastapi_middleware +from codecarbon.integrations.fastapi import add_codecarbon_middleware + + +@pytest.fixture +def app(): + application = FastAPI() + + @application.get("/items/{item_id}") + def get_item(item_id: int): + return {"item_id": item_id} + + @application.get("/health") + def health(): + return {"ok": True} + + add_codecarbon_middleware( + application, + project_name="test-api", + response_headers="emissions", + ) + return application + + +@patch.object(cc_fastapi_middleware, "EmissionsTracker") +def test_middleware_tracks_routed_request(MockTracker, app): + tracker_instance = MockTracker.return_value + tracker_instance.stop.return_value = 0.001 + tracker_instance.final_emissions_data = MagicMock( + emissions=0.001, duration=0.5, energy_consumed=0.002, emissions_rate=0.002 + ) + + client = TestClient(app) + response = client.get("/items/7") + + assert response.status_code == 200 + MockTracker.assert_called_once() + tracker_instance.start.assert_called_once() + tracker_instance.stop.assert_called_once() + assert response.headers["X-CodeCarbon-Emissions-kg"] == "0.001" + + +@patch.object(cc_fastapi_middleware, "EmissionsTracker") +def test_middleware_applies_default_response_headers(MockTracker): + application = FastAPI() + + @application.get("/predict") + def predict(): + return {"ok": True} + + add_codecarbon_middleware(application, response_headers="default") + tracker_instance = MockTracker.return_value + tracker_instance.stop.return_value = 0.001 + tracker_instance.final_emissions_data = MagicMock( + emissions=0.001, + duration=1.2, + energy_consumed=0.003, + emissions_rate=0.0008, + ) + + response = TestClient(application).get("/predict") + assert response.headers["X-CodeCarbon-Emissions-kg"] == "0.001" + assert response.headers["X-CodeCarbon-Duration-s"] == "1.2" + assert response.headers["X-CodeCarbon-Energy-Consumed-kwh"] == "0.003" + + +@patch.object(cc_fastapi_middleware, "EmissionsTracker") +def test_middleware_custom_header_formatter(MockTracker): + application = FastAPI() + + @application.get("/predict") + def predict(): + return {"ok": True} + + def formatter(data, request): + return { + "X-CodeCarbon-Emissions-kg": f"{data.emissions:.4f}", + "X-CodeCarbon-Route": request.url.path, + } + + add_codecarbon_middleware(application, header_formatter=formatter) + tracker_instance = MockTracker.return_value + tracker_instance.stop.return_value = 0.001 + tracker_instance.final_emissions_data = MagicMock(emissions=0.001234) + + response = TestClient(application).get("/predict") + assert response.headers["X-CodeCarbon-Emissions-kg"] == "0.0012" + assert response.headers["X-CodeCarbon-Route"] == "/predict" + + +@patch.object(cc_fastapi_middleware, "EmissionsTracker") +def test_middleware_skips_excluded_paths(MockTracker, app): + client = TestClient(app) + response = client.get("/health") + assert response.status_code == 200 + MockTracker.assert_not_called() + + +@patch.object(cc_fastapi_middleware, "EmissionsTracker") +def test_middleware_include_emissions_header_deprecated(MockTracker): + application = FastAPI() + + @application.get("/predict") + def predict(): + return {"ok": True} + + add_codecarbon_middleware(application, include_emissions_header=True) + tracker_instance = MockTracker.return_value + tracker_instance.stop.return_value = 0.001 + tracker_instance.final_emissions_data = MagicMock(emissions=0.002) + + response = TestClient(application).get("/predict") + assert response.headers["X-CodeCarbon-Emissions-kg"] == "0.002" + + +@patch.object(cc_fastapi_middleware, "EmissionsTracker") +def test_middleware_on_request_complete_callback(MockTracker): + application = FastAPI() + completed = [] + + @application.get("/predict") + def predict(): + return {"ok": True} + + def on_complete(request, response, emissions_data, task_name): + completed.append( + (request.url.path, response.status_code, emissions_data, task_name) + ) + + add_codecarbon_middleware( + application, + response_headers="emissions", + on_request_complete=on_complete, + ) + tracker_instance = MockTracker.return_value + emissions = MagicMock(emissions=0.001) + tracker_instance.stop.return_value = 0.001 + tracker_instance.final_emissions_data = emissions + + response = TestClient(application).get("/predict") + assert response.status_code == 200 + assert len(completed) == 1 + path, status, data, task_name = completed[0] + assert path == "/predict" + assert status == 200 + assert data is emissions + assert task_name == "GET /predict" + + +@patch.object(cc_fastapi_middleware, "EmissionsTracker") +def test_middleware_app_mode_uses_shared_tracker(MockTracker): + application = FastAPI() + tracker_instance = MagicMock() + emissions = MagicMock(emissions=0.003, duration=0.8) + tracker_instance.stop_task.return_value = emissions + MockTracker.return_value = tracker_instance + application.state.codecarbon_tracker = tracker_instance + completed = [] + + @application.get("/predict") + def predict(): + return {"ok": True} + + add_codecarbon_middleware( + application, + tracking_mode="app", + response_headers="emissions", + on_request_complete=lambda request, response, data, task_name: completed.append( + (request.url.path, data, task_name) + ), + ) + + response = TestClient(application).get("/predict") + assert response.status_code == 200 + MockTracker.assert_not_called() + tracker_instance.start_task.assert_called_once_with("GET /predict") + tracker_instance.stop_task.assert_called_once_with("GET /predict") + assert response.headers["X-CodeCarbon-Emissions-kg"] == "0.003" + assert completed == [("/predict", emissions, "GET /predict")] + + +@patch.object(cc_fastapi_middleware, "EmissionsTracker") +def test_middleware_skips_headers_without_emissions_data(MockTracker): + application = FastAPI() + + @application.get("/predict") + def predict(): + return {"ok": True} + + add_codecarbon_middleware(application, response_headers="emissions") + tracker_instance = MockTracker.return_value + tracker_instance.stop.return_value = 0.0 + tracker_instance.final_emissions_data = None + + response = TestClient(application).get("/predict") + assert response.status_code == 200 + assert "X-CodeCarbon-Emissions-kg" not in response.headers + + +@patch.object(cc_fastapi_middleware, "EmissionsTracker") +def test_middleware_app_mode_skips_callback_when_handler_raises(MockTracker): + application = FastAPI() + tracker_instance = MagicMock() + tracker_instance.stop_task.return_value = MagicMock(emissions=0.001) + MockTracker.return_value = tracker_instance + application.state.codecarbon_tracker = tracker_instance + completed = [] + + @application.get("/fail") + def fail(): + raise RuntimeError("boom") + + add_codecarbon_middleware( + application, + tracking_mode="app", + on_request_complete=lambda *args: completed.append(args), + ) + + with pytest.raises(RuntimeError, match="boom"): + TestClient(application, raise_server_exceptions=True).get("/fail") + + assert completed == [] + + +@patch.object(cc_fastapi_middleware, "EmissionsTracker") +def test_middleware_app_mode_lazy_tracker(MockTracker): + application = FastAPI() + tracker_instance = MagicMock() + emissions = MagicMock(emissions=0.005) + tracker_instance.stop_task.return_value = emissions + MockTracker.return_value = tracker_instance + + @application.get("/run") + def run(): + return {"ok": True} + + add_codecarbon_middleware( + application, + tracking_mode="app", + response_headers="emissions", + ) + + response = TestClient(application).get("/run") + assert response.status_code == 200 + MockTracker.assert_called_once() + tracker_instance.start.assert_called_once() + tracker_instance.start_task.assert_called_once_with("GET /run") + assert response.headers["X-CodeCarbon-Emissions-kg"] == "0.005" + + +@patch.object(cc_fastapi_middleware.asyncio, "to_thread") +@patch.object(cc_fastapi_middleware, "EmissionsTracker") +def test_middleware_request_mode_uses_to_thread(MockTracker, mock_to_thread): + application = FastAPI() + tracker_instance = MockTracker.return_value + emissions = MagicMock(emissions=0.001) + tracker_instance.final_emissions_data = emissions + + async def run_sync(func, *args, **kwargs): + return func(*args, **kwargs) + + mock_to_thread.side_effect = run_sync + + @application.get("/predict") + def predict(): + return {"ok": True} + + add_codecarbon_middleware(application, response_headers="emissions") + response = TestClient(application).get("/predict") + + assert response.status_code == 200 + assert mock_to_thread.call_count >= 2 + tracker_instance.start.assert_called_once() + tracker_instance.stop.assert_called_once() + + +@patch.object(cc_fastapi_middleware.asyncio, "create_task") +@patch.object(cc_fastapi_middleware, "EmissionsTracker") +def test_middleware_defer_measurement_skips_headers(MockTracker, mock_create_task): + application = FastAPI() + tracker_instance = MockTracker.return_value + tracker_instance.final_emissions_data = MagicMock(emissions=0.001) + + @application.get("/predict") + def predict(): + return {"ok": True} + + add_codecarbon_middleware( + application, + response_headers="emissions", + defer_measurement=True, + ) + response = TestClient(application).get("/predict") + + assert response.status_code == 200 + assert "X-CodeCarbon-Emissions-kg" not in response.headers + mock_create_task.assert_called_once() + + +@patch.object(cc_fastapi_middleware.asyncio, "create_task") +@patch.object(cc_fastapi_middleware, "EmissionsTracker") +def test_middleware_defer_measurement_runs_callback_via_background_task( + MockTracker, mock_create_task +): + application = FastAPI() + completed = [] + tracker_instance = MockTracker.return_value + emissions = MagicMock(emissions=0.001) + tracker_instance.final_emissions_data = emissions + + def run_deferred_task(coro): + def run_in_thread() -> None: + loop = asyncio.new_event_loop() + try: + loop.run_until_complete(coro) + finally: + loop.close() + + futures.ThreadPoolExecutor(max_workers=1).submit(run_in_thread).result() + return MagicMock() + + mock_create_task.side_effect = run_deferred_task + + @application.get("/predict") + def predict(): + return {"ok": True} + + add_codecarbon_middleware( + application, + defer_measurement=True, + on_request_complete=lambda request, response, data, task_name: completed.append( + (request.url.path, data, task_name) + ), + ) + + response = TestClient(application).get("/predict") + + assert response.status_code == 200 + assert completed == [("/predict", emissions, "GET /predict")] + + +@patch.object(cc_fastapi_middleware, "EmissionsTracker") +def test_middleware_include_endpoints_allowlist(MockTracker): + application = FastAPI() + + @application.get("/predict") + def predict(): + return {"ok": True} + + @application.get("/metrics") + def metrics(): + return {"ok": True} + + add_codecarbon_middleware( + application, + include=["GET /predict"], + response_headers="emissions", + ) + tracker_instance = MockTracker.return_value + tracker_instance.final_emissions_data = MagicMock(emissions=0.001) + + client = TestClient(application) + tracked = client.get("/predict") + skipped = client.get("/metrics") + + assert tracked.status_code == 200 + assert "X-CodeCarbon-Emissions-kg" in tracked.headers + assert skipped.status_code == 200 + assert "X-CodeCarbon-Emissions-kg" not in skipped.headers + MockTracker.assert_called_once() + + +@patch.object(cc_fastapi_middleware, "EmissionsTracker") +def test_middleware_exclude_endpoints(MockTracker): + application = FastAPI() + + @application.get("/predict") + def predict(): + return {"tracked": True} + + @application.get("/admin") + def admin(): + return {"admin": True} + + add_codecarbon_middleware( + application, + exclude=["GET /admin"], + response_headers="emissions", + ) + tracker_instance = MockTracker.return_value + tracker_instance.final_emissions_data = MagicMock(emissions=0.001) + + client = TestClient(application) + tracked = client.get("/predict") + skipped = client.get("/admin") + + assert "X-CodeCarbon-Emissions-kg" in tracked.headers + assert "X-CodeCarbon-Emissions-kg" not in skipped.headers + MockTracker.assert_called_once() diff --git a/tests/integrations/test_fastapi_routing.py b/tests/integrations/test_fastapi_routing.py new file mode 100644 index 000000000..2a0ce4b30 --- /dev/null +++ b/tests/integrations/test_fastapi_routing.py @@ -0,0 +1,88 @@ +"""Tests for route naming and endpoint filter helpers.""" + +from unittest.mock import MagicMock + +from codecarbon.integrations.fastapi._routing import ( + build_endpoint_key, + build_task_name, + matches_exclude, + should_track_request, +) + + +def test_build_task_name_uses_route_template() -> None: + request = MagicMock() + request.method = "GET" + route = MagicMock() + route.path = "/users/{user_id}" + request.scope = {"route": route} + assert build_task_name(request) == "GET /users/{user_id}" + + +def test_build_task_name_custom_formatter() -> None: + request = MagicMock() + request.url.path = "/webhook" + assert ( + build_task_name(request, formatter=lambda r: f"custom:{r.url.path}") + == "custom:/webhook" + ) + + +def test_build_task_name_fallback_to_url_path() -> None: + request = MagicMock() + request.method = "POST" + request.scope = {} + request.url.path = "/webhook" + assert build_task_name(request) == "POST /webhook" + + +def _mock_request(method: str, route_path: str | None, url_path: str) -> MagicMock: + request = MagicMock() + request.method = method + request.url.path = url_path + if route_path is None: + request.scope = {} + else: + route = MagicMock() + route.path = route_path + request.scope = {"route": route} + return request + + +def test_build_endpoint_key_uses_route_template() -> None: + request = _mock_request("GET", "/predict", "/predict") + assert build_endpoint_key(request) == "GET /predict" + + +def test_matches_exclude_path_prefix() -> None: + assert ( + matches_exclude("/docs", "/docs/oauth2-redirect", "GET /docs", "/docs") is True + ) + assert matches_exclude("/health", "/health", "GET /health", "/health") is True + + +def test_should_track_request_exclude_by_method_and_path() -> None: + request = _mock_request("GET", "/predict", "/predict") + assert should_track_request(request, None, ["GET /predict"]) is False + assert should_track_request(request, None, ["POST /predict"]) is True + + +def test_should_track_request_exclude_path_only() -> None: + request = _mock_request("POST", "/predict", "/predict") + assert should_track_request(request, None, ["/predict"]) is False + + +def test_should_track_request_include_allowlist() -> None: + request = _mock_request("GET", "/predict", "/predict") + other = _mock_request("GET", "/health", "/health") + include = ["GET /predict"] + assert should_track_request(request, include, []) is True + assert should_track_request(other, include, []) is False + + +def test_should_track_request_include_path_only() -> None: + get_request = _mock_request("GET", "/predict", "/predict") + post_request = _mock_request("POST", "/predict", "/predict") + include = ["/predict"] + assert should_track_request(get_request, include, []) is True + assert should_track_request(post_request, include, []) is True diff --git a/uv.lock b/uv.lock index c0ceab00d..b177b9414 100644 --- a/uv.lock +++ b/uv.lock @@ -41,6 +41,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + [[package]] name = "arrow" version = "1.4.0" @@ -443,6 +457,10 @@ carbonboard = [ { name = "dash-bootstrap-components" }, { name = "fire" }, ] +fastapi = [ + { name = "fastapi" }, + { name = "httpx" }, +] viz-legacy = [ { name = "dash" }, { name = "dash-bootstrap-components" }, @@ -453,6 +471,8 @@ viz-legacy = [ dev = [ { name = "black" }, { name = "bumpver" }, + { name = "fastapi" }, + { name = "httpx" }, { name = "jsonschema" }, { name = "logfire" }, { name = "mktestdocs" }, @@ -489,8 +509,10 @@ requires-dist = [ { name = "dash", marker = "extra == 'viz-legacy'" }, { name = "dash-bootstrap-components", marker = "extra == 'carbonboard'", specifier = ">1.0.0" }, { name = "dash-bootstrap-components", marker = "extra == 'viz-legacy'", specifier = ">1.0.0" }, + { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.100" }, { name = "fire", marker = "extra == 'carbonboard'" }, { name = "fire", marker = "extra == 'viz-legacy'" }, + { name = "httpx", marker = "extra == 'fastapi'" }, { name = "nvidia-ml-py" }, { name = "pandas", marker = "python_full_version < '3.14'" }, { name = "pandas", marker = "python_full_version >= '3.14'", specifier = ">=2.3.3" }, @@ -505,12 +527,14 @@ requires-dist = [ { name = "rich" }, { name = "typer" }, ] -provides-extras = ["carbonboard", "viz-legacy", "amdsmi"] +provides-extras = ["carbonboard", "viz-legacy", "amdsmi", "fastapi"] [package.metadata.requires-dev] dev = [ { name = "black" }, { name = "bumpver" }, + { name = "fastapi", specifier = ">=0.100" }, + { name = "httpx" }, { name = "jsonschema" }, { name = "logfire", specifier = ">=1.0.1" }, { name = "mktestdocs" }, @@ -794,6 +818,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, ] +[[package]] +name = "fastapi" +version = "0.136.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, +] + [[package]] name = "filelock" version = "3.29.0" @@ -865,6 +905,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "identify" version = "2.6.19" @@ -2958,6 +3035,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, ] +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + [[package]] name = "taskipy" version = "1.14.1"