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:
+8
-8
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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