Steuerrechnungstool/pruef_start_exe.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

133 lines
4.7 KiB
Python

# -*- coding: utf-8 -*-
"""Pruefstand: kommt bei der GEBAUTEN EXE wirklich ein Fenster?
DER FALL, DER DAZU GEFUEHRT HAT (06.09.2026)
--------------------------------------------
Nach dem Installieren lief das Programm - im Taskmanager stand es da, es
verbrauchte Rechenzeit - und auf dem Bildschirm war NICHTS. Ursache: der
Erprobungshinweis ging auf, bevor das Hauptfenster gezeichnet war. Der modale
Dialog sperrte das Hauptfenster, und beide blieben unsichtbar.
WARUM DAS KEIN ANDERER PRUEFSTAND FINDET
----------------------------------------
Alle uebrigen Pruefstaende fahren das Programm aus der QUELLE. Dort ist das
Fenster sofort gezeichnet, der Fehler tritt nicht auf. Er gehoert zur gepackten
EXE - also muss genau die gestartet werden.
Und "der Prozess laeuft" beweist nichts: er lief ja. Geprueft wird deshalb, ob
Windows ein Fenster dieses Programms als SICHTBAR fuehrt.
ZWEI FALLEN, die hier schon zugeschnappt sind:
* Eine PyInstaller-Onefile-EXE ist zweistufig. Der gestartete Prozess packt
nur aus; das Fenster gehoert dem KIND. Wer nur die eigene PID betrachtet,
sieht nie ein Fenster.
* terminate() beendet nur den Auspacker. Das Kind laeuft weiter - und dessen
Einzelinstanz-Sperre haelt danach jeden weiteren Start ab. Es muss die
ganze Sippe beendet werden.
"""
import ctypes
import os
import subprocess
import sys
import time
from ctypes import wintypes
HIER = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HIER)
import version # noqa: E402
from pruefhelfer import fehler, pruefe
stamm, endung = os.path.splitext(version.EXE_NAME)
EXE = os.path.join(HIER, "dist", f"{stamm}-{version.VERSION}{endung}")
if not os.path.exists(EXE):
print(f"UEBERSPRUNGEN | {os.path.basename(EXE)} ist nicht gebaut "
f"(vorher: python bauen.py)")
sys.exit(0)
u = ctypes.windll.user32
# Mit --test: dann geht das Fenster auf dem ZWEITEN Monitor auf und nicht
# mitten auf dem Hauptbildschirm, wo gerade jemand arbeitet. Seine Ansage am
# 07.09.2026 - und dieser Pruefstand war der eine, der sie noch nicht befolgte,
# weil er die gebaute EXE ganz normal startet. Sichtbar bleibt das Fenster,
# und genau das wird hier ja geprueft.
p = subprocess.Popen([EXE, "--test"])
print(f" (gestartet: {os.path.basename(EXE)}, PID {p.pid})")
RUECKRUF = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
gefunden = []
verwandte = {p.pid}
def sippe():
alle = {p.pid}
try:
e = subprocess.run(
["powershell", "-NoProfile", "-Command",
"Get-CimInstance Win32_Process | Where-Object "
f"{{ $_.ParentProcessId -eq {p.pid} }} | ForEach-Object {{ $_.ProcessId }}"],
capture_output=True, text=True, timeout=20)
for zeile in e.stdout.split():
if zeile.strip().isdigit():
alle.add(int(zeile.strip()))
except Exception: # noqa: BLE001 - dann bleibt es bei der eigenen PID
pass
return alle
def je_fenster(hwnd, _):
pid = wintypes.DWORD()
u.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
if pid.value not in verwandte:
return True
laenge = u.GetWindowTextLengthW(hwnd)
puffer = ctypes.create_unicode_buffer(laenge + 1)
u.GetWindowTextW(hwnd, puffer, laenge + 1)
r = wintypes.RECT()
u.GetWindowRect(hwnd, ctypes.byref(r))
gefunden.append((puffer.value, bool(u.IsWindowVisible(hwnd)),
r.right - r.left, r.bottom - r.top))
return True
ende = time.time() + 60
sichtbar = False
vorzeitig_weg = False
while time.time() < ende and not sichtbar:
if p.poll() is not None:
vorzeitig_weg = True
break
verwandte = sippe()
gefunden.clear()
u.EnumWindows(RUECKRUF(je_fenster), 0)
# Ein "Fenster" von wenigen Pixeln ist Verwaltung (IME, Notifier), kein
# Programmfenster. Deshalb die Groesse mitpruefen.
sichtbar = any(s and b > 300 and h > 300 for _, s, b, h in gefunden)
time.sleep(0.5)
for titel, s, b, h in gefunden:
print(f" {titel!r:42s} sichtbar={s} {b}x{h}")
pruefe(not vorzeitig_weg,
f"das Programm bleibt am Leben (Rueckgabe {p.returncode})"
if vorzeitig_weg else "das Programm bleibt am Leben")
if vorzeitig_weg and p.returncode == 0:
print(" (Rueckgabe 0 heisst meist: es laeuft schon eine Fassung. "
"Erst beenden, dann noch einmal.)")
pruefe(sichtbar, "ein sichtbares Programmfenster ist da")
pruefe(any(str(t).startswith(stamm) and s for t, s, _, _ in gefunden),
f"und es traegt den Programmnamen ({stamm})")
subprocess.run(["taskkill", "/PID", str(p.pid), "/T", "/F"], capture_output=True)
try:
p.wait(10)
except subprocess.TimeoutExpired:
p.kill()
if fehler:
print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt")
sys.exit(1)
print("GRUEN | Die gebaute EXE zeigt ein Fenster")