rechnungstool/pruef_updater.py
TheMockTv 2b09d15cd0 Aufraeumen nach der Pruefkette: doppelter Code an eine Stelle
Die Runde vom 07.09.2026 hat neben den zwei echten Fehlern eine Reihe
Wiederholungen und stumme Stellen gemeldet. Abgearbeitet in Etappen, jede fuer
sich pruefbar - nach jeder liefen beide Pruefketten gruen.

* Menueleiste: der Wechsel von einem Titel zum anderen brauchte ZWEI Klicks.
  Der grab_set der offenen Liste schluckt den ersten, die <Button-1>-Bindung
  des Titels feuert gar nicht. Das native tk.Menu, das diese Leiste ersetzt
  hat, wechselte schon beim blossen Ueberfahren - zwei Klicks waren also eine
  Verschlechterung gegenueber dem, was vorher da war. Die Klappliste wird
  ausserdem jetzt wie jede andere Position ueber theme.auf_bildschirm()
  begrenzt; am unteren Fensterrand lief sie vorher hinaus.
  Neu: pruef_menueleiste.py misst beides ueber echte Klickereignisse.

* %LOCALAPPDATA%/ravokk wurde an drei Stellen einzeln ausgerechnet. Jetzt
  fragen alle gemeinsam.standard_ordner(). Laufen die je auseinander, fuehren
  die beiden Programme zwei Nummernbuecher - und das faellt erst auf, wenn
  eine Rechnungsnummer zum zweiten Mal vergeben ist (§ 14 Abs. 4 Nr. 4 UStG).

* pruefe() stand 35-mal im Quelltext, in sechs Fassungen -> pruefhelfer.py.
  Die zwei Ausreisser sind mitgezogen: pruef_bilder.py schrieb ein eigenes
  Format, pruef_storno_verrechnet.py zaehlte in einer Zahl und konnte am Ende
  nicht sagen, WAS fehlschlug.

* dlg_darstellung() und die drei Handgriffe des Umschaltens standen in beiden
  app.py fast wortgleich -> ThemeMixin in theme.py. Beide trugen inzwischen
  denselben langen Kommentar zu demselben Fehler; das war der Beweis, dass es
  eine Sache ist. Programmspezifisch bleiben der Hinweistext im Dialog (R31)
  und die Frage, ob die Einstellungen ausdruecklich gespeichert werden muessen.

* pruef_gemeinsam_automatisch.py sagte im Text "genau ein Buch" und prueft
  ">= 1" mit einem any() - eine Pruefung, die nicht rot werden kann. Jetzt
  == 1, und der Dateiname wird mitgeprueft.
  Dazu neu: die neun Dateien, die im Kopf zusagen, sie laegen in beiden
  Programmen gleich, werden byteweise verglichen. Bei hinweis.py stimmte die
  Zusage seit dem 06.09. nicht mehr - gleicher Inhalt, CRLF gegen LF.

* .gitattributes, damit die Zeilenenden nicht von der Maschine abhaengen.
  Ohne das meldet genau diese Pruefung nach einem frischen Checkout einen
  Unterschied, den es im Repository gar nicht gibt.

* Sechs stumme "except OSError" sagen jetzt, warum sie schweigen duerfen, und
  einer meldet statt zu schweigen: schlaegt die Uebernahme einer alten
  config.json fehl, faengt das Programm ohne Firmendaten, Katalog und Zaehler
  neu an - und der Erfolgsfall schrieb eine Zeile, der Fehlerfall nicht. Die
  24 stummen tk.TclError bleiben: dort wird ein Widget angefasst, das gerade
  zerstoert wurde, und eine Meldung waere Rauschen.

* Tote Parameter und Variablen aus dem Umbau des Vortags: symbol_setzen(dunkel=),
  kachel(grund=, radius=), mit_trennlinie, sechs x/y-Berechnungen, die von
  theme.mittig() sofort ueberschrieben wurden, vier lokale "import ctypes".

39 Pruefstaende, 0 rot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-07 18:39:03 +02:00

220 lines
9.7 KiB
Python

