"""Build-Orchestrierung mit Semantic Versioning. Liest die aktuelle Version aus CHANGELOG.md, bump't sie gemäß Benutzereingabe (major/minor/patch), aktualisiert das Changelog führt zuerst alle Tests aus, baut via PyInstaller eine standalone .exe nach 2_build/ und stellt sie anschließend als ZIP auf dem SMB-Laufwerk bereit. 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 from deploy import deploy, is_template_pdf, write_release_manifest # ── 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 _wait_for_changelog_edit() -> None: """Gibt dem Benutzer Zeit, den neuen Changelog-Eintrag zu bearbeiten. Der Build liest CHANGELOG.md erst nach dieser Pause; Änderungen in der Datei fließen also in den Build ein. """ print(f"\n✏️ Bitte den neuen Eintrag jetzt bearbeiten und speichern:\n {CHANGELOG_PATH}") try: input(" Danach Enter drücken, um den Build fortzusetzen (Strg+C bricht ab) … ") except (EOFError, KeyboardInterrupt): print("\nAbgebrochen.") raise KeyboardInterrupt 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 _run_tests() -> None: """Test with the same interpreter used to build the application.""" try: import pytest # noqa: F401 except ImportError: subprocess.check_call([sys.executable, "-m", "pip", "install", "pytest"]) print("\n[INFO] Führe alle Tests aus ...", flush=True) result = subprocess.run( [sys.executable, "-m", "pytest", str(APP_DIR / "tests"), "-v", "-p", "no:cacheprovider"], cwd=str(PROJECT_ROOT), ) if result.returncode != 0: print("[FEHLER] Tests fehlgeschlagen. Kein Build und kein Deployment.") raise SystemExit(1) 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) # Liefere die zum aktuellen Quellstand gehörende Konfiguration aus. 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(): shutil.copy2(f, config_dst / f.name) shutil.copy2(CHANGELOG_PATH, BUILD_OUTPUT_DIR / "CHANGELOG.md") # Alle Template-PDFs ausliefern (ohne Dateien mit '_' oder '.' am Anfang). # Veraltete PDFs aus früheren Builds werden entfernt. tpl_src = APP_DIR / "template" tpl_dst = BUILD_OUTPUT_DIR / "template" wanted = {p.name for p in tpl_src.iterdir() if is_template_pdf(p)} for old_pdf in tpl_dst.iterdir(): if is_template_pdf(old_pdf) and old_pdf.name not in wanted: old_pdf.unlink() for name in sorted(wanted): shutil.copy2(tpl_src / name, tpl_dst / 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", ) version_options = parser.add_mutually_exclusive_group() version_options.add_argument( "--bump", choices=["major", "minor", "patch"], help="Version bump-Typ (ohne Angabe: interaktiv)", ) version_options.add_argument("--no-bump", action="store_true", help="Aktuelle Version erneut bauen; Changelog unverändert übernehmen") parser.add_argument("--no-deploy", action="store_true", help="Nur lokal bauen, ohne anschließendes Deployment") parser.add_argument( "--message", "-m", help="Changelog-Beschreibung für diese Version", ) parser.add_argument( "--no-pause", action="store_true", help="Nicht auf die Bearbeitung des Changelogs warten (z.B. für CI)", ) 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: if args.no_bump: parser.error("Für --no-bump muss eine Version im CHANGELOG.md vorhanden sein.") 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 and not args.no_bump: 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 = current if args.no_bump else bump_version(current, bump_type) print(f"🚀 Neue Version: {new_version}") if args.dry_run: print("\n[Trockenlauf – kein Build]") print(" Würde zuerst alle Tests ausführen") if not args.no_bump: print(f" Würde CHANGELOG.md aktualisieren: [{new_version}]") print(f" Würde PyInstaller-Build starten") if not args.no_deploy: print(" Würde anschließend ein versioniertes ZIP auf dem SMB-Laufwerk bereitstellen") return _run_tests() previous_changelog = _read_changelog() try: _ensure_pyinstaller() (BUILD_OUTPUT_DIR / "release.json").unlink(missing_ok=True) if not args.no_bump: print("\n📝 Changelog …") update_changelog(new_version, bump_type, args.message) if not args.no_pause and sys.stdin.isatty(): _wait_for_changelog_edit() _prepare_build_dir() exe_path = _run_pyinstaller() final_exe = _copy_to_build(exe_path) write_release_manifest(BUILD_OUTPUT_DIR, new_version) except (Exception, SystemExit, KeyboardInterrupt): _write_changelog(previous_changelog) raise finally: _cleanup() _save_version(new_version) _print_summary(new_version, final_exe) if args.no_deploy: print("[INFO] Lokaler Build abgeschlossen; Deployment nicht angefordert.") return try: deploy() except (OSError, ValueError, KeyError) as exc: print(f"[FEHLER] {exc}") print("[INFO] Der Build ist fertig. Deployment später mit 1_build-pipeline\\deploy.bat wiederholen.") raise SystemExit(1) if __name__ == "__main__": try: main() except (OSError, ValueError, subprocess.CalledProcessError) as exc: print(f"[FEHLER] Pipeline abgebrochen: {exc}") raise SystemExit(1)