Steuerrechnungstool/updater.py
TheMockTv 96671027c8 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_storno_verrechnet.py zaehlte in
  einer Zahl und konnte am Ende nicht sagen, WAS fehlschlug, pruef_bilder.py
  im Rechnungstool schrieb ein eigenes Format.

* 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.

* Die stummen "except OSError" in api.py, updater.py und einzelinstanz.py
  sagen jetzt, warum sie schweigen duerfen. Die 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

404 lines
15 KiB
Python

# -*- coding: utf-8 -*-
"""Der Updater - er ist der Starter.
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."
"updater startet kurz als im ram und das main programm ist aus, sonst
kannst es nicht ersetzen."
WARUM DAS DER RICHTIGE AUFBAU IST
---------------------------------
Windows laesst eine laufende EXE nicht ueberschreiben. Ein Programm, das sich
selbst aktualisiert, muss deshalb Umwege gehen (Kopie im Temp, warten, bis der
eigene Prozess weg ist, tauschen). All das entfaellt, wenn der Updater das ist,
was der Nutzer startet: Er laeuft VOR dem Programm - die EXE ist gar nicht in
Benutzung, wenn sie ersetzt wird.
DIE NUMMER STEHT IM DATEINAMEN (seine Idee, 06.09.2026)
-------------------------------------------------------
"wir werden den namen mit nummer fuehren - wenn er merkt, eh warte mal, die
heisst anders, dann neu machen"
Die installierte Datei heisst `Rechnungstool-1.6.0.exe`. Daraus folgt dreierlei:
* Es wird nie etwas UEBERSCHRIEBEN - eine neue Fassung ist eine neue Datei.
Das Sperrproblem von Windows gibt es damit nicht einmal theoretisch.
* Die alte Fassung bleibt liegen und ist die Rueckfallebene, ohne dass
jemand eine Sicherung anlegen muesste.
* Der Updater sieht AM NAMEN, was installiert ist. Er muss keiner Angabe
glauben, die auseinanderlaufen koennte.
DIE REGEL, AN DER SOLCHE UPDATER SCHEITERN
------------------------------------------
Sie schreiben einen halb geladenen Download in die Installation, und das
Programm startet nie wieder. Deshalb hier: in einen Zwischenordner laden,
GROESSE UND PRUEFSUMME gegen die Angaben des Releases halten, und erst dann in
den Installationsordner legen.
AUFBAU
------
Dieser Teil ist eine BIBLIOTHEK ohne Fenster und ohne Netzzwang: jede
Entscheidung ist eine eigene Funktion, die sich einzeln pruefen laesst
(pruef_updater.py). Das Startprogramm steht unten unter __main__.
Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie
hinueber.
"""
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import urllib.request
API = "https://git.pcore.de/api/v1/repos"
# Wie die Buchfuehrung des Updaters heisst. Sie liegt neben der EXE und wird
# von IHM gefuehrt - nicht vom Programm. So kann nichts auseinanderlaufen:
# was hier steht, hat der Updater selbst installiert.
BUCH = "installiert.json"
# ---------------------------------------------------------------------------
# Versionen vergleichen
# ---------------------------------------------------------------------------
def zerlegen(fassung):
"""'v1.6.0' oder '1.6.0' -> (1, 6, 0). Unlesbares wird zu (0,).
Absichtlich nachsichtig: eine Nummer, die niemand versteht, darf kein
Update ausloesen - sie zaehlt als die aelteste denkbare.
"""
if not fassung:
return (0,)
teile = re.findall(r"\d+", str(fassung))
return tuple(int(t) for t in teile) if teile else (0,)
def ist_neuer(dort, hier):
"""Ist `dort` neuer als `hier`? Gleichstand ist NICHT neuer."""
return zerlegen(dort) > zerlegen(hier)
# ---------------------------------------------------------------------------
# Das passende Release finden
# ---------------------------------------------------------------------------
def passendes_release(releases, exe_name):
"""Das neueste Release, das eine Datei `exe_name` anhaengt.
Vorabversionen zaehlen MIT - alle Fassungen dieser Programme sind als
Vorabversion gekennzeichnet, weil sie erprobt werden. Wer hier
`/releases/latest` benutzt, bekommt nichts: Forgejo laesst Vorabversionen
dort aus. Genau das ist beim Bauen aufgefallen.
Entwuerfe (draft) zaehlen NICHT - die sind nicht veroeffentlicht.
"""
beste = None
for r in releases or []:
if r.get("draft"):
continue
anhang = next((a for a in r.get("assets") or []
if a.get("name") == exe_name), None)
if not anhang:
continue
if beste is None or ist_neuer(r.get("tag_name"), beste[0].get("tag_name")):
beste = (r, anhang)
return beste
def summe_aus_release(release, exe_name):
"""Die erwartete Pruefsumme aus dem Anhang `<exe>.sha256`, oder ''.
Das Release haengt neben der EXE eine kleine Textdatei mit der Pruefsumme
an. Fehlt sie, wird nur die Groesse geprueft - dann sagt das der Updater
aber auch, statt so zu tun, als haette er geprueft.
"""
name = exe_name + ".sha256"
for a in release.get("assets") or []:
if a.get("name") == name:
return a.get("browser_download_url") or ""
return ""
# ---------------------------------------------------------------------------
# Laden und pruefen
# ---------------------------------------------------------------------------
def beschreibbar(ordner):
"""Darf in diesen Ordner geschrieben werden? Wird ausprobiert, nicht geraten.
Ein nicht beschreibbarer Ordner sieht von aussen genauso aus wie ein
beschreibbarer - erst der Versuch zeigt es.
"""
probe = os.path.join(ordner, ".schreibprobe")
try:
with open(probe, "w", encoding="ascii") as f:
f.write("x")
os.remove(probe)
return True
except OSError:
return False
def sha256(pfad):
h = hashlib.sha256()
with open(pfad, "rb") as f:
for brocken in iter(lambda: f.read(1 << 20), b""):
h.update(brocken)
return h.hexdigest()
def holen(url, ziel, melden=None):
"""Datei laden. Gibt die Anzahl geladener Bytes zurueck."""
with urllib.request.urlopen(url, timeout=60) as antwort, open(ziel, "wb") as raus:
gesamt = int(antwort.headers.get("Content-Length") or 0)
geladen = 0
while True:
brocken = antwort.read(1 << 20)
if not brocken:
break
raus.write(brocken)
geladen += len(brocken)
if melden and gesamt:
melden(geladen, gesamt)
return geladen
def vollstaendig(pfad, erwartete_groesse, erwartete_summe=""):
"""Ist die geladene Datei vollstaendig und unverfaelscht?
Rueckgabe: (True, "") oder (False, Grund). Ein halb geladener Download darf
die Installation NIE erreichen - lieber gar kein Update als ein kaputtes.
"""
if not os.path.exists(pfad):
return False, "Die geladene Datei ist nicht da."
ist = os.path.getsize(pfad)
if erwartete_groesse and ist != erwartete_groesse:
return False, (f"Groesse stimmt nicht: {ist} Bytes geladen, "
f"{erwartete_groesse} erwartet.")
if erwartete_summe:
hat = sha256(pfad)
if hat.lower() != erwartete_summe.strip().lower():
return False, "Pruefsumme stimmt nicht."
return True, ""
# ---------------------------------------------------------------------------
# Die Nummer steht im Dateinamen
# ---------------------------------------------------------------------------
def basis_und_endung(exe_name):
"""'Rechnungstool.exe' -> ('Rechnungstool', '.exe')."""
stamm, endung = os.path.splitext(exe_name)
stamm = re.sub(r"-\d+(?:\.\d+)*$", "", stamm) # eine Nummer wieder abschneiden
return stamm, endung or ".exe"
def dateiname(exe_name, fassung):
"""'Rechnungstool.exe' + 'v1.6.0' -> 'Rechnungstool-1.6.0.exe'."""
stamm, endung = basis_und_endung(exe_name)
return f"{stamm}-{str(fassung).lstrip('v')}{endung}"
def installierte(ordner, exe_name):
"""Was liegt im Ordner? Liste (Fassung, Pfad), neueste zuerst.
Gelesen wird der DATEINAME - keine Angabe, der man glauben muesste. Eine
Datei ohne Nummer (aus der Zeit vor dieser Fassung) zaehlt als aelteste.
"""
stamm, endung = basis_und_endung(exe_name)
muster = re.compile(re.escape(stamm) + r"(?:-(\d+(?:\.\d+)*))?" +
re.escape(endung) + r"$", re.I)
gefunden = []
try:
namen = os.listdir(ordner)
except OSError:
return []
for name in namen:
treffer = muster.match(name)
if treffer:
gefunden.append((treffer.group(1) or "0", os.path.join(ordner, name)))
gefunden.sort(key=lambda x: zerlegen(x[0]), reverse=True)
return gefunden
def neueste_installierte(ordner, exe_name):
"""(Fassung, Pfad) der neuesten vorhandenen Datei - oder ('0', '')."""
liste = installierte(ordner, exe_name)
return liste[0] if liste else ("0", "")
def aufraeumen(ordner, exe_name, behalten=2):
"""Alte Fassungen wegraeumen, die neuesten `behalten` bleiben liegen.
Nicht aus Ordnungsliebe: die vorletzte Fassung IST die Rueckfallebene.
Deshalb wird nie unter zwei geraeumt.
"""
liste = installierte(ordner, exe_name)
weg = []
for _fassung, pfad in liste[max(2, behalten):]:
try:
os.remove(pfad)
weg.append(os.path.basename(pfad))
except OSError:
# Laeuft die alte Fassung noch oder haelt ein Virenscanner die
# Datei, sperrt Windows sie. Das ist kein Grund zum Abbruch: die
# Datei stoert nur, sie schadet nicht, und beim naechsten Mal
# klappt es. Was WEG ist, steht in der Rueckgabe - was nicht weg
# ist, taucht dort nicht auf.
pass
return weg
# ---------------------------------------------------------------------------
# Buchfuehrung
# ---------------------------------------------------------------------------
def buch_lesen(ordner):
pfad = os.path.join(ordner, BUCH)
try:
with open(pfad, encoding="utf-8") as f:
return json.load(f)
except (OSError, ValueError):
return {}
def buch_schreiben(ordner, daten):
pfad = os.path.join(ordner, BUCH)
try:
with open(pfad, "w", encoding="utf-8") as f:
json.dump(daten, f, ensure_ascii=False, indent=2)
return True
except OSError:
return False
def releases_holen(repository):
url = f"{API}/{repository}/releases?limit=20"
with urllib.request.urlopen(url, timeout=30) as antwort:
return json.loads(antwort.read().decode("utf-8"))
def text_holen(url):
with urllib.request.urlopen(url, timeout=30) as antwort:
return antwort.read().decode("utf-8", "replace").split()[0]
# ---------------------------------------------------------------------------
# Der Ablauf
# ---------------------------------------------------------------------------
def durchlauf(ordner, repository, exe_name, sagen=print, holen_=None):
"""Ein Durchlauf. Gibt den Pfad der Datei zurueck, die gestartet werden soll.
Nichts wird ueberschrieben: eine neue Fassung wird als eigene Datei
`<Name>-<Nummer>.exe` abgelegt. Die vorige bleibt liegen - sie ist die
Rueckfallebene, ohne dass jemand eine Sicherung anlegen muesste.
`holen_` laesst sich fuer den Pruefstand ersetzen, damit er ohne Netz
auskommt.
"""
holen_ = holen_ or releases_holen
hier, hier_pfad = neueste_installierte(ordner, exe_name)
buch = buch_lesen(ordner)
# Buch und Dateiname zusammen: wer eine Datei umbenennt, verursacht
# hoechstens einen ueberfluessigen Download, keinen Schaden.
if ist_neuer(buch.get("version") or "0", hier):
hier = buch.get("version")
try:
releases = holen_(repository)
except Exception as e: # noqa: BLE001 - ohne Netz wird einfach gestartet
sagen(f"Kein Update moeglich ({e}). Es wird die vorhandene Fassung gestartet.")
return hier_pfad
treffer = passendes_release(releases, exe_name)
if not treffer:
sagen("Im Repository liegt keine passende Datei. Vorhandene Fassung wird gestartet.")
return hier_pfad
release, anhang = treffer
dort = release.get("tag_name") or ""
if not ist_neuer(dort, hier):
sagen(f"Aktuell ({hier}). Kein Update noetig.")
return hier_pfad
ziel = os.path.join(ordner, dateiname(exe_name, dort))
if os.path.exists(ziel):
sagen(f"{os.path.basename(ziel)} liegt schon da. Es wird gestartet.")
return ziel
# Kann in den Installationsordner ueberhaupt geschrieben werden? Liegt die
# Installation in einem geschuetzten Ordner (etwa "Programme"), schlaegt das
# Ablegen fehl. Dann wird NICHT geladen - und vor allem: das Programm
# startet trotzdem. Ein fehlgeschlagenes Update darf nie dazu fuehren, dass
# gar nichts mehr geht.
if not beschreibbar(ordner):
sagen(f"In {ordner} darf nicht geschrieben werden - kein Update moeglich.")
sagen("Es wird die vorhandene Fassung gestartet.")
return hier_pfad
sagen(f"Neue Fassung {dort} gefunden (vorhanden: {hier}). Wird geladen ...")
zwischen = tempfile.mkdtemp(prefix="update_")
geladen = os.path.join(zwischen, exe_name)
try:
holen(anhang["browser_download_url"], geladen)
erwartete_summe = ""
url_summe = summe_aus_release(release, exe_name)
if url_summe:
try:
erwartete_summe = text_holen(url_summe)
except Exception: # noqa: BLE001
sagen("Pruefsumme nicht ladbar - es wird nur die Groesse geprueft.")
else:
sagen("Dieses Release hat keine Pruefsumme - es wird nur die Groesse geprueft.")
gut, grund = vollstaendig(geladen, anhang.get("size") or 0, erwartete_summe)
if not gut:
sagen(f"Das Update wurde VERWORFEN: {grund}")
sagen("Es wird die vorhandene Fassung gestartet.")
return hier_pfad
# Erst jetzt in den Installationsordner - vorher lag nichts Halbes dort.
try:
shutil.move(geladen, ziel)
except OSError as e: # noqa: BLE001
sagen(f"Die neue Fassung konnte nicht abgelegt werden ({e}).")
sagen("Es wird die vorhandene Fassung gestartet.")
return hier_pfad
buch_schreiben(ordner, {"version": dort.lstrip("v"),
"datei": os.path.basename(ziel),
"sha256": erwartete_summe,
"vorher": os.path.basename(hier_pfad) if hier_pfad else ""})
weg = aufraeumen(ordner, exe_name)
if weg:
sagen("Alte Fassungen entfernt: " + ", ".join(weg))
sagen(f"Aktualisiert auf {dort}.")
return ziel
finally:
shutil.rmtree(zwischen, ignore_errors=True)
def starten(pfad):
"""Das Programm starten und den Updater beenden."""
subprocess.Popen([pfad], close_fds=True)
if __name__ == "__main__":
import version
ordner = os.path.dirname(os.path.abspath(
sys.executable if getattr(sys, "frozen", False) else __file__))
print(f"Updater - {version.REPOSITORY}")
zu_starten = durchlauf(ordner, version.REPOSITORY, version.EXE_NAME)
if zu_starten and os.path.exists(zu_starten):
print("Starte", os.path.basename(zu_starten))
starten(zu_starten)
else:
print(f"FEHLER: keine startbare Fassung von {version.EXE_NAME} in {ordner}")
input("Enter zum Schliessen ...")