From c9dac7b9b871f31d5b297853a603f6d35cdc09a0 Mon Sep 17 00:00:00 2001 From: Rob Date: Tue, 30 Jun 2026 15:35:06 +0200 Subject: [PATCH] add build pipline --- app.py => 0_application/app.py | 9 +- 1_build-pipeline/build.bat | 46 ++++ 1_build-pipeline/build.py | 371 +++++++++++++++++++++++++++++++++ 1_build-pipeline/build.spec | 159 ++++++++++++++ 1_build-pipeline/run.py | 96 +++++++++ 5 files changed, 680 insertions(+), 1 deletion(-) rename app.py => 0_application/app.py (97%) create mode 100644 1_build-pipeline/build.bat create mode 100644 1_build-pipeline/build.py create mode 100644 1_build-pipeline/build.spec create mode 100644 1_build-pipeline/run.py diff --git a/app.py b/0_application/app.py similarity index 97% rename from app.py rename to 0_application/app.py index 2a41803..1acccdc 100644 --- a/app.py +++ b/0_application/app.py @@ -8,7 +8,14 @@ from pathlib import Path import streamlit as st # Projekt-Root zum Python-Path hinzufügen -PROJECT_ROOT = Path(__file__).resolve().parent +# Im gebundelten Modus (PyInstaller) zeigt APP_BASE_DIR auf das Verzeichnis der .exe, +# wo config/, input/, template/, output/ liegen. Im Entwicklungsmodus bleibt es None +# und PROJECT_ROOT wird aus __file__ abgeleitet. +_BASE_DIR = os.environ.get("APP_BASE_DIR") +if _BASE_DIR: + PROJECT_ROOT = Path(_BASE_DIR).resolve() +else: + PROJECT_ROOT = Path(__file__).resolve().parent sys.path.insert(0, str(PROJECT_ROOT)) from src.config import ( diff --git a/1_build-pipeline/build.bat b/1_build-pipeline/build.bat new file mode 100644 index 0000000..1485ab7 --- /dev/null +++ b/1_build-pipeline/build.bat @@ -0,0 +1,46 @@ +@echo off +chcp 65001 >nul +setlocal enabledelayedexpansion + +cd /d "%~dp0" +cd .. + +echo ============================================================ +echo Tätigkeitsnachweis-Generator – Build-Pipeline +echo ============================================================ +echo. + +REM ── Virtuelle Umgebung aktivieren ──────────────────────────── +if exist "0_application\.venv\Scripts\python.exe" ( + set PYTHON=0_application\.venv\Scripts\python.exe + echo [OK] Virtuelle Umgebung gefunden +) else if exist ".venv\Scripts\python.exe" ( + set PYTHON=.venv\Scripts\python.exe + echo [OK] Virtuelle Umgebung gefunden +) else ( + echo [WARN] Keine virtuelle Umgebung gefunden, nutze System-Python + set PYTHON=python +) + +echo. + +REM ── Parameter durchreichen ────────────────────────────────── +set ARGS=%* +if "%ARGS%"=="" ( + echo Starte interaktiven Build … + echo. +) + +REM ── Build ausführen ───────────────────────────────────────── +%PYTHON% "1_build-pipeline\build.py" %ARGS% + +if errorlevel 1 ( + echo. + echo [FEHLER] Build fehlgeschlagen! + pause + exit /b 1 +) + +echo. +echo Build abgeschlossen. +pause diff --git a/1_build-pipeline/build.py b/1_build-pipeline/build.py new file mode 100644 index 0000000..5fe3094 --- /dev/null +++ b/1_build-pipeline/build.py @@ -0,0 +1,371 @@ +"""Build-Orchestrierung mit Semantic Versioning. + +Liest die aktuelle Version aus CHANGELOG.md, bump't sie gemäß +Benutzereingabe (major/minor/patch), aktualisiert das Changelog +und baut via PyInstaller eine standalone .exe nach 2_build/. + +Verwendung: + python build.py # interaktiver Modus + python build.py --bump minor # nicht-interaktiv + python build.py --bump patch --message "Hotfix: PDF-Schriftgröße korrigiert" +""" + +import argparse +import os +import re +import shutil +import subprocess +import sys +from datetime import date +from pathlib import Path +from typing import Optional + +# ── Pfade ─────────────────────────────────────────────────────── +BUILD_PIPELINE_DIR = Path(__file__).resolve().parent +PROJECT_ROOT = BUILD_PIPELINE_DIR.parent +APP_DIR = PROJECT_ROOT / "0_application" +BUILD_OUTPUT_DIR = PROJECT_ROOT / "2_build" +CHANGELOG_PATH = APP_DIR / "CHANGELOG.md" +SPEC_PATH = BUILD_PIPELINE_DIR / "build.spec" +VERSION_FILE = BUILD_PIPELINE_DIR / ".last_version" + +# ── Versions-Helfer ───────────────────────────────────────────── + +def _read_changelog() -> str: + """Liest das gesamte CHANGELOG.md.""" + if not CHANGELOG_PATH.exists(): + return "" + return CHANGELOG_PATH.read_text(encoding="utf-8") + + +def _write_changelog(content: str) -> None: + """Schreibt CHANGELOG.md.""" + CHANGELOG_PATH.write_text(content, encoding="utf-8") + + +def get_current_version() -> Optional[str]: + """Ermittelt die aktuellste Version aus CHANGELOG.md. + + Sucht nach dem ersten Vorkommen von '## [X.Y.Z]' oder '## [X.Y]'. + """ + content = _read_changelog() + if not content: + return None + match = re.search(r"##\s*\[(\d+\.\d+(?:\.\d+)?)\]", content) + return match.group(1) if match else None + + +def bump_version(current: str, bump_type: str) -> str: + """Erhöht eine Semantic-Version. + + Args: + current: Aktuelle Version (z.B. '1.1' oder '1.1.0') + bump_type: 'major', 'minor' oder 'patch' + + Returns: + Neue Version als String. + """ + parts = current.split(".") + # Normalisiere auf 3 Teile (MAJOR.MINOR.PATCH) + while len(parts) < 3: + parts.append("0") + major, minor, patch = map(int, parts[:3]) + + if bump_type == "major": + major += 1 + minor = 0 + patch = 0 + elif bump_type == "minor": + minor += 1 + patch = 0 + elif bump_type == "patch": + patch += 1 + else: + raise ValueError(f"Unbekannter Bump-Typ: {bump_type}") + + # Gib kurzes Format zurück wenn Patch=0 + if patch == 0: + return f"{major}.{minor}" + return f"{major}.{minor}.{patch}" + + +def update_changelog( + new_version: str, + bump_type: str, + message: Optional[str] = None, +) -> None: + """Fügt einen neuen Versionseintrag am Anfang des Changelogs ein. + + Args: + new_version: Die neue Version (z.B. '1.2') + bump_type: Art des Bumps (für die Sektions-Vorlage) + message: Optionale Beschreibung der Änderungen. + """ + today = date.today().isoformat() + old_content = _read_changelog() + + # Sektionen-Vorlage je nach Bump-Typ + sections = _build_sections(bump_type, message) + + header = ( + f"## [{new_version}] – {today}\n\n" + f"{sections}\n" + f"---\n\n" + ) + + # Nach dem ersten '---' einfügen (nach dem Dateikopf von Keep a Changelog) + # oder einfach am Anfang nach der ersten Zeile + lines = old_content.splitlines(keepends=True) + + # Finde die erste Version-Header-Zeile (## [X.Y...) + insert_idx = None + for i, line in enumerate(lines): + if re.match(r"##\s*\[\d+\.\d+", line): + insert_idx = i + break + + if insert_idx is not None: + new_content = "".join(lines[:insert_idx]) + header + "".join(lines[insert_idx:]) + else: + # Fallback: nach dem Dateikopf (nach dem ersten '---') + for i, line in enumerate(lines): + if line.strip() == "---": + insert_idx = i + 1 + break + if insert_idx is not None: + new_content = "".join(lines[:insert_idx]) + "\n" + header + "".join(lines[insert_idx:]) + else: + new_content = old_content + "\n" + header + + _write_changelog(new_content) + print(f" ✓ CHANGELOG.md aktualisiert → [{new_version}]") + + +def _build_sections(bump_type: str, message: Optional[str]) -> str: + """Baut die Sektionen für den Changelog-Eintrag.""" + if message: + if bump_type == "major": + return f"### Hinzugefügt\n\n- {message}\n" + elif bump_type == "minor": + return f"### Hinzugefügt\n\n- {message}\n" + else: + return f"### Behoben\n\n- {message}\n" + + # Standard-Vorlagen + if bump_type == "major": + return ( + "### Hinzugefügt\n\n" + "- \n\n" + "### Geändert\n\n" + "- \n\n" + "### Entfernt\n\n" + "- \n" + ) + elif bump_type == "minor": + return ( + "### Hinzugefügt\n\n" + "- \n\n" + "### Geändert\n\n" + "- \n" + ) + else: + return ( + "### Behoben\n\n" + "- \n" + ) + + +# ── Build ─────────────────────────────────────────────────────── + +def _ensure_pyinstaller() -> None: + """Stellt sicher, dass PyInstaller installiert ist.""" + try: + import PyInstaller # noqa: F401 + except ImportError: + print("⚙ PyInstaller wird installiert …") + subprocess.check_call([sys.executable, "-m", "pip", "install", "pyinstaller"]) + print(" ✓ PyInstaller installiert") + + +def _prepare_build_dir() -> None: + """Bereitet das 2_build/-Verzeichnis vor.""" + BUILD_OUTPUT_DIR.mkdir(exist_ok=True) + + # Erstelle/aktualisiere Unterordner + for folder in ["input", "output", "template", "config"]: + target = BUILD_OUTPUT_DIR / folder + target.mkdir(exist_ok=True) + + # Kopiere config-Dateien, falls noch nicht vorhanden + config_src = APP_DIR / "config" + config_dst = BUILD_OUTPUT_DIR / "config" + if config_src.exists(): + for f in config_src.iterdir(): + if f.is_file() and not (config_dst / f.name).exists(): + shutil.copy2(f, config_dst / f.name) + + # README für input-Ordner + input_readme = BUILD_OUTPUT_DIR / "input" / "_DATEIEN_HIER_ABLEGEN_.txt" + if not input_readme.exists(): + input_readme.write_text( + "Excel-Dateien (.xlsx) in diesen Ordner legen.\n" + "Die App erkennt sie automatisch.\n", + encoding="utf-8", + ) + + # README für template-Ordner + tpl_readme = BUILD_OUTPUT_DIR / "template" / "_DATEIEN_HIER_ABLEGEN_.txt" + if not tpl_readme.exists(): + tpl_readme.write_text( + "PDF-Templates (.pdf) mit _Field-Platzhaltern in diesen Ordner legen.\n" + "Die App erkennt sie automatisch.\n", + encoding="utf-8", + ) + + print(f" ✓ 2_build/ vorbereitet") + + +def _run_pyinstaller() -> Path: + """Führt PyInstaller mit der Spec-Datei aus. + + Returns: + Pfad zur erstellten .exe. + """ + print("\n🔨 PyInstaller baut .exe …") + print(" (Dies kann einige Minuten dauern)\n") + + result = subprocess.run( + [ + sys.executable, "-m", "PyInstaller", + str(SPEC_PATH), + "--distpath", str(BUILD_PIPELINE_DIR / "dist"), + "--workpath", str(BUILD_PIPELINE_DIR / "build_temp"), + "--noconfirm", + "--clean", + ], + cwd=str(PROJECT_ROOT), + capture_output=False, + ) + + if result.returncode != 0: + print("\n❌ PyInstaller-Build fehlgeschlagen!") + sys.exit(1) + + exe_path = BUILD_PIPELINE_DIR / "dist" / "Tätigkeitsnachweis-Generator.exe" + if not exe_path.exists(): + print(f"\n❌ .exe nicht gefunden: {exe_path}") + sys.exit(1) + + print(f" ✓ .exe erstellt: {exe_path.name}") + return exe_path + + +def _copy_to_build(exe_path: Path) -> Path: + """Kopiert die .exe nach 2_build/.""" + dest = BUILD_OUTPUT_DIR / exe_path.name + shutil.copy2(exe_path, dest) + print(f" ✓ .exe nach 2_build/ kopiert") + return dest + + +def _cleanup() -> None: + """Räumt temporäre Build-Artefakte auf.""" + for d in ["dist", "build_temp"]: + path = BUILD_PIPELINE_DIR / d + if path.exists(): + shutil.rmtree(path, ignore_errors=True) + print(" ✓ Temporäre Dateien entfernt") + + +def _save_version(version: str) -> None: + """Speichert die Version für spätere Referenz.""" + VERSION_FILE.write_text(version) + + +def _print_summary(version: str, exe_path: Path) -> None: + """Gibt eine Build-Zusammenfassung aus.""" + print(f"\n{'='*60}") + print(f" ✅ Build {version} erfolgreich!") + print(f" 📂 {exe_path}") + print(f"{'='*60}\n") + print("Auslieferungs-Struktur (2_build/):") + print(" ├── Tätigkeitsnachweis-Generator.exe") + print(" ├── config/ ← Konfiguration (editierbar)") + print(" ├── input/ ← Excel-Dateien hier ablegen") + print(" ├── template/ ← PDF-Templates hier ablegen") + print(" └── output/ ← Generierte PDFs erscheinen hier") + + +# ── CLI ───────────────────────────────────────────────────────── + +def main() -> None: + parser = argparse.ArgumentParser( + description="Build mit Semantic Versioning", + ) + parser.add_argument( + "--bump", + choices=["major", "minor", "patch"], + help="Version bump-Typ (ohne Angabe: interaktiv)", + ) + parser.add_argument( + "--message", "-m", + help="Changelog-Beschreibung für diese Version", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Nur anzeigen, was passieren würde (kein Build)", + ) + args = parser.parse_args() + + # ── Version ermitteln ──────────────────────────────────── + current = get_current_version() + if not current: + print("❌ Keine Version in CHANGELOG.md gefunden. Beginne mit 0.1.") + current = "0.1" + + print(f"\n📋 Aktuelle Version: {current}") + + # Bump-Typ bestimmen + bump_type = args.bump + if not bump_type: + print("\nWelche Art von Änderung?") + print(" [1] major – inkompatible API-Änderungen") + print(" [2] minor – neue Funktionalität (abwärtskompatibel)") + print(" [3] patch – Bugfixes") + try: + choice = input(" → ").strip() + bump_type = {"1": "major", "2": "minor", "3": "patch"}.get(choice, "minor") + except (EOFError, KeyboardInterrupt): + print("\nAbgebrochen.") + sys.exit(0) + + new_version = bump_version(current, bump_type) + print(f"🚀 Neue Version: {new_version}") + + if args.dry_run: + print("\n[Trockenlauf – kein Build]") + print(f" Würde CHANGELOG.md aktualisieren: [{new_version}]") + print(f" Würde PyInstaller-Build starten") + return + + # ── CHANGELOG aktualisieren ────────────────────────────── + print("\n📝 Changelog …") + update_changelog(new_version, bump_type, args.message) + + # ── Build ──────────────────────────────────────────────── + _ensure_pyinstaller() + _prepare_build_dir() + + try: + exe_path = _run_pyinstaller() + final_exe = _copy_to_build(exe_path) + finally: + _cleanup() + + _save_version(new_version) + _print_summary(new_version, final_exe) + + +if __name__ == "__main__": + main() diff --git a/1_build-pipeline/build.spec b/1_build-pipeline/build.spec new file mode 100644 index 0000000..0e2fa31 --- /dev/null +++ b/1_build-pipeline/build.spec @@ -0,0 +1,159 @@ +# -*- mode: python ; coding: utf-8 -*- +"""PyInstaller Spec-Datei für den Tätigkeitsnachweis-Generator. + +Baut eine standalone .exe aus run.py, die Streamlit, openpyxl und +PyMuPDF (fitz) enthält. + +Verwendung: + pyinstaller build.spec +""" + +import os +import sys +from pathlib import Path + +# Pfade relativ zum Speicherort dieser Spec-Datei +# PyInstaller stellt SPECPATH bereit (__file__ existiert in exec()-Kontext nicht) +_SPEC_DIR = Path(SPECPATH).resolve() +_PROJECT_ROOT = _SPEC_DIR.parent +_APP_DIR = _PROJECT_ROOT / "0_application" + +# ── Streamlit & PyMuPDF Hidden Imports ────────────────────────── +# Streamlit hat viele dynamische Imports; collect_all holt alles. +# PyMuPDF (fitz) braucht die native DLL. + +hiddenimports = [ + # Streamlit-Komponenten + "streamlit", + "streamlit.web.bootstrap", + "streamlit.runtime", + "streamlit.runtime.scriptrunner", + "streamlit.runtime.state", + "streamlit.runtime.caching", + "streamlit.watcher", + "streamlit.watcher.local_sources_watcher", + "streamlit.watcher.poll_path_watcher", + "streamlit.watcher.event_based_path_watcher", + "streamlit.elements", + "streamlit.elements.arrow", + "streamlit.elements.pyplot", + "streamlit.elements.altair", + "streamlit.elements.plotly", + "streamlit.proto", + # openpyxl + "openpyxl", + "openpyxl.cell", + "openpyxl.styles", + # PyMuPDF + "fitz", + # Standard-Libs die manchmal übersehen werden + "tornado", + "watchdog", + "pandas", + "numpy", + "PIL", + "altair", + "pyarrow", + "git", + "blinker", + "rich", + "pydeck", +] + +# ── Binaries (PyMuPDF DLL) ────────────────────────────────────── +binaries = [] + +# Finde die PyMuPDF-DLL +try: + import fitz + fitz_dir = Path(fitz.__file__).parent + for f in fitz_dir.iterdir(): + if f.suffix.lower() in (".dll", ".pyd", ".so", ".dylib"): + binaries.append((str(f), "fitz")) +except ImportError: + pass + +# ── Data Files ────────────────────────────────────────────────── +datas = [ + (str(_APP_DIR / "app.py"), "."), + (str(_APP_DIR / "src"), "src"), + (str(_APP_DIR / "config"), "config"), +] + +# ── Alle Streamlit-Abhängigkeiten & Metadaten VOR Analysis() sammeln ─ +from PyInstaller.utils.hooks import collect_all, copy_metadata + +streamlit_datas, streamlit_binaries, streamlit_hidden = collect_all("streamlit") +datas.extend(streamlit_datas) +binaries.extend(streamlit_binaries) +hiddenimports.extend(streamlit_hidden) + +# Package-Metadaten: streamlit & Co. nutzen importlib.metadata.version(), +# das die METADATA-Dateien aus dem dist-info-Ordner benötigt. +for pkg in ["streamlit", "pyarrow", "numpy", "pandas", "altair", "openpyxl"]: + try: + datas += copy_metadata(pkg) + except Exception: + pass + +# Deduplizieren (Datas und Hiddenimports) +hiddenimports = list(set(hiddenimports)) +seen = set() +_unique_datas = [] +for src, dst in datas: + key = (src, dst) + if key not in seen: + seen.add(key) + _unique_datas.append((src, dst)) +datas = _unique_datas + +# ── Analyse ───────────────────────────────────────────────────── +a = Analysis( + [str(_SPEC_DIR / "run.py")], + pathex=[str(_APP_DIR)], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[ + "tkinter", + "matplotlib", + "scipy", + "IPython", + "jupyter", + "notebook", + "sqlalchemy", + ], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=None, + noarchive=False, +) + +# ── PyZ ───────────────────────────────────────────────────────── +pyz = PYZ(a.pure, a.zipped_data, cipher=None) + +# ── EXE ───────────────────────────────────────────────────────── +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name="Tätigkeitsnachweis-Generator", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, # True = Konsolenfenster sichtbar (zeigt Streamlit-Logs) + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/1_build-pipeline/run.py b/1_build-pipeline/run.py new file mode 100644 index 0000000..d90c2e5 --- /dev/null +++ b/1_build-pipeline/run.py @@ -0,0 +1,96 @@ +"""Einstiegspunkt für die gebundelte Tätigkeitsnachweis-Generator .exe. + +Wird von PyInstaller als Entry-Point verwendet. Im gebundelten Modus +(sys.frozen = True) liegen die Python-Dateien (app.py, src/) in sys._MEIPASS, +während die Daten-Ordner (config/, input/, template/, output/) neben der .exe +erwartet werden. + +Dieses Skript: +1. Setzt APP_BASE_DIR auf das Verzeichnis der .exe +2. Fügt sys._MEIPASS zum Python-Pfad hinzu (für src/-Imports) +3. Startet den Streamlit-Server und öffnet den Browser +""" + +import os +import sys +import threading +import time +import webbrowser +from pathlib import Path + + +def _get_base_dir() -> Path: + """Verzeichnis der .exe (bzw. des Skripts im Entwicklungsmodus).""" + if getattr(sys, 'frozen', False): + return Path(sys.executable).resolve().parent + else: + # Entwicklungsmodus: run.py liegt in 1_build-pipeline/, + # die App in 0_application/ + return Path(__file__).resolve().parent.parent / "0_application" + + +def _get_app_path() -> str: + """Pfad zur app.py (gebundelt oder im Entwicklungsmodus).""" + if getattr(sys, 'frozen', False): + return os.path.join(sys._MEIPASS, "app.py") + else: + return str(Path(__file__).resolve().parent.parent / "0_application" / "app.py") + + +def _ensure_dirs(base_dir: Path) -> None: + """Stellt sicher, dass input/, output/, template/, config/ existieren.""" + for folder in ["input", "output", "template", "config"]: + (base_dir / folder).mkdir(exist_ok=True) + + +def main() -> None: + base_dir = _get_base_dir() + + # Im gebundelten Modus: sys._MEIPASS enthält src/ und app.py + if getattr(sys, 'frozen', False): + if sys._MEIPASS not in sys.path: + sys.path.insert(0, sys._MEIPASS) + + # APP_BASE_DIR teilt app.py mit, wo die Daten-Ordner liegen + os.environ["APP_BASE_DIR"] = str(base_dir) + + _ensure_dirs(base_dir) + + # ── Streamlit-Konfiguration ────────────────────────── + os.environ["STREAMLIT_SERVER_ADDRESS"] = "localhost" + os.environ["STREAMLIT_SERVER_PORT"] = "8501" + os.environ["STREAMLIT_SERVER_HEADLESS"] = "true" + os.environ["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false" + os.environ["STREAMLIT_SERVER_ENABLE_CORS"] = "false" + os.environ["STREAMLIT_SERVER_ENABLE_XSRF_PROTECTION"] = "false" + + # Browser manuell öffnen + def _open_browser() -> None: + time.sleep(2) + webbrowser.open("http://localhost:8501") + + threading.Thread(target=_open_browser, daemon=True).start() + + # ── Streamlit via CLI-Codepfad starten ─────────────── + # Dieser Weg ist identisch zu 'streamlit run app.py' und + # funktioniert im PyInstaller-Bundle zuverlässiger als + # bootstrap.run() mit Streamlit >= 1.58. + sys.argv = [ + "streamlit", "run", _get_app_path(), + "--server.port=8501", + "--server.address=localhost", + "--server.headless=true", + "--browser.gatherUsageStats=false", + "--server.enableCORS=false", + "--server.enableXsrfProtection=false", + "--global.developmentMode=false", + ] + try: + from streamlit.cli import main as cli_main + except ImportError: + from streamlit.web.cli import main as cli_main + cli_main() + + +if __name__ == "__main__": + main()