"""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", ) # README.md aus 0_application/ kopieren readme_src = APP_DIR / "README.md" if readme_src.exists(): shutil.copy2(readme_src, BUILD_OUTPUT_DIR / "README.md") print(" ✓ README.md nach 2_build/ kopiert") 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()