add build pipline

This commit is contained in:
Rob committed 2026-06-30 21:28:28 +02:00
1 parent 94e245a3a0
commit c9dac7b9b8
5 files changed
+680 -1

No files matched your search

+371
View File
@@ -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()