asw/archive/packs/python/asw_server.py
exe.dev user e47a9f4401 asw-v01: archive deferred content (packs, site, lab, legacy examples)
- 2.1: packs/ -> archive/packs/
- 2.2: site/ -> archive/site/
- 2.3: src/lab/ -> archive/lab/
- 2.4: examples/ -> archive/examples-legacy/ (SSI-based)
2026-06-07 10:39:21 +02:00

348 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
ASW Dev Server — http.server with ASW-styled error pages.
Drop this in any directory and run it instead of `python -m http.server`.
All error responses (403, 404, 500, etc.) are styled with ASW CSS.
Usage:
python asw_server.py # serve on port 8000
python asw_server.py 3000 # custom port
python asw_server.py 3000 /dir # custom port + directory
The server inlines ASW styles so no external dependencies are needed.
"""
import http.server
import sys
import os
import socket
import urllib.parse
import html
# ── Minimal ASW inline styles ─────────────────────────────────────────────────
# Reduced version for standalone error pages — no external deps.
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); }
/* Directory listing */
.dir-listing { max-width: 72ch; width: 100%; }
.dir-listing h1 { font-size: 1.25rem; margin-bottom: 1.5rem; text-align: left; }
.dir-listing h1 code { font-size: 1rem; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid var(--asw-border); }
th { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--asw-text-muted); }
td a { color: var(--asw-accent); text-decoration: none; }
td a:hover { text-decoration: underline; }
.size { color: var(--asw-text-muted); font-family: var(--asw-font-mono); font-size: 0.8rem; }
.parent { color: var(--asw-text-muted); }
"""
ERROR_MESSAGES = {
400: ("Bad Request", "The server couldn't understand the request."),
401: ("Unauthorized", "Authentication is required to access this resource."),
403: ("Forbidden", "You don't have permission to access this path."),
404: ("Not Found", "The file or directory you requested doesn't exist."),
405: ("Method Not Allowed", "That HTTP method isn't supported here."),
408: ("Request Timeout", "The request took too long to process."),
500: ("Server Error", "Something went wrong on the server."),
501: ("Not Implemented", "The server doesn't support that feature."),
503: ("Service Unavailable", "The server is temporarily unable to handle requests."),
}
def error_page(code: int, path: str = "") -> bytes:
title, message = ERROR_MESSAGES.get(code, ("Error", "An error occurred."))
path_hint = f"<p>Path: <code>{html.escape(path)}</code></p>" if path else ""
body = 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>{ASW_INLINE}</style>
</head>
<body>
<nav>
<a href="/">&#x2302; Dev Server</a>
<span class="badge">http.server</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 to root</a>
</div>
</main>
<footer>ASW Dev Server &middot; <a href="https://github.com/trentuna/agentic-semantic-web">agentic-semantic-web</a></footer>
</body>
</html>"""
return body.encode("utf-8")
def dir_listing_page(path: str, display_path: str, entries: list) -> bytes:
rows = []
if display_path != "/":
parent = "/" + "/".join(display_path.strip("/").split("/")[:-1])
rows.append(f'<tr><td class="parent"><a href="{parent or "/"}">../</a></td><td class="size">—</td></tr>')
for name, is_dir, size in sorted(entries, key=lambda e: (not e[1], e[0].lower())):
link_name = html.escape(name) + ("/" if is_dir else "")
href = urllib.parse.quote(name) + ("/" if is_dir else "")
size_str = "" if is_dir else _human_size(size)
rows.append(f'<tr><td><a href="{href}">{link_name}</a></td><td class="size">{size_str}</td></tr>')
rows_html = "\n ".join(rows)
body = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Index of {html.escape(display_path)}</title>
<style>{ASW_INLINE}</style>
</head>
<body>
<nav>
<a href="/">&#x2302; Dev Server</a>
<span class="badge">http.server</span>
</nav>
<main>
<div class="dir-listing">
<h1>Index of <code>{html.escape(display_path)}</code></h1>
<table>
<thead><tr><th>Name</th><th>Size</th></tr></thead>
<tbody>
{rows_html}
</tbody>
</table>
</div>
</main>
<footer>ASW Dev Server &middot; <a href="https://github.com/trentuna/agentic-semantic-web">agentic-semantic-web</a></footer>
</body>
</html>"""
return body.encode("utf-8")
def _human_size(n: int) -> str:
for unit in ("B", "KB", "MB", "GB"):
if n < 1024:
return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
n /= 1024
return f"{n:.1f} TB"
class ASWHandler(http.server.SimpleHTTPRequestHandler):
"""SimpleHTTPRequestHandler with ASW-styled error pages and directory listings."""
server_version = "ASWServer/1.0"
def send_error(self, code, message=None, explain=None):
"""Override to send ASW-styled error pages."""
try:
short, long = self.responses[code]
except KeyError:
short, long = "???", "???"
if message is None:
message = short
if explain is None:
explain = long
# Log to terminal
self.log_error("code %d, message %s", code, message)
# Build ASW error page
path = getattr(self, "path", "")
content = error_page(code, path)
self.send_response(code, message)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(content)))
self.send_header("Connection", "close")
self.end_headers()
if self.command != "HEAD" and code >= 200 and code not in (204, 304):
self.wfile.write(content)
def list_directory(self, path):
"""Override to send ASW-styled directory listing."""
try:
names = os.listdir(path)
except OSError:
self.send_error(403, "No permission to list directory")
return None
display_path = urllib.parse.unquote(self.path)
entries = []
for name in names:
full = os.path.join(path, name)
is_dir = os.path.isdir(full)
size = 0
if not is_dir:
try:
size = os.path.getsize(full)
except OSError:
pass
entries.append((name, is_dir, size))
content = dir_listing_page(path, display_path, entries)
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(content)))
self.end_headers()
self.wfile.write(content)
return None
def log_message(self, fmt, *args):
"""Slightly cleaner terminal output."""
print(f" {self.address_string()}{fmt % args}")
def main():
port = 8000
directory = "."
if len(sys.argv) > 1:
try:
port = int(sys.argv[1])
except ValueError:
print(f"Usage: {sys.argv[0]} [port] [directory]", file=sys.stderr)
sys.exit(1)
if len(sys.argv) > 2:
directory = sys.argv[2]
directory = os.path.abspath(directory)
handler = lambda *args, **kwargs: ASWHandler(*args, directory=directory, **kwargs)
# Find a free port if needed (dev convenience)
for attempt in range(10):
try:
server = http.server.HTTPServer(("", port + attempt), handler)
actual_port = port + attempt
break
except OSError:
if attempt == 9:
print(f"Could not bind to ports {port}{port+9}", file=sys.stderr)
sys.exit(1)
hostname = socket.gethostname()
print(f"\n ASW Dev Server")
print(f" ──────────────────────────────────")
print(f" Serving: {directory}")
print(f" Local: http://localhost:{actual_port}")
print(f" Network: http://{hostname}:{actual_port}")
print(f" ──────────────────────────────────")
print(f" Press Ctrl+C to stop\n")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n Stopped.")
server.server_close()
if __name__ == "__main__":
main()