Commit everything on disk: capability text, codex and gemini sandbox switches, test updates, examples/ and mplex.py
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Executable
+456
@@ -0,0 +1,456 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SQLite FTS5 full-text search over een directory. Stdlib only, 3.6+.
|
||||
|
||||
Run:
|
||||
python3 fts-search.py index ./docs
|
||||
python3 fts-search.py search "deploy checklist"
|
||||
python3 fts-search.py serve --port 8083
|
||||
|
||||
Spec (dit bestand is de spec):
|
||||
|
||||
CLI:
|
||||
index <dir> [--db] walk + (her)indexeer, incrementeel op mtime+size
|
||||
search <query> BM25-ranking, snippets met <b>-highlights
|
||||
serve [--port] HTTP UI + JSON API op /api/search
|
||||
|
||||
JSON API:
|
||||
/api/search?q=&limit=&offset= 200 {"total","items":[{path,title,
|
||||
snippet,rank,mtime,size}]}
|
||||
/api/stats 200 {"docs","bytes","indexed_at"}
|
||||
|
||||
Ranking: SQLite bm25(fts), snippets via snippet() met <b>-markering.
|
||||
Query-taal: woorden worden AND-gecombineerd als frasen ("a" AND "b"),
|
||||
veilig ge-escaped, nooit rauwe MATCH-syntax van de gebruiker.
|
||||
|
||||
Indexeer-regels: hidden files/dirs overgeslagen, binaire bestanden
|
||||
overgeslagen (extensie-lijst + NUL-byte sniff), alleen .txt/.md/.rst/
|
||||
.py/.js/.html/.css/.json/.toml/.yaml/.xml/.log/.csv en extensieloze
|
||||
tekstbestanden.
|
||||
|
||||
Caps: MAX_FILE 1 MiB, MAX_FILES 10000, MAX_QUERY 200 chars, MAX_LIMIT 50,
|
||||
MAX_BODY-index 200 KiB per bestand.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import socketserver
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
TEXT_EXTS = frozenset([
|
||||
".txt", ".md", ".markdown", ".rst", ".py", ".js", ".ts", ".html",
|
||||
".htm", ".css", ".json", ".toml", ".yaml", ".yml", ".xml", ".log",
|
||||
".csv", ".tsv", ".ini", ".cfg", ".sh", ".sql", ".java", ".c", ".h",
|
||||
".go", ".rs", "",
|
||||
])
|
||||
MAX_FILE = 1024 * 1024
|
||||
MAX_FILES = 10000
|
||||
MAX_INDEX_CHARS = 200 * 1024
|
||||
|
||||
|
||||
class SearchError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SearchEngine(object):
|
||||
"""SQLite + FTS5 index. Een verbinding, RLock, WAL."""
|
||||
|
||||
SCHEMA = ("CREATE TABLE IF NOT EXISTS docs ("
|
||||
"path TEXT PRIMARY KEY,"
|
||||
"title TEXT NOT NULL,"
|
||||
"body TEXT NOT NULL,"
|
||||
"mtime REAL NOT NULL,"
|
||||
"size INTEGER NOT NULL,"
|
||||
"indexed_at TEXT NOT NULL)")
|
||||
|
||||
def __init__(self, db_path):
|
||||
self.db_path = db_path
|
||||
self._lock = threading.RLock()
|
||||
self.db = sqlite3.connect(db_path, check_same_thread=False)
|
||||
self.db.row_factory = sqlite3.Row
|
||||
self.db.execute("PRAGMA journal_mode=WAL")
|
||||
self.db.execute("PRAGMA busy_timeout=5000")
|
||||
self.db.execute("PRAGMA synchronous=NORMAL")
|
||||
with self.db:
|
||||
self.db.execute(self.SCHEMA)
|
||||
try:
|
||||
self.db.execute("CREATE VIRTUAL TABLE IF NOT EXISTS fts "
|
||||
"USING fts5(path, title, body, "
|
||||
"tokenize='porter unicode61')")
|
||||
except sqlite3.OperationalError:
|
||||
raise SearchError(
|
||||
"deze SQLite heeft geen FTS5 (nodig voor ranking). "
|
||||
"Python >= 3.9 bundelt het vrijwel altijd.")
|
||||
self.db.execute(
|
||||
"CREATE TRIGGER IF NOT EXISTS docs_ai AFTER INSERT ON docs "
|
||||
"BEGIN INSERT INTO fts(path, title, body) "
|
||||
"VALUES (new.path, new.title, new.body); END")
|
||||
self.db.execute(
|
||||
"CREATE TRIGGER IF NOT EXISTS docs_ad AFTER DELETE ON docs "
|
||||
"BEGIN DELETE FROM fts WHERE path=old.path; END")
|
||||
self.db.execute(
|
||||
"CREATE TRIGGER IF NOT EXISTS docs_au AFTER UPDATE ON docs "
|
||||
"BEGIN DELETE FROM fts WHERE path=old.path; "
|
||||
"INSERT INTO fts(path, title, body) "
|
||||
"VALUES (new.path, new.title, new.body); END")
|
||||
|
||||
@staticmethod
|
||||
def match_query(query):
|
||||
"""Bouw veilige MATCH-query: frasen met AND, alles ge-escaped."""
|
||||
words = [w for w in query.split() if w.strip("\"'")]
|
||||
terms = []
|
||||
for word in words[:10]:
|
||||
clean = word.strip("\"'()*:^").replace('"', "")
|
||||
if clean:
|
||||
terms.append('"%s"' % clean)
|
||||
return " AND ".join(terms)
|
||||
|
||||
def search(self, query, limit=20, offset=0):
|
||||
query = (query or "")[:200].strip()
|
||||
if not query:
|
||||
return 0, []
|
||||
match = self.match_query(query)
|
||||
if not match:
|
||||
return 0, []
|
||||
limit = min(max(limit, 1), 50)
|
||||
offset = max(offset, 0)
|
||||
with self._lock:
|
||||
try:
|
||||
total = self.db.execute(
|
||||
"SELECT COUNT(*) AS n FROM fts WHERE fts MATCH ?",
|
||||
(match,)).fetchone()["n"]
|
||||
rows = self.db.execute(
|
||||
"SELECT d.path, d.title, d.mtime, d.size,"
|
||||
" bm25(fts) AS rank,"
|
||||
" snippet(fts, 2, '<b>', '</b>', ' ... ', 32) AS snippet"
|
||||
" FROM fts JOIN docs d ON d.path=fts.path"
|
||||
" WHERE fts MATCH ? ORDER BY rank LIMIT ? OFFSET ?",
|
||||
(match, limit, offset)).fetchall()
|
||||
except sqlite3.OperationalError:
|
||||
return 0, []
|
||||
return total, [{"path": r["path"], "title": r["title"],
|
||||
"mtime": r["mtime"], "size": r["size"],
|
||||
"rank": r["rank"], "snippet": r["snippet"]}
|
||||
for r in rows]
|
||||
|
||||
def stats(self):
|
||||
with self._lock:
|
||||
row = self.db.execute(
|
||||
"SELECT COUNT(*) AS docs, COALESCE(SUM(size),0) AS bytes,"
|
||||
" MAX(indexed_at) AS indexed_at FROM docs").fetchone()
|
||||
return {"docs": row["docs"], "bytes": row["bytes"],
|
||||
"indexed_at": row["indexed_at"]}
|
||||
|
||||
def upsert(self, path, title, body, mtime, size):
|
||||
with self._lock:
|
||||
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
self.db.execute(
|
||||
"INSERT OR REPLACE INTO docs"
|
||||
" (path, title, body, mtime, size, indexed_at)"
|
||||
" VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(path, title, body[:MAX_INDEX_CHARS], mtime, size, now))
|
||||
|
||||
def remove(self, path):
|
||||
with self._lock:
|
||||
self.db.execute("DELETE FROM docs WHERE path=?", (path,))
|
||||
|
||||
def known(self):
|
||||
with self._lock:
|
||||
return {r["path"]: (r["mtime"], r["size"])
|
||||
for r in self.db.execute("SELECT path, mtime, size"
|
||||
" FROM docs").fetchall()}
|
||||
|
||||
def commit(self):
|
||||
with self._lock:
|
||||
self.db.commit()
|
||||
|
||||
|
||||
class DocIndexer(object):
|
||||
"""Walk + filter + incrementele indexering. Puur, testbaar."""
|
||||
|
||||
def __init__(self, engine):
|
||||
self.engine = engine
|
||||
|
||||
@staticmethod
|
||||
def is_indexable(name, full):
|
||||
base = os.path.basename(name)
|
||||
if base.startswith("."):
|
||||
return False
|
||||
_, ext = os.path.splitext(base)
|
||||
if ext.lower() not in TEXT_EXTS:
|
||||
return False
|
||||
try:
|
||||
if os.path.getsize(full) > MAX_FILE:
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def read_text(full):
|
||||
try:
|
||||
with open(full, "rb") as fh:
|
||||
raw = fh.read(MAX_FILE + 1)
|
||||
except OSError:
|
||||
return None
|
||||
if b"\x00" in raw[:8192]:
|
||||
return None
|
||||
try:
|
||||
return raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
try:
|
||||
return raw.decode("latin-1")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
|
||||
def index_dir(self, root):
|
||||
root = os.path.abspath(root)
|
||||
known = self.engine.known()
|
||||
seen = set()
|
||||
added = updated = skipped = 0
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
|
||||
dirnames.sort()
|
||||
for name in sorted(filenames):
|
||||
full = os.path.join(dirpath, name)
|
||||
rel = os.path.relpath(full, root)
|
||||
if len(seen) >= MAX_FILES:
|
||||
skipped += 1
|
||||
continue
|
||||
if not self.is_indexable(name, full):
|
||||
skipped += 1
|
||||
continue
|
||||
try:
|
||||
st = os.stat(full)
|
||||
except OSError:
|
||||
skipped += 1
|
||||
continue
|
||||
seen.add(rel)
|
||||
prev = known.get(rel)
|
||||
if prev == (st.st_mtime, st.st_size):
|
||||
continue
|
||||
text = self.read_text(full)
|
||||
if text is None:
|
||||
skipped += 1
|
||||
continue
|
||||
title = os.path.basename(rel)
|
||||
self.engine.upsert(rel, title, text, st.st_mtime, st.st_size)
|
||||
if prev is None:
|
||||
added += 1
|
||||
else:
|
||||
updated += 1
|
||||
removed = 0
|
||||
for rel in known:
|
||||
if rel not in seen:
|
||||
self.engine.remove(rel)
|
||||
removed += 1
|
||||
self.engine.commit()
|
||||
return {"added": added, "updated": updated, "removed": removed,
|
||||
"skipped": skipped, "total": len(seen)}
|
||||
|
||||
|
||||
class SearchHandler(BaseHTTPRequestHandler):
|
||||
server_version = "TaiSearch/1.0"
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
pass
|
||||
|
||||
def _send(self, code, body=b"", ctype="text/plain; charset=utf-8"):
|
||||
if isinstance(body, str):
|
||||
body = body.encode("utf-8")
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
if self.command != "HEAD" and body:
|
||||
self.wfile.write(body)
|
||||
|
||||
def _json(self, code, obj):
|
||||
self._send(code, json.dumps(obj), "application/json")
|
||||
|
||||
def do_GET(self):
|
||||
try:
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path == "/":
|
||||
self._send(200, SEARCH_HTML, "text/html; charset=utf-8")
|
||||
return
|
||||
if parsed.path == "/api/stats":
|
||||
self._json(200, self.server.engine.stats())
|
||||
return
|
||||
if parsed.path == "/api/search":
|
||||
qs = parse_qs(parsed.query)
|
||||
try:
|
||||
limit = int(qs.get("limit", ["20"])[0])
|
||||
offset = int(qs.get("offset", ["0"])[0])
|
||||
except ValueError:
|
||||
limit, offset = 20, 0
|
||||
total, items = self.server.engine.search(
|
||||
qs.get("q", [""])[0], limit=limit, offset=offset)
|
||||
self._json(200, {"total": total, "limit": limit,
|
||||
"offset": offset, "items": items})
|
||||
return
|
||||
self._send(404, "not found\n")
|
||||
except BrokenPipeError:
|
||||
pass
|
||||
|
||||
|
||||
SEARCH_HTML = """<!DOCTYPE html>
|
||||
<html lang="nl"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Zoeken</title>
|
||||
<style>
|
||||
*{box-sizing:border-box}
|
||||
body{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;margin:0;
|
||||
color:#1a1a1a;padding:0 1em 3em}
|
||||
main{max-width:44em;margin:0 auto}
|
||||
h1{font-size:1.4em;margin:1em 0 .2em}
|
||||
p.sub{color:#666;margin:.2em 0 1em;font-size:.9em}
|
||||
form{display:flex;gap:.5em}
|
||||
form input{flex:1;padding:.6em;font-size:1em;border:1px solid #bbb;
|
||||
border-radius:6px}
|
||||
form button{padding:.6em 1em;font-size:1em;border:1px solid #bbb;
|
||||
background:#f4f4f4;border-radius:6px;cursor:pointer}
|
||||
#meta{color:#666;font-size:.9em;margin:.8em 0}
|
||||
ol{list-style:none;margin:0;padding:0}
|
||||
li{padding:.7em 0;border-bottom:1px solid #e4e4e4}
|
||||
li .path{font-weight:700;overflow-wrap:break-word}
|
||||
li .path span{font-weight:400;color:#666;font-size:.85em}
|
||||
li .snip{margin-top:.3em;color:#333;overflow-wrap:break-word}
|
||||
li .snip b{background:#ffe58a;font-weight:700}
|
||||
.pager{display:flex;gap:.5em;align-items:center;justify-content:center;
|
||||
margin:1.2em 0;color:#666}
|
||||
.pager button{padding:.5em .9em;font-size:.95em;border:1px solid #bbb;
|
||||
background:#f4f4f4;border-radius:6px;cursor:pointer}
|
||||
@media(max-width:30em){form{flex-direction:column}}
|
||||
</style></head><body><main>
|
||||
<h1>Zoeken</h1><p class="sub" id="stats"></p>
|
||||
<form id="f"><input id="q" placeholder="zoekopdracht..." autocomplete="off">
|
||||
<button type="submit">Zoek</button></form>
|
||||
<p id="meta"></p><ol id="r"></ol>
|
||||
<div class="pager"><button id="prev">Vorige</button><span id="count"></span>
|
||||
<button id="next">Volgende</button></div>
|
||||
<script>
|
||||
"use strict";
|
||||
var off=0,LIM=20,total=0;
|
||||
function load(){
|
||||
var q=document.getElementById("q").value;
|
||||
fetch("api/search?q="+encodeURIComponent(q)+"&limit="+LIM+"&offset="+off)
|
||||
.then(function(r){return r.json();}).then(function(d){
|
||||
total=d.total;var ol=document.getElementById("r");ol.innerHTML="";
|
||||
d.items.forEach(function(it){
|
||||
var li=document.createElement("li");
|
||||
// snippet bevat alleen <b>-tags van SQLite zelf; pad wel escapen
|
||||
var div=document.createElement("div");div.textContent=it.path;
|
||||
li.innerHTML='<div class="path"></div><div class="snip">'+it.snippet+"</div>";
|
||||
li.querySelector(".path").appendChild(div.firstChild);
|
||||
var s=document.createElement("span");
|
||||
s.textContent=" ("+it.size+" B)";
|
||||
li.querySelector(".path").appendChild(s);
|
||||
ol.appendChild(li);});
|
||||
document.getElementById("meta").textContent=
|
||||
total+(total===1?" resultaat":" resultaten");
|
||||
document.getElementById("count").textContent=
|
||||
(total?off+1:0)+"-"+Math.min(off+LIM,total)+" van "+total;
|
||||
document.getElementById("prev").disabled=off<=0;
|
||||
document.getElementById("next").disabled=off+LIM>=total;});}
|
||||
document.getElementById("f").addEventListener("submit",function(e){
|
||||
e.preventDefault();off=0;load();});
|
||||
document.getElementById("prev").addEventListener("click",function(){
|
||||
off=Math.max(0,off-LIM);load();});
|
||||
document.getElementById("next").addEventListener("click",function(){
|
||||
off+=LIM;load();});
|
||||
fetch("api/stats").then(function(r){return r.json();}).then(function(s){
|
||||
document.getElementById("stats").textContent=
|
||||
s.docs+" documenten geindexeerd"+(s.indexed_at?" ("+s.indexed_at+")":"");});
|
||||
</script></main></body></html>
|
||||
"""
|
||||
|
||||
|
||||
class SearchServer(socketserver.ThreadingMixIn, HTTPServer):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
|
||||
|
||||
def default_db():
|
||||
home = os.environ.get("TAI_HOME") or os.path.expanduser("~/.tai")
|
||||
try:
|
||||
os.makedirs(home, exist_ok=True)
|
||||
return os.path.join(home, "fts-example.db")
|
||||
except OSError:
|
||||
return os.path.abspath("./fts-example.db")
|
||||
|
||||
|
||||
def cmd_index(args):
|
||||
if not os.path.isdir(args.dir):
|
||||
raise SearchError("geen directory: %s" % args.dir)
|
||||
engine = SearchEngine(args.db)
|
||||
result = DocIndexer(engine).index_dir(args.dir)
|
||||
print("index: %(added)d nieuw, %(updated)d bijgewerkt, "
|
||||
"%(removed)d weg, %(skipped)d overgeslagen "
|
||||
"(totaal %(total)d) -> %(db)s" % dict(result, db=args.db))
|
||||
|
||||
|
||||
def cmd_search(args):
|
||||
if not os.path.exists(args.db):
|
||||
raise SearchError("geen index (%s). Run eerst: index <dir>" % args.db)
|
||||
engine = SearchEngine(args.db)
|
||||
total, items = engine.search(args.query, limit=args.limit,
|
||||
offset=args.offset)
|
||||
print("%d resultaat(en) voor %r:" % (total, args.query))
|
||||
for item in items:
|
||||
clean = item["snippet"].replace("<b>", "").replace("</b>", "")
|
||||
print(" [%.2f] %s\n %s" % (item["rank"], item["path"], clean))
|
||||
|
||||
|
||||
def cmd_serve(args):
|
||||
if not os.path.exists(args.db):
|
||||
raise SearchError("geen index (%s). Run eerst: index <dir>" % args.db)
|
||||
engine = SearchEngine(args.db)
|
||||
stats = engine.stats()
|
||||
server = SearchServer((args.host, args.port), SearchHandler)
|
||||
server.engine = engine
|
||||
print("search: http://%s:%d/ (%d docs)" % (args.host, args.port,
|
||||
stats["docs"]))
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(
|
||||
description="SQLite FTS5 full-text search over een directory.")
|
||||
parser.add_argument("--db", default=None, help="sqlite index file")
|
||||
sub = parser.add_subparsers(dest="cmd") # 3.6: geen required-kwarg
|
||||
sub.required = True
|
||||
p_index = sub.add_parser("index", help="directory indexeren")
|
||||
p_index.add_argument("dir")
|
||||
p_search = sub.add_parser("search", help="zoeken op CLI")
|
||||
p_search.add_argument("query")
|
||||
p_search.add_argument("--limit", type=int, default=20)
|
||||
p_search.add_argument("--offset", type=int, default=0)
|
||||
p_serve = sub.add_parser("serve", help="HTTP UI + JSON API starten")
|
||||
p_serve.add_argument("--host", default="127.0.0.1")
|
||||
p_serve.add_argument("--port", type=int, default=8083)
|
||||
args = parser.parse_args(argv)
|
||||
if args.db is None:
|
||||
args.db = default_db()
|
||||
try:
|
||||
if args.cmd == "index":
|
||||
cmd_index(args)
|
||||
elif args.cmd == "search":
|
||||
cmd_search(args)
|
||||
elif args.cmd == "serve":
|
||||
cmd_serve(args)
|
||||
except SearchError as exc:
|
||||
parser.error(str(exc))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
Executable
+310
@@ -0,0 +1,310 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Round-robin reverse proxy met healthchecks. Stdlib only, single file, 3.6+.
|
||||
|
||||
Run:
|
||||
python3 reverse-proxy.py --backends http://127.0.0.1:9001,http://127.0.0.1:9002
|
||||
python3 reverse-proxy.py --backends http://127.0.0.1:9001 --port 8080 --health-path /healthz
|
||||
|
||||
Spec (dit bestand is de spec):
|
||||
|
||||
Gedrag:
|
||||
- Round-robin over gezonde backends (alleen http, geen https-targets).
|
||||
- Actieve healthcheck: GET {health-path} elke --health-interval s;
|
||||
2xx/3xx is gezond. Ongezonde backend tijdelijk uit rotatie.
|
||||
- Passieve healthcheck: connect/read-fout markeert backend direct down
|
||||
en de request failovert naar de volgende gezonde backend.
|
||||
- Retries: maximaal N pogingen (N = aantal backends), nooit een body
|
||||
tweemaal sturen bij POST/PUT tenzij klein genoeg om te bufferen.
|
||||
- Hop-header hygiene: Connection, Keep-Alive, Proxy-*, TE, Trailer,
|
||||
Transfer-Encoding en Upgrade worden nooit doorgestuurd.
|
||||
- X-Forwarded-For (client-IP appended), X-Forwarded-Proto, X-Forwarded-Host
|
||||
en Via worden gezet.
|
||||
- GET /__proxy__/status: JSON met backend-staten (alleen dit pad is
|
||||
van de proxy zelf; de rest wordt 1-op-1 doorgezet).
|
||||
|
||||
Caps: MAX_BODY 10 MiB, TIMEOUT 10 s (connect) / 30 s (read),
|
||||
HEALTH_INTERVAL 10 s, HEALTH_TIMEOUT 3 s, MAX_BACKENDS 32.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import socketserver
|
||||
import threading
|
||||
import time
|
||||
from http.client import HTTPConnection, HTTPException
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from urllib.parse import urlparse
|
||||
|
||||
HOP_HEADERS = frozenset([
|
||||
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
||||
"te", "trailer", "transfer-encoding", "upgrade",
|
||||
])
|
||||
MAX_BODY = 10 * 1024 * 1024
|
||||
CONNECT_TIMEOUT = 10
|
||||
READ_TIMEOUT = 30
|
||||
|
||||
|
||||
class Backend(object):
|
||||
"""Een upstream + zijn gezondheidsstaat."""
|
||||
|
||||
def __init__(self, url):
|
||||
parsed = urlparse(url.strip())
|
||||
if parsed.scheme != "http" or not parsed.hostname:
|
||||
raise ValueError("backend must be http://host[:port][/prefix]: %s"
|
||||
% url)
|
||||
self.name = url.strip()
|
||||
self.host = parsed.hostname
|
||||
self.port = parsed.port or 80
|
||||
self.prefix = parsed.path.rstrip("/")
|
||||
self._lock = threading.RLock()
|
||||
self.healthy = True
|
||||
self.last_check = 0.0
|
||||
self.last_error = ""
|
||||
self.requests = 0
|
||||
self.failures = 0
|
||||
|
||||
def mark(self, healthy, error=""):
|
||||
with self._lock:
|
||||
self.healthy = healthy
|
||||
self.last_check = time.time()
|
||||
self.last_error = error[:200] if error else ""
|
||||
if healthy:
|
||||
self.requests += 1
|
||||
else:
|
||||
self.failures += 1
|
||||
|
||||
def status(self):
|
||||
with self._lock:
|
||||
return {"name": self.name, "healthy": self.healthy,
|
||||
"last_check": self.last_check,
|
||||
"last_error": self.last_error,
|
||||
"requests": self.requests, "failures": self.failures}
|
||||
|
||||
|
||||
class BackendPool(object):
|
||||
"""Round-robin over gezonde backends. Thread-safe."""
|
||||
|
||||
def __init__(self, backends):
|
||||
self.backends = backends
|
||||
self._lock = threading.RLock()
|
||||
self._next = 0
|
||||
|
||||
def pick_order(self):
|
||||
"""Volgorde voor deze request: rotatie-start, gezonde eerst."""
|
||||
with self._lock:
|
||||
backends = list(self.backends)
|
||||
start = self._next % len(backends)
|
||||
self._next += 1
|
||||
ordered = backends[start:] + backends[:start]
|
||||
healthy = [b for b in ordered if b.healthy]
|
||||
return healthy + [b for b in ordered if not b.healthy]
|
||||
|
||||
def status(self):
|
||||
return {"backends": [b.status() for b in self.backends]}
|
||||
|
||||
|
||||
class HealthChecker(threading.Thread):
|
||||
"""Achtergrondlus: pollt elke interval het health-path per backend."""
|
||||
|
||||
daemon = True
|
||||
|
||||
def __init__(self, pool, path="/", interval=10, timeout=3):
|
||||
super(HealthChecker, self).__init__(name="healthchecker")
|
||||
self.pool = pool
|
||||
self.path = path or "/"
|
||||
self.interval = max(1, interval)
|
||||
self.timeout = timeout
|
||||
self._stop = threading.Event()
|
||||
|
||||
def stop(self):
|
||||
self._stop.set()
|
||||
|
||||
def run(self):
|
||||
while not self._stop.wait(self.interval):
|
||||
for backend in self.pool.backends:
|
||||
self.check_once(backend)
|
||||
|
||||
def check_once(self, backend):
|
||||
try:
|
||||
conn = HTTPConnection(backend.host, backend.port,
|
||||
timeout=self.timeout)
|
||||
try:
|
||||
conn.request("GET", backend.prefix + self.path,
|
||||
headers={"Connection": "close",
|
||||
"Host": backend.host})
|
||||
resp = conn.getresponse()
|
||||
resp.read(65536)
|
||||
ok = 200 <= resp.status < 400
|
||||
backend.mark(True if ok else False,
|
||||
"" if ok else "health %d" % resp.status)
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
backend.mark(False, "%s: %s" % (type(exc).__name__, exc))
|
||||
|
||||
|
||||
class ProxyHandler(BaseHTTPRequestHandler):
|
||||
server_version = "TaiProxy/1.0"
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
pass
|
||||
|
||||
def _send(self, code, body=b"", ctype="text/plain; charset=utf-8"):
|
||||
if isinstance(body, str):
|
||||
body = body.encode("utf-8")
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
if self.command != "HEAD" and body:
|
||||
self.wfile.write(body)
|
||||
|
||||
def _forward_headers(self):
|
||||
client_ip = self.client_address[0]
|
||||
fwd = self.headers.get("X-Forwarded-For", "")
|
||||
out = {}
|
||||
for key, value in self.headers.items():
|
||||
if key.lower() in HOP_HEADERS:
|
||||
continue
|
||||
out[key] = value
|
||||
out["X-Forwarded-For"] = (fwd + ", " + client_ip) if fwd else client_ip
|
||||
out["X-Forwarded-Proto"] = "http"
|
||||
out["X-Forwarded-Host"] = self.headers.get("Host", "")
|
||||
via = self.headers.get("Via", "")
|
||||
out["Via"] = (via + ", " if via else "") + "1.1 TaiProxy"
|
||||
out["Connection"] = "close"
|
||||
return out
|
||||
|
||||
def _read_body(self):
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
except ValueError:
|
||||
return None
|
||||
if length < 0 or length > MAX_BODY:
|
||||
return None
|
||||
if length == 0:
|
||||
return b""
|
||||
return self.rfile.read(length)
|
||||
|
||||
def _try_backend(self, backend, path, body, headers):
|
||||
target = backend.prefix + path
|
||||
conn = HTTPConnection(backend.host, backend.port,
|
||||
timeout=CONNECT_TIMEOUT)
|
||||
try:
|
||||
out_headers = dict(headers)
|
||||
out_headers["Host"] = backend.host + (
|
||||
"" if backend.port == 80 else ":%d" % backend.port)
|
||||
conn.request(self.command, target, body=body,
|
||||
headers=out_headers)
|
||||
conn.sock.settimeout(READ_TIMEOUT)
|
||||
resp = conn.getresponse()
|
||||
self.send_response(resp.status, resp.reason)
|
||||
for key, value in resp.getheaders():
|
||||
if key.lower() in HOP_HEADERS:
|
||||
continue
|
||||
self.send_header(key, value)
|
||||
self.send_header("Via", "1.1 TaiProxy")
|
||||
self.end_headers()
|
||||
if self.command != "HEAD":
|
||||
while True:
|
||||
chunk = resp.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
self.wfile.write(chunk)
|
||||
backend.mark(True)
|
||||
return True
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _proxy(self):
|
||||
pool = self.server.pool # type: BackendPool
|
||||
if self.path == "/__proxy__/status" and self.command == "GET":
|
||||
self._send(200, json.dumps(pool.status()), "application/json")
|
||||
return
|
||||
body = self._read_body()
|
||||
if body is None:
|
||||
self._send(413, "body too large or bad length (max 10 MiB)")
|
||||
return
|
||||
headers = self._forward_headers()
|
||||
errors = []
|
||||
for backend in pool.pick_order():
|
||||
try:
|
||||
if self._try_backend(backend, self.path, body, headers):
|
||||
return
|
||||
except (HTTPException, OSError) as exc:
|
||||
backend.mark(False, "%s: %s" % (type(exc).__name__, exc))
|
||||
errors.append("%s: %s" % (backend.name, exc))
|
||||
continue
|
||||
except BrokenPipeError:
|
||||
return
|
||||
self._send(502, "no healthy backend (%s)" % "; ".join(errors))
|
||||
|
||||
def _dispatch(self):
|
||||
try:
|
||||
self._proxy()
|
||||
except BrokenPipeError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
try:
|
||||
self._send(500, "proxy error: %s" % exc)
|
||||
except (BrokenPipeError, OSError):
|
||||
pass
|
||||
|
||||
do_GET = _dispatch
|
||||
do_HEAD = _dispatch
|
||||
do_POST = _dispatch
|
||||
do_PUT = _dispatch
|
||||
do_PATCH = _dispatch
|
||||
do_DELETE = _dispatch
|
||||
do_OPTIONS = _dispatch
|
||||
|
||||
|
||||
class ProxyServer(socketserver.ThreadingMixIn, HTTPServer):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Round-robin reverse proxy met healthchecks.")
|
||||
parser.add_argument("--backends", required=True,
|
||||
help="comma-separated http://host:port[/prefix]")
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=8080)
|
||||
parser.add_argument("--health-path", default="/",
|
||||
help="pad voor healthchecks (default /)")
|
||||
parser.add_argument("--health-interval", type=int, default=10,
|
||||
help="seconden tussen healthchecks (default 10)")
|
||||
args = parser.parse_args(argv)
|
||||
urls = [u for u in args.backends.split(",") if u.strip()]
|
||||
if not urls or len(urls) > 32:
|
||||
parser.error("need 1..32 backends")
|
||||
try:
|
||||
backends = [Backend(u) for u in urls]
|
||||
except ValueError as exc:
|
||||
parser.error(str(exc))
|
||||
pool = BackendPool(backends)
|
||||
server = ProxyServer((args.host, args.port), ProxyHandler)
|
||||
server.pool = pool
|
||||
checker = HealthChecker(pool, path=args.health_path,
|
||||
interval=args.health_interval)
|
||||
for backend in backends:
|
||||
checker.check_once(backend)
|
||||
checker.start()
|
||||
print("proxy: http://%s:%d/ -> %s" % (args.host, args.port,
|
||||
", ".join(urls)))
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
checker.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
Executable
+435
@@ -0,0 +1,435 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Multi-room chat met Server-Sent Events. Stdlib only, single file, 3.6+.
|
||||
|
||||
Run:
|
||||
python3 sse-chat.py --port 8082
|
||||
|
||||
Open http://127.0.0.1:8082/ in twee browsers en chat. Geen JS-deps:
|
||||
alleen EventSource + fetch.
|
||||
|
||||
Spec (dit bestand is de spec):
|
||||
|
||||
Endpoint Method Statusen Beschrijving
|
||||
/api/rooms GET 200 Kamers + aantallen
|
||||
/api/rooms POST 201/400 Kamer aanmaken {"room"}
|
||||
/api/rooms/{room}/messages GET 200/404 History (?since=&limit=)
|
||||
/api/rooms/{room}/messages POST 201/400/404 Bericht {"nick","text"}
|
||||
/events?room={room} GET 200/404 SSE-stream (id/event/retry)
|
||||
/ GET 200 Embedded UI
|
||||
/healthz GET 200 {"ok":true,...}
|
||||
|
||||
SSE-events: "message" (JSON-bericht), "hello" (laatste id bij connect).
|
||||
Reconnect: Last-Event-ID-header of ?since= wordt gerespecteerd, gemiste
|
||||
berichten worden nagezonden. Heartbeat: ": ping" elke 15 s.
|
||||
|
||||
Kamernamen: ^[a-z0-9-]{1,32}$. Standaardkamers: lobby, random, tech.
|
||||
|
||||
Caps: MAX_ROOMS 50, MAX_HISTORY 200/room, MAX_NICK 32, MAX_TEXT 2000,
|
||||
MAX_BODY 8 KiB, HEARTBEAT 15 s.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import socketserver
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
try:
|
||||
from queue import Queue, Empty
|
||||
except ImportError: # Python 2 fallback, nooit gebruikt maar goedkoop
|
||||
from Queue import Queue, Empty
|
||||
|
||||
ROOM_RE = re.compile(r"^[a-z0-9-]{1,32}$")
|
||||
|
||||
|
||||
class ChatError(Exception):
|
||||
def __init__(self, status, message):
|
||||
super(ChatError, self).__init__(message)
|
||||
self.status = status
|
||||
self.message = message
|
||||
|
||||
|
||||
class ChatStore(object):
|
||||
"""Kamers, history en SSE-abonnees. Alles achter een RLock."""
|
||||
|
||||
MAX_ROOMS = 50
|
||||
MAX_HISTORY = 200
|
||||
MAX_SUBSCRIBERS = 500
|
||||
DEFAULT_ROOMS = ("lobby", "random", "tech")
|
||||
|
||||
def __init__(self):
|
||||
self._lock = threading.RLock()
|
||||
self._rooms = {}
|
||||
self._subs = {}
|
||||
for room in self.DEFAULT_ROOMS:
|
||||
self._rooms[room] = {"messages": deque(maxlen=self.MAX_HISTORY),
|
||||
"next_id": 1}
|
||||
|
||||
@staticmethod
|
||||
def _now():
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
def rooms(self):
|
||||
with self._lock:
|
||||
return [{"room": name, "messages": len(info["messages"])}
|
||||
for name, info in sorted(self._rooms.items())]
|
||||
|
||||
def create_room(self, room):
|
||||
if not isinstance(room, str) or not ROOM_RE.match(room):
|
||||
raise ChatError(400, "bad room (want [a-z0-9-]{1,32})")
|
||||
with self._lock:
|
||||
if room in self._rooms:
|
||||
raise ChatError(409, "room exists")
|
||||
if len(self._rooms) >= self.MAX_ROOMS:
|
||||
raise ChatError(507, "too many rooms")
|
||||
self._rooms[room] = {"messages": deque(maxlen=self.MAX_HISTORY),
|
||||
"next_id": 1}
|
||||
return {"room": room, "messages": 0}
|
||||
|
||||
def _require(self, room):
|
||||
if room not in self._rooms:
|
||||
raise ChatError(404, "no such room")
|
||||
return self._rooms[room]
|
||||
|
||||
def history(self, room, since=0, limit=100):
|
||||
with self._lock:
|
||||
info = self._require(room)
|
||||
items = [m for m in info["messages"] if m["id"] > since]
|
||||
return items[-limit:]
|
||||
|
||||
def post(self, room, nick, text):
|
||||
if not isinstance(nick, str) or not 1 <= len(nick.strip()) <= 32:
|
||||
raise ChatError(400, "nick must be 1..32 chars")
|
||||
if not isinstance(text, str) or not 1 <= len(text.strip()) <= 2000:
|
||||
raise ChatError(400, "text must be 1..2000 chars")
|
||||
with self._lock:
|
||||
info = self._require(room)
|
||||
msg = {"id": info["next_id"], "room": room,
|
||||
"nick": nick.strip()[:32], "text": text.strip()[:2000],
|
||||
"ts": self._now()}
|
||||
info["next_id"] += 1
|
||||
info["messages"].append(msg)
|
||||
subs = list(self._subs.get(room, []))
|
||||
for queue in subs:
|
||||
try:
|
||||
queue.put_nowait(msg)
|
||||
except Exception:
|
||||
pass
|
||||
return msg
|
||||
|
||||
def subscribe(self, room):
|
||||
with self._lock:
|
||||
self._require(room)
|
||||
total = sum(len(v) for v in self._subs.values())
|
||||
if total >= self.MAX_SUBSCRIBERS:
|
||||
raise ChatError(503, "too many subscribers")
|
||||
queue = Queue(maxsize=200)
|
||||
self._subs.setdefault(room, []).append(queue)
|
||||
return queue
|
||||
|
||||
def unsubscribe(self, room, queue):
|
||||
with self._lock:
|
||||
subs = self._subs.get(room, [])
|
||||
if queue in subs:
|
||||
subs.remove(queue)
|
||||
|
||||
|
||||
class ChatHandler(BaseHTTPRequestHandler):
|
||||
server_version = "TaiChat/1.0"
|
||||
protocol_version = "HTTP/1.1"
|
||||
MAX_BODY = 8 * 1024
|
||||
HEARTBEAT = 15
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
pass
|
||||
|
||||
def _send(self, code, body=b"", ctype="text/plain; charset=utf-8"):
|
||||
if isinstance(body, str):
|
||||
body = body.encode("utf-8")
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
if self.command != "HEAD" and body:
|
||||
self.wfile.write(body)
|
||||
|
||||
def _json(self, code, obj):
|
||||
self._send(code, json.dumps(obj), "application/json")
|
||||
|
||||
def _read_json(self):
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
except ValueError:
|
||||
raise ChatError(400, "bad Content-Length")
|
||||
if length < 0 or length > self.MAX_BODY:
|
||||
raise ChatError(413, "body too large")
|
||||
raw = self.rfile.read(length) if length else b""
|
||||
try:
|
||||
data = json.loads((raw or b"{}").decode("utf-8"))
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
raise ChatError(400, "body is not valid JSON")
|
||||
if not isinstance(data, dict):
|
||||
raise ChatError(400, "body must be a JSON object")
|
||||
return data
|
||||
|
||||
def _events(self, room, since):
|
||||
store = self.server.chat # type: ChatStore
|
||||
try:
|
||||
queue = store.subscribe(room)
|
||||
except ChatError as exc:
|
||||
self._json(exc.status, {"error": exc.message})
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.send_header("Connection", "keep-alive")
|
||||
self.send_header("X-Accel-Buffering", "no")
|
||||
self.end_headers()
|
||||
try:
|
||||
self._sse("hello", {"room": room, "since": since})
|
||||
for msg in store.history(room, since=since, limit=200):
|
||||
self._sse("message", msg, msg["id"])
|
||||
while True:
|
||||
try:
|
||||
msg = queue.get(timeout=self.HEARTBEAT)
|
||||
except Empty:
|
||||
self.wfile.write(b": ping\n\n")
|
||||
self.wfile.flush()
|
||||
continue
|
||||
if msg["id"] <= since:
|
||||
continue
|
||||
since = msg["id"]
|
||||
self._sse("message", msg, msg["id"])
|
||||
except (BrokenPipeError, ConnectionResetError, OSError):
|
||||
pass
|
||||
finally:
|
||||
store.unsubscribe(room, queue)
|
||||
|
||||
def _sse(self, event, obj, msg_id=None):
|
||||
lines = ["event: %s" % event]
|
||||
if msg_id is not None:
|
||||
lines.append("id: %d" % msg_id)
|
||||
lines.append("retry: 3000")
|
||||
for chunk in json.dumps(obj).split("\n"):
|
||||
lines.append("data: %s" % chunk)
|
||||
self.wfile.write(("\n".join(lines) + "\n\n").encode("utf-8"))
|
||||
self.wfile.flush()
|
||||
|
||||
def _handle(self):
|
||||
store = self.server.chat # type: ChatStore
|
||||
parsed = urlparse(self.path)
|
||||
parts = [p for p in parsed.path.split("/") if p]
|
||||
method = self.command
|
||||
if not parts:
|
||||
if method != "GET":
|
||||
raise ChatError(405, "method not allowed")
|
||||
self._send(200, CHAT_HTML, "text/html; charset=utf-8")
|
||||
return
|
||||
if parts == ["healthz"]:
|
||||
rooms = store.rooms()
|
||||
self._json(200, {"ok": True, "rooms": len(rooms),
|
||||
"messages": sum(r["messages"] for r in rooms)})
|
||||
return
|
||||
if parts == ["events"] and method == "GET":
|
||||
qs = parse_qs(parsed.query)
|
||||
room = qs.get("room", ["lobby"])[0]
|
||||
since = qs.get("since", ["0"])[0]
|
||||
last_id = self.headers.get("Last-Event-ID", "").strip()
|
||||
if last_id.isdigit():
|
||||
since = last_id
|
||||
try:
|
||||
since = max(0, int(since))
|
||||
except ValueError:
|
||||
raise ChatError(400, "bad since")
|
||||
self._events(room, since)
|
||||
return
|
||||
if parts[0] != "api" or len(parts) < 2 or parts[1] != "rooms":
|
||||
raise ChatError(404, "unknown endpoint")
|
||||
if len(parts) == 2:
|
||||
if method == "GET":
|
||||
self._json(200, {"rooms": store.rooms()})
|
||||
elif method == "POST":
|
||||
data = self._read_json()
|
||||
self._json(201, store.create_room(data.get("room", "")))
|
||||
else:
|
||||
raise ChatError(405, "method not allowed")
|
||||
return
|
||||
room = parts[2]
|
||||
if len(parts) == 4 and parts[3] == "messages":
|
||||
if method == "GET":
|
||||
qs = parse_qs(parsed.query)
|
||||
try:
|
||||
since = max(0, int(qs.get("since", ["0"])[0]))
|
||||
limit = min(max(int(qs.get("limit", ["100"])[0]), 1), 200)
|
||||
except ValueError:
|
||||
raise ChatError(400, "bad since/limit")
|
||||
self._json(200, {"room": room, "messages": store.history(
|
||||
room, since=since, limit=limit)})
|
||||
elif method == "POST":
|
||||
data = self._read_json()
|
||||
self._json(201, store.post(room, data.get("nick", ""),
|
||||
data.get("text", "")))
|
||||
else:
|
||||
raise ChatError(405, "method not allowed")
|
||||
return
|
||||
raise ChatError(404, "unknown endpoint")
|
||||
|
||||
def _dispatch(self):
|
||||
try:
|
||||
self._handle()
|
||||
except ChatError as exc:
|
||||
self._json(exc.status, {"error": exc.message})
|
||||
except BrokenPipeError:
|
||||
pass
|
||||
|
||||
do_GET = _dispatch
|
||||
do_HEAD = _dispatch
|
||||
do_POST = _dispatch
|
||||
|
||||
|
||||
CHAT_HTML = """<!DOCTYPE html>
|
||||
<html lang="nl"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Chat</title>
|
||||
<style>
|
||||
*{box-sizing:border-box}
|
||||
body{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;margin:0;
|
||||
color:#1a1a1a;display:flex;height:100vh}
|
||||
aside{width:12em;flex-shrink:0;border-right:1px solid #ddd;padding:1em;
|
||||
overflow-y:auto}
|
||||
aside h2{font-size:.85em;text-transform:uppercase;letter-spacing:.04em;
|
||||
color:#666;margin:0 0 .6em}
|
||||
aside ul{list-style:none;margin:0;padding:0}
|
||||
aside li button{width:100%;text-align:left;padding:.5em .6em;border:none;
|
||||
background:none;font-size:1em;border-radius:6px;cursor:pointer}
|
||||
aside li button[aria-current=true]{background:#1a1a1a;color:#fff}
|
||||
aside form{display:flex;gap:.4em;margin-top:.8em}
|
||||
aside form input{flex:1;min-width:0;padding:.4em;font-size:.9em;
|
||||
border:1px solid #ddd;border-radius:6px}
|
||||
main{flex:1;display:flex;flex-direction:column;min-width:0}
|
||||
header{padding:.7em 1em;border-bottom:1px solid #ddd;display:flex;
|
||||
align-items:center;gap:.6em}
|
||||
header h1{font-size:1.1em;margin:0}
|
||||
header .status{font-size:.8em;color:#666;margin-left:auto}
|
||||
.roomselect{display:none;padding:.6em 1em;border-bottom:1px solid #ddd}
|
||||
.roomselect select{width:100%;padding:.5em;font-size:1em}
|
||||
#log{flex:1;overflow-y:auto;padding:1em;margin:0;list-style:none}
|
||||
#log li{margin-bottom:.55em;overflow-wrap:break-word}
|
||||
#log .nick{font-weight:700}
|
||||
#log .ts{color:#888;font-size:.8em;margin-left:.5em}
|
||||
#log .sys{color:#666;font-style:italic}
|
||||
form.send{display:flex;gap:.5em;padding:.8em;border-top:1px solid #ddd}
|
||||
form.send input{padding:.6em;font-size:1em;border:1px solid #ddd;
|
||||
border-radius:6px}
|
||||
form.send #nick{width:7em;flex-shrink:0}
|
||||
form.send #text{flex:1;min-width:0}
|
||||
@media(max-width:40em){
|
||||
aside{display:none}
|
||||
.roomselect{display:block}
|
||||
form.send #nick{width:5em}
|
||||
}
|
||||
</style></head><body>
|
||||
<aside><h2>Kamers</h2><ul id="rooms"></ul>
|
||||
<form id="newroom"><input id="roomname" maxlength="32" placeholder="nieuwe-kamer"
|
||||
pattern="[a-z0-9-]{1,32}"><button type="submit">+</button></form></aside>
|
||||
<main>
|
||||
<header><h1 id="title">#lobby</h1><span class="status" id="status">verbinden...</span></header>
|
||||
<div class="roomselect"><select id="roompick" aria-label="Kamer"></select></div>
|
||||
<ul id="log"></ul>
|
||||
<form class="send" id="send"><input id="nick" maxlength="32" placeholder="naam"
|
||||
autocomplete="off"><input id="text" maxlength="2000" placeholder="bericht..."
|
||||
autocomplete="off"><button type="submit">Stuur</button></form>
|
||||
</main>
|
||||
<script>
|
||||
"use strict";
|
||||
var room="lobby",es=null,lastId=0;
|
||||
function esc(s){return s.replace(/[&<>"]/g,function(c){
|
||||
return {"&":"&","<":"<",">":">",'"':"""}[c];});}
|
||||
function addMsg(m){
|
||||
lastId=Math.max(lastId,m.id);
|
||||
var li=document.createElement("li");
|
||||
var d=new Date(m.ts.replace("Z","+00:00"));
|
||||
var t=("0"+d.getHours()).slice(-2)+":"+("0"+d.getMinutes()).slice(-2);
|
||||
li.innerHTML='<span class="nick">'+esc(m.nick)+"</span> "+
|
||||
esc(m.text)+'<span class="ts">'+t+"</span>";
|
||||
var log=document.getElementById("log");
|
||||
log.appendChild(li);log.scrollTop=log.scrollHeight;}
|
||||
function sys(t){var li=document.createElement("li");
|
||||
li.innerHTML='<span class="sys">'+esc(t)+"</span>";
|
||||
var log=document.getElementById("log");
|
||||
log.appendChild(li);log.scrollTop=log.scrollHeight;}
|
||||
function connect(){
|
||||
if(es)es.close();
|
||||
document.getElementById("log").innerHTML="";lastId=0;
|
||||
document.getElementById("status").textContent="verbinden...";
|
||||
es=new EventSource("events?room="+encodeURIComponent(room));
|
||||
es.addEventListener("hello",function(){
|
||||
document.getElementById("status").textContent="live";});
|
||||
es.addEventListener("message",function(e){addMsg(JSON.parse(e.data));});
|
||||
es.onerror=function(){
|
||||
document.getElementById("status").textContent="opnieuw verbinden...";};}
|
||||
function loadRooms(){
|
||||
fetch("api/rooms").then(function(r){return r.json();}).then(function(d){
|
||||
var ul=document.getElementById("rooms");ul.innerHTML="";
|
||||
var pick=document.getElementById("roompick");pick.innerHTML="";
|
||||
d.rooms.forEach(function(r){
|
||||
var li=document.createElement("li");
|
||||
var b=document.createElement("button");b.textContent="#"+r.room;
|
||||
b.setAttribute("aria-current",r.room===room?"true":"false");
|
||||
b.addEventListener("click",function(){setRoom(r.room);});
|
||||
li.appendChild(b);ul.appendChild(li);
|
||||
var o=document.createElement("option");o.value=r.room;
|
||||
o.textContent="#"+r.room+" ("+r.messages+")";
|
||||
if(r.room===room)o.selected=true;pick.appendChild(o);});});}
|
||||
function setRoom(r){room=r;
|
||||
document.getElementById("title").textContent="#"+r;connect();loadRooms();}
|
||||
document.getElementById("roompick").addEventListener("change",function(e){
|
||||
setRoom(e.target.value);});
|
||||
document.getElementById("newroom").addEventListener("submit",function(e){
|
||||
e.preventDefault();var i=document.getElementById("roomname");
|
||||
if(!i.value)return;
|
||||
fetch("api/rooms",{method:"POST",headers:{"Content-Type":"application/json"},
|
||||
body:JSON.stringify({room:i.value})}).then(function(r){
|
||||
if(r.ok){i.value="";loadRooms();}else{r.json().then(function(d){
|
||||
alert(d.error);});}});});
|
||||
document.getElementById("send").addEventListener("submit",function(e){
|
||||
e.preventDefault();
|
||||
var n=document.getElementById("nick"),t=document.getElementById("text");
|
||||
if(!n.value.trim()||!t.value.trim())return;
|
||||
fetch("api/rooms/"+encodeURIComponent(room)+"/messages",{method:"POST",
|
||||
headers:{"Content-Type":"application/json"},
|
||||
body:JSON.stringify({nick:n.value,text:t.value})}).then(function(r){
|
||||
if(r.ok){t.value="";}else{r.json().then(function(d){sys(d.error);});}});});
|
||||
loadRooms();connect();
|
||||
setInterval(loadRooms,30000);
|
||||
</script></body></html>
|
||||
"""
|
||||
|
||||
|
||||
class ChatServer(socketserver.ThreadingMixIn, HTTPServer):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Multi-room SSE chat (stdlib only).")
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=8082)
|
||||
args = parser.parse_args(argv)
|
||||
server = ChatServer((args.host, args.port), ChatHandler)
|
||||
server.chat = ChatStore()
|
||||
print("chat: http://%s:%d/" % (args.host, args.port))
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
Executable
+501
@@ -0,0 +1,501 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Todo REST API + SQLite + embedded UI. Stdlib only, single file, 3.6+.
|
||||
|
||||
Run:
|
||||
python3 todo-rest-api.py --port 8081
|
||||
python3 todo-rest-api.py --port 8081 --db ./todos.db
|
||||
|
||||
Open http://127.0.0.1:8081/ voor de UI, of praat JSON tegen /api/todos.
|
||||
|
||||
Spec (dit bestand is de spec):
|
||||
|
||||
Endpoint Method Statusen Beschrijving
|
||||
/api/todos GET 200 Lijst (filter/paging/sort)
|
||||
/api/todos POST 201/400/413 Aanmaken, JSON body
|
||||
/api/todos/{id} GET 200/404 Een todo
|
||||
/api/todos/{id} PUT 200/400/404 Vervangen (volledig)
|
||||
/api/todos/{id} PATCH 200/400/404 Deels bijwerken
|
||||
/api/todos/{id} DELETE 204/404 Verwijderen
|
||||
/api/todos/{id}/toggle POST 200/404 done-flag omklappen
|
||||
/ GET 200 Embedded UI (geen deps)
|
||||
/healthz GET 200 {"ok": true, "todos": N}
|
||||
|
||||
Query params (lijst): status=open|done|all (default all), q=zoektekst,
|
||||
limit=1..100 (default 50), offset>=0 (default 0),
|
||||
sort=created|updated|priority|title (default updated),
|
||||
order=asc|desc (default desc).
|
||||
|
||||
Todo-object: {"id": int, "title": str, "notes": str, "priority": 1..5,
|
||||
"done": bool, "due": "YYYY-MM-DD"|null,
|
||||
"created_at": iso, "updated_at": iso}.
|
||||
|
||||
Caps: MAX_TITLE 200, MAX_NOTES 5000, MAX_BODY 64 KiB, MAX_LIMIT 100,
|
||||
MAX_TODOS 100000. Concurrency: een SQLite-verbinding + RLock, WAL-mode,
|
||||
busy_timeout 5s.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socketserver
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
DUE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
|
||||
|
||||
class TodoError(Exception):
|
||||
def __init__(self, status, message):
|
||||
super(TodoError, self).__init__(message)
|
||||
self.status = status
|
||||
self.message = message
|
||||
|
||||
|
||||
class TodoValidator(object):
|
||||
"""Enige plek die beslist wat een geldige todo is."""
|
||||
|
||||
MAX_TITLE = 200
|
||||
MAX_NOTES = 5000
|
||||
|
||||
@classmethod
|
||||
def clean(cls, data, partial=False):
|
||||
if not isinstance(data, dict):
|
||||
raise TodoError(400, "body must be a JSON object")
|
||||
out = {}
|
||||
if "title" in data or not partial:
|
||||
title = data.get("title", "")
|
||||
if not isinstance(title, str) or not title.strip():
|
||||
raise TodoError(400, "title is required (1..200 chars)")
|
||||
title = title.strip()
|
||||
if len(title) > cls.MAX_TITLE:
|
||||
raise TodoError(400, "title too long (max 200)")
|
||||
out["title"] = title
|
||||
if "notes" in data:
|
||||
notes = data["notes"]
|
||||
if notes is None:
|
||||
notes = ""
|
||||
if not isinstance(notes, str):
|
||||
raise TodoError(400, "notes must be a string")
|
||||
if len(notes) > cls.MAX_NOTES:
|
||||
raise TodoError(400, "notes too long (max 5000)")
|
||||
out["notes"] = notes
|
||||
if "priority" in data:
|
||||
prio = data["priority"]
|
||||
if not isinstance(prio, int) or not 1 <= prio <= 5:
|
||||
raise TodoError(400, "priority must be int 1..5")
|
||||
out["priority"] = prio
|
||||
if "done" in data:
|
||||
done = data["done"]
|
||||
if not isinstance(done, bool):
|
||||
raise TodoError(400, "done must be boolean")
|
||||
out["done"] = done
|
||||
if "due" in data:
|
||||
due = data["due"]
|
||||
if due is not None and (not isinstance(due, str)
|
||||
or not DUE_RE.match(due)):
|
||||
raise TodoError(400, "due must be YYYY-MM-DD or null")
|
||||
out["due"] = due
|
||||
return out
|
||||
|
||||
|
||||
class TodoStore(object):
|
||||
"""SQLite-opslag. Thread-safe via een RLock rond een verbinding."""
|
||||
|
||||
SCHEMA = ("CREATE TABLE IF NOT EXISTS todos ("
|
||||
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
"title TEXT NOT NULL,"
|
||||
"notes TEXT NOT NULL DEFAULT '',"
|
||||
"priority INTEGER NOT NULL DEFAULT 3,"
|
||||
"done INTEGER NOT NULL DEFAULT 0,"
|
||||
"due TEXT,"
|
||||
"created_at TEXT NOT NULL,"
|
||||
"updated_at TEXT NOT NULL)")
|
||||
INDEXES = ("CREATE INDEX IF NOT EXISTS idx_todos_done ON todos(done)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_todos_updated "
|
||||
"ON todos(updated_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_todos_priority "
|
||||
"ON todos(priority)")
|
||||
|
||||
SORTS = {"created": "created_at", "updated": "updated_at",
|
||||
"priority": "priority", "title": "title"}
|
||||
MAX_TODOS = 100000
|
||||
|
||||
def __init__(self, path):
|
||||
self._lock = threading.RLock()
|
||||
self.db = sqlite3.connect(path, check_same_thread=False)
|
||||
self.db.row_factory = sqlite3.Row
|
||||
self.db.execute("PRAGMA journal_mode=WAL")
|
||||
self.db.execute("PRAGMA busy_timeout=5000")
|
||||
self.db.execute("PRAGMA synchronous=NORMAL")
|
||||
with self.db:
|
||||
self.db.execute(self.SCHEMA)
|
||||
for index in self.INDEXES:
|
||||
self.db.execute(index)
|
||||
|
||||
@staticmethod
|
||||
def _now():
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
@staticmethod
|
||||
def _row_to_dict(row):
|
||||
return {"id": row["id"], "title": row["title"],
|
||||
"notes": row["notes"], "priority": row["priority"],
|
||||
"done": bool(row["done"]), "due": row["due"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"]}
|
||||
|
||||
def count(self):
|
||||
with self._lock:
|
||||
row = self.db.execute("SELECT COUNT(*) AS n FROM todos").fetchone()
|
||||
return row["n"]
|
||||
|
||||
def create(self, fields):
|
||||
with self._lock:
|
||||
if self.count() >= self.MAX_TODOS:
|
||||
raise TodoError(507, "todo store full")
|
||||
now = self._now()
|
||||
cur = self.db.execute(
|
||||
"INSERT INTO todos (title, notes, priority, done, due,"
|
||||
" created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(fields["title"], fields.get("notes", ""),
|
||||
fields.get("priority", 3), int(fields.get("done", False)),
|
||||
fields.get("due"), now, now))
|
||||
self.db.commit()
|
||||
return self.get(cur.lastrowid)
|
||||
|
||||
def get(self, todo_id):
|
||||
with self._lock:
|
||||
row = self.db.execute("SELECT * FROM todos WHERE id=?",
|
||||
(todo_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TodoError(404, "no such todo")
|
||||
return self._row_to_dict(row)
|
||||
|
||||
def list(self, status="all", q="", limit=50, offset=0,
|
||||
sort="updated", order="desc"):
|
||||
column = self.SORTS.get(sort, "updated_at")
|
||||
direction = "ASC" if order == "asc" else "DESC"
|
||||
where = []
|
||||
params = []
|
||||
if status == "open":
|
||||
where.append("done=0")
|
||||
elif status == "done":
|
||||
where.append("done=1")
|
||||
if q:
|
||||
where.append("(title LIKE ? OR notes LIKE ?)")
|
||||
params.extend(["%" + q + "%", "%" + q + "%"])
|
||||
sql = "SELECT * FROM todos"
|
||||
if where:
|
||||
sql += " WHERE " + " AND ".join(where)
|
||||
sql += " ORDER BY %s %s, id %s LIMIT ? OFFSET ?" % (column, direction,
|
||||
direction)
|
||||
params.extend([limit, offset])
|
||||
count_sql = "SELECT COUNT(*) AS n FROM todos"
|
||||
if where:
|
||||
count_sql += " WHERE " + " AND ".join(where)
|
||||
with self._lock:
|
||||
total = self.db.execute(count_sql, params[:-2]).fetchone()["n"]
|
||||
rows = self.db.execute(sql, params).fetchall()
|
||||
return total, [self._row_to_dict(r) for r in rows]
|
||||
|
||||
def update(self, todo_id, fields):
|
||||
with self._lock:
|
||||
current = self.get(todo_id)
|
||||
merged = {"title": current["title"], "notes": current["notes"],
|
||||
"priority": current["priority"], "done": current["done"],
|
||||
"due": current["due"]}
|
||||
merged.update(fields)
|
||||
now = self._now()
|
||||
self.db.execute(
|
||||
"UPDATE todos SET title=?, notes=?, priority=?, done=?,"
|
||||
" due=?, updated_at=? WHERE id=?",
|
||||
(merged["title"], merged["notes"], merged["priority"],
|
||||
int(merged["done"]), merged["due"], now, todo_id))
|
||||
self.db.commit()
|
||||
return self.get(todo_id)
|
||||
|
||||
def delete(self, todo_id):
|
||||
with self._lock:
|
||||
cur = self.db.execute("DELETE FROM todos WHERE id=?", (todo_id,))
|
||||
self.db.commit()
|
||||
if cur.rowcount == 0:
|
||||
raise TodoError(404, "no such todo")
|
||||
|
||||
def toggle(self, todo_id):
|
||||
with self._lock:
|
||||
current = self.get(todo_id)
|
||||
return self.update(todo_id, {"done": not current["done"]})
|
||||
|
||||
|
||||
class TodoHandler(BaseHTTPRequestHandler):
|
||||
"""Een instantie per request. Deelt de store via server.store."""
|
||||
|
||||
server_version = "TaiTodo/1.0"
|
||||
protocol_version = "HTTP/1.1"
|
||||
MAX_BODY = 64 * 1024
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
pass
|
||||
|
||||
# -- generieke hulpjes ------------------------------------------------
|
||||
|
||||
def _send(self, code, body=b"", ctype="text/plain; charset=utf-8",
|
||||
extra=None):
|
||||
if isinstance(body, str):
|
||||
body = body.encode("utf-8")
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
for key, value in (extra or {}).items():
|
||||
self.send_header(key, value)
|
||||
self.end_headers()
|
||||
if self.command != "HEAD" and body:
|
||||
self.wfile.write(body)
|
||||
|
||||
def _json(self, code, obj):
|
||||
self._send(code, json.dumps(obj), "application/json")
|
||||
|
||||
def _read_json(self):
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
except ValueError:
|
||||
raise TodoError(400, "bad Content-Length")
|
||||
if length < 0 or length > self.MAX_BODY:
|
||||
raise TodoError(413, "body too large (max 64 KiB)")
|
||||
raw = self.rfile.read(length) if length else b""
|
||||
if not raw:
|
||||
raise TodoError(400, "empty body, want JSON object")
|
||||
try:
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
raise TodoError(400, "body is not valid JSON")
|
||||
|
||||
def _route(self):
|
||||
"""Splits pad in (resource, id, action). Gooit TodoError bij 404."""
|
||||
parsed = urlparse(self.path)
|
||||
parts = [p for p in parsed.path.split("/") if p]
|
||||
if not parts:
|
||||
return ("ui", None, None, parsed)
|
||||
if parts == ["healthz"]:
|
||||
return ("healthz", None, None, parsed)
|
||||
if parts[0] != "api" or len(parts) < 2 or parts[1] != "todos":
|
||||
raise TodoError(404, "unknown endpoint")
|
||||
if len(parts) == 2:
|
||||
return ("todos", None, None, parsed)
|
||||
try:
|
||||
todo_id = int(parts[2])
|
||||
if todo_id <= 0:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise TodoError(404, "bad todo id")
|
||||
if len(parts) == 3:
|
||||
return ("todos", todo_id, None, parsed)
|
||||
if len(parts) == 4 and parts[3] == "toggle":
|
||||
return ("todos", todo_id, "toggle", parsed)
|
||||
raise TodoError(404, "unknown endpoint")
|
||||
|
||||
def _handle(self):
|
||||
store = self.server.store # type: TodoStore
|
||||
resource, todo_id, action, parsed = self._route()
|
||||
method = self.command
|
||||
if resource == "ui":
|
||||
if method not in ("GET", "HEAD"):
|
||||
raise TodoError(405, "method not allowed")
|
||||
self._send(200, UI_HTML, "text/html; charset=utf-8")
|
||||
return
|
||||
if resource == "healthz":
|
||||
if method not in ("GET", "HEAD"):
|
||||
raise TodoError(405, "method not allowed")
|
||||
self._json(200, {"ok": True, "todos": store.count()})
|
||||
return
|
||||
if todo_id is None:
|
||||
if method == "GET":
|
||||
qs = parse_qs(parsed.query)
|
||||
status = qs.get("status", ["all"])[0]
|
||||
if status not in ("open", "done", "all"):
|
||||
raise TodoError(400, "status must be open|done|all")
|
||||
try:
|
||||
limit = int(qs.get("limit", ["50"])[0])
|
||||
offset = int(qs.get("offset", ["0"])[0])
|
||||
except ValueError:
|
||||
raise TodoError(400, "limit/offset must be ints")
|
||||
limit = min(max(limit, 1), 100)
|
||||
offset = max(offset, 0)
|
||||
total, items = store.list(
|
||||
status=status, q=qs.get("q", [""])[0][:200],
|
||||
limit=limit, offset=offset,
|
||||
sort=qs.get("sort", ["updated"])[0],
|
||||
order=qs.get("order", ["desc"])[0])
|
||||
self._json(200, {"total": total, "limit": limit,
|
||||
"offset": offset, "items": items})
|
||||
elif method == "POST":
|
||||
todo = store.create(TodoValidator.clean(self._read_json()))
|
||||
self._json(201, todo)
|
||||
else:
|
||||
raise TodoError(405, "method not allowed")
|
||||
return
|
||||
if action == "toggle":
|
||||
if method != "POST":
|
||||
raise TodoError(405, "method not allowed")
|
||||
self._json(200, store.toggle(todo_id))
|
||||
return
|
||||
if method == "GET":
|
||||
self._json(200, store.get(todo_id))
|
||||
elif method == "PUT":
|
||||
self._json(200, store.update(
|
||||
todo_id, TodoValidator.clean(self._read_json())))
|
||||
elif method == "PATCH":
|
||||
self._json(200, store.update(
|
||||
todo_id, TodoValidator.clean(self._read_json(), partial=True)))
|
||||
elif method == "DELETE":
|
||||
store.delete(todo_id)
|
||||
self._send(204, b"")
|
||||
else:
|
||||
raise TodoError(405, "method not allowed")
|
||||
|
||||
def _dispatch(self):
|
||||
try:
|
||||
self._handle()
|
||||
except TodoError as exc:
|
||||
self._json(exc.status, {"error": exc.message})
|
||||
except BrokenPipeError:
|
||||
pass
|
||||
|
||||
do_GET = _dispatch
|
||||
do_HEAD = _dispatch
|
||||
do_POST = _dispatch
|
||||
do_PUT = _dispatch
|
||||
do_PATCH = _dispatch
|
||||
do_DELETE = _dispatch
|
||||
|
||||
|
||||
UI_HTML = """<!DOCTYPE html>
|
||||
<html lang="nl"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Todo</title>
|
||||
<style>
|
||||
:root{--bg:#fff;--fg:#1a1a1a;--muted:#666;--line:#ddd;--accent:#0a5;}
|
||||
*{box-sizing:border-box}
|
||||
body{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;background:var(--bg);
|
||||
color:var(--fg);margin:0;padding:0 1em 3em}
|
||||
main{max-width:36em;margin:0 auto}
|
||||
h1{font-size:1.4em;margin:1em 0 .3em}
|
||||
.toolbar{display:flex;gap:.5em;flex-wrap:wrap;margin:.8em 0}
|
||||
.toolbar input[type=search]{flex:1;min-width:8em;padding:.5em;font-size:1em;
|
||||
border:1px solid var(--line);border-radius:6px}
|
||||
.btnrow{display:flex;gap:.4em}
|
||||
button{padding:.5em .9em;font-size:.95em;border:1px solid var(--line);
|
||||
background:#f4f4f4;border-radius:6px;cursor:pointer}
|
||||
button[aria-pressed=true]{background:#1a1a1a;color:#fff;border-color:#1a1a1a}
|
||||
form.add{display:flex;gap:.5em;margin:.8em 0}
|
||||
form.add input{flex:1;padding:.6em;font-size:1em;border:1px solid var(--line);
|
||||
border-radius:6px}
|
||||
ul{list-style:none;margin:0;padding:0}
|
||||
li{display:flex;gap:.6em;align-items:flex-start;padding:.6em .2em;
|
||||
border-bottom:1px solid var(--line)}
|
||||
li input[type=checkbox]{margin-top:.25em;width:1.1em;height:1.1em}
|
||||
li .body{flex:1;min-width:0}
|
||||
li .title{font-weight:600;overflow-wrap:break-word}
|
||||
li.done .title{text-decoration:line-through;color:var(--muted)}
|
||||
li .meta{font-size:.85em;color:var(--muted);margin-top:.2em}
|
||||
li .del{background:none;border:none;font-size:1.1em;line-height:1;
|
||||
padding:.2em .4em;color:#a00}
|
||||
.pager{display:flex;gap:.5em;align-items:center;justify-content:center;
|
||||
margin:1.2em 0;color:var(--muted)}
|
||||
@media(max-width:30em){
|
||||
.btnrow{width:100%}
|
||||
.btnrow button{flex:1}
|
||||
form.add{flex-direction:column}
|
||||
}
|
||||
</style></head><body><main>
|
||||
<h1>Todo</h1>
|
||||
<form class="add" id="add"><input id="title" maxlength="200"
|
||||
placeholder="Nieuwe todo..." autocomplete="off"><button type="submit">Toevoegen</button></form>
|
||||
<div class="toolbar">
|
||||
<input type="search" id="q" placeholder="Zoeken...">
|
||||
<div class="btnrow" role="group" aria-label="Filter">
|
||||
<button data-f="all" aria-pressed="true">Alle</button>
|
||||
<button data-f="open" aria-pressed="false">Open</button>
|
||||
<button data-f="done" aria-pressed="false">Klaar</button>
|
||||
</div></div>
|
||||
<ul id="list"></ul>
|
||||
<div class="pager"><button id="prev">Vorige</button><span id="count"></span>
|
||||
<button id="next">Volgende</button></div>
|
||||
<script>
|
||||
"use strict";
|
||||
var f="all",q="",off=0,total=0,LIM=20;
|
||||
function esc(s){return s.replace(/[&<>"]/g,function(c){
|
||||
return {"&":"&","<":"<",">":">",'"':"""}[c];});}
|
||||
function load(){
|
||||
fetch("api/todos?status="+f+"&q="+encodeURIComponent(q)+"&limit="+LIM+"&offset="+off)
|
||||
.then(function(r){return r.json();}).then(function(d){
|
||||
total=d.total;var ul=document.getElementById("list");ul.innerHTML="";
|
||||
d.items.forEach(function(t){
|
||||
var li=document.createElement("li");if(t.done)li.className="done";
|
||||
li.innerHTML='<input type="checkbox"'+(t.done?" checked":"")+' aria-label="klaar">'+
|
||||
'<div class="body"><div class="title">'+esc(t.title)+"</div>"+
|
||||
'<div class="meta">p'+t.priority+(t.due?" · "+esc(t.due):"")+"</div></div>"+
|
||||
'<button class="del" aria-label="verwijderen">×</button>';
|
||||
li.querySelector("input").addEventListener("change",function(){
|
||||
fetch("api/todos/"+t.id+"/toggle",{method:"POST"}).then(load);});
|
||||
li.querySelector(".del").addEventListener("click",function(){
|
||||
if(confirm("Verwijderen?"))fetch("api/todos/"+t.id,{method:"DELETE"}).then(load);});
|
||||
ul.appendChild(li);});
|
||||
document.getElementById("count").textContent=
|
||||
(total?off+1:0)+"-"+Math.min(off+LIM,total)+" van "+total;
|
||||
document.getElementById("prev").disabled=off<=0;
|
||||
document.getElementById("next").disabled=off+LIM>=total;});}
|
||||
document.getElementById("add").addEventListener("submit",function(e){
|
||||
e.preventDefault();var i=document.getElementById("title");
|
||||
if(!i.value.trim())return;
|
||||
fetch("api/todos",{method:"POST",headers:{"Content-Type":"application/json"},
|
||||
body:JSON.stringify({title:i.value})}).then(function(){i.value="";off=0;load();});});
|
||||
document.getElementById("q").addEventListener("input",function(e){
|
||||
q=e.target.value;off=0;load();});
|
||||
document.querySelectorAll(".btnrow button").forEach(function(b){
|
||||
b.addEventListener("click",function(){
|
||||
document.querySelectorAll(".btnrow button").forEach(function(x){
|
||||
x.setAttribute("aria-pressed","false");});
|
||||
b.setAttribute("aria-pressed","true");f=b.getAttribute("data-f");off=0;load();});});
|
||||
document.getElementById("prev").addEventListener("click",function(){
|
||||
off=Math.max(0,off-LIM);load();});
|
||||
document.getElementById("next").addEventListener("click",function(){
|
||||
off+=LIM;load();});
|
||||
load();
|
||||
</script></main></body></html>
|
||||
"""
|
||||
|
||||
|
||||
class TodoServer(socketserver.ThreadingMixIn, HTTPServer):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Todo REST API + SQLite + embedded UI (stdlib only).")
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=8081)
|
||||
parser.add_argument("--db", default=":memory:",
|
||||
help="sqlite file (default :memory:)")
|
||||
args = parser.parse_args(argv)
|
||||
if args.db != ":memory:":
|
||||
parent = os.path.dirname(os.path.abspath(args.db))
|
||||
if not os.path.isdir(parent):
|
||||
parser.error("db directory does not exist: %s" % parent)
|
||||
server = TodoServer((args.host, args.port), TodoHandler)
|
||||
server.store = TodoStore(args.db)
|
||||
print("todo: http://%s:%d/ (db=%s)" % (args.host, args.port, args.db))
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
Executable
+1051
File diff suppressed because it is too large
Load Diff
@@ -331,13 +331,13 @@ def orientation_block(directory=None, sandbox=False):
|
||||
|
||||
def capability_block(yolo=False, auto=False):
|
||||
"""Live approval state for the system prompt. Grounds the model in what
|
||||
this session can actually do, so it never invents a read-only jail."""
|
||||
this session can actually do, so it never invents false limits."""
|
||||
if yolo or auto:
|
||||
return ("\n\n## Session capabilities\nApprovals are off and every tool is callable, "
|
||||
"including shell and file writes. This session is not read-only: only a failed "
|
||||
"including shell and file writes. Only a failed "
|
||||
"tool call proves a limit, so act first and adjust on evidence.")
|
||||
return ("\n\n## Session capabilities\nEvery tool is callable, including shell and file writes; "
|
||||
"some commands ask for approval first. This session is not read-only: only a failed "
|
||||
"some commands ask for approval first. Only a failed "
|
||||
"tool call proves a limit, so act first and adjust on evidence.")
|
||||
|
||||
|
||||
@@ -4441,6 +4441,13 @@ def codex_parse_output(stdout, outfile_text="", used_schema=False):
|
||||
return opencode_parse(stdout)
|
||||
|
||||
|
||||
def codex_sandbox_args():
|
||||
"""Codex CLI sandbox flags. Default off; set TAI_CODEX_SANDBOX=1 to re-enable."""
|
||||
if (os.environ.get("TAI_CODEX_SANDBOX") or "").strip().lower() in ("1", "true", "yes", "on", "read-only", "readonly"):
|
||||
return ["--sandbox", "read-only"]
|
||||
return []
|
||||
|
||||
|
||||
def codex_run(prompt, timeout):
|
||||
binary = codex_binary()
|
||||
if not binary:
|
||||
@@ -4451,7 +4458,7 @@ def codex_run(prompt, timeout):
|
||||
out_path = os.path.join(folder, "last.txt")
|
||||
with open(schema_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(schema)
|
||||
base = [binary, "exec", "--json", "--ephemeral", "--sandbox", "read-only", "--skip-git-repo-check"]
|
||||
base = [binary, "exec", "--json", "--ephemeral"] + codex_sandbox_args() + ["--skip-git-repo-check"]
|
||||
if len(prompt.encode("utf-8", "replace")) > 120000:
|
||||
variants = [
|
||||
(base + ["--output-schema", schema_path, "-o", out_path, "-"], prompt, True),
|
||||
@@ -4509,16 +4516,23 @@ def gemini_parse_output(stdout):
|
||||
return opencode_parse(stdout)
|
||||
|
||||
|
||||
def gemini_plan_enabled():
|
||||
"""Gemini plan-mode variant. Default off; set TAI_GEMINI_PLAN=1 to re-enable."""
|
||||
return (os.environ.get("TAI_GEMINI_PLAN") or "").strip().lower() in ("1", "true", "yes", "on", "plan")
|
||||
|
||||
|
||||
def gemini_run(prompt, timeout):
|
||||
binary = gemini_binary()
|
||||
if not binary:
|
||||
raise BackendError("gemini CLI not found (GEMINI_BIN or PATH)")
|
||||
base = [binary, "-p", prompt]
|
||||
variants = [
|
||||
base + ["--output-format", "json", "--approval-mode", "plan"],
|
||||
variants = []
|
||||
if gemini_plan_enabled():
|
||||
variants.append(base + ["--output-format", "json", "--approval-mode", "plan"])
|
||||
variants.extend([
|
||||
base + ["--output-format", "json"],
|
||||
base,
|
||||
]
|
||||
])
|
||||
last_error = ""
|
||||
for argv in variants:
|
||||
done = _cli_run(argv, None, timeout, "gemini")
|
||||
|
||||
+32
-4
@@ -2569,13 +2569,39 @@ class CapabilityTests(unittest.TestCase):
|
||||
def test_yolo_variant(self):
|
||||
block = tai.capability_block(yolo=True)
|
||||
self.assertIn("Approvals are off", block)
|
||||
self.assertIn("not read-only", block)
|
||||
self.assertIn("only a failed tool call proves a limit", block)
|
||||
self.assertIn("Only a failed tool call proves a limit", block)
|
||||
self.assertNotIn("read-only", block.lower())
|
||||
|
||||
def test_default_variant(self):
|
||||
block = tai.capability_block()
|
||||
self.assertIn("ask for approval", block)
|
||||
self.assertIn("not read-only", block)
|
||||
self.assertIn("Only a failed tool call proves a limit", block)
|
||||
self.assertNotIn("read-only", block.lower())
|
||||
|
||||
def test_no_session_mode_mentions(self):
|
||||
for block in (tai.capability_block(), tai.capability_block(yolo=True), tai.capability_block(auto=True)):
|
||||
lowered = block.lower()
|
||||
self.assertNotIn("read-only", lowered)
|
||||
self.assertNotIn("read only", lowered)
|
||||
self.assertNotIn("readonly", lowered)
|
||||
|
||||
def test_codex_sandbox_default_off(self):
|
||||
os.environ.pop("TAI_CODEX_SANDBOX", None)
|
||||
self.assertEqual(tai.codex_sandbox_args(), [])
|
||||
os.environ["TAI_CODEX_SANDBOX"] = "1"
|
||||
try:
|
||||
self.assertEqual(tai.codex_sandbox_args(), ["--sandbox", "read-only"])
|
||||
finally:
|
||||
os.environ.pop("TAI_CODEX_SANDBOX", None)
|
||||
|
||||
def test_gemini_plan_default_off(self):
|
||||
os.environ.pop("TAI_GEMINI_PLAN", None)
|
||||
self.assertFalse(tai.gemini_plan_enabled())
|
||||
os.environ["TAI_GEMINI_PLAN"] = "1"
|
||||
try:
|
||||
self.assertTrue(tai.gemini_plan_enabled())
|
||||
finally:
|
||||
os.environ.pop("TAI_GEMINI_PLAN", None)
|
||||
|
||||
def test_apply_system_injects_capabilities(self):
|
||||
system = self.agent.messages[0]["content"]
|
||||
@@ -4445,7 +4471,7 @@ class ToolArgumentsTests(unittest.TestCase):
|
||||
|
||||
class OpencodeAgentTests(unittest.TestCase):
|
||||
def test_agent_is_not_plan(self):
|
||||
# "plan" makes opencode inject "Plan mode ACTIVE - READ-ONLY" into the
|
||||
# "plan" makes opencode inject a plan-mode notice into the
|
||||
# model, which then refuses to emit tai's write tool calls.
|
||||
self.assertNotEqual(tai.OPENCODE_AGENT, "plan")
|
||||
captured = {}
|
||||
@@ -10563,6 +10589,8 @@ class CtxStoreTests(unittest.TestCase):
|
||||
self.assertTrue(any("c00" in line for line in lines))
|
||||
self.assertFalse(any("c04" in line for line in lines))
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The grr client behind `tai.py --tui`: strings, formats, client, stream,
|
||||
# router, page engine and the shell against a small fake grr server.
|
||||
|
||||
Reference in New Issue
Block a user