#!/usr/bin/env python3 """ ASW Flask/FastAPI Error Pack — styled error responses for Python web frameworks. Drop this into your project directory and call register_asw_errors(app). Usage (Flask): from flask import Flask from asw_errors import register_asw_errors app = Flask(__name__) register_asw_errors(app) Usage (FastAPI): from fastapi import FastAPI from asw_errors import register_asw_errors app = FastAPI() register_asw_errors(app) Optional: link to a hosted asw.css instead of inline styles register_asw_errors(app, css_url="/static/asw.css") register_asw_errors(app, css_url="https://cdn.example.com/asw.css") """ import html as _html # ── Inline styles ───────────────────────────────────────────────────────────── # Minimal ASW tokens — no external dependency. Matches asw.css dark theme. _ASW_INLINE = """ :root { --asw-bg: #0d1117; --asw-bg-elevated: #161b22; --asw-bg-overlay: #1c2128; --asw-text: #e6edf3; --asw-text-secondary: #8b949e; --asw-text-muted: #484f58; --asw-accent: #3fb950; --asw-border: #30363d; --asw-font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; --asw-font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', ui-monospace, monospace; } *, *::before, *::after { box-sizing: border-box; } html { font-size: 16px; } body { background: var(--asw-bg); color: var(--asw-text); font-family: var(--asw-font-body); font-size: 1rem; line-height: 1.6; margin: 0; display: flex; flex-direction: column; min-height: 100vh; } nav { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1.5rem; border-bottom: 1px solid var(--asw-border); background: var(--asw-bg-elevated); } nav a { color: var(--asw-text); text-decoration: none; font-weight: 600; } nav a:hover { color: var(--asw-accent); } .badge { font-family: var(--asw-font-mono); font-size: 0.75rem; color: var(--asw-text-muted); background: var(--asw-bg-overlay); padding: 0.25rem 0.5rem; border-radius: 0.25rem; border: 1px solid var(--asw-border); } main { flex: 1; display: flex; align-items: center; justify-content: center; padding: 2rem; } .error-card { max-width: 42ch; text-align: center; } .error-code { font-family: var(--asw-font-mono); font-size: 6rem; font-weight: 700; line-height: 1; color: var(--asw-text-muted); letter-spacing: -0.05em; margin: 0 0 0.5rem; } h1 { margin: 0 0 1rem; font-size: 1.5rem; color: var(--asw-text); } p { margin: 0 0 0.75rem; color: var(--asw-text-secondary); } a.button { display: inline-block; margin-top: 1.5rem; padding: 0.5rem 1.25rem; background: transparent; color: var(--asw-accent); border: 1px solid var(--asw-accent); border-radius: 0.375rem; text-decoration: none; font-size: 0.875rem; transition: background 0.15s; } a.button:hover { background: rgba(63, 185, 80, 0.1); } code { font-family: var(--asw-font-mono); font-size: 0.85em; background: var(--asw-bg-overlay); padding: 0.1em 0.35em; border-radius: 0.2rem; color: var(--asw-text-secondary); } footer { text-align: center; padding: 1rem; font-size: 0.75rem; color: var(--asw-text-muted); border-top: 1px solid var(--asw-border); } footer a { color: var(--asw-text-muted); } """ _ERROR_MESSAGES = { 400: ("Bad Request", "The server couldn't understand the request."), 401: ("Unauthorized", "Authentication required. Please sign in."), 403: ("Forbidden", "You don't have permission to access this resource."), 404: ("Not Found", "The page or resource you requested doesn't exist."), 405: ("Method Not Allowed", "That HTTP method isn't supported for this endpoint."), 408: ("Request Timeout", "The request took too long. Please try again."), 409: ("Conflict", "The request conflicts with the current state of the server."), 410: ("Gone", "This resource has been permanently removed."), 415: ("Unsupported Media Type", "The Content-Type you sent isn't accepted."), 422: ("Unprocessable Entity", "The request was well-formed but contains invalid data."), 429: ("Too Many Requests", "You've sent too many requests. Please slow down."), 500: ("Internal Server Error", "Something went wrong on the server."), 501: ("Not Implemented", "This feature hasn't been implemented yet."), 502: ("Bad Gateway", "The server received an invalid response from upstream."), 503: ("Service Unavailable", "The server is temporarily unavailable. Try again soon."), 504: ("Gateway Timeout", "The upstream server didn't respond in time."), } # Codes that receive an extra hint about the path in the response _SHOW_PATH_CODES = {404, 403, 405} def _make_style_tag(css_url=None): if css_url: return f'' return f"" def error_html(code: int, path: str = "", css_url=None, app_name: str = "API") -> str: """ Generate an ASW-styled error page as an HTML string. Args: code: HTTP status code path: Request path (shown for 403/404/405) css_url: Optional URL to link instead of inlining styles app_name: Shown in the nav bar (default: "API") """ title, message = _ERROR_MESSAGES.get(code, ("Error", "An error occurred.")) style_block = _make_style_tag(css_url) path_hint = "" if path and code in _SHOW_PATH_CODES: escaped = _html.escape(path) path_hint = f'

