- 2.1: packs/ -> archive/packs/ - 2.2: site/ -> archive/site/ - 2.3: src/lab/ -> archive/lab/ - 2.4: examples/ -> archive/examples-legacy/ (SSI-based)
293 lines
10 KiB
Python
293 lines
10 KiB
Python
#!/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'<link rel="stylesheet" href="{_html.escape(css_url)}">'
|
|
return f"<style>{_ASW_INLINE}</style>"
|
|
|
|
|
|
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'<p>Path: <code>{escaped}</code></p>'
|
|
|
|
return f"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>{code} — {title}</title>
|
|
{style_block}
|
|
</head>
|
|
<body>
|
|
<nav>
|
|
<a href="/">{_html.escape(app_name)}</a>
|
|
<span class="badge">{code}</span>
|
|
</nav>
|
|
<main>
|
|
<div class="error-card">
|
|
<p class="error-code">{code}</p>
|
|
<h1>{title}</h1>
|
|
<p>{message}</p>
|
|
{path_hint}
|
|
<a class="button" href="/">← Back</a>
|
|
</div>
|
|
</main>
|
|
<footer>Powered by <a href="https://github.com/trentuna/agentic-semantic-web">agentic-semantic-web</a></footer>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
# ── 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'."
|
|
)
|