Files
workflow-editor/tools/editor_test_server.py
T
Sven f979c797ba feat: Editor-Aufruf mit Task- und Typenrückgabe statt MongoDB
Tasks, eigene Typen und Metadaten pro Editor-Aufruf laden und an den Aufrufer zurückgeben. MongoDB-Persistenz entfernen und Header auf Rückgängig, Abbrechen und Fertig umstellen.

Python-Testserver und Regressionstests ergänzen; Verbindungslinien nach Import und Layoutänderungen neu berechnen.
2026-10-07 21:06:28 +02:00

286 lines
13 KiB
Python

#!/usr/bin/env python3
"""Lokaler Aufrufer für den TaskList Editor; benötigt nur Python 3.9+.
Start: python3 tools/editor_test_server.py --json tools/example_tasks.json
Der Editor muss separat laufen (standardmäßig http://127.0.0.1:8080).
"""
import argparse
import json
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
MAX_BODY_BYTES = 2 * 1024 * 1024
TOKEN_TTL_SECONDS = 60 * 60
RESULT_TYPE = "tasklist-editor:result"
HTML_PATH = Path(__file__).with_suffix(".html")
PRINT_LOCK = threading.Lock()
class RequestError(Exception):
def __init__(self, status, message):
super().__init__(message)
self.status = status
def reject_constant(value):
raise ValueError("Ungültiger JSON-Zahlenwert: " + value)
def decode_json(raw):
try:
return json.loads(raw.decode("utf-8-sig"), parse_constant=reject_constant)
except (UnicodeError, ValueError, RecursionError) as exc:
raise RequestError(400, "Ungültiges JSON: " + str(exc)) from exc
def origin_for(url):
parsed = urllib.parse.urlsplit(url)
if parsed.scheme not in ("http", "https") or not parsed.hostname:
raise ValueError("Die Editor-URL muss eine vollständige http(s)-URL sein.")
if parsed.username or parsed.password:
raise ValueError("Zugangsdaten in der Editor-URL werden nicht unterstützt.")
port = parsed.port
host = parsed.hostname.lower()
if ":" in host:
host = "[" + host + "]"
if port and port != {"http": 80, "https": 443}[parsed.scheme]:
host += ":" + str(port)
return parsed.scheme + "://" + host
def normalize_payload(value):
if isinstance(value, list):
value = {"tasks": value}
if not isinstance(value, dict):
raise RequestError(400, "Erwartet wird ein tasks-Array oder ein Objekt mit name, tasks, customTypes und metadata.")
tasks = value.get("tasks")
if tasks is None:
tasks = []
if not isinstance(tasks, list) or any(not isinstance(task, dict) for task in tasks):
raise RequestError(400, "tasks muss ein Array aus Task-Objekten sein; [] legt eine neue Liste an.")
custom_types = value.get("customTypes")
if custom_types is None:
custom_types = []
if not isinstance(custom_types, list) or any(not isinstance(item, dict) for item in custom_types):
raise RequestError(400, "customTypes muss ein Array aus Typ-Objekten sein; [] übergibt keine eigenen Typen.")
if value.get("name") is not None and not isinstance(value["name"], str):
raise RequestError(400, "name muss eine Zeichenkette oder null sein.")
payload = {"tasks": tasks, "customTypes": custom_types}
for key in ("name", "metadata"):
if key in value:
payload[key] = value[key]
return payload
class EditorTestServer(ThreadingHTTPServer):
daemon_threads = True
def __init__(self, port, editor_url, initial_payload):
editor_url = editor_url.rstrip("/")
self.editor_origin = origin_for(editor_url)
parsed = urllib.parse.urlsplit(editor_url)
if parsed.query or parsed.fragment:
raise ValueError("Die Editor-URL darf keine Query-Parameter oder Fragmente enthalten.")
self.editor_url = editor_url
self.initial_payload = normalize_payload(initial_payload)
self.sessions = {}
self.session_lock = threading.Lock()
super().__init__(("127.0.0.1", port), EditorTestHandler)
self.local_origin = "http://127.0.0.1:" + str(self.server_port)
def open_editor(self, value):
payload = normalize_payload(value)
payload["returnOrigin"] = self.local_origin
request = urllib.request.Request(
self.editor_url + "/api/editor/import",
data=json.dumps(payload, ensure_ascii=False, allow_nan=False).encode("utf-8"),
headers={"Content-Type": "application/json", "Accept": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=15) as response:
raw = response.read(MAX_BODY_BYTES + 1)
except urllib.error.HTTPError as exc:
detail = exc.read(4096).decode("utf-8", errors="replace").strip()
raise RequestError(502, "Editor lehnt den Import ab (HTTP {}): {}".format(
exc.code, detail or exc.reason)) from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise RequestError(502, "Editor unter {} nicht erreichbar: {}".format(self.editor_url, exc)) from exc
if len(raw) > MAX_BODY_BYTES:
raise RequestError(502, "Die Antwort des Editors ist zu groß.")
try:
result = decode_json(raw)
token = result.get("token") if isinstance(result, dict) else None
deep_link = result.get("deepLink") if isinstance(result, dict) else None
if not isinstance(token, str) or not token or len(token) > 512:
raise ValueError("token fehlt oder ist ungültig")
if not isinstance(deep_link, str) or origin_for(deep_link) != self.editor_origin:
raise ValueError("deepLink muss auf den konfigurierten Editor-Origin zeigen")
except (RequestError, ValueError) as exc:
raise RequestError(502, "Ungültige Import-Antwort des Editors: " + str(exc)) from exc
with self.session_lock:
now = time.monotonic()
self.sessions = {key: session for key, session in self.sessions.items()
if now - session["created"] < TOKEN_TTL_SECONDS}
if len(self.sessions) >= 1000:
raise RequestError(503, "Zu viele offene Tests. Bitte den Testserver neu starten.")
self.sessions[token] = {"created": now, "result": None}
return {"token": token, "deepLink": deep_link}
def receive_result(self, result):
if not isinstance(result, dict) or result.get("type") != RESULT_TYPE:
raise RequestError(400, "Ungültiger Rückgabetyp.")
token = result.get("token")
if not isinstance(token, str):
raise RequestError(400, "Die Rückgabe benötigt ein token.")
status = result.get("status")
if status not in ("completed", "cancelled"):
raise RequestError(400, "status muss completed oder cancelled sein.")
if status == "completed" and (not isinstance(result.get("tasks"), list)
or any(not isinstance(task, dict) for task in result["tasks"])):
raise RequestError(400, "Eine abgeschlossene Bearbeitung benötigt ein tasks-Array.")
if status == "completed" and (not isinstance(result.get("customTypes"), list)
or any(not isinstance(item, dict) for item in result["customTypes"])):
raise RequestError(400, "Eine abgeschlossene Bearbeitung benötigt den vollständigen customTypes-Katalog als Array.")
if status == "cancelled" and ("tasks" in result or "customTypes" in result):
raise RequestError(400, "Eine abgebrochene Bearbeitung darf keine tasks oder customTypes zurückgeben.")
if result.get("name") is not None and not isinstance(result["name"], str):
raise RequestError(400, "name muss eine Zeichenkette oder null sein.")
with self.session_lock:
session = self.sessions.get(token)
if session is None or time.monotonic() - session["created"] >= TOKEN_TTL_SECONDS:
raise RequestError(404, "Unbekanntes oder abgelaufenes token. Bitte einen neuen Test öffnen.")
# Ein Retry nach verlorener HTTP-Antwort darf dieselbe Rückgabe erneut bestätigen.
if session["result"] is not None:
if session["result"] != result:
raise RequestError(409, "Für dieses token wurde bereits ein anderes Ergebnis empfangen.")
return {"ok": True, "duplicate": True}
with PRINT_LOCK:
print("\n--- Editor-Rückgabe ---", flush=True)
print(json.dumps(result, ensure_ascii=False, indent=2, allow_nan=False), flush=True)
session["result"] = result
return {"ok": True}
class EditorTestHandler(BaseHTTPRequestHandler):
server_version = "TaskListTestServer/1.0"
def setup(self):
super().setup()
self.connection.settimeout(20)
def log_message(self, fmt, *args):
with PRINT_LOCK:
print("[{}] {}".format(self.log_date_time_string(), fmt % args), file=sys.stderr, flush=True)
def send_bytes(self, status, body, content_type):
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Referrer-Policy", "no-referrer")
self.end_headers()
self.wfile.write(body)
def send_json(self, status, value):
self.send_bytes(status, json.dumps(value, ensure_ascii=False, allow_nan=False).encode("utf-8"),
"application/json; charset=utf-8")
def check_host(self):
expected = urllib.parse.urlsplit(self.server.local_origin).netloc
if self.headers.get("Host") != expected:
raise RequestError(403, "Bitte den Testserver über {} öffnen.".format(self.server.local_origin))
def read_json(self):
if self.headers.get("Origin") != self.server.local_origin:
raise RequestError(403, "Anfragen müssen von der lokalen Testseite stammen.")
if self.headers.get_content_type() != "application/json":
raise RequestError(415, "Content-Type application/json wird benötigt.")
if self.headers.get("Transfer-Encoding"):
raise RequestError(400, "Transfer-Encoding wird nicht unterstützt.")
try:
length = int(self.headers["Content-Length"])
except (TypeError, ValueError) as exc:
raise RequestError(411, "Content-Length wird benötigt.") from exc
if length <= 0 or length > MAX_BODY_BYTES:
raise RequestError(413, "Der JSON-Body muss zwischen 1 Byte und 2 MiB groß sein.")
raw = self.rfile.read(length)
if len(raw) != length:
raise RequestError(400, "Unvollständiger Request-Body.")
return decode_json(raw)
def do_GET(self):
try:
self.check_host()
if self.path in ("/", "/index.html"):
self.send_bytes(200, HTML_PATH.read_bytes(), "text/html; charset=utf-8")
elif self.path == "/api/config":
self.send_json(200, {"editorUrl": self.server.editor_url,
"editorOrigin": self.server.editor_origin,
"initialPayload": self.server.initial_payload})
else:
raise RequestError(404, "Nicht gefunden.")
except RequestError as exc:
self.send_json(exc.status, {"error": str(exc)})
except OSError as exc:
self.send_json(500, {"error": "Testseite konnte nicht geladen werden: " + str(exc)})
def do_POST(self):
try:
self.check_host()
if self.path not in ("/api/open", "/api/result"):
raise RequestError(404, "Nicht gefunden.")
value = self.read_json()
result = self.server.open_editor(value) if self.path == "/api/open" else self.server.receive_result(value)
self.send_json(200, result)
except RequestError as exc:
self.send_json(exc.status, {"error": str(exc)})
except (TimeoutError, OSError) as exc:
self.send_json(408, {"error": "Zeitüberschreitung beim Lesen der Anfrage: " + str(exc)})
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--port", type=int, default=8090, help="Lokaler Port (Standard: 8090)")
parser.add_argument("--editor-url", default="http://127.0.0.1:8080", help="Basis-URL des laufenden Editors")
parser.add_argument("--json", type=Path, help="JSON-Datei zum Vorbelegen (Standard: tools/example_tasks.json)")
parser.add_argument("--no-browser", action="store_true", help="Browser nicht automatisch öffnen")
args = parser.parse_args()
if not 0 <= args.port <= 65535:
parser.error("--port muss zwischen 0 und 65535 liegen.")
try:
json_path = args.json or Path(__file__).with_name("example_tasks.json")
with json_path.open("rb") as source:
raw = source.read(MAX_BODY_BYTES + 1)
if len(raw) > MAX_BODY_BYTES:
raise ValueError("Die JSON-Datei darf höchstens 2 MiB groß sein.")
initial_payload = decode_json(raw)
server = EditorTestServer(args.port, args.editor_url, initial_payload)
except (RequestError, ValueError, OSError) as exc:
parser.error(str(exc))
print("Testseite: " + server.local_origin + "/", flush=True)
print("Editor: " + server.editor_url, flush=True)
print("Rückgaben erscheinen hier als JSON. Beenden mit Strg+C.", flush=True)
if not args.no_browser:
webbrowser.open(server.local_origin + "/")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nTestserver beendet.", flush=True)
finally:
server.server_close()
if __name__ == "__main__":
main()