Path: {escaped}

' return f""" {code} — {title} {style_block}

{code}

{title}

{message}

{path_hint} ← Back
""" # ── Flask registration ──────────────────────────────────────────────────────── def _register_flask(app, css_url, app_name): """Register error handlers on a Flask application.""" from flask import request as flask_request from flask import Response as FlaskResponse codes = list(_ERROR_MESSAGES.keys()) def make_handler(code): def handler(e): path = getattr(flask_request, "path", "") body = error_html(code, path=path, css_url=css_url, app_name=app_name) return FlaskResponse(body, status=code, mimetype="text/html") handler.__name__ = f"asw_error_{code}" return handler for code in codes: app.register_error_handler(code, make_handler(code)) # ── FastAPI / Starlette registration ────────────────────────────────────────── def _register_fastapi(app, css_url, app_name): """Register exception handlers on a FastAPI application.""" from starlette.requests import Request from starlette.responses import HTMLResponse from starlette.exceptions import HTTPException as StarletteHTTPException async def http_exception_handler(request: Request, exc: StarletteHTTPException): code = exc.status_code path = request.url.path body = error_html(code, path=path, css_url=css_url, app_name=app_name) return HTMLResponse(content=body, status_code=code) # Catch all Starlette HTTP exceptions (covers 4xx and 5xx) app.add_exception_handler(StarletteHTTPException, http_exception_handler) # Also catch FastAPI's RequestValidationError (unprocessable entity — 422) try: from fastapi.exceptions import RequestValidationError async def validation_exception_handler(request: Request, exc: RequestValidationError): path = request.url.path body = error_html(422, path=path, css_url=css_url, app_name=app_name) return HTMLResponse(content=body, status_code=422) app.add_exception_handler(RequestValidationError, validation_exception_handler) except ImportError: pass # Pure Starlette without FastAPI — skip # ── Public API ──────────────────────────────────────────────────────────────── def register_asw_errors(app, css_url=None, app_name=None): """ Register ASW-styled HTTP error handlers on a Flask or FastAPI application. Args: app: Flask or FastAPI application instance css_url: Optional URL to link asw.css instead of inlining styles. Use this when your app already serves asw.css: register_asw_errors(app, css_url="/static/asw.css") app_name: Label shown in the nav bar. Defaults to the app's name. Raises: TypeError: If the app type is not recognized as Flask or FastAPI. """ app_type = type(app).__name__ # Resolve app_name from app if not provided if app_name is None: if app_type == "Flask": app_name = app.name or "Flask App" elif app_type == "FastAPI": app_name = app.title or "FastAPI" else: app_name = "API" if app_type == "Flask": _register_flask(app, css_url, app_name) elif app_type == "FastAPI": _register_fastapi(app, css_url, app_name) else: raise TypeError( f"Unsupported app type: {app_type!r}. " "Expected 'Flask' or 'FastAPI'." )