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:
Michael Stangl
2026-07-11 12:49:42 +02:00
parent 00dffc59eb
commit 9de80d2f2a
41 changed files with 5054 additions and 4033 deletions
+8 -8
View File
@@ -1,6 +1,6 @@
"""
Liest eine Gaesteliste im CSV-Format ein — der Web-Ersatz fuer bestellung.xml
(Meilenstein 1 der Roadmap, Adapter analog zu XMLConfig in Strukturdaten.py).
Liest eine Gaesteliste im CSV-Format ein — der Web-Ersatz fuer bestellung.json
(Meilenstein 1 der Roadmap, Adapter analog zu JSONConfig in Strukturdaten.py).
CSV-Format (Kopfzeile noetig, Spaltenreihenfolge egal, Trennzeichen ; oder ,):
@@ -12,10 +12,10 @@ CSV-Format (Kopfzeile noetig, Spaltenreihenfolge egal, Trennzeichen ; oder ,):
zusammen und sollen moeglichst zusammen sitzen.
Leer = Einzelperson (bildet eine eigene Ein-Personen-Gruppe).
- Tisch: optionale Tischnummer — die Person ist VIP und wird fest an
diesen Tisch gesetzt (wie <Tisch> in bestellung.xml).
diesen Tisch gesetzt (wie "tisch" in bestellung.json).
VIPs laufen nicht durch die GA-Platzierung.
- Anzahl: optionale Reservierungsanzahl — expandiert die Zeile in so
viele identische Buchungen (wie <Anzahl> in bestellung.xml,
viele identische Buchungen (wie "anzahl" in bestellung.json,
z.B. fuer anonyme Sammelbuchungen). Leer = 1.
Zusaetzlich werden die Spalten "MagNicht" und "MagGern" akzeptiert,
@@ -35,7 +35,7 @@ from Strukturdaten import Person, Personen, Gruppe, Gruppen
class CSVConfig:
"""aus CSV Text/Datei alles einlesen (Rueckgabe wie XMLConfig.laden,
"""aus CSV Text/Datei alles einlesen (Rueckgabe wie JSONConfig.laden,
plus Gruppennamen fuer die Anzeige im Web-Frontend)"""
PflichtSpalten = ['vorname', 'nachname']
@@ -46,11 +46,11 @@ class CSVConfig:
"""parst den CSV-Text und baut Personen/Gruppen/VIPs auf
Rueckgabe: [ PN, GN, VIPGruppe, VipHash, GruppenNamen ]
- PN: Personen-Container (ohne VIPs, wie XMLConfig)
- GN: Gruppen-Container (ohne VIP-Gruppe, wie XMLConfig)
- PN: Personen-Container (ohne VIPs, wie JSONConfig)
- GN: Gruppen-Container (ohne VIP-Gruppe, wie JSONConfig)
- VIPGruppe: Gruppe(0) mit allen fest gesetzten Personen
- VipHash: { PersonId: Tischnummer } fuer VIPsSetzen()
- GruppenNamen: { GruppenId: Anzeigename } (neu gegenueber XMLConfig)
- GruppenNamen: { GruppenId: Anzeigename }
"""
# Trennzeichen automatisch bestimmen: die Kopfzeile entscheidet
Kopfzeile = Text.lstrip('').splitlines()[0] if Text.strip() else ''
+142 -97
View File
@@ -11,11 +11,12 @@ __license__ = "Python"
from itertools import chain
from random import *
import ast
import configparser, os
import copy
import math
from collections import defaultdict
from functools import total_ordering
from xml.dom import minidom
# Strukturklassen: Person, Gruppe, Tisch
@@ -256,12 +257,15 @@ class Tische:
config.read( os.path.expanduser(FileName) )
for section in config.sections():
Tid = int(section)
Nummer = eval(config.get( section, 'Nummer' ))
nPl = eval(config.get( section, 'Plaetze' ))
Koords = eval(config.get( section, 'Koordinaten' ))
Nachbarn = eval(config.get( section, 'Nachbarliste' ))
Hofid = eval(config.get( section, 'Hof' ))
T = Tisch( Tid, Plaetze=nPl, Koordinaten=Koords, Nachbarliste=Nachbarn, Hof=Hofid, Nummer=Nummer )
# ast.literal_eval statt eval: tische.ini kann eine hochgeladene
# Fremddatei sein (Web-M1), da darf kein beliebiger Code laufen.
# Erlaubt weiterhin Zahlen, Tupel und Listen wie zuvor.
Nummer = ast.literal_eval(config.get( section, 'Nummer' ))
nPl = ast.literal_eval(config.get( section, 'Plaetze' ))
Koords = ast.literal_eval(config.get( section, 'Koordinaten' ))
Nachbarn = ast.literal_eval(config.get( section, 'Nachbarliste' ))
Hofid = ast.literal_eval(config.get( section, 'Hof' ))
T = Tisch( Tid, Plaetze=nPl, Koordinaten=Koords, Nachbarliste=Nachbarn, Hof=Hofid, Nummer=Nummer )
self.Tisch_dazu( T )
def vonId(self, SearchedId):
for T in chain(self.TischListe):
@@ -281,19 +285,21 @@ class Tische:
class Plaetze:
"""Informationen ueber freie oder benachbarte Plaetze"""
def __init__(self, Tische):
# mache Listen mit den Tischen mit n Freien Plaetzen
self.__TIDsNFreie = []
# je Anzahl freier Plaetze ein Set von Tisch-Ids. defaultdict(set)
# erspart die manuelle Vorbelegung und liefert fuer noch nie
# gesehene Fuellstaende automatisch ein leeres Set.
self.__TIDsNFreie = defaultdict(set)
self.__Groesster = Tische.Groesster
self.__Tische = Tische
for i in range( self.__Groesster + 1 ):
self.__TIDsNFreie.append( set() )
for T in Tische.TischListe:
n_freie = T.freiePlaetze()
self.__TIDsNFreie[n_freie].add(T.Id)
def __repr__(self):
result = "\n-- Tische mit n Freien Plaetzen:\n"
for i in range(self.__Groesster+1):
result = result + " " +repr(i) + " :" + repr(self.__TIDsNFreie[i]) + "\n"
# .get statt [i], damit das Anzeigen keine leeren Eintraege
# ins defaultdict schreibt
result = result + " " +repr(i) + " :" + repr(self.__TIDsNFreie.get(i, set())) + "\n"
return result
def IdsnFreie(self, n_freie):
"""gibt ein Set mit allen Tischids zurueck, wo noch X freie Plaetze sind"""
@@ -406,11 +412,13 @@ class Strafliste:
print("-- Strafliste laden")
config = configparser.ConfigParser()
config.read( os.path.expanduser(FileName) )
self.NichtNachbar = eval(config.get( 'Globals', 'NichtNachbar' ))
self.Allein = eval(config.get( 'Globals', 'Allein' ))
self.TischWert = eval( config.get( 'Globals', 'TischWertigkeiten' ) )
# ast.literal_eval statt eval: die Werte sind reine Literale
# (Zahlen und Dicts wie {2:-10}), also kein Code noetig.
self.NichtNachbar = ast.literal_eval(config.get( 'Globals', 'NichtNachbar' ))
self.Allein = ast.literal_eval(config.get( 'Globals', 'Allein' ))
self.TischWert = ast.literal_eval( config.get( 'Globals', 'TischWertigkeiten' ) )
for (k,v) in config.items( 'Trennung' ):
self.GruppenTrennen[int(k)] = eval(v)
self.GruppenTrennen[int(k)] = ast.literal_eval(v)
# Sitzplatzverteilung, bestehend aus den Einzelelementen
@@ -618,10 +626,6 @@ class Sitzplatzverteilung:
Teile.append(
'<circle cx="%g" cy="%g" r="%g" fill="white" '
'stroke="black" stroke-width="1.5"/>\n' % (Mx, My, TR) )
Teile.append(
'<text x="%g" y="%g" text-anchor="middle" '
'font-size="10" fill="black">Tisch %s</text>\n'
% (Mx, My - TR - 2, T.Id) )
Plaetze = list(T)
n = len(Plaetze)
@@ -636,6 +640,19 @@ class Sitzplatzverteilung:
'stroke="black" stroke-width="1"/>\n'
% (Px, Py, PersonenRadius, Farbe) )
# Tischnummer zentriert in die Tischmitte (nach den Personen-
# kreisen gezeichnet, damit sie oben liegt; ein halbtransparenter
# weisser Kreis darunter haelt sie auch ueber Personenkreisen
# lesbar, falls die Tischmitte belegt sein sollte)
Teile.append(
'<circle cx="%g" cy="%g" r="%g" fill="white" '
'fill-opacity="0.75"/>\n' % (Mx, My, PersonenRadius) )
Teile.append(
'<text x="%g" y="%g" text-anchor="middle" '
'dominant-baseline="central" font-size="%g" '
'font-weight="bold" fill="black">%s</text>\n'
% (Mx, My, PersonenRadius * 1.1, T.Nummer) )
Teile.append( '</svg>\n' )
fout = open( OutputSVG, 'w' )
@@ -897,11 +914,19 @@ class Sitzplatzverteilung:
Tisch = Tische.vonId(TischId)
self.PersonSetzen( Person, Tisch )
def TopX(self, Liste, Anzahl=-1 ):
"""gibt eine Liste mit X Ids zurueck, die die schlechtesten Wertungen haben"""
"""gibt eine Liste mit X Ids zurueck, die die schlechtesten Wertungen haben
Achtung: Dies ist bewusst *nicht* heapq.nsmallest. Der laufende
Schwellwert Min sinkt monoton mit, so dass nur Ids aufgenommen
werden, die waehrend der Iteration einen neuen Tiefpunkt erreichen
oder gleichziehen. Das Ergebnis haengt an der Iterationsreihenfolge
und praegt das Mutationsverhalten des GA (mutieren() ->
SchlechteGruppenTopX). Eine Umstellung auf heapq wuerde die
Loesungsfindung veraendern und ist daher bewusst unterlassen.
"""
Min = 0
RListe=[]
Wertigkeit=[]
# TODO filter verwenden
for Id, Wert in Liste.items():
if Wert <= Min:
RListe.append(Id)
@@ -911,92 +936,112 @@ class Sitzplatzverteilung:
# Sitzplatzverteilung, bestehend aus den Einzelelementen
class XMLConfig:
"""aus Xml file alles einlesen"""
def getText(self, nodelist):
rc = ""
for node in nodelist:
if node.nodeType == node.TEXT_NODE:
rc = rc + node.data
return rc
def handlePersonNode( self, person ):
"""Personendaten erfassen"""
info = {}
try:
Titel = person.getElementsByTagName("Titel")[0]
info["Titel"] = self.getText(Titel.childNodes)
except IndexError:
info["Titel"] = ""
Vorname = person.getElementsByTagName("Vorname")[0]
Nachname = person.getElementsByTagName("Nachname")[0]
info["Vorname"] = self.getText(Vorname.childNodes)
info["Nachname"] = self.getText(Nachname.childNodes)
return info
def HandleBestellung(self, Bestellung):
"""alle Bloecke in der XML Bestellung parsen"""
PidListe = []
VipHash = {}
class JSONConfig:
"""liest die Gaesteliste aus einer bestellung.json ein
(Nachfolger des frueheren XMLConfig; JSON hat die alte XML-Form
abgeloest, siehe doc/dateiformate.md).
Erwartete Struktur:
{
"gruppen": [
{
"name": "Familie Huber", # optional, nur Anzeige
"tisch": 1, # optional -> VIP-Fixplatz
"anzahl": 1, # optional -> Sammelbuchung
"personen": [
{"vorname": "Anna", "nachname": "Huber", "titel": ""}
]
}
]
}
"tisch" macht die ganze Gruppe zu VIPs an diesem Tisch (laeuft
nicht durch die GA-Platzierung). "anzahl" > 1 vervielfacht jede
Person der Gruppe in so viele identische Buchungen (anonyme
Sammelbuchung). "titel" ist optional.
"""
def HandleBestellung(self, Daten):
"""die geladene JSON-Struktur in Personen/Gruppen/VIPs zerlegen
Rueckgabe: [ PN, GN, VIPGruppe, VipHash, GruppenNamen ]
(wie CSVConfig; das fruehere XMLConfig gab die ersten vier
Elemente zurueck - Aufrufer, die GruppenNamen nicht brauchen,
koennen es ignorieren.)
"""
if not isinstance( Daten, dict ) or 'gruppen' not in Daten:
raise ValueError("bestellung.json braucht ein Objekt mit dem "
"Schluessel 'gruppen'")
PN = Personen()
GN = Gruppen()
VIPGruppe = Gruppe( 0 )
VipHash = {}
GruppenNamen = {}
print('-HandleBestellung')
AlleGruppenXML = Bestellung.getElementsByTagName("Gruppe")
PID = 0
GID = 0
for GruppeXML in AlleGruppenXML:
GID = GID + 1
print('-- GID ' + repr(GID))
G = Gruppe( GID )
for Index, GruppeJSON in enumerate( Daten['gruppen'] ):
if 'personen' not in GruppeJSON:
raise ValueError("Gruppe %d: 'personen' fehlt" % (Index + 1))
IsVip = False
AnzahlReservierungen = 1
for Node in GruppeXML.childNodes:
if Node.nodeName == 'Tisch':
TischNr = self.getText(Node.childNodes)
print('TischNr:', TischNr)
IsVip = True
if Node.nodeName == 'Anzahl':
AnzahlReservierungen = int(self.getText(Node.childNodes))
print('Anzahl der Reservierungen:', AnzahlReservierungen)
IsContingent = True
IsVip = 'tisch' in GruppeJSON and GruppeJSON['tisch'] is not None
if IsVip:
try:
TischNr = int( GruppeJSON['tisch'] )
except (TypeError, ValueError):
raise ValueError("Gruppe %d: 'tisch' ist keine "
"Tischnummer" % (Index + 1))
for Node in GruppeXML.childNodes:
if Node.nodeName == 'Person':
NRes = 0
for i in range(int(AnzahlReservierungen)):
NRes = NRes + 1
PID = PID + 1
print("PID =" + repr(PID))
info = self.handlePersonNode( Node )
try:
AnzahlReservierungen = int( GruppeJSON.get( 'anzahl', 1 ) )
except (TypeError, ValueError):
raise ValueError("Gruppe %d: 'anzahl' ist keine Zahl"
% (Index + 1))
if AnzahlReservierungen < 1:
raise ValueError("Gruppe %d: 'anzahl' muss >= 1 sein"
% (Index + 1))
# regulaere (nicht-VIP) Gruppen bekommen eine GID
if not IsVip:
GID = GID + 1
G = Gruppe( GID )
GruppenNamen[ GID ] = GruppeJSON.get( 'name', 'Gruppe %d' % GID )
for PersonJSON in GruppeJSON['personen']:
Vorname = str( PersonJSON.get( 'vorname', '' ) ).strip()
Nachname = str( PersonJSON.get( 'nachname', '' ) ).strip()
Titel = str( PersonJSON.get( 'titel', '' ) ).strip()
if Vorname == '' or Nachname == '':
raise ValueError("Gruppe %d: jede Person braucht Vorname "
"und Nachname" % (Index + 1))
for i in range( AnzahlReservierungen ):
PID = PID + 1
P = Person( PID, Vorname, Nachname, Titel )
if IsVip:
VipHash[ PID ] = TischNr
VIPGruppe.Person_dazu( P )
else:
PN.PersonAdd( P )
G.Person_dazu( P )
if not IsVip:
if G.Anzahl() > 0:
GN.Gruppe_dazu( G )
else:
# leere Gruppe wieder verwerfen
del GruppenNamen[ GID ]
GID = GID - 1
return [ PN, GN, VIPGruppe, VipHash, GruppenNamen ]
Vorname = info["Vorname"]
Nachname = info["Nachname"]
Titel = info["Titel"]
P = Person( PID, Vorname, Nachname, Titel)
if IsVip == True:
PidListe.append( int(PID) )
VipHash[ int(PID) ] = int(TischNr)
VIPGruppe.Person_dazu( P )
else:
PN.PersonAdd( P )
G.Person_dazu( P )
if (G.Anzahl() > 0) & (IsVip != True):
GN.Gruppe_dazu( G )
if IsVip == True:
GID = GID - 1
print(PN)
print(GN)
print(VIPGruppe)
print(VipHash)
print('==========')
return [ PN, GN, VIPGruppe, VipHash ]
def laden(self, Bestellung):
"""lese alle Eingangsdaten aus einem XML File ein"""
fsock = open( Bestellung, 'r')
xmlini = minidom.parse(fsock)
return self.HandleBestellung(xmlini)
"""lese alle Eingangsdaten aus einer JSON-Datei ein"""
import json
# utf-8-sig schluckt ein evtl. vorhandenes BOM
fsock = open( Bestellung, 'r', encoding='utf-8-sig' )
Daten = json.load( fsock )
fsock.close()
return self.HandleBestellung( Daten )
+186 -3
View File
@@ -6,9 +6,37 @@ from random import *
from itertools import chain
from copy import deepcopy
from functools import total_ordering
import ast
import configparser, os
def _StringAusdruck(text):
"""wertet einen einfachen String-Ausdruck sicher aus.
zyklus.cfg nutzt kompakte Schreibweisen wie
'e,'+'s,z,m,j,z,'*3+'s'
also String-Literale, mit '+' verkettet und mit '*' vervielfacht.
ast.literal_eval kann das nicht (nur reine Literale), eval() waere
unsicher. Dieser Mini-Auswerter erlaubt ausschliesslich str-Literale,
ganze Zahlen sowie die Operatoren + und * - kein Funktionsaufruf,
keine Namen, kein beliebiger Code.
"""
knoten = ast.parse(text, mode='eval').body
def rechne(n):
if isinstance(n, ast.Constant) and isinstance(n.value, (str, int)):
return n.value
if isinstance(n, ast.BinOp) and isinstance(n.op, (ast.Add, ast.Mult)):
links, rechts = rechne(n.left), rechne(n.right)
return links + rechts if isinstance(n.op, ast.Add) else links * rechts
raise ValueError("unerlaubter Ausdruck in zyklus.cfg: %r" % text)
ergebnis = rechne(knoten)
if not isinstance(ergebnis, str):
raise ValueError("zyklus.cfg-Ausdruck ergibt keinen String: %r" % text)
return ergebnis
__author__ = "Michael Stangl"
__version__ = "$Revision: $"
__date__ = "$Date: $"
@@ -65,24 +93,174 @@ class Zyklus:
ZyklusPath= os.path.join( FileName )
config.read( ZyklusPath )
self.Name = ZyklusName
Abfolge = eval( config.get( ZyklusName, 'Abfolge' ))
Anzahl = eval( config.get( ZyklusName, 'Anzahl' ))
Abfolge = _StringAusdruck( config.get( ZyklusName, 'Abfolge' ))
Anzahl = _StringAusdruck( config.get( ZyklusName, 'Anzahl' ))
self.Abfolge = Abfolge.split(',')
self.Anzahl = Anzahl.split(',')
pass
class Entwicklung:
"""Beobachtungsobjekt: zeichnet den Verlauf der Loesungsfindung auf.
Nach jeder Zyklus-Aktion wird ein Messpunkt erfasst (bester,
durchschnittlicher und schlechtester Wert im aktuellen Pool).
alsSVG() zeichnet daraus eine Verlaufsgrafik: Zeitachse (Aktions-
index) nach rechts, Wertigkeit auf der Y-Achse. Domaenen-agnostisch;
funktioniert mit jeder Loesung, die ein Attribut .value hat.
"""
# Buchstaben der Zyklus-Abfolge -> lesbare Namen fuer die Beschriftung
AktionsNamen = {
'e': 'erzeugen', 'm': 'mutieren', 's': 'selektieren',
'S': 'zufaellig', 'j': 'behalten', 'z': 'Generation' }
def __init__(self):
# je Messpunkt: (Schrittindex, Aktion, bester, schnitt, schlechtester, N)
self.Punkte = []
def erfassen(self, Aktion, Pool):
"""einen Messpunkt aus dem aktuellen Pool aufnehmen"""
if not Pool:
return
Werte = [ L.value for L in Pool ]
self.Punkte.append( (
len(self.Punkte),
Aktion,
max(Werte),
sum(Werte) / len(Werte),
min(Werte),
len(Werte) ) )
def __repr__(self):
r = "-- Entwicklung (%d Messpunkte):\n" % len(self.Punkte)
for (i, Aktion, best, schnitt, schlecht, n) in self.Punkte:
r = r + (" %3d %-12s best=%s schnitt=%.1f schlecht=%s (N=%d)\n"
% (i, self.AktionsNamen.get(Aktion, Aktion),
best, schnitt, schlecht, n))
return r
def alsSVG(self, OutputSVG, Breite=900, Hoehe=500, Rand=60):
"""zeichnet den aufgezeichneten Verlauf als SVG-Liniendiagramm"""
if not self.Punkte:
raise ValueError("keine Messpunkte aufgezeichnet")
AlleWerte = []
for (_i, _a, best, schnitt, schlecht, _n) in self.Punkte:
AlleWerte.extend( (best, schnitt, schlecht) )
WMin, WMax = min(AlleWerte), max(AlleWerte)
if WMax == WMin:
WMax = WMin + 1 # flache Linie nicht auf den Rand kleben
N = len(self.Punkte)
PlotBreite = Breite - 2 * Rand
PlotHoehe = Hoehe - 2 * Rand
def X(i):
if N == 1:
return Rand + PlotBreite / 2
return Rand + PlotBreite * i / (N - 1)
def Y(wert):
# hoher (besserer) Wert oben, niedriger unten
return Rand + PlotHoehe * (WMax - wert) / (WMax - WMin)
def Polyline(Index, Farbe, Beschriftung):
punkte = ' '.join( '%.1f,%.1f' % (X(i), Y(p[Index]))
for i, p in enumerate(self.Punkte) )
return ('<polyline points="%s" fill="none" stroke="%s" '
'stroke-width="2"/>\n' % (punkte, Farbe), Beschriftung, Farbe)
Teile = []
Teile.append( '<?xml version="1.0" encoding="UTF-8"?>\n' )
Teile.append(
'<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" '
'viewBox="0 0 %d %d" font-family="sans-serif">\n'
% (Breite, Hoehe, Breite, Hoehe) )
Teile.append( '<rect width="100%" height="100%" fill="white"/>\n' )
Teile.append(
'<text x="%d" y="30" text-anchor="middle" font-size="16" '
'fill="#333">Entwicklung der Loesungsfindung</text>\n'
% (Breite // 2) )
# Achsen
Teile.append(
'<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="#333" '
'stroke-width="1.5"/>\n' % (Rand, Rand, Rand, Hoehe - Rand) )
Teile.append(
'<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="#333" '
'stroke-width="1.5"/>\n'
% (Rand, Hoehe - Rand, Breite - Rand, Hoehe - Rand) )
# Y-Gitter + Beschriftung (5 Linien)
for k in range(6):
wert = WMin + (WMax - WMin) * k / 5
y = Y(wert)
Teile.append(
'<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" stroke="#eee" '
'stroke-width="1"/>\n' % (Rand, y, Breite - Rand, y) )
Teile.append(
'<text x="%d" y="%.1f" text-anchor="end" font-size="11" '
'fill="#666">%.0f</text>\n' % (Rand - 8, y + 4, wert) )
Teile.append(
'<text x="18" y="%d" text-anchor="middle" font-size="12" '
'fill="#333" transform="rotate(-90 18 %d)">Wertigkeit '
'(hoeher = besser)</text>\n' % (Hoehe // 2, Hoehe // 2) )
Teile.append(
'<text x="%d" y="%d" text-anchor="middle" font-size="12" '
'fill="#333">Zyklus-Aktion (Zeit)</text>\n'
% (Breite // 2, Hoehe - 18) )
# X-Beschriftung: Aktionsbuchstaben (bei wenigen Punkten alle,
# sonst ausgeduennt, damit die Achse lesbar bleibt)
Schritt = max(1, N // 30)
for i, p in enumerate(self.Punkte):
if i % Schritt == 0 or i == N - 1:
Teile.append(
'<text x="%.1f" y="%d" text-anchor="middle" font-size="10" '
'fill="#999">%s</text>\n' % (X(i), Hoehe - Rand + 16, p[1]) )
# die drei Kurven
Kurven = [
Polyline(2, '#2f6f4f', 'beste Loesung'),
Polyline(3, '#c08a2d', 'Durchschnitt'),
Polyline(4, '#a03030', 'schlechteste Loesung') ]
for (svg, _besch, _farbe) in Kurven:
Teile.append( svg )
# Legende oben rechts
LegX = Breite - Rand - 160
LegY = Rand + 10
for k, (_svg, besch, farbe) in enumerate(Kurven):
y = LegY + k * 18
Teile.append(
'<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" '
'stroke-width="2"/>\n' % (LegX, y, LegX + 24, y, farbe) )
Teile.append(
'<text x="%d" y="%d" font-size="11" fill="#333">%s</text>\n'
% (LegX + 30, y + 4, besch) )
Teile.append( '</svg>\n' )
fout = open( OutputSVG, 'w' )
fout.writelines( Teile )
fout.close()
class Farm:
"""enthaelt und pflegt Loesungsobjekte, kreuzt, mutiert, selektiert sie"""
def __init__(self, Zyklus, Klasse, *Arguments, **keywords):
"""initialisiere die Farm
mit einem definerten Zyklus fuer die Zucht und dem gewunschten zu Zuchtobjekt
Beobachtung: self.Entwicklung zeichnet nach jeder Zyklus-Aktion
den Pool-Zustand auf und laesst sich per alsSVG() visualisieren.
"""
self.Position = 0
self.Pool = []
self.PoolNeu = []
self.Zyklus = Zyklus
self.Klasse = Klasse
self.Entwicklung = Entwicklung()
for Aktion, Zahl in zip(Zyklus.Abfolge,Zyklus.Anzahl):
#print 'Aktion:',Aktion
#print 'Zahl:',Zahl
@@ -101,6 +279,11 @@ class Farm:
self.NeueGeneration()
else:
pass
# nach jeder Aktion den aktuellen Pool-Zustand aufzeichnen.
# Der beobachtete Pool ist der, in dem gerade die Loesungen
# liegen: PoolNeu, sobald dort etwas gesammelt wurde, sonst Pool.
self.Entwicklung.erfassen(
Aktion, self.PoolNeu if self.PoolNeu else self.Pool )
def LoesungenErzeugen(self, N, Klasse, *Arguments, **keywords):
""" Erzeuge Loesungen"""
for i in range(N):
+14 -5
View File
@@ -18,16 +18,21 @@ import optparse
if __name__ == '__main__':
# Kommandozeilenoption --indir: Verzeichnis mit den Eingabedateien
# (tische.ini, bestellung.xml) und Ziel fuer die Ausgabedateien
# (tische.ini, bestellung.json) und Ziel fuer die Ausgabedateien
# (TischePersonen.txt, PersonenTische.csv)
parser = optparse.OptionParser()
parser.add_option( "--indir", dest="indir", default=None,
help="Verzeichnis mit tische.ini und bestellung.xml; "
help="Verzeichnis mit tische.ini und bestellung.json; "
"dort werden auch TischePersonen.txt und PersonenTische.csv abgelegt" )
parser.add_option( "--tosvg", dest="tosvg", action="store_true", default=False,
help="zusaetzlich eine Sitzplatzverteilung.svg im --indir Verzeichnis "
"erzeugen (Tische als weisse Kreise, Personen als farbige "
"Kreise je Gruppe)" )
parser.add_option( "--show-development", dest="showdev", action="store_true",
default=False,
help="zusaetzlich eine Entwicklung.svg im --indir Verzeichnis "
"erzeugen: Verlauf der Loesungsfindung (Zeitachse nach rechts, "
"beste/durchschnittliche/schlechteste Wertigkeit auf der Y-Achse)" )
(options, args) = parser.parse_args()
if options.indir is None:
@@ -51,12 +56,13 @@ if __name__ == '__main__':
# lade Eingabedateien
Tischconfig = os.path.join( InDir, 'tische.ini' )
Bestellung = os.path.join( InDir, 'bestellung.xml' )
Bestellung = os.path.join( InDir, 'bestellung.json' )
# lade Ausgabedateien
AusgabeTP= os.path.join( InDir, 'TischePersonen.txt' )
AusgabePT = os.path.join( InDir, 'PersonenTische.csv' )
AusgabeSVG = os.path.join( InDir, 'Sitzplatzverteilung.svg' )
AusgabeEntwicklung = os.path.join( InDir, 'Entwicklung.svg' )
Z = Zyklus()
Z.laden( Zyklusconfig, Zyklusart )
@@ -68,8 +74,8 @@ if __name__ == '__main__':
TE.laden( Tischconfig )
print(TE)
InputFile = XMLConfig()
[ PN, GN, VIPGruppe, VIPListePT ] = InputFile.laden( Bestellung )
InputFile = JSONConfig()
[ PN, GN, VIPGruppe, VIPListePT, GruppenNamen ] = InputFile.laden( Bestellung )
print(PN)
print(GN)
print(VIPGruppe)
@@ -83,6 +89,9 @@ if __name__ == '__main__':
S.speichern( AusgabeTP, AusgabePT )
if options.tosvg:
S.alsSVG( AusgabeSVG )
if options.showdev:
F1.Entwicklung.alsSVG( AusgabeEntwicklung )
print( "Entwicklungsverlauf geschrieben:", AusgabeEntwicklung )
else:
pass
+376
View File
@@ -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 ) )