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:
2026-10-17 19:54:12 +02:00
co-authored by Claude Sonnet 5.5
parent 560ab4cb4d
commit ad8c241e6e
8 changed files with 9654 additions and 11 deletions
+456
View File
@@ -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()
+310
View File
@@ -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()
+435
View File
@@ -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 {"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[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()
+501
View File
@@ -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 {"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[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?" &middot; "+esc(t.due):"")+"</div></div>"+
'<button class="del" aria-label="verwijderen">&times;</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()
+1051
View File
File diff suppressed because it is too large Load Diff
+6848
View File
File diff suppressed because it is too large Load Diff
Executable → Regular
+21 -7
View File
@@ -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
View File
@@ -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.