From 18f6072e2befbfe6e7f9d9cc08e9c22f66574995 Mon Sep 17 00:00:00 2001 From: Richard Quaicoe Date: Wed, 11 Mar 2026 22:30:50 +0000 Subject: [PATCH] Added dashboard feature to manage routes --- pyproject.toml | 7 +- shield/dashboard/__init__.py | 5 + shield/dashboard/app.py | 105 ++++ shield/dashboard/auth.py | 61 +++ shield/dashboard/routes.py | 421 ++++++++++++++++ shield/dashboard/templates/audit.html | 87 ++++ shield/dashboard/templates/base.html | 125 +++++ shield/dashboard/templates/index.html | 107 +++++ .../templates/partials/audit_row.html | 47 ++ .../templates/partials/audit_rows.html | 3 + .../partials/global_maintenance.html | 79 +++ .../dashboard/templates/partials/modal.html | 115 +++++ .../partials/modal_global_disable.html | 51 ++ .../partials/modal_global_enable.html | 114 +++++ .../templates/partials/route_row.html | 160 +++++++ .../templates/partials/routes_table.html | 4 + tests/dashboard/test_routes.py | 453 ++++++++++++++++++ uv.lock | 111 +++++ 18 files changed, 2054 insertions(+), 1 deletion(-) create mode 100644 shield/dashboard/app.py create mode 100644 shield/dashboard/auth.py create mode 100644 shield/dashboard/routes.py create mode 100644 shield/dashboard/templates/audit.html create mode 100644 shield/dashboard/templates/base.html create mode 100644 shield/dashboard/templates/index.html create mode 100644 shield/dashboard/templates/partials/audit_row.html create mode 100644 shield/dashboard/templates/partials/audit_rows.html create mode 100644 shield/dashboard/templates/partials/global_maintenance.html create mode 100644 shield/dashboard/templates/partials/modal.html create mode 100644 shield/dashboard/templates/partials/modal_global_disable.html create mode 100644 shield/dashboard/templates/partials/modal_global_enable.html create mode 100644 shield/dashboard/templates/partials/route_row.html create mode 100644 shield/dashboard/templates/partials/routes_table.html create mode 100644 tests/dashboard/test_routes.py diff --git a/pyproject.toml b/pyproject.toml index 290c1a9..cfc870b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,11 @@ Issues = "https://github.com/Attakay78/api-shield/issues" [project.optional-dependencies] fastapi = ["fastapi>=0.100"] redis = ["redis[asyncio]>=5.0"] -dashboard = ["jinja2>=3.1", "aiofiles>=23.0"] +dashboard = [ + "jinja2>=3.1", + "aiofiles>=23.0", + "python-multipart>=0.0.22", +] cli = ["typer>=0.12"] yaml = ["pyyaml>=6.0"] toml = ["tomli-w>=1.0"] @@ -47,6 +51,7 @@ all = [ "typer>=0.12", "pyyaml>=6.0", "tomli-w>=1.0", + "python-multipart>=0.0.22", ] dev = [ "pytest>=8.0", diff --git a/shield/dashboard/__init__.py b/shield/dashboard/__init__.py index e69de29..69dc022 100644 --- a/shield/dashboard/__init__.py +++ b/shield/dashboard/__init__.py @@ -0,0 +1,5 @@ +"""Shield dashboard — mountable HTMX admin UI.""" + +from shield.dashboard.app import ShieldDashboard + +__all__ = ["ShieldDashboard"] diff --git a/shield/dashboard/app.py b/shield/dashboard/app.py new file mode 100644 index 0000000..3e6d838 --- /dev/null +++ b/shield/dashboard/app.py @@ -0,0 +1,105 @@ +"""Shield dashboard — mountable Starlette admin UI factory.""" + +from __future__ import annotations + +import importlib.metadata +from pathlib import Path +from typing import TYPE_CHECKING + +from starlette.applications import Starlette +from starlette.routing import Route +from starlette.templating import Jinja2Templates + +from shield.core.engine import ShieldEngine +from shield.dashboard import routes as r + +if TYPE_CHECKING: + from starlette.types import ASGIApp + +_TEMPLATES_DIR = Path(__file__).parent / "templates" + + +def ShieldDashboard( + engine: ShieldEngine, + prefix: str = "/shield", + auth: tuple[str, str] | None = None, +) -> ASGIApp: + """Create a mountable Starlette admin UI for the Shield engine. + + Mount it on any FastAPI / Starlette application:: + + app.mount("/shield", ShieldDashboard(engine=engine)) + + The dashboard is completely self-contained and does **not** affect the + parent application's routing, OpenAPI schema, or middleware stack. + + Parameters + ---------- + engine: + The :class:`~shield.core.engine.ShieldEngine` whose state this + dashboard will display and control. + prefix: + The URL prefix at which the dashboard is mounted. Used to build + correct links inside templates. Should match the path passed to + ``app.mount()``. Defaults to ``"/shield"``. + auth: + Optional ``(username, password)`` tuple. When provided, all + dashboard requests must carry a valid ``Authorization: Basic …`` + header. Without credentials the dashboard is open to anyone who + can reach it. + + Returns + ------- + ASGIApp + A Starlette ASGI application. Pass it directly to + ``app.mount()``. + """ + templates = Jinja2Templates(directory=str(_TEMPLATES_DIR)) + + # Register custom Jinja2 filter so templates can base64-encode path keys + # for safe embedding in URL segments. + import base64 + + templates.env.filters["encode_path"] = lambda p: ( + base64.urlsafe_b64encode(p.encode()).decode().rstrip("=") + ) + # Expose path_slug as a global so templates can call it without import. + templates.env.globals["path_slug"] = r.path_slug + + try: + version = importlib.metadata.version("api-shield") + except importlib.metadata.PackageNotFoundError: + version = "0.1.0" + + starlette_app = Starlette( + routes=[ + Route("/", r.index), + Route("/routes", r.routes_partial), + Route("/modal/global/enable", r.modal_global_enable), + Route("/modal/global/disable", r.modal_global_disable), + Route("/modal/{action}/{path_key}", r.action_modal), + Route("/global-maintenance/enable", r.global_maintenance_enable, methods=["POST"]), + Route("/global-maintenance/disable", r.global_maintenance_disable, methods=["POST"]), + Route("/toggle/{path_key}", r.toggle, methods=["POST"]), + Route("/disable/{path_key}", r.disable, methods=["POST"]), + Route("/enable/{path_key}", r.enable, methods=["POST"]), + Route("/schedule", r.schedule, methods=["POST"]), + Route("/schedule/{path_key}", r.cancel_schedule, methods=["DELETE"]), + Route("/audit", r.audit_page), + Route("/audit/rows", r.audit_rows), + Route("/events", r.events), + ], + ) + + # Inject shared state so route handlers can access engine, templates, etc. + starlette_app.state.engine = engine + starlette_app.state.templates = templates + starlette_app.state.prefix = prefix.rstrip("/") + starlette_app.state.version = version + + if auth is not None: + from shield.dashboard.auth import BasicAuthMiddleware + + return BasicAuthMiddleware(starlette_app, username=auth[0], password=auth[1]) + + return starlette_app diff --git a/shield/dashboard/auth.py b/shield/dashboard/auth.py new file mode 100644 index 0000000..54aebca --- /dev/null +++ b/shield/dashboard/auth.py @@ -0,0 +1,61 @@ +"""Optional HTTP Basic Auth middleware for the Shield dashboard.""" + +from __future__ import annotations + +import base64 +import binascii + +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.requests import Request +from starlette.responses import Response +from starlette.types import ASGIApp + + +class BasicAuthMiddleware(BaseHTTPMiddleware): + """Wrap the dashboard Starlette app with HTTP Basic Authentication. + + When *auth* credentials are provided to :func:`ShieldDashboard`, this + middleware is added to the inner app. All requests must carry a valid + ``Authorization: Basic …`` header, otherwise a ``401`` challenge is + returned. + + Parameters + ---------- + app: + The ASGI application to protect. + username: + Expected username. + password: + Expected password. + """ + + def __init__(self, app: ASGIApp, username: str, password: str) -> None: + super().__init__(app) + self._username = username + self._password = password + + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + """Validate Basic Auth credentials before passing the request through.""" + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Basic "): + return self._challenge() + + try: + decoded = base64.b64decode(auth_header[6:]).decode("utf-8") + username, _, password = decoded.partition(":") + except (binascii.Error, UnicodeDecodeError): + return self._challenge() + + if username != self._username or password != self._password: + return self._challenge() + + return await call_next(request) + + @staticmethod + def _challenge() -> Response: + """Return a 401 Unauthorized response with a WWW-Authenticate challenge.""" + return Response( + status_code=401, + headers={"WWW-Authenticate": 'Basic realm="Shield Dashboard"'}, + content="Unauthorized", + ) diff --git a/shield/dashboard/routes.py b/shield/dashboard/routes.py new file mode 100644 index 0000000..b8a5df9 --- /dev/null +++ b/shield/dashboard/routes.py @@ -0,0 +1,421 @@ +"""Shield dashboard HTTP route handlers.""" + +from __future__ import annotations + +import base64 +import logging +from datetime import UTC, datetime + +import anyio +from starlette.requests import Request +from starlette.responses import HTMLResponse, Response, StreamingResponse +from starlette.templating import Jinja2Templates + +from shield.core.engine import ShieldEngine +from shield.core.exceptions import RouteProtectedException +from shield.core.models import MaintenanceWindow, RouteState + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Request helpers +# --------------------------------------------------------------------------- + + +def _engine(request: Request) -> ShieldEngine: + """Return the ShieldEngine from app state.""" + return request.app.state.engine # type: ignore[no-any-return] + + +def _templates(request: Request) -> Jinja2Templates: + """Return the Jinja2Templates instance from app state.""" + return request.app.state.templates # type: ignore[no-any-return] + + +def _prefix(request: Request) -> str: + """Return the dashboard mount prefix from app state.""" + return request.app.state.prefix # type: ignore[no-any-return] + + +# --------------------------------------------------------------------------- +# Path encoding utilities +# --------------------------------------------------------------------------- + + +def path_slug(path: str) -> str: + """Convert a route path key to a CSS-safe slug for HTML IDs and SSE events. + + Examples + -------- + ``"/payments"`` → ``"payments"`` + ``"/api/v1/payments"`` → ``"api-v1-payments"`` + ``"GET:/payments"`` → ``"GET--payments"`` + """ + slug = path.lstrip("/") + for char in "/:._": + slug = slug.replace(char, "-") + return slug or "root" + + +def _encode_path(path: str) -> str: + """Base64url-encode *path* for safe embedding in URL segments.""" + return base64.urlsafe_b64encode(path.encode()).decode().rstrip("=") + + +def _decode_path(encoded: str) -> str: + """Decode a base64url-encoded route path key from a URL segment.""" + # Re-add stripped base64 padding. + padding = 4 - len(encoded) % 4 + if padding != 4: + encoded += "=" * padding + return base64.urlsafe_b64decode(encoded).decode() + + +# --------------------------------------------------------------------------- +# Template rendering helper +# --------------------------------------------------------------------------- + + +def _render_route_row(tpl: Jinja2Templates, state: RouteState, prefix: str) -> str: + """Render the ``route_row.html`` partial synchronously and return the HTML string.""" + return tpl.env.get_template("partials/route_row.html").render( + state=state, + path_slug=path_slug(state.path), + prefix=prefix, + ) + + +# --------------------------------------------------------------------------- +# Route handlers +# --------------------------------------------------------------------------- + + +async def index(request: Request) -> Response: + """Render the main routes page (full page).""" + engine = _engine(request) + tpl = _templates(request) + prefix = _prefix(request) + + states = await engine.list_states() + global_config = await engine.get_global_maintenance() + return tpl.TemplateResponse( + request, + "index.html", + { + "states": states, + "global_config": global_config, + "prefix": prefix, + "active_tab": "routes", + "version": request.app.state.version, + "path_slug": path_slug, + }, + ) + + +async def routes_partial(request: Request) -> Response: + """Return only the routes table rows (HTMX polling fallback).""" + engine = _engine(request) + tpl = _templates(request) + prefix = _prefix(request) + + states = await engine.list_states() + return tpl.TemplateResponse( + request, + "partials/routes_table.html", + { + "states": states, + "prefix": prefix, + "path_slug": path_slug, + }, + ) + + +async def toggle(request: Request) -> HTMLResponse: + """Toggle the route between ``active`` and ``maintenance``. + + If the route is currently in maintenance, enable it. Otherwise put it + into maintenance mode. + """ + engine = _engine(request) + tpl = _templates(request) + prefix = _prefix(request) + route_path = _decode_path(request.path_params["path_key"]) + + form_data = await request.form() + reason = str(form_data.get("reason", "") or request.headers.get("HX-Prompt", "")) + try: + state = await engine.get_state(route_path) + if state.status.value == "maintenance": + new_state = await engine.enable(route_path, reason=reason, actor="dashboard") + else: + new_state = await engine.set_maintenance( + route_path, + reason=reason, + actor="dashboard", + ) + except RouteProtectedException: + new_state = await engine.get_state(route_path) + + return HTMLResponse(_render_route_row(tpl, new_state, prefix)) + + +async def disable(request: Request) -> HTMLResponse: + """Disable a route, returning 503 for all subsequent requests.""" + engine = _engine(request) + tpl = _templates(request) + prefix = _prefix(request) + route_path = _decode_path(request.path_params["path_key"]) + + form_data = await request.form() + reason = str(form_data.get("reason", "") or request.headers.get("HX-Prompt", "")) + try: + new_state = await engine.disable(route_path, reason=reason, actor="dashboard") + except RouteProtectedException: + new_state = await engine.get_state(route_path) + + return HTMLResponse(_render_route_row(tpl, new_state, prefix)) + + +async def enable(request: Request) -> HTMLResponse: + """Enable a route, restoring it to active status.""" + engine = _engine(request) + tpl = _templates(request) + prefix = _prefix(request) + route_path = _decode_path(request.path_params["path_key"]) + + form_data = await request.form() + reason = str(form_data.get("reason", "") or request.headers.get("HX-Prompt", "")) + try: + new_state = await engine.enable(route_path, reason=reason, actor="dashboard") + except RouteProtectedException: + new_state = await engine.get_state(route_path) + + return HTMLResponse(_render_route_row(tpl, new_state, prefix)) + + +async def schedule(request: Request) -> HTMLResponse: + """Schedule a future maintenance window from HTML form data. + + Expected form fields: ``path``, ``start`` (datetime-local), ``end`` + (datetime-local), ``reason`` (optional). + """ + engine = _engine(request) + tpl = _templates(request) + prefix = _prefix(request) + + form = await request.form() + route_path = str(form["path"]) + reason = str(form.get("reason", "")) + start_str = str(form.get("start", "")) + end_str = str(form.get("end", "")) + + # datetime-local values are ISO-like strings without timezone — treat as UTC. + start_dt = datetime.fromisoformat(start_str).replace(tzinfo=UTC) + end_dt = datetime.fromisoformat(end_str).replace(tzinfo=UTC) + + window = MaintenanceWindow(start=start_dt, end=end_dt, reason=reason) + try: + await engine.schedule_maintenance(route_path, window, actor="dashboard") + except RouteProtectedException: + pass + + new_state = await engine.get_state(route_path) + return HTMLResponse(_render_route_row(tpl, new_state, prefix)) + + +async def cancel_schedule(request: Request) -> HTMLResponse: + """Cancel a pending scheduled maintenance window.""" + engine = _engine(request) + tpl = _templates(request) + prefix = _prefix(request) + route_path = _decode_path(request.path_params["path_key"]) + + await engine.scheduler.cancel(route_path) + new_state = await engine.get_state(route_path) + return HTMLResponse(_render_route_row(tpl, new_state, prefix)) + + +async def audit_page(request: Request) -> Response: + """Render the audit log page (full page).""" + engine = _engine(request) + tpl = _templates(request) + prefix = _prefix(request) + + entries = await engine.get_audit_log(limit=50) + return tpl.TemplateResponse( + request, + "audit.html", + { + "entries": entries, + "prefix": prefix, + "active_tab": "audit", + "version": request.app.state.version, + }, + ) + + +async def audit_rows(request: Request) -> Response: + """Return only the audit log rows partial (for HTMX auto-refresh).""" + engine = _engine(request) + tpl = _templates(request) + + entries = await engine.get_audit_log(limit=50) + return tpl.TemplateResponse( + request, + "partials/audit_rows.html", + {"entries": entries}, + ) + + +def _render_global_widget(tpl: Jinja2Templates, config: object, prefix: str) -> str: + """Render the global maintenance status widget partial.""" + return tpl.env.get_template("partials/global_maintenance.html").render( + config=config, + prefix=prefix, + ) + + +async def modal_global_enable(request: Request) -> HTMLResponse: + """Return the global maintenance enable modal form.""" + tpl = _templates(request) + prefix = _prefix(request) + html = tpl.env.get_template("partials/modal_global_enable.html").render(prefix=prefix) + return HTMLResponse(html) + + +async def modal_global_disable(request: Request) -> HTMLResponse: + """Return the global maintenance disable confirmation modal.""" + tpl = _templates(request) + prefix = _prefix(request) + html = tpl.env.get_template("partials/modal_global_disable.html").render(prefix=prefix) + return HTMLResponse(html) + + +async def global_maintenance_enable(request: Request) -> HTMLResponse: + """Enable global maintenance mode from form data. + + Expected form fields: ``reason``, ``exempt_paths`` (newline-separated), + ``include_force_active`` (checkbox, value ``"1"``). + """ + engine = _engine(request) + tpl = _templates(request) + prefix = _prefix(request) + + form = await request.form() + reason = str(form.get("reason", "")) + exempt_raw = str(form.get("exempt_paths", "")) + exempt_paths = [p.strip() for p in exempt_raw.splitlines() if p.strip()] + include_force_active = form.get("include_force_active") == "1" + + await engine.enable_global_maintenance( + reason=reason, + exempt_paths=exempt_paths, + include_force_active=include_force_active, + actor="dashboard", + ) + config = await engine.get_global_maintenance() + return HTMLResponse(_render_global_widget(tpl, config, prefix)) + + +async def global_maintenance_disable(request: Request) -> HTMLResponse: + """Disable global maintenance mode, restoring per-route states.""" + engine = _engine(request) + tpl = _templates(request) + prefix = _prefix(request) + + await engine.disable_global_maintenance(actor="dashboard") + config = await engine.get_global_maintenance() + return HTMLResponse(_render_global_widget(tpl, config, prefix)) + + +async def action_modal(request: Request) -> HTMLResponse: + """Return the styled action confirmation modal content. + + Renders ``partials/modal.html`` with action-specific copy and the form + action URL pre-filled. The modal is loaded into the ```` element + via HTMX; the JS bootstrap in ``base.html`` calls ``showModal()`` after + the swap. + + Parameters (URL path) + --------------------- + action: + One of ``"enable"``, ``"maintenance"``, or ``"disable"``. + path_key: + Base64url-encoded route path key. + """ + action = request.path_params["action"] + path_key = request.path_params["path_key"] + route_path = _decode_path(path_key) + tpl = _templates(request) + prefix = _prefix(request) + + action_map = { + "enable": f"{prefix}/enable/{path_key}", + "maintenance": f"{prefix}/toggle/{path_key}", + "disable": f"{prefix}/disable/{path_key}", + } + submit_path = action_map.get(action, f"{prefix}/toggle/{path_key}") + + html = tpl.env.get_template("partials/modal.html").render( + action=action, + route_path=route_path, + path_slug=path_slug(route_path), + submit_path=submit_path, + prefix=prefix, + ) + return HTMLResponse(html) + + +async def events(request: Request) -> StreamingResponse: + """SSE endpoint that streams live route state changes. + + When the backend supports ``subscribe()`` (e.g. ``MemoryBackend``), + each state change is pushed to connected clients as an SSE event named + ``shield:update:{path_slug}``. HTMX receives the event and replaces + the matching ```` via ``sse-swap``. + + When the backend does **not** support ``subscribe()`` (e.g. + ``FileBackend``), a ``NotImplementedError`` is raised on the first + iteration. In that case the endpoint falls back to sending a + keepalive comment every 15 seconds so the browser connection stays + open without errors. + + Keepalive comments (``": keepalive\\n\\n"``) are valid SSE syntax that + browsers silently ignore. + """ + engine = _engine(request) + tpl = _templates(request) + prefix = _prefix(request) + + async def _generate() -> object: + try: + async for state in engine.backend.subscribe(): + slug = path_slug(state.path) + html = _render_route_row(tpl, state, prefix) + # Format as multi-line SSE data — each HTML line prefixed with "data: ". + data_lines = "\ndata: ".join(html.splitlines()) + yield f"event: shield:update:{slug}\ndata: {data_lines}\n\n" + except NotImplementedError: + # Backend does not support pub/sub — fall through to keepalive loop. + pass + except Exception: + logger.exception("shield dashboard: SSE subscription error, falling back to keepalive") + + # Keepalive ping loop — runs when subscribe() is unsupported OR after + # the subscription ends. Browsers keep the connection alive. + while True: + yield ": keepalive\n\n" + try: + await anyio.sleep(15) + except Exception: + break + + return StreamingResponse( + _generate(), # type: ignore[arg-type] + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, + ) diff --git a/shield/dashboard/templates/audit.html b/shield/dashboard/templates/audit.html new file mode 100644 index 0000000..85d89f5 --- /dev/null +++ b/shield/dashboard/templates/audit.html @@ -0,0 +1,87 @@ +{% extends "base.html" %} + +{% block content %} + +{# ── Page title ───────────────────────────────────────────────────── #} +
+
+

Audit Log

+

+ Most recent {{ entries | length }} state change{{ 's' if entries | length != 1 else '' }} +

+
+
+ {# Manual refresh button — no background polling #} + + + + + + Routes + +
+
+ +{% if entries %} + +
+
+ + + + + + + + + + + + + {% for entry in entries %} + {% include "partials/audit_row.html" with context %} + {% endfor %} + +
TimestampRouteActionStatus Change
+
+
+ +{% else %} + +{# ── Empty state ──────────────────────────────────────────────────── #} +
+
+
+ + + +
+

No audit entries yet

+

State changes from the dashboard, CLI, or API will appear here.

+
+
+ +{% endif %} +{% endblock %} diff --git a/shield/dashboard/templates/base.html b/shield/dashboard/templates/base.html new file mode 100644 index 0000000..562033a --- /dev/null +++ b/shield/dashboard/templates/base.html @@ -0,0 +1,125 @@ + + + + + + Shield — Route Dashboard + + + + + + + + + +
+ +
+ + +
+ {% block content %}{% endblock %} +
+ + + + + + + + diff --git a/shield/dashboard/templates/index.html b/shield/dashboard/templates/index.html new file mode 100644 index 0000000..f79d743 --- /dev/null +++ b/shield/dashboard/templates/index.html @@ -0,0 +1,107 @@ +{% extends "base.html" %} + +{% block content %} + +{# ── Global maintenance control ───────────────────────────────────── #} +{% set config = global_config %} +{% include "partials/global_maintenance.html" with context %} + +{# ── Compute per-status counts for the summary bar ────────────────── #} +{% set n_active = states | selectattr("status", "equalto", "active") | list | length %} +{% set n_maintenance = states | selectattr("status", "equalto", "maintenance") | list | length %} +{% set n_disabled = states | selectattr("status", "equalto", "disabled") | list | length %} +{% set n_env_gated = states | selectattr("status", "equalto", "env_gated") | list | length %} +{% set n_deprecated = states | selectattr("status", "equalto", "deprecated") | list | length %} + +{# ── Page title ───────────────────────────────────────────────────── #} +
+
+

Routes

+

+ {{ states | length }} route{{ 's' if states | length != 1 else '' }} registered + {% if n_maintenance > 0 %} + · {{ n_maintenance }} in maintenance + {% endif %} + {% if n_disabled > 0 %} + · {{ n_disabled }} disabled + {% endif %} +

+
+ {# Live indicator #} +
+ + + + + Live +
+
+ +{% if states %} + +{# ── Status summary cards ─────────────────────────────────────────── #} +
+
+
{{ n_active }}
+
Active
+
+
+
{{ n_maintenance }}
+
Maintenance
+
+
+
{{ n_disabled }}
+
Disabled
+
+
+
{{ n_env_gated }}
+
Env Gated
+
+
+
{{ n_deprecated }}
+
Deprecated
+
+
+ +{# ── Routes table ─────────────────────────────────────────────────── #} +
+
+ + + + + + + + + + + + {% for state in states %} + {% set path_slug = path_slug(state.path) %} + {% include "partials/route_row.html" with context %} + {% endfor %} + +
PathStatusActions
+
+
+ +{% else %} + +{# ── Empty state ──────────────────────────────────────────────────── #} +
+
+
+ + + +
+

No routes registered

+

Routes appear here once the application starts with ShieldRouter or ShieldMiddleware.

+
+
+ +{% endif %} +{% endblock %} diff --git a/shield/dashboard/templates/partials/audit_row.html b/shield/dashboard/templates/partials/audit_row.html new file mode 100644 index 0000000..27beb1b --- /dev/null +++ b/shield/dashboard/templates/partials/audit_row.html @@ -0,0 +1,47 @@ +{% set status_colors = { + "active": "bg-emerald-50 text-emerald-700 ring-emerald-600/20", + "maintenance": "bg-amber-50 text-amber-700 ring-amber-600/20", + "disabled": "bg-red-50 text-red-700 ring-red-600/20", + "env_gated": "bg-blue-50 text-blue-700 ring-blue-600/20", + "deprecated": "bg-slate-100 text-slate-500 ring-slate-400/20" +} %} + + + + {{ entry.timestamp.strftime("%Y-%m-%d") }} + · + {{ entry.timestamp.strftime("%H:%M:%S") }} + + + {{ entry.path }} + + + + {{ entry.action }} + + + +
+ + + + {{ entry.actor }} +
+ + +
+ + {{ entry.previous_status }} + + + + + + {{ entry.new_status }} + +
+ + + {{ entry.reason or "—" }} + + diff --git a/shield/dashboard/templates/partials/audit_rows.html b/shield/dashboard/templates/partials/audit_rows.html new file mode 100644 index 0000000..348e6e4 --- /dev/null +++ b/shield/dashboard/templates/partials/audit_rows.html @@ -0,0 +1,3 @@ +{% for entry in entries %} +{% include "partials/audit_row.html" with context %} +{% endfor %} diff --git a/shield/dashboard/templates/partials/global_maintenance.html b/shield/dashboard/templates/partials/global_maintenance.html new file mode 100644 index 0000000..64696f0 --- /dev/null +++ b/shield/dashboard/templates/partials/global_maintenance.html @@ -0,0 +1,79 @@ +
+{% if config.enabled %} + + {# ── Active: prominent warning banner ─────────────────────────── #} +
+
+ + + +
+
+
+ Global Maintenance Active + + + All routes → 503 + +
+ {% if config.reason %} +

{{ config.reason }}

+ {% endif %} +
+ {% if config.exempt_paths %} +

+ Exempt: + {{ config.exempt_paths | join(", ") }} +

+ {% endif %} + {% if config.include_force_active %} +

⚠ @force_active routes also blocked

+ {% else %} +

@force_active routes still reachable

+ {% endif %} +
+
+ +
+ +{% else %} + + {# ── Inactive: subtle control bar ─────────────────────────────── #} +
+
+
+ +
+
+

Global maintenance is off

+ +
+
+ +
+ +{% endif %} +
diff --git a/shield/dashboard/templates/partials/modal.html b/shield/dashboard/templates/partials/modal.html new file mode 100644 index 0000000..a29c3e8 --- /dev/null +++ b/shield/dashboard/templates/partials/modal.html @@ -0,0 +1,115 @@ +{% if action == "enable" %} + {% set icon_bg = "bg-emerald-100" %} + {% set icon_color = "text-emerald-600" %} + {% set title = "Re-enable Route" %} + {% set description = "This route will immediately start serving requests again." %} + {% set btn_class = "bg-emerald-600 hover:bg-emerald-700 focus:ring-emerald-500" %} + {% set btn_label = "Enable" %} + {% set warning = false %} +{% elif action == "maintenance" %} + {% set icon_bg = "bg-amber-100" %} + {% set icon_color = "text-amber-600" %} + {% set title = "Set Maintenance Mode" %} + {% set description = "All requests to this route will return 503 until re-enabled." %} + {% set btn_class = "bg-amber-500 hover:bg-amber-600 focus:ring-amber-400" %} + {% set btn_label = "Set Maintenance" %} + {% set warning = false %} +{% elif action == "disable" %} + {% set icon_bg = "bg-red-100" %} + {% set icon_color = "text-red-600" %} + {% set title = "Disable Route" %} + {% set description = "This route will be permanently disabled until manually re-enabled." %} + {% set btn_class = "bg-red-600 hover:bg-red-700 focus:ring-red-500" %} + {% set btn_label = "Disable Route" %} + {% set warning = true %} +{% endif %} + +
+ + {# ── Header ───────────────────────────────────────────────── #} +
+
+ {% if action == "enable" %} + + + + {% elif action == "maintenance" %} + + + + {% elif action == "disable" %} + + + + {% endif %} +
+
+

{{ title }}

+

{{ description }}

+
+ + + + + {{ route_path }} + +
+
+
+ + {# ── Disable warning banner ────────────────────────────────── #} + {% if warning %} +
+ + + +

+ Requests will fail immediately. + All traffic to this route returns 503 until you explicitly re-enable it. +

+
+ {% endif %} + + {# ── Reason form ──────────────────────────────────────────── #} +
+ +
+ + +
+ +
+ + +
+
+
diff --git a/shield/dashboard/templates/partials/modal_global_disable.html b/shield/dashboard/templates/partials/modal_global_disable.html new file mode 100644 index 0000000..a615675 --- /dev/null +++ b/shield/dashboard/templates/partials/modal_global_disable.html @@ -0,0 +1,51 @@ +
+ + {# ── Header ───────────────────────────────────────────────── #} +
+
+ + + +
+
+

Restore Service

+

+ Disable global maintenance mode. All routes will immediately resume their individual lifecycle states. +

+
+
+ + {# ── Info box ─────────────────────────────────────────────── #} +
+ + + +

+ Routes that were individually set to maintenance, disabled, or env-gated + will keep those states — only the global override is lifted. +

+
+ + {# ── Action buttons ───────────────────────────────────────── #} +
+ + +
+
diff --git a/shield/dashboard/templates/partials/modal_global_enable.html b/shield/dashboard/templates/partials/modal_global_enable.html new file mode 100644 index 0000000..81e2ca9 --- /dev/null +++ b/shield/dashboard/templates/partials/modal_global_enable.html @@ -0,0 +1,114 @@ +
+ + {# ── Header ───────────────────────────────────────────────── #} +
+
+ + + +
+
+

Enable Global Maintenance

+

+ Every route returns 503 immediately. Use for emergency downtime or infrastructure work. +

+
+
+ + {# ── Warning banner ───────────────────────────────────────── #} +
+ + + +

+ All routes will fail immediately. + This overrides every per-route state. Add exempt paths below to keep critical routes reachable. +

+
+ + {# ── Form ─────────────────────────────────────────────────── #} +
+ + {# Reason #} +
+ + +
+ + {# Exempt paths #} +
+ + +

+ Accepts bare paths (/health) and + method-prefixed keys (GET:/status). +

+
+ + {# Force-active checkbox #} +
+ +
+ + {# Buttons #} +
+ + +
+
+
diff --git a/shield/dashboard/templates/partials/route_row.html b/shield/dashboard/templates/partials/route_row.html new file mode 100644 index 0000000..b0ccb96 --- /dev/null +++ b/shield/dashboard/templates/partials/route_row.html @@ -0,0 +1,160 @@ + + + {# ── Path ──────────────────────────────────────────────────────── #} + +
+ {{ state.path }} + {% if state.force_active %} + + + + + protected + + {% endif %} +
+ + + {# ── Status badge ──────────────────────────────────────────────── #} + + {% if state.status == "active" %} + + Active + + {% elif state.status == "maintenance" %} + + Maintenance + + {% elif state.status == "disabled" %} + + Disabled + + {% elif state.status == "env_gated" %} + + Env Gated + + {% elif state.status == "deprecated" %} + + Deprecated + + {% else %} + + {{ state.status | upper }} + + {% endif %} + + + {# ── Reason ────────────────────────────────────────────────────── #} + + + {{ state.reason or "—" }} + + + + {# ── Environments ──────────────────────────────────────────────── #} + + {% if state.allowed_envs %} +
+ {% for env in state.allowed_envs %} + {{ env }} + {% endfor %} +
+ {% else %} + + {% endif %} + + + {# ── Actions ───────────────────────────────────────────────────── #} + + {% if state.force_active %} + Protected + {% else %} +
+ {# Action buttons — each opens the custom action modal #} +
+ {% if state.status != "active" %} + + {% endif %} + {% if state.status != "maintenance" %} + + {% endif %} + {% if state.status != "disabled" %} + + {% endif %} +
+ + {# Schedule window — collapsible form #} + {% if state.status != "maintenance" %} +
+ + + + + Schedule window + +
+
+ +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ {% endif %} + +
+ {% endif %} + + diff --git a/shield/dashboard/templates/partials/routes_table.html b/shield/dashboard/templates/partials/routes_table.html new file mode 100644 index 0000000..c0c3325 --- /dev/null +++ b/shield/dashboard/templates/partials/routes_table.html @@ -0,0 +1,4 @@ +{% for state in states %} +{% set path_slug = path_slug(state.path) %} +{% include "partials/route_row.html" with context %} +{% endfor %} diff --git a/tests/dashboard/test_routes.py b/tests/dashboard/test_routes.py new file mode 100644 index 0000000..17f9fc5 --- /dev/null +++ b/tests/dashboard/test_routes.py @@ -0,0 +1,453 @@ +"""Tests for the Shield dashboard route handlers (v0.3).""" + +from __future__ import annotations + +import base64 +import os +import tempfile +from datetime import UTC, datetime, timedelta + +import pytest +from httpx import ASGITransport, AsyncClient + +from shield.core.engine import ShieldEngine +from shield.core.models import MaintenanceWindow, RouteState, RouteStatus +from shield.dashboard.app import ShieldDashboard + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _encode_path(path: str) -> str: + """Base64url-encode *path* for use in a URL segment (mirrors routes.py).""" + return base64.urlsafe_b64encode(path.encode()).decode().rstrip("=") + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +async def engine() -> ShieldEngine: + """Provide a ShieldEngine pre-loaded with a couple of test routes.""" + e = ShieldEngine() + await e.backend.set_state("/payments", RouteState(path="/payments", status=RouteStatus.ACTIVE)) + await e.backend.set_state("/health", RouteState(path="/health", status=RouteStatus.ACTIVE)) + return e + + +@pytest.fixture +def dashboard(engine: ShieldEngine) -> object: + """Return a ShieldDashboard ASGI app (no auth).""" + return ShieldDashboard(engine=engine) + + +@pytest.fixture +async def client(dashboard: object) -> AsyncClient: + """Return an httpx AsyncClient pointing at the dashboard.""" + async with AsyncClient( + transport=ASGITransport(app=dashboard), # type: ignore[arg-type] + base_url="http://testserver", + ) as c: + yield c + + +# --------------------------------------------------------------------------- +# Index / routes page +# --------------------------------------------------------------------------- + + +async def test_index_returns_200(client: AsyncClient) -> None: + """GET / renders the routes page with status 200.""" + resp = await client.get("/") + assert resp.status_code == 200 + + +async def test_index_contains_shield_brand(client: AsyncClient) -> None: + """Index page contains the Shield brand name.""" + resp = await client.get("/") + assert "Shield" in resp.text + + +async def test_index_shows_registered_routes(client: AsyncClient) -> None: + """Index page lists registered route paths.""" + resp = await client.get("/") + assert "/payments" in resp.text + assert "/health" in resp.text + + +async def test_index_shows_status_badge(client: AsyncClient) -> None: + """Index page contains a status badge.""" + resp = await client.get("/") + assert "active" in resp.text.lower() + + +# --------------------------------------------------------------------------- +# Routes partial +# --------------------------------------------------------------------------- + + +async def test_routes_partial_returns_200(client: AsyncClient) -> None: + """GET /routes returns 200 with table rows partial.""" + resp = await client.get("/routes") + assert resp.status_code == 200 + assert "/payments" in resp.text + + +# --------------------------------------------------------------------------- +# Toggle +# --------------------------------------------------------------------------- + + +async def test_toggle_active_to_maintenance(client: AsyncClient, engine: ShieldEngine) -> None: + """POST /toggle/{key} puts an active route into maintenance.""" + resp = await client.post(f"/toggle/{_encode_path('/payments')}") + assert resp.status_code == 200 + # Response is an HTML partial containing the updated row. + assert "row-payments" in resp.text + assert "maintenance" in resp.text.lower() + + state = await engine.get_state("/payments") + assert state.status == RouteStatus.MAINTENANCE + + +async def test_toggle_maintenance_to_active(client: AsyncClient, engine: ShieldEngine) -> None: + """POST /toggle/{key} re-enables a route that is in maintenance.""" + await engine.set_maintenance("/payments", reason="test") + resp = await client.post(f"/toggle/{_encode_path('/payments')}") + assert resp.status_code == 200 + assert "active" in resp.text.lower() + + state = await engine.get_state("/payments") + assert state.status == RouteStatus.ACTIVE + + +# --------------------------------------------------------------------------- +# Disable +# --------------------------------------------------------------------------- + + +async def test_disable_route(client: AsyncClient, engine: ShieldEngine) -> None: + """POST /disable/{key} disables a route and returns the updated partial.""" + resp = await client.post(f"/disable/{_encode_path('/payments')}") + assert resp.status_code == 200 + assert "disabled" in resp.text.lower() + + state = await engine.get_state("/payments") + assert state.status == RouteStatus.DISABLED + + +# --------------------------------------------------------------------------- +# Enable +# --------------------------------------------------------------------------- + + +async def test_enable_route(client: AsyncClient, engine: ShieldEngine) -> None: + """POST /enable/{key} re-enables a disabled route.""" + await engine.disable("/payments", reason="test") + resp = await client.post(f"/enable/{_encode_path('/payments')}") + assert resp.status_code == 200 + assert "active" in resp.text.lower() + + state = await engine.get_state("/payments") + assert state.status == RouteStatus.ACTIVE + + +# --------------------------------------------------------------------------- +# Schedule +# --------------------------------------------------------------------------- + + +async def test_schedule_maintenance_window(client: AsyncClient, engine: ShieldEngine) -> None: + """POST /schedule sets a future maintenance window via form data.""" + start = (datetime.now(UTC) + timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M") + end = (datetime.now(UTC) + timedelta(hours=2)).strftime("%Y-%m-%dT%H:%M") + + resp = await client.post( + "/schedule", + data={ + "path": "/payments", + "start": start, + "end": end, + "reason": "Scheduled migration", + }, + ) + assert resp.status_code == 200 + assert "row-payments" in resp.text + + +# --------------------------------------------------------------------------- +# Cancel schedule +# --------------------------------------------------------------------------- + + +async def test_cancel_schedule(client: AsyncClient, engine: ShieldEngine) -> None: + """DELETE /schedule/{key} cancels a pending scheduled window.""" + window = MaintenanceWindow( + start=datetime.now(UTC) + timedelta(hours=1), + end=datetime.now(UTC) + timedelta(hours=2), + reason="Test window", + ) + await engine.schedule_maintenance("/payments", window, actor="test") + + resp = await client.delete(f"/schedule/{_encode_path('/payments')}") + assert resp.status_code == 200 + assert "row-payments" in resp.text + + +# --------------------------------------------------------------------------- +# Audit log +# --------------------------------------------------------------------------- + + +async def test_audit_page_returns_200(client: AsyncClient, engine: ShieldEngine) -> None: + """GET /audit renders the audit log page.""" + await engine.disable("/payments", reason="audit-test", actor="tester") + resp = await client.get("/audit") + assert resp.status_code == 200 + + +async def test_audit_page_contains_entries(client: AsyncClient, engine: ShieldEngine) -> None: + """Audit page lists recent state changes.""" + await engine.disable("/payments", reason="for-audit", actor="tester") + resp = await client.get("/audit") + assert "disable" in resp.text + + +async def test_audit_rows_partial(client: AsyncClient, engine: ShieldEngine) -> None: + """GET /audit/rows returns only the rows partial (for HTMX auto-refresh).""" + await engine.enable("/payments", actor="auto-refresh-test") + resp = await client.get("/audit/rows") + assert resp.status_code == 200 + + +# --------------------------------------------------------------------------- +# SSE endpoint +# --------------------------------------------------------------------------- + + +async def test_sse_handler_returns_streaming_response(engine: ShieldEngine) -> None: + """events() handler returns a StreamingResponse with text/event-stream media type.""" + + from starlette.requests import Request + from starlette.responses import StreamingResponse + + from shield.dashboard import routes as r + + app = ShieldDashboard(engine=engine) + scope: dict = { + "type": "http", + "method": "GET", + "path": "/events", + "query_string": b"", + "headers": [], + "app": app, + } + request = Request(scope) + response = await r.events(request) + + assert isinstance(response, StreamingResponse) + assert response.media_type == "text/event-stream" + assert "no-cache" in response.headers.get("cache-control", "") + + +async def test_sse_generator_emits_on_state_change(engine: ShieldEngine) -> None: + """SSE generator yields ``shield:update:*`` event when a route state changes.""" + import asyncio + + from starlette.requests import Request + + from shield.dashboard import routes as r + + app = ShieldDashboard(engine=engine) + scope: dict = { + "type": "http", + "method": "GET", + "path": "/events", + "query_string": b"", + "headers": [], + "app": app, + } + request = Request(scope) + response = await r.events(request) + + # Trigger a state change slightly after the generator starts listening. + async def _trigger() -> None: + await asyncio.sleep(0.02) + await engine.disable("/payments", reason="sse-test", actor="test") + + trigger_task = asyncio.create_task(_trigger()) + gen = response.body_iterator # type: ignore[union-attr] + first_chunk = await asyncio.wait_for(gen.__anext__(), timeout=3.0) # type: ignore[union-attr] + trigger_task.cancel() + try: + await trigger_task + except asyncio.CancelledError: + pass + + text = first_chunk.decode() if isinstance(first_chunk, bytes) else str(first_chunk) + assert "shield:update:" in text + + +async def test_sse_keepalive_when_subscribe_unsupported() -> None: + """SSE generator sends keepalive comment when backend raises NotImplementedError.""" + import asyncio + import unittest.mock + + from starlette.requests import Request + + from shield.dashboard import routes as r + + with tempfile.NamedTemporaryFile(suffix=".json", delete=False, mode="w") as f: + f.write('{"states": {}, "audit": [], "global": null}') + fname = f.name + + try: + from shield.core.backends.file import FileBackend + + file_engine = ShieldEngine(backend=FileBackend(fname)) + app = ShieldDashboard(engine=file_engine) + scope: dict = { + "type": "http", + "method": "GET", + "path": "/events", + "query_string": b"", + "headers": [], + "app": app, + } + request = Request(scope) + response = await r.events(request) + + # Patch anyio.sleep to return immediately so the keepalive fires instantly. + with unittest.mock.patch("shield.dashboard.routes.anyio.sleep", return_value=None): + gen = response.body_iterator # type: ignore[union-attr] + first_chunk = await asyncio.wait_for(gen.__anext__(), timeout=2.0) # type: ignore[union-attr] + + text = first_chunk.decode() if isinstance(first_chunk, bytes) else str(first_chunk) + assert ": keepalive" in text + finally: + os.unlink(fname) + + +# --------------------------------------------------------------------------- +# Basic auth +# --------------------------------------------------------------------------- + + +async def test_basic_auth_blocks_unauthenticated() -> None: + """Dashboard with auth configured returns 401 for unauthenticated requests.""" + e = ShieldEngine() + app = ShieldDashboard(engine=e, auth=("admin", "s3cr3t")) + + async with AsyncClient( + transport=ASGITransport(app=app), # type: ignore[arg-type] + base_url="http://testserver", + ) as c: + resp = await c.get("/") + assert resp.status_code == 401 + assert "WWW-Authenticate" in resp.headers + assert 'Basic realm="Shield Dashboard"' in resp.headers["WWW-Authenticate"] + + +async def test_basic_auth_allows_valid_credentials() -> None: + """Dashboard with auth passes requests that carry correct credentials.""" + e = ShieldEngine() + app = ShieldDashboard(engine=e, auth=("admin", "s3cr3t")) + + async with AsyncClient( + transport=ASGITransport(app=app), # type: ignore[arg-type] + base_url="http://testserver", + auth=("admin", "s3cr3t"), + ) as c: + resp = await c.get("/") + assert resp.status_code == 200 + + +async def test_basic_auth_rejects_wrong_password() -> None: + """Dashboard with auth rejects requests with an incorrect password.""" + e = ShieldEngine() + app = ShieldDashboard(engine=e, auth=("admin", "s3cr3t")) + + async with AsyncClient( + transport=ASGITransport(app=app), # type: ignore[arg-type] + base_url="http://testserver", + auth=("admin", "wrong"), + ) as c: + resp = await c.get("/") + assert resp.status_code == 401 + + +async def test_basic_auth_rejects_missing_header() -> None: + """Dashboard returns 401 when Authorization header is absent.""" + e = ShieldEngine() + app = ShieldDashboard(engine=e, auth=("admin", "s3cr3t")) + + async with AsyncClient( + transport=ASGITransport(app=app), # type: ignore[arg-type] + base_url="http://testserver", + ) as c: + resp = await c.get("/audit") + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# OpenAPI isolation +# --------------------------------------------------------------------------- + + +async def test_dashboard_does_not_pollute_parent_openapi() -> None: + """Mounting the dashboard does not add routes to the parent FastAPI schema.""" + from fastapi import FastAPI + + e = ShieldEngine() + fastapi_app = FastAPI() + fastapi_app.mount("/shield", ShieldDashboard(engine=e)) + + async with AsyncClient( + transport=ASGITransport(app=fastapi_app), + base_url="http://testserver", + ) as c: + resp = await c.get("/openapi.json") + schema = resp.json() + paths = schema.get("paths", {}) + assert not any(p.startswith("/shield") for p in paths), ( + f"Dashboard paths leaked into OpenAPI schema: " + f"{[p for p in paths if p.startswith('/shield')]}" + ) + + +async def test_dashboard_accessible_when_mounted_on_fastapi() -> None: + """Dashboard routes are reachable when mounted on a FastAPI parent app.""" + from fastapi import FastAPI + + e = ShieldEngine() + fastapi_app = FastAPI() + fastapi_app.mount("/shield", ShieldDashboard(engine=e)) + + async with AsyncClient( + transport=ASGITransport(app=fastapi_app), + base_url="http://testserver", + ) as c: + resp = await c.get("/shield/") + assert resp.status_code == 200 + + +# --------------------------------------------------------------------------- +# Method-prefixed route keys +# --------------------------------------------------------------------------- + + +async def test_toggle_method_prefixed_route(engine: ShieldEngine) -> None: + """Dashboard handles method-prefixed route keys like ``GET:/payments``.""" + key = "GET:/payments" + await engine.backend.set_state(key, RouteState(path=key, status=RouteStatus.ACTIVE)) + + app = ShieldDashboard(engine=engine) + async with AsyncClient( + transport=ASGITransport(app=app), # type: ignore[arg-type] + base_url="http://testserver", + ) as c: + resp = await c.post(f"/toggle/{_encode_path(key)}") + assert resp.status_code == 200 diff --git a/uv.lock b/uv.lock index c3b3688..8d098a1 100644 --- a/uv.lock +++ b/uv.lock @@ -72,6 +72,7 @@ cli = [ dashboard = [ { name = "aiofiles" }, { name = "jinja2" }, + { name = "python-multipart" }, ] dev = [ { name = "aiofiles" }, @@ -79,6 +80,7 @@ dev = [ { name = "fastapi" }, { name = "httpx" }, { name = "mypy" }, + { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff" }, @@ -110,9 +112,11 @@ requires-dist = [ { name = "jinja2", marker = "extra == 'all'", specifier = ">=3.1" }, { name = "jinja2", marker = "extra == 'dashboard'", specifier = ">=3.1" }, { name = "mypy", marker = "extra == 'dev'" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.7" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, + { name = "python-multipart", marker = "extra == 'dashboard'", specifier = ">=0.0.22" }, { name = "pyyaml", marker = "extra == 'all'", specifier = ">=6.0" }, { name = "pyyaml", marker = "extra == 'yaml'", specifier = ">=6.0" }, { name = "redis", extras = ["asyncio"], marker = "extra == 'all'", specifier = ">=5.0" }, @@ -178,6 +182,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195 }, ] +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445 }, +] + [[package]] name = "click" version = "8.3.1" @@ -199,6 +212,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047 }, +] + [[package]] name = "fastapi" version = "0.135.1" @@ -215,6 +237,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999 }, ] +[[package]] +name = "filelock" +version = "3.25.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8b/4c32ecde6bea6486a2a5d05340e695174351ff6b06cf651a74c005f9df00/filelock-3.25.1.tar.gz", hash = "sha256:b9a2e977f794ef94d77cdf7d27129ac648a61f585bff3ca24630c1629f701aa9", size = 40319 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/b8/2f664b56a3b4b32d28d3d106c71783073f712ba43ff6d34b9ea0ce36dc7b/filelock-3.25.1-py3-none-any.whl", hash = "sha256:18972df45473c4aa2c7921b609ee9ca4925910cc3a0fb226c96b92fc224ef7bf", size = 26720 }, +] + [[package]] name = "h11" version = "0.16.0" @@ -252,6 +283,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, ] +[[package]] +name = "identify" +version = "2.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/84/376a3b96e5a8d33a7aa2c5b3b31a4b3c364117184bf0b17418055f6ace66/identify-2.6.17.tar.gz", hash = "sha256:f816b0b596b204c9fdf076ded172322f2723cf958d02f9c3587504834c8ff04d", size = 99579 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/66/71c1227dff78aaeb942fed29dd5651f2aec166cc7c9aeea3e8b26a539b7d/identify-2.6.17-py2.py3-none-any.whl", hash = "sha256:be5f8412d5ed4b20f2bd41a65f920990bdccaa6a4a18a08f1eefdcd0bdd885f0", size = 99382 }, +] + [[package]] name = "idna" version = "3.11" @@ -498,6 +538,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 }, ] +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438 }, +] + [[package]] name = "outcome" version = "1.3.0.post0" @@ -528,6 +577,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206 }, ] +[[package]] +name = "platformdirs" +version = "4.9.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216 }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -537,6 +595,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, ] +[[package]] +name = "pre-commit" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437 }, +] + [[package]] name = "pycparser" version = "3.0" @@ -688,6 +762,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075 }, ] +[[package]] +name = "python-discovery" +version = "1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/7e/9f3b0dd3a074a6c3e1e79f35e465b1f2ee4b262d619de00cfce523cc9b24/python_discovery-1.1.3.tar.gz", hash = "sha256:7acca36e818cd88e9b2ba03e045ad7e93e1713e29c6bbfba5d90202310b7baa5", size = 56945 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/80/73211fc5bfbfc562369b4aa61dc1e4bf07dc7b34df7b317e4539316b809c/python_discovery-1.1.3-py3-none-any.whl", hash = "sha256:90e795f0121bc84572e737c9aa9966311b9fde44ffb88a5953b3ec9b31c6945e", size = 31485 }, +] + +[[package]] +name = "python-multipart" +version = "0.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579 }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -894,3 +990,18 @@ sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, ] + +[[package]] +name = "virtualenv" +version = "21.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703c2666e809c4f686c54ef0a68b0f6afccf518c0b1eb9/virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", size = 5840618 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084 }, +]