372 lines
12 KiB
Python
372 lines
12 KiB
Python
"""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()
|