Web-M1-Ausbau: JSON-Gaesteliste, GA-Verlauf, Python-Serversteuerung, tische.ini-Upload
- bestellung.xml -> bestellung.json: neuer JSONConfig (loest XMLConfig ab), alle work-Beispiele konvertiert, Doku (CLAUDE/README/dateiformate) umgestellt - ga.py: Beobachtungsobjekt Entwicklung zeichnet den Loesungsverlauf auf, neuer Schalter --show-development erzeugt Entwicklung.svg (beste/mittlere/ schlechteste Wertigkeit ueber die Zyklus-Aktionen) - web/main.py: neuer Endpunkt /tische-datei (tische.ini-Upload, ast.literal_eval), Frontend kann jetzt eine tische.ini laden - libs/webserver.py: plattformunabhaengige start/stop/status-Steuerung der Web-Testinstanz (ersetzt run_web_helper.ps1), raeumt auch verwaiste --reload-Prozessbaeume zuverlaessig auf - Sicherheit/Modernisierung: eval() -> ast.literal_eval in Tische/Strafliste, sicherer Mini-Auswerter fuer die String-Ausdruecke in zyklus.cfg, defaultdict(set) in Plaetze - SVG: Tischnummer fett zentriert im Tischkreis statt "Tisch x" darueber - Tests: JSONConfig, Entwicklung und der Zyklus-Auswerter abgedeckt (9 Tests) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,376 @@
|
||||
"""
|
||||
Plattformunabhaengige Prozessverwaltung fuer die Web-Testinstanz
|
||||
(ersetzt die fruehere PowerShell-Loesung run_web_helper.ps1, damit
|
||||
dasselbe Skript unter Windows und Linux funktioniert).
|
||||
|
||||
Aufruf (ueber bin/run_web.bat bzw. bin/run_web.sh oder direkt):
|
||||
|
||||
python libs/webserver.py start # Server im Hintergrund starten
|
||||
python libs/webserver.py stop # Server beenden
|
||||
python libs/webserver.py status # laeuft er? PID? Port?
|
||||
python libs/webserver.py run # im Vordergrund (Strg+C beendet)
|
||||
python libs/webserver.py # wie "run"
|
||||
|
||||
--port N weicht vom Standardport 8000 ab
|
||||
|
||||
Designentscheidung gegen Zombie-Prozesse: Im Hintergrundbetrieb
|
||||
("start") laeuft uvicorn OHNE --reload als ein einzelner Prozess,
|
||||
der per PID-Datei sauber beendet werden kann. Der Auto-Reload
|
||||
(--reload) startet Arbeitsprozesse als multiprocessing-Spawn-Kinder,
|
||||
die bei unsanftem Ende des Reloaders MIT geerbtem Server-Socket
|
||||
weiterleben und den Port unsichtbar weiterbedienen koennen (unter
|
||||
Windows tauchen sie weder als Socket-Besitzer noch mit "uvicorn" in
|
||||
der Kommandozeile auf). Deshalb gibt es --reload nur im
|
||||
Vordergrundmodus ("run"), wo Strg+C alles gemeinsam beendet.
|
||||
"""
|
||||
|
||||
__author__ = "Michael Stangl"
|
||||
__copyright__ = "Copyright (c) 2026 Michael Stangl"
|
||||
__license__ = "Python"
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
BASISDIR = os.path.dirname( os.path.dirname( os.path.abspath( __file__ ) ) )
|
||||
WEBDIR = os.path.join( BASISDIR, 'web' )
|
||||
LOGDIR = os.path.join( BASISDIR, 'log' )
|
||||
PIDDATEI = os.path.join( LOGDIR, 'run_web.pid' )
|
||||
LOGDATEI = os.path.join( LOGDIR, 'run_web.log' )
|
||||
HOST = '127.0.0.1'
|
||||
STANDARDPORT = 8000
|
||||
APPTITEL = 'Sitz & Platz' # steht im OpenAPI-Titel von web/main.py
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Sonden
|
||||
def PortOffen(Port):
|
||||
"""True, wenn auf dem Port etwas Verbindungen annimmt"""
|
||||
with socket.socket( socket.AF_INET, socket.SOCK_STREAM ) as s:
|
||||
s.settimeout( 1.0 )
|
||||
return s.connect_ex( (HOST, Port) ) == 0
|
||||
|
||||
|
||||
def IstUnsereApp(Port):
|
||||
"""True/False, ob auf dem Port unsere FastAPI-App antwortet;
|
||||
None, wenn keine (verwertbare) Antwort kommt"""
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
'http://%s:%d/openapi.json' % (HOST, Port), timeout=3 ) as antwort:
|
||||
daten = json.load( antwort )
|
||||
return APPTITEL in daten.get( 'info', {} ).get( 'title', '' )
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def PidLebt(Pid):
|
||||
"""True, wenn der Prozess existiert (portabel)"""
|
||||
if Pid is None or Pid <= 0:
|
||||
return False
|
||||
if sys.platform == 'win32':
|
||||
# os.kill(pid, 0) ist unter Windows unbrauchbar (wirft WinError 87
|
||||
# statt zu pruefen). Stattdessen im Toolhelp-Snapshot nachsehen.
|
||||
return Pid in _ProzessTabelle()
|
||||
try:
|
||||
os.kill( Pid, 0 )
|
||||
return True
|
||||
except PermissionError:
|
||||
return True # existiert, gehoert aber jemand anderem
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
# ------------------------------------------------------------- PID-Datei
|
||||
def PidLesen():
|
||||
try:
|
||||
with open( PIDDATEI ) as f:
|
||||
return int( f.read().strip() )
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def PidSchreiben(Pid):
|
||||
os.makedirs( LOGDIR, exist_ok=True )
|
||||
with open( PIDDATEI, 'w' ) as f:
|
||||
f.write( '%d\n' % Pid )
|
||||
|
||||
|
||||
def PidLoeschen():
|
||||
try:
|
||||
os.remove( PIDDATEI )
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def ListenerPid(Port):
|
||||
"""PID des Prozesses, der auf dem Port lauscht (Fallback, wenn die
|
||||
PID-Datei fehlt); None wenn nicht feststellbar"""
|
||||
try:
|
||||
if sys.platform == 'win32':
|
||||
ausgabe = subprocess.run(
|
||||
['netstat', '-ano', '-p', 'tcp'],
|
||||
capture_output=True, text=True, timeout=10 ).stdout
|
||||
for zeile in ausgabe.splitlines():
|
||||
teile = zeile.split()
|
||||
# Zustandsspalte NICHT auf "LISTENING" pruefen - netstat
|
||||
# ist lokalisiert (deutsch: "ABHOEREN"). Ein Listener ist
|
||||
# an der Gegenstelle 0.0.0.0:0 erkennbar; PID = letzte Spalte.
|
||||
if ( len(teile) >= 5 and teile[0] == 'TCP'
|
||||
and teile[1].endswith( ':%d' % Port )
|
||||
and teile[2] in ('0.0.0.0:0', '[::]:0') ):
|
||||
return int( teile[-1] )
|
||||
else:
|
||||
ausgabe = subprocess.run(
|
||||
['ss', '-tlnp'],
|
||||
capture_output=True, text=True, timeout=10 ).stdout
|
||||
for zeile in ausgabe.splitlines():
|
||||
if ':%d ' % Port in zeile and 'pid=' in zeile:
|
||||
return int( zeile.split('pid=')[1].split(',')[0] )
|
||||
except (OSError, ValueError, subprocess.SubprocessError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------- Prozessbaum-Hilfen
|
||||
def _ProzessTabelle():
|
||||
"""{ Pid: (ElternPid, Prozessname) } aller laufenden Prozesse -
|
||||
Windows ueber einen Toolhelp-Snapshot (ctypes, Standardbibliothek),
|
||||
Linux ueber /proc"""
|
||||
tabelle = {}
|
||||
if sys.platform == 'win32':
|
||||
import ctypes
|
||||
import ctypes.wintypes as wt
|
||||
|
||||
class PROCESSENTRY32(ctypes.Structure):
|
||||
_fields_ = [
|
||||
('dwSize', wt.DWORD), ('cntUsage', wt.DWORD),
|
||||
('th32ProcessID', wt.DWORD),
|
||||
('th32DefaultHeapID', ctypes.POINTER( ctypes.c_ulong )),
|
||||
('th32ModuleID', wt.DWORD), ('cntThreads', wt.DWORD),
|
||||
('th32ParentProcessID', wt.DWORD),
|
||||
('pcPriClassBase', ctypes.c_long),
|
||||
('dwFlags', wt.DWORD), ('szExeFile', ctypes.c_char * 260) ]
|
||||
|
||||
TH32CS_SNAPPROCESS = 0x2
|
||||
k32 = ctypes.windll.kernel32
|
||||
schnappschuss = k32.CreateToolhelp32Snapshot( TH32CS_SNAPPROCESS, 0 )
|
||||
if schnappschuss == -1:
|
||||
return tabelle
|
||||
eintrag = PROCESSENTRY32()
|
||||
eintrag.dwSize = ctypes.sizeof( PROCESSENTRY32 )
|
||||
if k32.Process32First( schnappschuss, ctypes.byref( eintrag ) ):
|
||||
while True:
|
||||
tabelle[ int(eintrag.th32ProcessID) ] = (
|
||||
int(eintrag.th32ParentProcessID),
|
||||
eintrag.szExeFile.decode( errors='replace' ).lower() )
|
||||
if not k32.Process32Next( schnappschuss,
|
||||
ctypes.byref( eintrag ) ):
|
||||
break
|
||||
k32.CloseHandle( schnappschuss )
|
||||
else:
|
||||
for name in os.listdir( '/proc' ):
|
||||
if not name.isdigit():
|
||||
continue
|
||||
try:
|
||||
with open( '/proc/%s/stat' % name ) as f:
|
||||
stat = f.read()
|
||||
# Format: pid (comm) zustand ppid ... - comm kann
|
||||
# Leerzeichen enthalten, daher an der Klammer trennen
|
||||
kommando = stat[ stat.index('(') + 1 : stat.rindex(')') ]
|
||||
felder = stat[ stat.rindex(')') + 2 : ].split()
|
||||
tabelle[ int(name) ] = ( int(felder[1]), kommando.lower() )
|
||||
except (OSError, ValueError, IndexError):
|
||||
continue
|
||||
return tabelle
|
||||
|
||||
|
||||
def ProzessWurzel(Pid):
|
||||
"""laeuft vom Prozess die Elternkette hoch, solange die Eltern auch
|
||||
Python-Prozesse sind, und liefert die Wurzel des Python-Baums.
|
||||
Noetig fuer fremde/alte --reload-Server: wird nur der Arbeits-
|
||||
prozess beendet, startet der Reloader ihn sofort neu."""
|
||||
tabelle = _ProzessTabelle()
|
||||
aktuell = Pid
|
||||
for _ in range( 10 ): # Schutz gegen Zyklen
|
||||
eintrag = tabelle.get( aktuell )
|
||||
if eintrag is None:
|
||||
break
|
||||
eltern, _name = eintrag
|
||||
elternEintrag = tabelle.get( eltern )
|
||||
if elternEintrag is None or not elternEintrag[1].startswith( 'python' ):
|
||||
break
|
||||
aktuell = eltern
|
||||
return aktuell
|
||||
|
||||
|
||||
# ------------------------------------------------------------- Aktionen
|
||||
def UvicornKommando(Port, Reload):
|
||||
Kommando = [ sys.executable, '-m', 'uvicorn', 'main:app',
|
||||
'--app-dir', WEBDIR, '--port', str(Port) ]
|
||||
if Reload:
|
||||
Kommando.append( '--reload' )
|
||||
return Kommando
|
||||
|
||||
|
||||
def Starten(Port):
|
||||
if PortOffen( Port ):
|
||||
if IstUnsereApp( Port ):
|
||||
print( 'Server laeuft bereits: http://localhost:%d' % Port )
|
||||
else:
|
||||
print( 'FEHLER: Port %d ist von einem fremden Programm belegt.' % Port )
|
||||
return 1
|
||||
|
||||
os.makedirs( LOGDIR, exist_ok=True )
|
||||
logdatei = open( LOGDATEI, 'w' )
|
||||
|
||||
# vom Terminal abkoppeln, damit der Server das Schliessen der
|
||||
# Konsole ueberlebt; eigene Prozessgruppe fuer sauberes Beenden
|
||||
if sys.platform == 'win32':
|
||||
flags = ( subprocess.DETACHED_PROCESS
|
||||
| subprocess.CREATE_NEW_PROCESS_GROUP )
|
||||
prozess = subprocess.Popen(
|
||||
UvicornKommando( Port, Reload=False ),
|
||||
stdout=logdatei, stderr=subprocess.STDOUT,
|
||||
cwd=BASISDIR, creationflags=flags )
|
||||
else:
|
||||
prozess = subprocess.Popen(
|
||||
UvicornKommando( Port, Reload=False ),
|
||||
stdout=logdatei, stderr=subprocess.STDOUT,
|
||||
cwd=BASISDIR, start_new_session=True )
|
||||
PidSchreiben( prozess.pid )
|
||||
|
||||
# warten bis der Port antwortet (oder der Prozess frueh stirbt)
|
||||
for _ in range( 30 ):
|
||||
if prozess.poll() is not None:
|
||||
print( 'FEHLER: Server sofort wieder beendet - siehe %s' % LOGDATEI )
|
||||
PidLoeschen()
|
||||
return 1
|
||||
if PortOffen( Port ):
|
||||
print( 'Server laeuft: http://localhost:%d (PID %d, Log: %s)'
|
||||
% (Port, prozess.pid, os.path.relpath( LOGDATEI, BASISDIR )) )
|
||||
print( 'Hinweis: Hintergrundbetrieb ohne Auto-Reload - fuer '
|
||||
'Entwicklung mit Reload "run" im Vordergrund nutzen.' )
|
||||
return 0
|
||||
time.sleep( 0.5 )
|
||||
print( 'FEHLER: Server antwortet nicht auf Port %d - siehe %s'
|
||||
% (Port, LOGDATEI) )
|
||||
return 1
|
||||
|
||||
|
||||
def BaumBeenden(Pid):
|
||||
"""beendet einen Prozess samt Kindprozessen (portabel)"""
|
||||
if sys.platform == 'win32':
|
||||
subprocess.run( ['taskkill', '/PID', str(Pid), '/T', '/F'],
|
||||
capture_output=True )
|
||||
else:
|
||||
try:
|
||||
os.killpg( os.getpgid( Pid ), signal.SIGTERM )
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
try:
|
||||
os.kill( Pid, signal.SIGTERM )
|
||||
except OSError:
|
||||
pass
|
||||
# kurze Gnadenfrist, dann hart nachsetzen
|
||||
for _ in range( 10 ):
|
||||
if not PidLebt( Pid ):
|
||||
return
|
||||
time.sleep( 0.3 )
|
||||
try:
|
||||
os.killpg( os.getpgid( Pid ), signal.SIGKILL )
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
def Stoppen(Port):
|
||||
gefunden = False
|
||||
|
||||
Pid = PidLesen()
|
||||
if Pid is not None and PidLebt( Pid ):
|
||||
BaumBeenden( Pid )
|
||||
gefunden = True
|
||||
|
||||
# Fallback: PID-Datei fehlt/veraltet, aber es lauscht noch jemand -
|
||||
# dann die Wurzel des Python-Prozessbaums beenden (sonst startet ein
|
||||
# --reload-Elternprozess den abgeschossenen Worker sofort neu)
|
||||
if PortOffen( Port ):
|
||||
LPid = ListenerPid( Port )
|
||||
if LPid is not None and PidLebt( LPid ):
|
||||
BaumBeenden( ProzessWurzel( LPid ) )
|
||||
gefunden = True
|
||||
|
||||
# nachpruefen, ob der Port wirklich frei geworden ist
|
||||
for _ in range( 10 ):
|
||||
if not PortOffen( Port ):
|
||||
break
|
||||
time.sleep( 0.3 )
|
||||
PidLoeschen()
|
||||
|
||||
if PortOffen( Port ):
|
||||
print( 'WARNUNG: Auf Port %d antwortet immer noch etwas. Moeglicher '
|
||||
'verwaister Prozess mit geerbtem Socket - Kandidaten mit '
|
||||
'"netstat -ano" (Windows) bzw. "ss -tlnp" (Linux) suchen.'
|
||||
% Port )
|
||||
return 1
|
||||
print( 'Beendet.' if gefunden else 'Es lief nichts.' )
|
||||
return 0
|
||||
|
||||
|
||||
def Status(Port):
|
||||
Pid = PidLesen()
|
||||
if PortOffen( Port ):
|
||||
Zusatz = ''
|
||||
if Pid is not None and PidLebt( Pid ):
|
||||
# uvicorn haelt den Port oft in einem Kindprozess des in der
|
||||
# PID-Datei vermerkten Hauptprozesses - das ist derselbe Server
|
||||
Zusatz = ' (PID %d)' % Pid
|
||||
elif ( LPid := ListenerPid( Port ) ) is not None:
|
||||
Zusatz = ' (Listener-PID %d, keine PID-Datei)' % LPid
|
||||
if IstUnsereApp( Port ):
|
||||
print( 'LAEUFT: http://localhost:%d%s' % (Port, Zusatz) )
|
||||
return 0
|
||||
print( 'BELEGT: Port %d antwortet, aber nicht mit unserer App%s'
|
||||
% (Port, Zusatz) )
|
||||
return 1
|
||||
if Pid is not None and PidLebt( Pid ):
|
||||
print( 'UNKLAR: Prozess %d aus der PID-Datei lebt, aber Port %d '
|
||||
'antwortet nicht (Startphase oder haengt?)' % (Pid, Port) )
|
||||
return 1
|
||||
print( 'GESTOPPT: kein Server auf Port %d' % Port )
|
||||
return 1
|
||||
|
||||
|
||||
def Vordergrund(Port):
|
||||
print( 'Starte Web-Testinstanz im Vordergrund mit Auto-Reload '
|
||||
'(Strg+C zum Beenden) ...' )
|
||||
try:
|
||||
return subprocess.call( UvicornKommando( Port, Reload=True ),
|
||||
cwd=BASISDIR )
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ Main
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(
|
||||
description='verwaltet die lokale Web-Testinstanz (FastAPI/uvicorn)' )
|
||||
parser.add_argument( 'aktion', nargs='?', default='run',
|
||||
choices=['start', 'stop', 'status', 'run'],
|
||||
help='start=Hintergrund, stop=beenden, status=anzeigen, '
|
||||
'run=Vordergrund mit Auto-Reload (Standard)' )
|
||||
parser.add_argument( '--port', type=int, default=STANDARDPORT,
|
||||
help='Port der Testinstanz (Standard: %d)' % STANDARDPORT )
|
||||
argumente = parser.parse_args()
|
||||
|
||||
if argumente.aktion == 'start':
|
||||
sys.exit( Starten( argumente.port ) )
|
||||
elif argumente.aktion == 'stop':
|
||||
sys.exit( Stoppen( argumente.port ) )
|
||||
elif argumente.aktion == 'status':
|
||||
sys.exit( Status( argumente.port ) )
|
||||
else:
|
||||
sys.exit( Vordergrund( argumente.port ) )
|
||||
Reference in New Issue
Block a user