# -*- coding: utf-8 -*-
"""Pruefstand: der Updater - vor allem die Faelle, in denen er NICHTS tun darf.
Sein Bild (06.09.2026):
"der updater schaut beim starten erst: ist auf dem git link eine neue
version mit exe? wenn ja, erst laden, ersetzen und dann starten."
"einmal pruefen auf vollstaendigkeit."
"wir werden den namen mit nummer fuehren - wenn er merkt, eh warte mal,
die heisst anders, dann neu machen"
Ein Updater ist gefaehrlicher als das Programm, das er aktualisiert: Er
entscheidet ueber die Datei, die der Betrieb braucht. Deshalb wird hier weniger
geprueft, DASS er aktualisiert, als dass er es in den falschen Faellen NICHT
tut - und dass er nie etwas ueberschreibt.
Ohne Netz: die Release-Liste wird eingesetzt, nicht geholt.
"""
import os
import shutil
import sys
import tempfile
QUELLE = r"C:\claude\rechnungstool"
sys.path.insert(0, QUELLE)
import updater as U # noqa: E402
from pruefhelfer import fehler, pruefe
EXE = "Rechnungstool.exe"
# ---- Versionen vergleichen ---------------------------------------------
pruefe(U.zerlegen("v1.6.0") == (1, 6, 0), "v1.6.0 wird zu (1, 6, 0)")
pruefe(U.zerlegen("") == (0,), "leere Angabe zaehlt als aelteste")
pruefe(U.zerlegen("kaputt") == (0,), "unlesbare Angabe zaehlt als aelteste")
pruefe(U.ist_neuer("v1.7.0", "1.6.9"), "1.7.0 ist neuer als 1.6.9 (nicht alphabetisch!)")
pruefe(not U.ist_neuer("v1.6.0", "1.6.0"), "Gleichstand ist NICHT neuer")
pruefe(U.ist_neuer("v1.10.0", "1.9.0"), "1.10.0 ist neuer als 1.9.0")
# ---- Die Nummer im Dateinamen ------------------------------------------
pruefe(U.dateiname(EXE, "v1.6.0") == "Rechnungstool-1.6.0.exe",
"aus Name und Tag wird Rechnungstool-1.6.0.exe")
pruefe(U.dateiname("Rechnungstool-1.5.0.exe", "v1.6.0") == "Rechnungstool-1.6.0.exe",
"eine schon vorhandene Nummer wird ersetzt, nicht angehaengt")
pruefe(U.basis_und_endung(EXE) == ("Rechnungstool", ".exe"), "Stamm und Endung sauber getrennt")
# ---- Das passende Release finden ---------------------------------------
def release(tag, dateien, entwurf=False, vorab=True):
return {"tag_name": tag, "draft": entwurf, "prerelease": vorab,
"assets": [{"name": n, "size": g,
"browser_download_url": f"http://x/{tag}/{n}"}
for n, g in dateien]}
liste = [release("v1.5.0", [(EXE, 10)]),
release("v1.6.0", [(EXE, 20)]),
release("v1.5.5", [(EXE, 15)])]
treffer = U.passendes_release(liste, EXE)
pruefe(treffer and treffer[0]["tag_name"] == "v1.6.0",
"aus einer unsortierten Liste wird die neueste genommen")
pruefe(U.passendes_release([release("v9.9.9", [("anderes.exe", 5)])], EXE) is None,
"ein Release ohne passende Datei zaehlt nicht")
pruefe(U.passendes_release([release("v9.9.9", [(EXE, 5)], entwurf=True)], EXE) is None,
"ein Entwurf zaehlt nicht")
# Vorabversionen MUESSEN zaehlen - alle Fassungen sind so gekennzeichnet.
pruefe(U.passendes_release([release("v2.0.0", [(EXE, 30)], vorab=True)], EXE) is not None,
"Vorabversionen zaehlen mit (sonst faende der Updater nie etwas)")
# ---- Vollstaendigkeit ---------------------------------------------------
tmp = tempfile.mkdtemp(prefix="upd_pruef_")
probe = os.path.join(tmp, "probe.bin")
with open(probe, "wb") as f:
f.write(b"A" * 1000)
summe = U.sha256(probe)
gut, _ = U.vollstaendig(probe, 1000, summe)
pruefe(gut, "richtige Groesse und Pruefsumme werden angenommen")
gut, grund = U.vollstaendig(probe, 999, summe)
pruefe(not gut and "Groesse" in grund, f"falsche Groesse wird abgelehnt ({grund})")
gut, grund = U.vollstaendig(probe, 1000, "0" * 64)
pruefe(not gut and "Pruefsumme" in grund, f"falsche Pruefsumme wird abgelehnt ({grund})")
gut, _ = U.vollstaendig(probe, 1000, "")
pruefe(gut, "ohne Pruefsumme entscheidet die Groesse allein")
gut, _ = U.vollstaendig(os.path.join(tmp, "gibtsnicht"), 1000, "")
pruefe(not gut, "eine fehlende Datei ist nicht vollstaendig")
# ---- Was liegt im Ordner? ----------------------------------------------
ordner = os.path.join(tmp, "install")
os.makedirs(ordner)
for name, inhalt in (("Rechnungstool-1.5.0.exe", b"ALT"),
("Rechnungstool-1.6.0.exe", b"AKTUELL"),
("nichts-zu-tun.txt", b"x")):
with open(os.path.join(ordner, name), "wb") as f:
f.write(inhalt)
fassung, pfad = U.neueste_installierte(ordner, EXE)
pruefe(fassung == "1.6.0", f"die neueste vorhandene Fassung wird am Namen erkannt ({fassung})")
pruefe(os.path.basename(pfad) == "Rechnungstool-1.6.0.exe", "und der Pfad stimmt")
pruefe(len(U.installierte(ordner, EXE)) == 2, "fremde Dateien im Ordner stoeren nicht")
# ---- Der ganze Durchlauf, ohne Netz -------------------------------------
meldungen = []
def sagen(t):
meldungen.append(str(t))
def alle_da():
return sorted(os.path.basename(p) for _f, p in U.installierte(ordner, EXE))
# 1. gleiche Fassung -> nichts laden, vorhandene starten
meldungen.clear()
start = U.durchlauf(ordner, "x/y", EXE, sagen=sagen,
holen_=lambda _r: [release("v1.6.0", [(EXE, 11)])])
pruefe(os.path.basename(start) == "Rechnungstool-1.6.0.exe",
"gleiche Fassung: die vorhandene wird gestartet")
pruefe(alle_da() == ["Rechnungstool-1.5.0.exe", "Rechnungstool-1.6.0.exe"],
"und im Ordner hat sich nichts geaendert")
# 2. aeltere Fassung im Netz -> nichts tun
start = U.durchlauf(ordner, "x/y", EXE, sagen=sagen,
holen_=lambda _r: [release("v1.5.0", [(EXE, 11)])])
pruefe(os.path.basename(start) == "Rechnungstool-1.6.0.exe",
"aeltere Fassung im Netz: es wird nicht aktualisiert")
# 3. kein Netz -> vorhandene starten und sagen, warum
meldungen.clear()
def wirft(_r):
raise OSError("kein Netz")
start = U.durchlauf(ordner, "x/y", EXE, sagen=sagen, holen_=wirft)
pruefe(os.path.basename(start) == "Rechnungstool-1.6.0.exe",
"ohne Netz wird die vorhandene Fassung gestartet")
pruefe(any("Kein Update" in m for m in meldungen), "und es wird gesagt, warum")
# 4. neuere Fassung, aber Groesse passt nicht -> VERWERFEN
meldungen.clear()
falsch = release("v1.9.0", [(EXE, 999999)])
falsch["assets"][0]["browser_download_url"] = "file:///" + probe.replace("\\", "/")
start = U.durchlauf(ordner, "x/y", EXE, sagen=sagen, holen_=lambda _r: [falsch])
pruefe(any("VERWORFEN" in m for m in meldungen), "falsche Groesse: das Update wird verworfen")
pruefe(os.path.basename(start) == "Rechnungstool-1.6.0.exe",
"und die vorhandene Fassung wird gestartet")
pruefe(not os.path.exists(os.path.join(ordner, "Rechnungstool-1.9.0.exe")),
"die halbe Datei erreicht den Installationsordner NICHT - der wichtigste Punkt")
# 5. neuere Fassung, alles stimmt -> als NEUE Datei dazu
meldungen.clear()
richtig = release("v1.9.0", [(EXE, 1000)])
richtig["assets"][0]["browser_download_url"] = "file:///" + probe.replace("\\", "/")
start = U.durchlauf(ordner, "x/y", EXE, sagen=sagen, holen_=lambda _r: [richtig])
pruefe(os.path.basename(start) == "Rechnungstool-1.9.0.exe",
"die neue Fassung wird gestartet")
pruefe(os.path.exists(os.path.join(ordner, "Rechnungstool-1.9.0.exe")),
"sie liegt als EIGENE Datei im Ordner")
pruefe(os.path.exists(os.path.join(ordner, "Rechnungstool-1.6.0.exe")),
"die vorige bleibt liegen - sie ist die Rueckfallebene")
pruefe(not os.path.exists(os.path.join(ordner, "Rechnungstool-1.5.0.exe")),
"die vorvorige wurde aufgeraeumt (es bleiben immer zwei)")
buch = U.buch_lesen(ordner)
pruefe(buch.get("version") == "1.9.0",
f"der Updater fuehrt Buch ueber das, was er installiert hat ({buch})")
# 6. gleich noch einmal -> nichts laden, es ist ja jetzt aktuell
meldungen.clear()
vorher = alle_da()
start = U.durchlauf(ordner, "x/y", EXE, sagen=sagen, holen_=lambda _r: [richtig])
pruefe(any("Aktuell" in m for m in meldungen),
f"unmittelbar danach gilt es als aktuell ({meldungen})")
pruefe(alle_da() == vorher, "und es wird nichts noch einmal geladen")
# 7. Buch verloren, Datei aber da -> nicht noch einmal laden
# Kommt vor, wenn jemand installiert.json loescht. Der Dateiname allein traegt
# die Entscheidung: die 1.9.0 liegt da, also wird sie nur gestartet.
meldungen.clear()
os.remove(os.path.join(ordner, U.BUCH))
start = U.durchlauf(ordner, "x/y", EXE, sagen=sagen, holen_=lambda _r: [richtig])
pruefe(os.path.basename(start) == "Rechnungstool-1.9.0.exe",
"auch ohne Buch wird die richtige Datei gestartet")
pruefe(not any("Wird geladen" in m for m in meldungen),
f"und nichts noch einmal geladen ({meldungen})")
# 8. Ordner nicht beschreibbar -> kein Update, aber das Programm startet
# Sein Einwand: "was ist, wenn ein user den ordner aendert?" Liegt die
# Installation dann in einem geschuetzten Ordner, darf das Update scheitern -
# aber NIE der Start. Ein fehlgeschlagenes Update, nach dem gar nichts mehr
# geht, waere schlimmer als kein Update.
meldungen.clear()
gesperrt = os.path.join(tmp, "gesperrt")
os.makedirs(gesperrt)
with open(os.path.join(gesperrt, "Rechnungstool-1.6.0.exe"), "wb") as f:
f.write(b"INSTALLIERT")
echt_beschreibbar = U.beschreibbar
U.beschreibbar = lambda _o: False # so, als waere es "Programme"
try:
start = U.durchlauf(gesperrt, "x/y", EXE, sagen=sagen, holen_=lambda _r: [richtig])
finally:
U.beschreibbar = echt_beschreibbar
pruefe(os.path.basename(start) == "Rechnungstool-1.6.0.exe",
"geschuetzter Ordner: die vorhandene Fassung wird trotzdem gestartet")
pruefe(any("nicht geschrieben werden" in m for m in meldungen),
f"und es wird gesagt, warum ({meldungen})")
pruefe(not os.path.exists(os.path.join(gesperrt, "Rechnungstool-1.9.0.exe")),
"es wurde nichts abgelegt")
shutil.rmtree(tmp, ignore_errors=True)
if fehler:
print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt")
sys.exit(1)
print("GRUEN | Der Updater aktualisiert - und laesst in den falschen Faellen die Finger davon")