Kopflogo, Bau an einer Stelle, Startprobe fuer die EXE

Logo
  Das Programmlogo (Wolke mit Paragraphenzeichen) steht jetzt rechts in der
  Kopfleiste - damit sich die beiden Werkzeuge auf einen Blick unterscheiden.

bauen.py
  Der Weg von der Quelle zur Setup-Datei war eine Folge von Befehlen, die
  jemand von Hand eintippen musste. Wer einen Schritt vergisst, liefert eine
  Setup-Datei mit einer alten EXE aus - und sieht es ihr nicht an. Jetzt ist
  es ein Befehl, die Nummer kommt aus version.py, und am Ende stehen die
  SHA256-Summen fuer den Release-Text.

pruef_start_exe.py
  Prueft, was kein anderer Pruefstand prueft: ob die GEBAUTE EXE ein
  sichtbares Fenster zeigt. Alle uebrigen fahren das Programm aus der Quelle,
  dort trat der Fehler nie auf.

pruef_einzelinstanz.py wieder gruen
  Der Erprobungshinweis haette in jedem der drei Starts ein echtes Fenster
  geoeffnet - in einem eigenen Prozess laesst sich kein Dialog abfangen, es
  klickt niemand. Geprueft wird dort die SPERRE, nicht der Hinweis (der hat
  einen eigenen Pruefstand). Die Kenntnisnahme wird deshalb vorher
  eingetragen, und zwar mit genau dem Code, den auch der Klick auf "Ja"
  ausfuehrt. Das trifft nur die Kopie im Temp-Ordner: im echten Journal
  entsteht keine Zustimmung, die niemand gegeben hat.

Aufgeraeumt
  Beherbergungssteuer.exe und die gleichnamige .spec sind raus - das Programm
  heisst jetzt anders. Gebaute Dateien gehoeren ins Release, nicht ins Repo;
  die .spec-Dateien dagegen schon.

Fassung 1.8.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
This commit is contained in:
TheMockTv 2026-09-06 20:35:23 +02:00
parent b9094e7ebb
commit 96aa51ac22
17 changed files with 688 additions and 27 deletions

9
.gitignore vendored
View file

@ -10,3 +10,12 @@ __pycache__/
build/
dist/
*.spec
# Gebaute Dateien - die gehoeren ins Release, nicht ins Repo.
*.exe
setup/
# Die Bauanleitungen selbst gehoeren SEHR WOHL hinein: ohne sie kann niemand
# die EXE nachbauen, die der Updater ausliefert.
!Steuerrechnungstool.spec
!Updater.spec

Binary file not shown.

93
Installer.iss Normal file
View file

@ -0,0 +1,93 @@
; Installer fuer das Steuerrechnungstool (Inno Setup 6)
;
; ENTSCHEIDUNGEN, DIE HIER STECKEN - und warum
; --------------------------------------------
; 1. INSTALLATION PRO BENUTZER nach %LOCALAPPDATA%\Programs, NICHT nach
; "Program Files". Grund: der Updater muss die EXE ablegen duerfen. In
; Program Files darf ein normaler Benutzer nicht schreiben - jedes Update
; wuerde an fehlenden Rechten scheitern oder eine Administrator-Abfrage
; erzwingen. Chrome, VS Code, Discord, Teams und Fusion 360 machen es
; genauso, aus demselben Grund.
;
; 2. DIE VERKNUEPFUNG ZEIGT AUF DEN UPDATER, nicht auf das Programm. So schaut
; jeder Start zuerst nach einer neuen Fassung. Der Updater startet danach
; die eigentliche EXE - die ist dabei noch gar nicht in Benutzung und laesst
; sich deshalb problemlos ersetzen.
;
; 3. KEINE DATEN ANFASSEN. Der Installer legt keine Rechnungsordner an, kein
; Journal, kein Nummernbuch - und beim Deinstallieren wird davon nichts
; entfernt. Das Programm schreibt aufbewahrungspflichtige Belege
; (§ 147 AO, zehn Jahre). Ein Deinstallieren darf sie nie mitnehmen.
;
; 4. Der Eintrag unter "Apps & Features" entsteht von selbst - Inno Setup legt
; den Registry-Schluessel unter HKCU an. Da muss niemand von Hand hinein.
#define Name "Steuerrechnungstool"
#define Herausgeber "ravokk"
#define ExeUpdater "Updater.exe"
#define Webseite "https://git.pcore.de/TheMockTv/Steuerrechnungstool"
[Setup]
AppId={{B4D71E08-9C36-42AF-8E5B-13A7F0C29D64}
AppName={#Name}
AppVersion={#Version}
AppVerName={#Name} {#Version}
AppPublisher={#Herausgeber}
AppPublisherURL={#Webseite}
AppSupportURL={#Webseite}/issues
AppUpdatesURL={#Webseite}/releases
; --- pro Benutzer, ohne Adminrechte ---
PrivilegesRequired=lowest
; Der Zielordner ist NICHT waehlbar. Wer hier "Programme" auswaehlen
; koennte, braeche sich den Updater: dort darf ein normaler Benutzer
; nicht schreiben. Chrome, Teams und Discord blenden diese Seite aus
; demselben Grund aus. In "Programme" gehoert hoechstens eine
; Verknuepfung - und die liegt ohnehin im Startmenue.
DisableDirPage=yes
DefaultDirName={localappdata}\Programs\{#Name}
DefaultGroupName={#Name}
DisableProgramGroupPage=yes
UsePreviousAppDir=yes
OutputDir=setup
OutputBaseFilename=Steuerrechnungstool-Setup-{#Version}
Compression=lzma2
SolidCompression=yes
WizardStyle=modern
SetupIconFile=assets\programm.ico
UninstallDisplayIcon={app}\{#ExeProgramm}
ArchitecturesInstallIn64BitMode=x64compatible
; Erprobungsfassung - das steht auch im Programm selbst.
AppComments=Erprobungsfassung. Benutzung auf eigene Gefahr.
[Languages]
Name: "deutsch"; MessagesFile: "compiler:Languages\German.isl"
[Tasks]
Name: "desktopicon"; Description: "Verknüpfung auf dem Desktop anlegen"; \
GroupDescription: "Zusätzliche Verknüpfungen:"; Flags: unchecked
[Files]
; Der Updater - das ist, was der Benutzer startet.
Source: "dist\{#ExeUpdater}"; DestDir: "{app}"; Flags: ignoreversion
; Das Programm selbst, mit der Nummer im Namen. Der Updater erkennt daran,
; was installiert ist, und legt neue Fassungen daneben statt darueber.
Source: "dist\{#ExeProgramm}"; DestDir: "{app}"; Flags: ignoreversion
[Icons]
; Beide Verknuepfungen zeigen auf den UPDATER, nicht auf das Programm.
Name: "{group}\{#Name}"; Filename: "{app}\{#ExeUpdater}"
Name: "{group}\{#Name} deinstallieren"; Filename: "{uninstallexe}"
Name: "{userdesktop}\{#Name}"; Filename: "{app}\{#ExeUpdater}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#ExeUpdater}"; Description: "{#Name} jetzt starten"; \
Flags: nowait postinstall skipifsilent
[UninstallDelete]
; Nur die Buchfuehrung des Updaters.
; NICHTS von den Daten des Betriebs - keine Rechnungen, kein Journal, kein
; Nummernbuch. Die liegen ohnehin woanders und muessen zehn Jahre bleiben.
Type: files; Name: "{app}\installiert.json"

39
Steuerrechnungstool.spec Normal file
View file

@ -0,0 +1,39 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['app.py'],
pathex=[],
binaries=[],
datas=[('assets/programm.ico', 'assets'), ('assets/programm_logo.png', 'assets'),],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='Steuerrechnungstool',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
icon='assets/programm.ico',
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)

24
Updater.spec Normal file
View file

@ -0,0 +1,24 @@
# -*- mode: python ; coding: utf-8 -*-
# Der Updater ist der Starter: winzige Konsolen-EXE, die vor dem Programm laeuft.
a = Analysis(
['updater.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=['version'],
hookspath=[],
runtime_hooks=[],
excludes=['tkinter', 'reportlab', 'PIL', 'pypdf', 'pypdfium2', 'numpy'],
noarchive=False,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz, a.scripts, a.binaries, a.datas, [],
name='Updater',
debug=False,
strip=False,
upx=True,
console=True,
disable_windowed_traceback=False,
icon='assets/programm.ico',
)

55
app.py
View file

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Steuerrechnungstool – Steuerjournal für den Campinghof Bartl.
Steuerrechnungstool – das Steuerjournal zum Rechnungstool.
Ersetzt die Excel-Mappe: Die Rechnungs-PDFs aus dem Rechnungstool sind die
Quelle, das Programm liest sie ein, führt das Journal (SQLite) und schreibt
@ -26,6 +26,7 @@ from tkinter import ttk, filedialog, messagebox
# Nur beim echten Start: Pruefstaende importieren app als Modul und sollen
# nicht an der Sperre des laufenden Programms scheitern.
import hinweis
import wasserzeichen
from einzelinstanz import (eigene_pid_merken, einzelinstanz_pruefen,
fenster_nach_vorn, laufende_pid_lesen)
@ -79,7 +80,8 @@ log = logging.getLogger("bst.app")
class App(tk.Tk):
def __init__(self):
super().__init__()
self.title("Steuerrechnungstool – Campinghof Bartl")
self.title("Steuerrechnungstool")
self._fenstersymbol()
self.geometry("1000x680")
self.minsize(820, 520)
@ -103,6 +105,7 @@ class App(tk.Tk):
self.bind("<Control-f>", lambda _e: self.suchfeld.focus_set())
self.protocol("WM_DELETE_WINDOW", self._beenden)
self.aktualisieren()
self._wasserzeichen_setzen()
# NICHTS laeuft an, bevor der Erprobungshinweis bestaetigt ist - sonst
# stuenden beim Ablehnen Timer in der Warteschlange, die auf ein
@ -110,11 +113,12 @@ class App(tk.Tk):
self._briefkasten_stand = "?" # "?" = noch nicht nachgesehen
self._push_kasten = queue.Queue() # Zettel aus dem API-Faden
self._nachricht_stand = 0 # bis hierher sind Meldungen gelesen
# after_idle statt after(0): laeuft, sobald Tk nichts mehr zu zeichnen hat -
# also nachdem das Hauptfenster steht. Bei after(0) haengt der modale Dialog
# an einem noch ungezeichneten Fenster und wirkt wie eingefroren; eine feste
# Wartezeit dagegen erreicht ein Pruefstand nie, der keine Schleife dreht.
self.after_idle(self._hinweis_pruefen)
# Der Hinweis darf erst aufgehen, wenn das Hauptfenster WIRKLICH
# gezeichnet ist. after_idle reicht dafuer nicht: in der gepackten
# EXE ist das Fenster dann noch nicht sichtbar, der modale Dialog
# sperrt es - und beide bleiben unsichtbar. Das sah aus wie ein
# haengendes Programm (06.09.2026, am Installer aufgefallen).
self.after(50, self._hinweis_wenn_sichtbar)
# ------------------------------------------------- Erprobungshinweis
def _hinweis_ablage(self):
@ -132,6 +136,36 @@ class App(tk.Tk):
return Ablage()
def _fenstersymbol(self):
"""Das Programmsymbol auf das Fenster legen."""
return wasserzeichen.symbol_setzen(self, melden=log.info)
def _wasserzeichen_setzen(self, groesse=220):
"""Das Programmlogo blass in die untere rechte Ecke."""
bild = wasserzeichen.hole(groesse, 0.06, master=self, melden=log.info)
if bild is None:
return
# Die Referenz MUSS bleiben - sonst raeumt Python das Bild weg und Tk
# zeigt eine leere Flaeche.
self._wz_bild = bild
self._wz = tk.Label(self, image=bild, borderwidth=0)
self._wz.place(relx=1.0, rely=1.0, anchor='se', x=-14, y=-30)
self._wz.lower()
def _hinweis_wenn_sichtbar(self, versuche=0):
"""Wartet, bis das Fenster gezeichnet ist, und fragt dann.
winfo_viewable() sagt, ob das Fenster tatsaechlich auf dem Schirm
ist. Solange nicht, wird in kurzen Abstaenden nachgesehen. Nach
rund zwei Sekunden wird trotzdem gefragt - im Pruefstand ist das
Fenster absichtlich nicht sichtbar, und dort soll es trotzdem
laufen.
"""
if not self.winfo_viewable() and versuche < 40:
self.after(50, lambda: self._hinweis_wenn_sichtbar(versuche + 1))
return
self._hinweis_pruefen()
def _hinweis_pruefen(self):
"""Hinweis auf die Erprobungsfassung. Ablehnen beendet das Programm."""
self.update_idletasks() # Fenster fertig zeichnen lassen
@ -225,6 +259,13 @@ class App(tk.Tk):
leiste = ttk.Frame(self, padding=(10, 8))
leiste.pack(fill="x")
# Das Programmlogo rechts in der Kopfzeile - das Erkennungszeichen.
# Es steht rechts, damit es die Bedienelemente nicht nach hinten
# schiebt, wenn es einmal fehlt.
self._kopf_bild = wasserzeichen.kopf(28, master=self, melden=log.info)
if self._kopf_bild is not None:
ttk.Label(leiste, image=self._kopf_bild).pack(side="right")
ttk.Label(leiste, text="Jahr:").pack(side="left")
self.jahr_wahl = ttk.Combobox(leiste, width=6, state="readonly",
textvariable=self.jahr, values=self._jahre())

BIN
assets/programm.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

BIN
assets/programm_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

27
assets/programm_logo.svg Normal file
View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,-566.85,0)">
<g id="Artboard2" transform="matrix(1,0,0,1,566.85,0)">
<rect x="0" y="0" width="512" height="512" style="fill:none;"/>
<g transform="matrix(1.94766,0,0,2.05245,-243.041,-218.664)">
<g id="Links">
<path d="M222.131,215.66L222.13,208.593L238.702,219.494L222.133,230.399L222.131,215.66ZM216.151,130.644C218.188,126.804 220.833,123.524 224.142,119.685C234.021,108.226 250.634,106.769 256,106.556C265.488,106.18 280.606,111.644 287.029,118.435C289.041,120.563 302.548,133.524 301.132,155.668L293.14,155.646C293.608,141.507 288.592,132.589 281.714,125.1C276.056,118.94 266.39,114.408 256,114.561C242.327,114.761 218.593,124.295 219.086,155.601L210.89,155.613C211.196,148.263 211.937,142.778 213.154,138.311C205.649,135.18 198.203,136.466 193.569,139.394C183.234,145.923 181.557,158.851 184.735,165.224L184.044,165.286C162.105,162.842 155.274,179.91 154.506,190.518C153.545,203.798 166.473,214.688 176.844,215.811L222.131,215.66L222.13,223.927L175.884,223.738C156.922,221.252 145.975,205.354 146.25,190.467C146.541,174.685 156.471,160.659 175.619,157.066C176.48,145.398 181.102,139.165 187.459,134.119C195.54,127.705 207.218,127.153 216.151,130.644Z" style="fill:rgb(18,89,109);"/>
<path d="M228.019,252.582L228.078,252.582C227.944,248.705 228.245,244.088 231.648,238.835C232.85,236.98 237.587,231.659 244.591,228.229C248.987,226.077 251.828,221.17 251.928,217.367L252.1,180.838L241.711,180.855L257.771,156.928L257.771,143.768L227.834,188.271L245.057,188.511L244.885,216.83C244.671,218.4 244.258,219.657 242.763,220.467C234.731,224.82 225.142,231.462 228.019,252.582Z" style="fill:rgb(14,79,101);"/>
</g>
<g id="Rechts">
<g transform="matrix(-0.988012,0,0,1,508.931,0)">
<path d="M237.047,251.22C237.178,239.336 245.86,235.025 256.588,236.213L256.575,235.972C252.442,233.264 248.389,227.365 248.529,222.683L248.522,180.85L237.787,180.865L254.207,157.002L254.207,143.768L223.645,188.5L241.142,188.545L241.351,222.218C241.342,223.858 241.699,225.564 241.829,226.526L241.828,226.772C228.424,230.549 224.979,242.528 224.456,251.314L237.047,251.22Z" style="fill:rgb(38,38,40);"/>
</g>
<path d="M290.321,215.66L335.608,215.811C345.979,214.688 358.907,203.798 357.946,190.518C357.178,179.91 350.347,162.842 328.408,165.286L327.717,165.224C330.895,158.851 329.218,145.923 318.883,139.394C312.886,135.605 304.481,133.711 292.087,142.516L288.929,134.747C297.75,127.533 314.633,125.896 324.993,134.119C331.35,139.165 335.972,145.398 336.833,157.066C355.981,160.659 365.911,174.685 366.202,190.467C366.477,205.354 355.53,221.252 336.568,223.738L290.322,223.927L290.321,215.66ZM290.321,215.66L290.319,230.399L273.75,219.494L290.322,208.593L290.321,215.66Z" style="fill:rgb(38,38,40);"/>
</g>
<g transform="matrix(0.584177,0,0,0.584177,-224.63,106.538)">
<path d="M851.907,348.191L860.766,356.196C875.59,345.97 881.708,334.438 881.778,321.058C881.863,304.967 873.648,293.16 861.58,285.668L812.266,258.32C800.634,252.557 789.781,232.581 806.866,214.138L806.854,213.679C771.946,231.774 777.593,260.635 790.222,270.44L799.315,277.718C818.663,292.911 843.341,301.213 850.523,307.455L850.991,307.887C862.325,316.409 870.637,330.48 851.907,348.191Z" style="fill:rgb(56,70,82);"/>
</g>
<g transform="matrix(0.584177,0,0,0.584177,-224.63,106.538)">
<path d="M775.465,379.883C777.214,420.92 813.794,430.9 837.42,425.799C854.616,422.087 866.406,413.927 871.906,398.762C874.547,391.476 876.695,367.794 861.667,355.002L853.134,346.714L803.048,318.965C791.6,310.022 779.665,299.004 799.076,277.362L790.324,270.421C776.315,279.645 765.735,298.665 772.73,317.019C775.78,325.023 779.864,332.602 796.367,342.874L842.363,369.055C866.733,390.878 840.321,405.594 834.774,406.552C802.323,412.156 798.439,391.489 795.956,379.852L775.465,379.883Z" style="fill:rgb(56,70,82);"/>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

109
bauen.py Normal file
View file

@ -0,0 +1,109 @@
# -*- coding: utf-8 -*-
"""Der ganze Weg von der Quelle zur Setup-Datei - an EINER Stelle.
WARUM DAS HIER STEHT
--------------------
Bis hierher lag der Bau in einer Folge von Befehlen, die jemand von Hand
eintippen musste: PyInstaller fuers Programm, PyInstaller fuer den Updater,
umbenennen, Inno Setup mit den richtigen Schaltern. Wer einen Schritt
vergisst, liefert eine Setup-Datei aus, die eine alte EXE enthaelt - und
niemand sieht es ihr an. Genau das ist am 06.09.2026 passiert (der Installer
schrieb ueber eine laufende, gesperrte EXE hinweg und meldete trotzdem Erfolg).
Ab jetzt ist der Bau ein Befehl:
python bauen.py
WAS ER TUT
----------
1. Die Nummer kommt aus version.py - nicht von der Kommandozeile. So kann sie
nicht auseinanderlaufen mit dem, was das Programm ueber sich selbst sagt.
2. Programm und Updater werden gebaut.
3. Die Programm-EXE bekommt die Nummer in den Namen. Der Updater erkennt daran,
welche Fassungen installiert sind, und legt neue DANEBEN statt darueber -
deshalb kann er auch dann austauschen, wenn Windows die alte Datei sperrt.
4. Inno Setup baut die Setup-Datei.
5. Zum Schluss stehen die SHA256-Summen da. Die gehoeren in den Release-Text:
nur damit laesst sich spaeter nachweisen, dass die heruntergeladene Datei
genau die gebaute ist.
"""
import hashlib
import os
import shutil
import subprocess
import sys
HIER = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HIER)
import version # noqa: E402
# Inno Setup liegt bei ihm unter LOCALAPPDATA (Installation ohne Adminrechte).
ISCC_ORTE = [
os.path.join(os.environ.get("LOCALAPPDATA", ""), "Programs", "Inno Setup 6", "ISCC.exe"),
r"C:\Program Files (x86)\Inno Setup 6\ISCC.exe",
r"C:\Program Files\Inno Setup 6\ISCC.exe",
]
def summe(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 lauf(befehl, was):
print(f"--- {was}")
e = subprocess.run(befehl, cwd=HIER, capture_output=True, text=True,
encoding="utf-8", errors="replace")
if e.returncode != 0:
print(e.stdout[-3000:])
print(e.stderr[-3000:])
raise SystemExit(f"{was} fehlgeschlagen (Rueckgabe {e.returncode})")
def iscc():
for p in ISCC_ORTE:
if p and os.path.exists(p):
return p
raise SystemExit("ISCC.exe nicht gefunden - Inno Setup 6 fehlt")
def main():
stamm, endung = os.path.splitext(version.EXE_NAME)
mit_nummer = f"{stamm}-{version.VERSION}{endung}"
# Die Bauordner werden geleert. Ein PyInstaller-Lauf ueber einen alten
# Bauordner uebernimmt sonst Reste, die es in der Quelle nicht mehr gibt -
# und in setup/ laege noch die Datei der VORIGEN Nummer herum. Beim
# Ausliefern greift man dann leicht zur falschen.
for ordner in ("build", "dist", "setup"):
shutil.rmtree(os.path.join(HIER, ordner), ignore_errors=True)
lauf([sys.executable, "-m", "PyInstaller", "--noconfirm", f"{stamm}.spec"],
f"{stamm} {version.VERSION}")
lauf([sys.executable, "-m", "PyInstaller", "--noconfirm", "Updater.spec"],
"Updater")
quelle = os.path.join(HIER, "dist", version.EXE_NAME)
ziel = os.path.join(HIER, "dist", mit_nummer)
os.replace(quelle, ziel)
lauf([iscc(), f"/DVersion={version.VERSION}", f"/DExeProgramm={mit_nummer}",
"Installer.iss"], "Setup")
print()
print(f"FERTIG | {stamm} {version.VERSION}")
for name in sorted(os.listdir(os.path.join(HIER, "dist"))):
p = os.path.join(HIER, "dist", name)
print(f" {name:44s} {os.path.getsize(p):>10,d} B {summe(p)}")
setup = os.path.join(HIER, "setup")
for name in sorted(os.listdir(setup)) if os.path.isdir(setup) else []:
p = os.path.join(setup, name)
print(f" {name:44s} {os.path.getsize(p):>10,d} B {summe(p)}")
if __name__ == "__main__":
main()

View file

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Liest eine Rechnungs-PDF des Rechnungstools (Campinghof Bartl) und macht
Liest eine Rechnungs-PDF des Rechnungstools und macht
daraus eine Buchung fürs Steuerjournal.
Der PDF-Text ist zeilenweise aufgebaut: erst das Label, dann in der

View file

@ -67,6 +67,47 @@ for name in os.listdir(HIER):
shutil.copy(os.path.join(HIER, name), os.path.join(prog, name))
# Auch die Beilagen mitnehmen - ohne assets/ faende das Programm sein Logo
# nicht. Das stuerzt zwar nicht ab, aber ein Pruefstand soll das Programm
# fahren, wie es ausgeliefert wird, nicht eine abgespeckte Fassung.
for name in ("assets",):
quelle = os.path.join(HIER, name)
if os.path.isdir(quelle):
shutil.copytree(quelle, os.path.join(prog, name))
# Der Erprobungshinweis wuerde in JEDEM dieser drei Starts ein echtes Fenster
# oeffnen. Hier laeuft das Programm als eigener Prozess - da laesst sich kein
# Dialog abfangen, es klickt niemand, und der Pruefstand liefe in die
# Reissleine. Geprueft wird an dieser Stelle die SPERRE; der Hinweis hat seinen
# eigenen Pruefstand (pruef_hinweis.py).
#
# Also wird die Kenntnisnahme vorher eingetragen - mit genau dem Code, den auch
# der Klick auf "Ja" ausfuehrt (hinweis.vermerken auf die Einstellungstabelle).
# Nichts nachgebaut, nichts von Hand ins SQL geschrieben.
#
# Das trifft NUR die Kopie im Temp-Ordner: aus der Quelle heraus liegt der
# Datenordner neben app.py (ablage.daten_ordner()). Im echten Journal des
# Betriebs entsteht dadurch keine Zustimmung, die niemand gegeben hat.
def hinweis_vorab_eintragen():
import db
import hinweis
journal = db.Journal(os.path.join(prog, "journal.sqlite3"))
class Ablage:
def get(self, schluessel, standard=""):
return journal.hole(schluessel, standard)
def __setitem__(self, schluessel, wert):
journal.setze(schluessel, str(wert))
hinweis.vermerken(Ablage())
print(f" (Kenntnisnahme in der Kopie vermerkt, Fassung {hinweis.FASSUNG})")
hinweis_vorab_eintragen()
def starte():
# --test: kein Erststart-Dialog nach dem gemeinsamen Ordner. Die Sperre
# selbst greift unabhaengig davon - genau die wird hier geprueft.

137
pruef_start_exe.py Normal file
View file

@ -0,0 +1,137 @@
# -*- 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
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kod = sys.stdout.encoding or "utf-8"
print(zeile.encode(kod, "replace").decode(kod))
if not bedingung:
fehler.append(text)
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
p = subprocess.Popen([EXE])
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")

View file

@ -70,6 +70,26 @@ from tkinter import messagebox # noqa: E402
messagebox.showinfo = lambda *a, **k: None
messagebox.showwarning = lambda *a, **k: None
# Der Abfang MUSS vor dem ersten Fenster stehen: sonst geht ein echter Dialog
# auf und der Lauf wartet auf einen Klick, den niemand macht. Genau das ist am
# 06.09.2026 passiert - der Hinweis kam, bevor der Abfang gesetzt war.
# Erprobungshinweis und Freigabe vor dem Beleg werden hier bejaht - geprueft
# werden sie eigens in pruef_hinweis.py und pruef_freigabe.py des Rechnungstools.
antworten = {"Fertig": False, "endgültig erstellen": True, "Hausnummer": True,
"Storno wirklich": True, "Storno verwerfen": True}
antworten["Erprobungsfassung"] = True
antworten["jetzt erstellen?"] = True
def askyesno(titel, text, **kw):
for schlue, wert in antworten.items():
if schlue in titel:
return wert
raise AssertionError(f"unangemeldeter Dialog: {titel!r}")
messagebox.askyesno = askyesno
journal = JA.App()
# Der Erprobungshinweis haengt an after(0) - ohne Ereignisschleife kaeme er nie
# dran, und damit auch der verzoegerte Start nicht. update() treibt die Schleife
@ -129,22 +149,6 @@ json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=Fal
import app as A # noqa: E402
# Erprobungshinweis und Freigabe vor dem Beleg werden hier bejaht - geprueft
# werden sie eigens in pruef_hinweis.py und pruef_freigabe.py des Rechnungstools.
antworten = {"Fertig": False, "endgültig erstellen": True, "Hausnummer": True,
"Storno wirklich": True, "Storno verwerfen": True}
antworten["Erprobungsfassung"] = True
antworten["jetzt erstellen?"] = True
def askyesno(titel, text, **kw):
for schlue, wert in antworten.items():
if schlue in titel:
return wert
raise AssertionError(f"unangemeldeter Dialog: {titel!r}")
messagebox.askyesno = askyesno
tool = A.RechnungsApp()
tool.update() # dasselbe fuer das Rechnungstool

View file

@ -129,6 +129,22 @@ def summe_aus_release(release, exe_name):
# ---------------------------------------------------------------------------
# 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:
@ -309,6 +325,16 @@ def durchlauf(ordner, repository, exe_name, sagen=print, holen_=None):
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)
@ -332,7 +358,12 @@ def durchlauf(ordner, repository, exe_name, sagen=print, holen_=None):
return hier_pfad
# Erst jetzt in den Installationsordner - vorher lag nichts Halbes dort.
shutil.move(geladen, ziel)
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,

View file

@ -10,11 +10,11 @@ und NUR hier; alles andere (Info-Dialog, Updater, Release) liest sie von hier.
Regel
-----
Die Nummer wird beim Ausliefern erhoeht und entspricht dem Release-Tag ohne das
"v": Tag `v1.7.0` -> VERSION "1.7.0". Wer das eine ohne das andere aendert,
"v": Tag `v1.8.0` -> VERSION "1.8.0". Wer das eine ohne das andere aendert,
baut dem Updater eine Falle.
"""
VERSION = "1.7.0"
VERSION = "1.8.0"
# Zu welchem Repository dieses Programm gehoert - der Updater sucht dort.
REPOSITORY = "TheMockTv/Steuerrechnungstool"

106
wasserzeichen.py Normal file
View file

@ -0,0 +1,106 @@
# -*- coding: utf-8 -*-
"""Das Programmlogo - als Fenstersymbol und als blasses Wasserzeichen.
WOHER DAS BILD KOMMT
--------------------
Die Quelle ist die SVG aus dem Entwurf (`assets/programm_logo.svg`). Sie wird
BEIM BAUEN einmal in 512 Pixel gerendert (`assets/programm_logo.png`) und in
eine `programm.ico` fuer Windows umgesetzt. Hier zur Laufzeit wird nur noch
skaliert.
Warum nicht direkt aus der SVG zeichnen? Weil dafuer `svglib` und `lxml` mit in
die EXE muessten - und bis 512 Pixel ist das Ergebnis pixelgenau dasselbe. Wird
das Wasserzeichen je groesser gebraucht, gehoert das umgestellt.
Warum ueberhaupt eine ICO? Weil Windows das Symbol einer EXE als Ressource in
der Datei erwartet, und dieses Format ist ICO. Das gilt fuer jede Sprache, nicht
nur fuer Python - eine SVG kann Windows dort nicht verwenden.
Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie
hinueber.
"""
import os
import sys
import tkinter as tk
_lager = {} # (fenster, groesse, deckkraft) -> PhotoImage
def _ordner():
"""Alle Orte, an denen die Bilder liegen koennen - Quelle wie EXE."""
hier = os.path.dirname(os.path.abspath(__file__))
aus_exe = getattr(sys, "_MEIPASS", "")
neben_exe = os.path.dirname(os.path.abspath(sys.executable)) \
if getattr(sys, "frozen", False) else ""
return [p for p in (aus_exe, neben_exe, hier) if p]
def pfad(name):
"""Voller Pfad zu einer Datei in assets/ - oder ''."""
for basis in _ordner():
p = os.path.join(basis, "assets", name)
if os.path.exists(p):
return p
return ""
def symbol_setzen(fenster, melden=print):
"""Das Programmsymbol auf ein Fenster legen. True bei Erfolg.
Die EXE traegt es ohnehin - das Fenster aber nicht von selbst. Ohne das
steht in der Titelleiste das nackte Tk-Symbol, obwohl die Datei ein eigenes
hat.
"""
p = pfad("programm.ico")
if not p:
melden("[symbol] assets/programm.ico nicht gefunden")
return False
try:
fenster.iconbitmap(default=p)
return True
except Exception as e: # noqa: BLE001 - ohne Symbol laeuft es weiter
melden(f"[symbol] konnte nicht gesetzt werden: {e}")
return False
def hole(groesse=240, deckkraft=0.06, master=None, melden=print):
"""Das Logo blass als PhotoImage - oder None.
`deckkraft` 0..1, klein halten. Ein Wasserzeichen, das man LIEST, stoert
beim Arbeiten; eines, das man ahnt, gehoert zum Programm.
`master` MUSS mitgegeben werden, wenn mehr als ein Tk-Fenster laeuft:
sonst gehoert das Bild dem falschen Fenster und Tk meldet spaeter, es
kenne es nicht.
"""
fenster = master if master is not None else tk._default_root
if fenster is None:
return None
schluessel = (id(fenster), groesse, round(deckkraft, 3))
if schluessel in _lager:
return _lager[schluessel]
p = pfad("programm_logo.png")
if not p:
melden("[wasserzeichen] assets/programm_logo.png nicht gefunden")
return None
try:
from PIL import Image, ImageTk
bild = Image.open(p).convert("RGBA").resize((groesse, groesse), Image.LANCZOS)
bild.putalpha(bild.getchannel("A").point(lambda w: int(w * deckkraft)))
fertig = ImageTk.PhotoImage(bild, master=fenster)
except Exception as e: # noqa: BLE001 - ein Wasserzeichen darf nichts blockieren
melden(f"[wasserzeichen] nicht ladbar: {e}")
return None
_lager[schluessel] = fertig
return fertig
def kopf(hoehe=28, master=None, melden=print):
"""Das Logo fuer die Kopfzeile - volle Deckkraft, kleine Hoehe.
Getrennt von hole(): dort geht es um ein blasses Wasserzeichen, hier um ein
Erkennungszeichen. Zwei Zwecke, zwei Funktionen - sonst haette eine davon
einen Schalter, den man beim Lesen nachschlagen muss.
"""
return hole(hoehe, 1.0, master=master, melden=melden)