Compare commits
9
Commits
971459c9bf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb059ac20d | ||
|
|
5e2fab7fd3 | ||
|
|
0ec5de50bb | ||
|
|
fb191449d1 | ||
|
|
4e3b2237ff | ||
|
|
7de6984ebc | ||
|
|
2237779e56 | ||
|
|
7d63063024 | ||
|
|
2aad2c9d99 |
No files matched your search
@@ -61,3 +61,7 @@ $RECYCLE.BIN/
|
||||
*.vsix
|
||||
3_Archiv
|
||||
2_build
|
||||
|
||||
# Python-Test- und Build-Artefakte
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
@@ -7,6 +7,50 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
---
|
||||
|
||||
## [1.4] – 2026-11-03
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- automatische Update Funktion
|
||||
---
|
||||
|
||||
## [1.3.4] – 2026-11-03
|
||||
|
||||
### Aktualisiert
|
||||
- Template PDF erneuert. Neues und altes PDF sind nun enthalten
|
||||
|
||||
---
|
||||
|
||||
## [1.3.3] – 2026-10-07
|
||||
|
||||
### Behoben
|
||||
|
||||
- internal development changes
|
||||
|
||||
---
|
||||
|
||||
## [1.3.2] – 2026-10-07
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Input-Dateien hinzufügen**: Bei leerem Input-Ordner können `.xlsx`-Dateien per Drag-and-drop oder Dateiauswahl in den Input-Ordner kopiert werden
|
||||
- **Input-Ordner öffnen**: Ein Button öffnet den Input-Ordner direkt im Explorer
|
||||
- **Input-Ordner neu einlesen**: Dateiliste kann über Button aktualisiert werden
|
||||
|
||||
### Behoben
|
||||
|
||||
- EXE erkennt laufende Entwicklungsinstanz und fragt vor dem Neustart
|
||||
|
||||
---
|
||||
|
||||
## [1.3.1] – 2026-10-07
|
||||
|
||||
### Behoben
|
||||
|
||||
- Python script erkennt jetzt laufende Entwicklungsinstanz und fragt vor dem Neustart
|
||||
|
||||
---
|
||||
|
||||
## [1.3] – 2026-08-11
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
@@ -103,7 +103,8 @@ Per Doppelklick bleibt das Ergebnisfenster geöffnet. Für automatisierte Aufruf
|
||||
|
||||
Dieser Aufruf erfolgt aus dem Projekt-Hauptordner. Bei fehlgeschlagenen Tests
|
||||
liefert das Skript einen Fehlercode und führt die übrigen Tests trotzdem aus.
|
||||
Die Tests laufen nicht automatisch beim Programmstart oder Build.
|
||||
Beim Build laufen die Tests automatisch vor der Versionierung und dem Bauen.
|
||||
Beim Programmstart werden sie nicht ausgeführt.
|
||||
|
||||
End-to-End-Tests parsen die feste Testdatei (`tests/fixture/2026 JM.xlsx`) und das
|
||||
PDF-Template (`template/Tätigkeitsnachweise Stellenförderung.pdf`) und validieren
|
||||
@@ -124,3 +125,57 @@ Die Tests prüfen:
|
||||
von Januar bis Mai 2026 mit zehn Seiten, Antragsnummer, Name, Stellenanteil,
|
||||
Monat, Funktion, Tätigkeit, Ort und korrekten Monatsenddaten
|
||||
- Neustartlogik unter Windows mit simulierten Prozessen
|
||||
|
||||
## Build und Deployment unter Windows
|
||||
|
||||
Alle folgenden Befehle werden im Projekt-Hauptordner ausgeführt:
|
||||
|
||||
```powershell
|
||||
# Interaktiv: Versionsänderung auswählen
|
||||
.\1_build-pipeline\build.bat
|
||||
|
||||
# Bugfix-Version mit Changelog-Beschreibung
|
||||
.\1_build-pipeline\build.bat --bump patch --message "Fehler beim Programmstart behoben"
|
||||
|
||||
# Ablauf anzeigen, ohne Tests, Build oder Deployment auszuführen
|
||||
.\1_build-pipeline\build.bat --bump patch --dry-run
|
||||
|
||||
# Aktuelle Version mit aktualisiertem Changelog erneut als EXE bauen
|
||||
.\1_build-pipeline\build.bat --no-bump --no-deploy
|
||||
|
||||
# Nur den letzten erfolgreichen Build bereitstellen (kein neuer Build)
|
||||
.\1_build-pipeline\deploy.bat
|
||||
|
||||
# Deployment ohne abschließende Tasteneingabe
|
||||
.\1_build-pipeline\deploy.bat --no-pause
|
||||
```
|
||||
|
||||
Die Pipeline führt zuerst alle Tests aus. Bei einem Testfehler bleiben Version
|
||||
und Changelog unverändert; Build und Deployment werden nicht gestartet.
|
||||
Nach erfolgreichen Tests aktualisiert sie die Version, erstellt die EXE und
|
||||
legt sie mit Konfiguration, PDF-Template, README und Changelog in `2_build/` ab.
|
||||
`release.json` hält die Version und Prüfsummen des erfolgreichen Builds fest.
|
||||
Bei einem Buildfehler wird der Changelog zurückgesetzt.
|
||||
|
||||
Danach erzeugt das Deployment `Tätigkeitsnachweis-Generator vX.Y.Z.zip` und
|
||||
überträgt es an:
|
||||
|
||||
```text
|
||||
j:\Fachbereiche\BO\Produktionsschule\Maßnahmeunterlagen\20_Personal\05_Tätigkeitsnachweise ESF\Nachweise-automatisiert-generieren\
|
||||
```
|
||||
|
||||
Das Ziel muss bereits vorhanden und über VPN/SMB erreichbar sein. Ist es nicht
|
||||
erreichbar oder fehlen Schreibrechte, bricht das Deployment verständlich ab.
|
||||
Der lokale Build bleibt erhalten. Nach Wiederherstellung der VPN-Verbindung
|
||||
kann `deploy.bat` die Bereitstellung ohne erneutes Testen, Bauen oder Erhöhen
|
||||
der Versionsnummer wiederholen. Das Skript akzeptiert nur einen bestätigten,
|
||||
unveränderten Build mit `release.json`; ältere Builds müssen einmal neu gebaut
|
||||
werden.
|
||||
|
||||
Das ZIP enthält leere `input/`- und `output/`-Ordner; Arbeitsdateien und
|
||||
Testdaten werden nicht mit ausgeliefert. Es wird zunächst als temporäre Datei
|
||||
übertragen, per Prüfsumme geprüft und erst dann unter dem Versionsnamen
|
||||
sichtbar gemacht. Bestehende Versionen mit anderem Inhalt werden nicht
|
||||
überschrieben. Ein identisches Paket kann erneut bereitgestellt werden.
|
||||
Die Neustarttests simulieren Prozesse und Dialogantworten; ein manueller
|
||||
Starttest der fertigen EXE bleibt vor der Weitergabe sinnvoll.
|
||||
@@ -9,6 +9,10 @@ Um das Programm zu starten: Doppelklick auf `Tätigkeitsnachweis-Generator.exe`
|
||||
## Bedienung
|
||||
|
||||
1. **Input-Datei wählen** – Dropdown zeigt alle `.xlsx`-Dateien aus dem `input/`-Ordner
|
||||
- Bei leerem Input-Ordner: „Input-Ordner öffnen“ öffnet den Explorer.
|
||||
- Excel-Dateien können per Drag-and-drop hochgeladen werden. Sie werden in den
|
||||
Input-Ordner kopiert; danach wird die Dateiliste automatisch neu eingelesen.
|
||||
- Nach manuellem Kopieren im Explorer „Input-Ordner neu einlesen“ anklicken.
|
||||
- Nach Auswahl wird die Datei sofort validiert
|
||||
- Angezeigt werden: Anzahl Mitarbeiter, Antragsnummern, Einträge pro Monat
|
||||
2. **Template wählen** – Dropdown zeigt alle `.xlsx`-Dateien aus dem `template/`-Ordner
|
||||
@@ -21,6 +25,20 @@ Um das Programm zu starten: Doppelklick auf `Tätigkeitsnachweis-Generator.exe`
|
||||
5. **"Tätigkeitsnachweise generieren"** klicken
|
||||
- Die generierten Dateien werden im `output/`-Ordner abgelegt
|
||||
|
||||
## Update
|
||||
|
||||
Liegt eine neuere Version auf dem Laufwerk J:, erscheint oben rechts ein Hinweis.
|
||||
|
||||
- **„Anwendung ersetzen“** richtet die neue Version in einem eigenen Ordner neben der
|
||||
aktuellen ein (z. B. `Tätigkeitsnachweis-Generator v1.4.0`), übernimmt `input/`,
|
||||
`output/` und eigene Template-Dateien und startet sie. Das folgende Rückfragefenster
|
||||
mit „Ja“ bestätigen, dann wird die alte Version beendet.
|
||||
- Die alte Version bleibt als Ordner erhalten und kann gelöscht werden, sobald die neue läuft.
|
||||
- Wurde `settings.ini` angepasst, liegt die bisherige Datei als
|
||||
`config/settings.vorherige-version.ini` daneben.
|
||||
- Desktop-Verknüpfungen zeigen weiterhin auf den alten Ordner und müssen neu angelegt werden.
|
||||
- **„Update Ordner öffnen“** öffnet das Verzeichnis auf J: für ein manuelles Update.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
Alle Einstellungen liegen im `config/`-Ordner:
|
||||
|
||||
+98
-6
@@ -30,6 +30,15 @@ from src.parser import parse_input_file, group_by_employee, get_unique_antragsnu
|
||||
from src.validator import validate_input_file, validate_template
|
||||
from src.writer import generate_activity_reports
|
||||
from version_check import UPDATE_DIR, get_latest_version
|
||||
from update_installer import can_replace_application, install_update, start_application
|
||||
|
||||
|
||||
def open_input_folder(directory: Path):
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
if sys.platform == "win32":
|
||||
os.startfile(str(directory))
|
||||
else:
|
||||
subprocess.run(["open" if sys.platform == "darwin" else "xdg-open", str(directory)], check=True)
|
||||
|
||||
|
||||
def set_active_employee(employee_name: str):
|
||||
@@ -83,7 +92,7 @@ try:
|
||||
local_version, latest_version = get_latest_version()
|
||||
current_version_text = f"v{'.'.join(map(str, local_version))}"
|
||||
if latest_version:
|
||||
available_version, _ = latest_version
|
||||
available_version, update_path = latest_version
|
||||
version_text = ".".join(map(str, available_version))
|
||||
st.html(
|
||||
f'''<style>
|
||||
@@ -97,7 +106,7 @@ try:
|
||||
#update-notice-dismiss:checked + .update-notice {{ display:none; }}
|
||||
body:has(#update-notice-dismiss:checked) div.st-key-update_notice_open {{ display:none!important; }}
|
||||
.update-notice__actions {{ display:flex; justify-content:flex-end; gap:8px; margin-top:8px; }}
|
||||
.update-notice__actions--footer {{ min-height:26px; }}
|
||||
.update-notice__actions--footer {{ min-height:60px; }}
|
||||
.update-notice__button {{
|
||||
display:inline-flex; align-items:center; justify-content:center;
|
||||
width:26px; height:26px; color:#5f4700; background:#fffdf3;
|
||||
@@ -106,11 +115,12 @@ try:
|
||||
}}
|
||||
.update-notice__button:hover {{ background:#f6e7b2; }}
|
||||
div.st-key-update_notice_open {{
|
||||
position:fixed; top:8.5rem; right:3.75rem; z-index:10001; width:26px;
|
||||
position:fixed; top:8.5rem; right:calc(1rem + 13px); z-index:10001;
|
||||
width:264px; gap:6px;
|
||||
}}
|
||||
div.st-key-update_notice_open div[data-testid="stButton"] {{ margin:0; }}
|
||||
div.st-key-update_notice_open div[data-testid="stButton"] button {{
|
||||
min-width:200px; min-height:26px; height:26px; padding:0; color:#5f4700;
|
||||
width:100%; min-height:26px; height:26px; padding:0; color:#5f4700;
|
||||
background:#fffdf3; border:1px solid #d9bd66; border-radius:5px;
|
||||
}}
|
||||
</style>
|
||||
@@ -128,6 +138,33 @@ try:
|
||||
if st.button("Update Ordner öffnen 📁", key="open_update_directory", help="Update-Ordner öffnen"):
|
||||
if sys.platform == "win32":
|
||||
os.startfile(str(UPDATE_DIR))
|
||||
replace_supported = can_replace_application()
|
||||
replace_clicked = st.button(
|
||||
"Anwendung ersetzen 🔄", key="replace_application",
|
||||
disabled=not replace_supported or st.session_state.get("update_started", False),
|
||||
help=("Neue Version neben der aktuellen einrichten, input/ und output/ übernehmen "
|
||||
"und starten. Die aktuelle Version bleibt als Ordner erhalten."
|
||||
if replace_supported else "Nur in der installierten Windows-Anwendung verfügbar."))
|
||||
if replace_clicked:
|
||||
try:
|
||||
with st.spinner(f"Version v{version_text} wird eingerichtet …"):
|
||||
result = install_update(update_path, PROJECT_ROOT)
|
||||
start_application(result.target_dir)
|
||||
except Exception as exc: # Der umgebende Block verschluckt sonst jeden Fehler.
|
||||
st.error(f"Update fehlgeschlagen: {exc}\n\n"
|
||||
"Alternativ das Update manuell aus dem Update-Ordner einrichten.")
|
||||
else:
|
||||
st.session_state.update_started = True
|
||||
details = [f"Neuer Programmordner: `{result.target_dir}`"]
|
||||
if result.already_installed:
|
||||
details.append("Die Version war dort bereits eingerichtet und wurde nur gestartet.")
|
||||
else:
|
||||
details.append("Die Ordner input/ und output/ wurden übernommen.")
|
||||
if result.previous_settings:
|
||||
details.append(f"Die bisherige settings.ini wurde als `{result.previous_settings.name}` aufbewahrt.")
|
||||
details.append("Bitte im folgenden Fenster bestätigen, dass die alte Version beendet wird. "
|
||||
"Desktop-Verknüpfungen zeigen noch auf den alten Ordner.")
|
||||
st.success(f"Version v{version_text} wird gestartet.\n\n" + "\n\n".join(details))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -155,9 +192,64 @@ col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
st.subheader("1. Input-Datei")
|
||||
input_files = get_available_input_files(app_config)
|
||||
for message in st.session_state.pop("input_upload_messages", []):
|
||||
st.success(message)
|
||||
for message in st.session_state.pop("input_upload_errors", []):
|
||||
st.error(message)
|
||||
if not input_files:
|
||||
st.warning("Keine .xlsx-Dateien im input/-Ordner gefunden.")
|
||||
with st.container(border=True, key="input_file_panel"):
|
||||
st.markdown("**Excel-Datei hinzufügen**")
|
||||
st.caption("Keine .xlsx-Dateien im Input/-Ordner gefunden.")
|
||||
input_directory = Path(app_config.input_dir)
|
||||
uploaded_files = st.file_uploader(
|
||||
"Excel-Dateien hierher ziehen oder auswählen",
|
||||
type=["xlsx"], accept_multiple_files=True,
|
||||
key=f"input_upload_{st.session_state.get('input_upload_generation', 0)}",
|
||||
label_visibility="collapsed",
|
||||
)
|
||||
if uploaded_files:
|
||||
saved, errors = [], []
|
||||
for uploaded_file in uploaded_files:
|
||||
try:
|
||||
name = uploaded_file.name
|
||||
if (Path(name).name != name or "/" in name or "\\" in name
|
||||
or Path(name).suffix.lower() != ".xlsx" or name.startswith("~")):
|
||||
raise ValueError("Bitte eine .xlsx-Datei mit gültigem Dateinamen auswählen.")
|
||||
input_directory.mkdir(parents=True, exist_ok=True)
|
||||
destination = input_directory / name
|
||||
counter = 1
|
||||
while True:
|
||||
try:
|
||||
with destination.open("xb") as output:
|
||||
output.write(uploaded_file.getbuffer())
|
||||
break
|
||||
except FileExistsError:
|
||||
counter += 1
|
||||
destination = input_directory / f"{Path(name).stem} ({counter}).xlsx"
|
||||
saved.append(f"{destination.name} wurde in den Input-Ordner kopiert.")
|
||||
except (OSError, ValueError) as exc:
|
||||
errors.append(f"{uploaded_file.name} konnte nicht übernommen werden: {exc}")
|
||||
st.session_state.input_upload_messages = saved
|
||||
st.session_state.input_upload_errors = errors
|
||||
st.session_state.input_upload_generation = st.session_state.get("input_upload_generation", 0) + 1
|
||||
st.session_state.last_input_file = None
|
||||
st.rerun()
|
||||
st.caption("Dateien hierher ziehen oder auswählen. Sie werden automatisch im Input-Ordner gespeichert.")
|
||||
folder_action, refresh_action = st.columns(2)
|
||||
with folder_action:
|
||||
if st.button("Ordner öffnen", icon="📁", key="open_input_directory", use_container_width=True):
|
||||
try:
|
||||
open_input_folder(input_directory)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
st.error(f"Der Input-Ordner konnte nicht geöffnet werden: {exc}")
|
||||
with refresh_action:
|
||||
if st.button("Neu einlesen", icon="🔄", key="refresh_input_directory", use_container_width=True,
|
||||
help="Dateiliste aktualisieren, nachdem Dateien im Explorer hinzugefügt wurden."):
|
||||
st.rerun()
|
||||
selected_input = None
|
||||
st.session_state.parsed_records = []
|
||||
st.session_state.input_validated = False
|
||||
st.session_state.last_input_file = None
|
||||
else:
|
||||
# Bei nur einer Datei: automatisch selektieren (keine Leerauswahl)
|
||||
if len(input_files) == 1:
|
||||
@@ -484,7 +576,7 @@ if "generated_files" in st.session_state and st.session_state.generated_files:
|
||||
key=f"open_folder_{i}",
|
||||
on_click=open_path,
|
||||
args=(str(fpath.parent),),
|
||||
help="Ordner im Finder öffnen",
|
||||
help="Ordner im Explorer öffnen",
|
||||
use_container_width=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ def main():
|
||||
# ── Datei öffnen (macOS) ──────────────────────────────────────────
|
||||
if sys.platform == "darwin":
|
||||
print()
|
||||
print("[INFO] Öffne Datei im Finder…")
|
||||
print("[INFO] Öffne Datei im Explorer")
|
||||
subprocess.run(["open", str(output_file)], check=False)
|
||||
|
||||
print()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_CONFIRM_SCRIPT = r'''
|
||||
@@ -25,9 +26,18 @@ def stop_existing_server(port: int = 8501) -> bool:
|
||||
if os.name != "nt":
|
||||
raise RuntimeError("Der kontrollierte Server-Neustart unterstützt nur Windows.")
|
||||
|
||||
app_path = str(Path(__file__).resolve().with_name("app.py"))
|
||||
app_path = Path(__file__).resolve().with_name("app.py")
|
||||
app_paths = [str(app_path)]
|
||||
if getattr(sys, "frozen", False):
|
||||
# A locally built EXE in 2_build/ must also recognize the sibling
|
||||
# development application, whose path differs from _MEIPASS/app.py.
|
||||
exe_dir = Path(sys.executable).resolve().parent
|
||||
development_app = exe_dir.parent / "0_application" / "app.py"
|
||||
if exe_dir.name.lower() == "2_build" and development_app.is_file():
|
||||
app_paths.append(str(development_app))
|
||||
# Pass paths through the environment, never interpolate them into shell code.
|
||||
env = dict(os.environ, APP_RESTART_PATH=app_path, APP_RESTART_PID=str(os.getpid()),
|
||||
env = dict(os.environ, APP_RESTART_PATH=str(app_path),
|
||||
APP_RESTART_PATHS=json.dumps(app_paths), APP_RESTART_PID=str(os.getpid()),
|
||||
APP_RESTART_PORT=str(port),
|
||||
APP_RESTART_VENV=str(Path(app_path).parent / ".venv" / "Scripts"))
|
||||
script = _CONFIRM_SCRIPT + r'''
|
||||
@@ -47,11 +57,15 @@ foreach ($processId in $processIds) {
|
||||
if (-not $process) { continue }
|
||||
$isExe = $process.Name -eq 'Tätigkeitsnachweis-Generator.exe'
|
||||
$command = $process.CommandLine
|
||||
$appArgument = '(?i)\bstreamlit(?:\.exe)?"?\s+run\s+"?' +
|
||||
[regex]::Escape($env:APP_RESTART_PATH) + '"?(?:\s|$)'
|
||||
$matchesApp = $false
|
||||
foreach ($appPath in ($env:APP_RESTART_PATHS | ConvertFrom-Json)) {
|
||||
$appArgument = '(?i)\bstreamlit(?:\.exe)?"?\s+run\s+"?' +
|
||||
[regex]::Escape($appPath) + '"?(?:\s|$)'
|
||||
if ($command -match $appArgument) { $matchesApp = $true }
|
||||
}
|
||||
$isDev = $process.Name -match '^(python(?:w)?(?:\d+(?:\.\d+)*)?(?:w)?|streamlit)\.exe$' -and
|
||||
$command -match '(?i)\bstreamlit(?:\.exe)?"?\s+run\s+' -and
|
||||
($command -match $appArgument -or
|
||||
($matchesApp -or
|
||||
($command -match '(?i)\bstreamlit(?:\.exe)?"?\s+run\s+"?app\.py"?(?:\s|$)' -and
|
||||
[IO.Path]::GetDirectoryName($process.ExecutablePath) -eq $env:APP_RESTART_VENV))
|
||||
if (-not ($isExe -or $isDev)) {
|
||||
|
||||
File renamed without changes.
File renamed without changes.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,34 @@
|
||||
# Aktualisieren der PDF Datei
|
||||
|
||||
Wenn sich die PDF-Datei welche als Vorlagen genutzt wird geändert hat, kann diese nicht direkt vom Programm verwendet werden, da es die zu verwendenden Felder nicht erkennen kann. Wir müssen ihm kurz zeigen wo die zu nutzenden Felder sind.
|
||||
|
||||
Öffne Dazu eine bereits bestehende *.odg Datei im Ordner template und schau Dir die Textfelder an, welche in die entsprechenden Felder gelegt, damit das Programm diese später erkennen kann.
|
||||
|
||||
Öffne nun das neue PDF mit dem Programm Libre Office Writer und erzeuge in jedem Formularfeld ein Textfeld mit dem entspechenden Feldnamen wie im Beispiel-odg. Die Felder heißen einfach so wie sie im PDF genutzt werden plus Unterstrich Field. Also:
|
||||
- Antragsnummer_Field
|
||||
- Name_Field
|
||||
- Qulifikation_Field
|
||||
- Funktion_Field
|
||||
- Stellenanteil_Field
|
||||
- Monat_Field
|
||||
|
||||
In das Feld Tätigkeiten muss kein neues Textfeld eingefügt werden, weil es schon ein Textfeld ist, daher kann in dieses direkt
|
||||
- Tätigkeiten_Field
|
||||
|
||||
eingetragen werden.
|
||||
|
||||
Auf Seite zwei muss nur ein Feld
|
||||
- Datum_Field
|
||||
|
||||
erzeugt werden. Denn dieses kann einfach kopiert und im zweiten Datumfeld plaziert werden.
|
||||
Für die beiden Ortsfelder muss auch jeweils ein Textfeld eingefügt werden, dieses enthält aber einfach den Text:
|
||||
- Dresden
|
||||
|
||||
da es vom Programm nicht geändert wird
|
||||
|
||||
# Styling
|
||||
Um einen einheitlichen look zu garantieren müssen alle eingefügten Textfelder wie folgt gestyled sein:
|
||||
- das Textfeld selber füllt das komplette Feld bis an alle 4 Ränder aus
|
||||
- Textgröße: 12 pt
|
||||
- Font/Schriftart: Arial
|
||||
- Absatz Textausrichtung: mittig
|
||||
@@ -27,7 +27,7 @@ echo ""
|
||||
echo "[INFO] Führe End-to-End-Tests aus…"
|
||||
echo ""
|
||||
|
||||
"$VENV_DIR/bin/python" -m pytest tests/test_e2e.py -v
|
||||
"$VENV_DIR/bin/python" -m pytest tests -v
|
||||
|
||||
echo ""
|
||||
read -p "Drücke Enter zum Beenden..."
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Verify pipeline gates and deployment against temporary local directories."""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from unittest.mock import patch, Mock
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
PIPELINE_DIR = Path(__file__).resolve().parents[2] / "1_build-pipeline"
|
||||
sys.path.insert(0, str(PIPELINE_DIR))
|
||||
import deploy
|
||||
|
||||
spec = importlib.util.spec_from_file_location("build_pipeline", PIPELINE_DIR / "build.py")
|
||||
build = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(build)
|
||||
|
||||
|
||||
TEMPLATE_NAMES = ["template/A Vorlage.pdf", "template/B Vorlage.PDF"]
|
||||
IGNORED_NAMES = ["template/_alt.pdf", "template/.versteckt.pdf", "template/A Vorlage.odg"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def release(tmp_path):
|
||||
directory = tmp_path / "build"
|
||||
directory.mkdir()
|
||||
for name in deploy.PACKAGE_FILES + TEMPLATE_NAMES:
|
||||
path = directory / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b"test release payload")
|
||||
deploy.write_release_manifest(directory, "1.3")
|
||||
target = tmp_path / "share"
|
||||
target.mkdir()
|
||||
return directory, target
|
||||
|
||||
|
||||
def test_deployment_packages_release_without_work_files(release):
|
||||
directory, target = release
|
||||
for name in IGNORED_NAMES:
|
||||
(directory / name).write_bytes(b"ignored")
|
||||
for folder in ["input", "output"]:
|
||||
(directory / folder).mkdir()
|
||||
(directory / folder / "private.xlsx").write_bytes(b"private")
|
||||
result = deploy.deploy(directory, target)
|
||||
assert result.name == "Tätigkeitsnachweis-Generator v1.3.0.zip"
|
||||
with zipfile.ZipFile(result) as archive:
|
||||
assert set(archive.namelist()) == set(deploy.PACKAGE_FILES + TEMPLATE_NAMES + ["input/", "output/"])
|
||||
assert archive.testzip() is None
|
||||
assert not list(target.glob(".upload-*"))
|
||||
# An unchanged release can be retried without overwriting the published ZIP.
|
||||
original_time = result.stat().st_mtime_ns
|
||||
assert deploy.deploy(directory, target) == result
|
||||
assert result.stat().st_mtime_ns == original_time
|
||||
|
||||
|
||||
def test_missing_vpn_target_is_not_created(release):
|
||||
directory, target = release
|
||||
missing = target / "missing-share"
|
||||
with pytest.raises(OSError, match="VPN"):
|
||||
deploy.deploy(directory, missing)
|
||||
assert not missing.exists()
|
||||
assert not list(directory.glob("*.zip"))
|
||||
|
||||
|
||||
def test_changed_payload_is_not_deployed(release):
|
||||
directory, target = release
|
||||
(directory / deploy.EXE_NAME).write_bytes(b"different executable")
|
||||
with pytest.raises(ValueError, match="verändert"):
|
||||
deploy.deploy(directory, target)
|
||||
assert not list(target.iterdir())
|
||||
|
||||
|
||||
def test_unconfirmed_old_build_is_not_deployed(release):
|
||||
directory, target = release
|
||||
(directory / deploy.MANIFEST_NAME).unlink()
|
||||
with pytest.raises(ValueError, match="bestätigter Build"):
|
||||
deploy.deploy(directory, target)
|
||||
|
||||
|
||||
def test_existing_different_release_is_preserved(release):
|
||||
directory, target = release
|
||||
existing = target / "Tätigkeitsnachweis-Generator v1.3.0.zip"
|
||||
existing.write_bytes(b"already published")
|
||||
with pytest.raises(FileExistsError, match="anderem Inhalt"):
|
||||
deploy.deploy(directory, target)
|
||||
assert existing.read_bytes() == b"already published"
|
||||
|
||||
|
||||
def test_interrupted_upload_is_not_published(release):
|
||||
directory, target = release
|
||||
with patch.object(deploy.shutil, "copyfile", side_effect=OSError("VPN disconnected")):
|
||||
with pytest.raises(OSError, match="lokale Build bleibt erhalten"):
|
||||
deploy.deploy(directory, target)
|
||||
assert not list(target.iterdir())
|
||||
assert list(directory.glob("*.zip"))
|
||||
assert deploy.validate_release(directory) == "1.3.0"
|
||||
|
||||
|
||||
def test_corrupt_upload_is_not_published(release):
|
||||
directory, target = release
|
||||
def corrupt_copy(source, destination):
|
||||
Path(destination).write_bytes(b"corrupted")
|
||||
with patch.object(deploy.shutil, "copyfile", side_effect=corrupt_copy):
|
||||
with pytest.raises(OSError):
|
||||
deploy.deploy(directory, target)
|
||||
assert not list(target.iterdir())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pipeline(tmp_path, monkeypatch):
|
||||
changelog = tmp_path / "CHANGELOG.md"
|
||||
changelog.write_text("## [1.3]\n\nOriginal release\n", encoding="utf-8")
|
||||
version_file = tmp_path / ".last_version"
|
||||
version_file.write_text("1.3", encoding="utf-8")
|
||||
monkeypatch.setattr(build, "CHANGELOG_PATH", changelog)
|
||||
monkeypatch.setattr(build, "VERSION_FILE", version_file)
|
||||
monkeypatch.setattr(build, "BUILD_OUTPUT_DIR", tmp_path)
|
||||
monkeypatch.setattr(sys, "argv", ["build.py", "--bump", "patch", "--message", "Fix"])
|
||||
events = []
|
||||
names = ["_run_tests", "_ensure_pyinstaller", "_prepare_build_dir", "_run_pyinstaller",
|
||||
"_copy_to_build", "write_release_manifest", "_cleanup", "_print_summary", "deploy"]
|
||||
mocks = {}
|
||||
for name in names:
|
||||
mocks[name] = Mock(side_effect=lambda *args, _name=name: events.append(_name))
|
||||
monkeypatch.setattr(build, name, mocks[name])
|
||||
return changelog, version_file, events, mocks
|
||||
|
||||
|
||||
def test_pipeline_tests_build_then_deploy(pipeline):
|
||||
changelog, version_file, events, mocks = pipeline
|
||||
build.main()
|
||||
assert events == ["_run_tests", "_ensure_pyinstaller", "_prepare_build_dir", "_run_pyinstaller",
|
||||
"_copy_to_build", "write_release_manifest", "_cleanup", "_print_summary", "deploy"]
|
||||
assert version_file.read_text() == "1.3.1"
|
||||
assert "## [1.3.1]" in changelog.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_failing_tests_prevent_version_change_build_and_deploy(pipeline):
|
||||
changelog, version_file, events, mocks = pipeline
|
||||
original = changelog.read_bytes()
|
||||
mocks["_run_tests"].side_effect = SystemExit(1)
|
||||
with pytest.raises(SystemExit):
|
||||
build.main()
|
||||
assert changelog.read_bytes() == original
|
||||
assert version_file.read_text() == "1.3"
|
||||
mocks["_run_pyinstaller"].assert_not_called()
|
||||
mocks["deploy"].assert_not_called()
|
||||
|
||||
|
||||
def test_build_failure_rolls_back_changelog_and_prevents_deployment(pipeline):
|
||||
changelog, version_file, events, mocks = pipeline
|
||||
original = changelog.read_text(encoding="utf-8")
|
||||
mocks["_run_pyinstaller"].side_effect = SystemExit(1)
|
||||
with pytest.raises(SystemExit):
|
||||
build.main()
|
||||
assert changelog.read_text(encoding="utf-8") == original
|
||||
assert version_file.read_text() == "1.3"
|
||||
mocks["deploy"].assert_not_called()
|
||||
mocks["_cleanup"].assert_called_once()
|
||||
|
||||
|
||||
def test_deploy_failure_keeps_successful_build_version(pipeline):
|
||||
changelog, version_file, events, mocks = pipeline
|
||||
mocks["deploy"].side_effect = OSError("VPN unavailable")
|
||||
with pytest.raises(SystemExit):
|
||||
build.main()
|
||||
assert version_file.read_text() == "1.3.1"
|
||||
assert "## [1.3.1]" in changelog.read_text(encoding="utf-8")
|
||||
mocks["write_release_manifest"].assert_called_once()
|
||||
|
||||
|
||||
def test_dry_run_does_not_test_build_or_deploy(pipeline, monkeypatch):
|
||||
changelog, version_file, events, mocks = pipeline
|
||||
monkeypatch.setattr(sys, "argv", ["build.py", "--bump", "patch", "--dry-run"])
|
||||
build.main()
|
||||
assert not events
|
||||
assert version_file.read_text() == "1.3"
|
||||
|
||||
|
||||
def test_rebuild_preserves_changelog_and_version(pipeline, monkeypatch):
|
||||
changelog, version_file, events, mocks = pipeline
|
||||
original = changelog.read_bytes()
|
||||
monkeypatch.setattr(sys, "argv", ["build.py", "--no-bump", "--no-deploy"])
|
||||
build.main()
|
||||
assert changelog.read_bytes() == original
|
||||
assert version_file.read_text() == "1.3"
|
||||
mocks["write_release_manifest"].assert_called_once_with(build.BUILD_OUTPUT_DIR, "1.3")
|
||||
mocks["_run_tests"].assert_called_once()
|
||||
mocks["_run_pyinstaller"].assert_called_once()
|
||||
mocks["deploy"].assert_not_called()
|
||||
@@ -15,7 +15,7 @@ from src.writer import generate_activity_reports
|
||||
|
||||
CONFIG_DIR = PROJECT_ROOT / "config"
|
||||
INPUT_FILE = Path(__file__).resolve().parent / "fixture" / "2026 JM.xlsx"
|
||||
TEMPLATE_FILE = PROJECT_ROOT / "template" / "Tätigkeitsnachweise Stellenförderung.pdf"
|
||||
TEMPLATE_FILE = PROJECT_ROOT / "template" / "06-2026 Tätigkeitsnachweise Stellenförderung.pdf"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
|
||||
@@ -13,14 +13,18 @@ import server_lifecycle
|
||||
|
||||
@unittest.skipUnless(os.name == "nt", "Windows process management")
|
||||
class ServerLifecycleTests(unittest.TestCase):
|
||||
def simulate(self, process, listening=True, stop_error=False, confirm=True):
|
||||
def simulate(self, process, listening=True, stop_error=False, confirm=True, frozen=False):
|
||||
captured = {}
|
||||
|
||||
def capture(command, **kwargs):
|
||||
captured.update(script=command[-1], env=kwargs["env"])
|
||||
return subprocess.CompletedProcess(command, 0, "[]", "")
|
||||
|
||||
with patch.object(server_lifecycle.subprocess, "run", side_effect=capture):
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
with patch.object(server_lifecycle.subprocess, "run", side_effect=capture), \
|
||||
patch.object(server_lifecycle.sys, "frozen", frozen, create=True), \
|
||||
patch.object(server_lifecycle.sys, "executable", str(root / "2_build" / "Tätigkeitsnachweis-Generator.exe")), \
|
||||
patch.object(server_lifecycle, "__file__", str(root / "_MEI-test" / "server_lifecycle.py") if frozen else server_lifecycle.__file__):
|
||||
server_lifecycle.stop_existing_server()
|
||||
|
||||
setup = r'''
|
||||
@@ -140,6 +144,32 @@ function Confirm-ServerRestart {
|
||||
self.assertNotIn("CONFIRM_CALLED", result.stderr)
|
||||
self.assertNotIn("STOP_CALLED", result.stderr)
|
||||
|
||||
def test_exe_recognizes_sibling_development_server(self):
|
||||
process = r'''[pscustomobject]@{
|
||||
Name='python3.13.exe'; ExecutablePath='C:\Python\python3.13.exe';
|
||||
CommandLine=('".venv\Scripts\python.exe" -m streamlit run "' +
|
||||
(($env:APP_RESTART_PATHS | ConvertFrom-Json)[1]) +
|
||||
'" --server.port=8501 --server.address=localhost')
|
||||
}'''
|
||||
for answer, expected_code in [(True, 0), (False, 2)]:
|
||||
with self.subTest(confirm=answer):
|
||||
result = self.simulate(process, frozen=True, confirm=answer)
|
||||
self.assertEqual(result.returncode, expected_code, result.stderr)
|
||||
self.assertIn("CONFIRM_CALLED", result.stderr)
|
||||
if answer:
|
||||
self.assertIn("STOP_CALLED", result.stderr)
|
||||
else:
|
||||
self.assertNotIn("STOP_CALLED", result.stderr)
|
||||
|
||||
def test_exe_preserves_unrelated_development_server(self):
|
||||
result = self.simulate(r'''[pscustomobject]@{
|
||||
Name='python3.13.exe'; ExecutablePath='C:\Python\python3.13.exe';
|
||||
CommandLine='python -m streamlit run "C:\other-project\app.py"'
|
||||
}''', frozen=True)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertNotIn("CONFIRM_CALLED", result.stderr)
|
||||
self.assertNotIn("STOP_CALLED", result.stderr)
|
||||
|
||||
def test_unrelated_server_is_preserved(self):
|
||||
result = self.simulate(r'''[pscustomobject]@{
|
||||
Name='python.exe'; CommandLine='streamlit run app.py';
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Tests für das Einrichten einer neuen Version neben der aktuellen Installation."""
|
||||
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
import update_installer
|
||||
from update_installer import EXE_NAME, UpdateError, install_update
|
||||
|
||||
|
||||
class InstallUpdateTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._temporary = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self._temporary.name).resolve()
|
||||
self.current = self.root / "Tätigkeitsnachweis-Generator v1.3.3"
|
||||
for folder in ["input", "output/2026", "config", "template"]:
|
||||
(self.current / folder).mkdir(parents=True)
|
||||
(self.current / EXE_NAME).write_bytes(b"old")
|
||||
(self.current / "input" / "daten.xlsx").write_bytes(b"input")
|
||||
(self.current / "output" / "2026" / "nachweis.pdf").write_bytes(b"output")
|
||||
(self.current / "config" / "settings.ini").write_bytes(b"[a]\nx=1\n")
|
||||
(self.current / "template" / "vorlage.pdf").write_bytes(b"old template")
|
||||
(self.current / "template" / "eigene.pdf").write_bytes(b"own template")
|
||||
self.share = self.root / "share"
|
||||
self.share.mkdir()
|
||||
|
||||
def tearDown(self):
|
||||
self._temporary.cleanup()
|
||||
|
||||
def make_zip(self, files=None, version="1.4.0"):
|
||||
files = files if files is not None else {
|
||||
EXE_NAME: b"new",
|
||||
"config/settings.ini": b"[a]\nx=1\n",
|
||||
"template/vorlage.pdf": b"new template",
|
||||
"input/": b"", "output/": b"",
|
||||
}
|
||||
path = self.share / f"Tätigkeitsnachweis-Generator v{version}.zip"
|
||||
with zipfile.ZipFile(path, "w") as package:
|
||||
for name, content in files.items():
|
||||
package.writestr(name, content)
|
||||
return path
|
||||
|
||||
def test_installs_next_to_current_and_copies_user_data(self):
|
||||
result = install_update(self.make_zip(), self.current)
|
||||
target = self.root / "Tätigkeitsnachweis-Generator v1.4.0"
|
||||
self.assertEqual(result.target_dir, target)
|
||||
self.assertFalse(result.already_installed)
|
||||
self.assertIsNone(result.previous_settings)
|
||||
self.assertEqual((target / EXE_NAME).read_bytes(), b"new")
|
||||
self.assertEqual((target / "input" / "daten.xlsx").read_bytes(), b"input")
|
||||
self.assertEqual((target / "output" / "2026" / "nachweis.pdf").read_bytes(), b"output")
|
||||
self.assertEqual((target / "template" / "vorlage.pdf").read_bytes(), b"new template")
|
||||
self.assertEqual((target / "template" / "eigene.pdf").read_bytes(), b"own template")
|
||||
# Die alte Installation bleibt unverändert.
|
||||
self.assertEqual((self.current / EXE_NAME).read_bytes(), b"old")
|
||||
self.assertEqual([p.name for p in self.root.iterdir() if p.name.startswith(".")], [])
|
||||
|
||||
def test_keeps_changed_settings_next_to_new_ones(self):
|
||||
(self.current / "config" / "settings.ini").write_text("[a]\nx=2\n", encoding="utf-8")
|
||||
result = install_update(self.make_zip(), self.current)
|
||||
config = result.target_dir / "config"
|
||||
self.assertEqual((config / "settings.ini").read_text(encoding="utf-8"), "[a]\nx=1\n")
|
||||
self.assertEqual(result.previous_settings, config / "settings.vorherige-version.ini")
|
||||
self.assertEqual(result.previous_settings.read_text(encoding="utf-8"), "[a]\nx=2\n")
|
||||
|
||||
def test_existing_installation_is_only_reused(self):
|
||||
zip_path = self.make_zip()
|
||||
install_update(zip_path, self.current)
|
||||
result = install_update(zip_path, self.current)
|
||||
self.assertTrue(result.already_installed)
|
||||
|
||||
def test_rejects_package_without_exe(self):
|
||||
with self.assertRaisesRegex(UpdateError, "keine"):
|
||||
install_update(self.make_zip({"README.md": b"x"}), self.current)
|
||||
self.assertFalse((self.root / "Tätigkeitsnachweis-Generator v1.4.0").exists())
|
||||
|
||||
def test_rejects_path_traversal(self):
|
||||
with self.assertRaisesRegex(UpdateError, "ungültigen Pfad"):
|
||||
install_update(self.make_zip({EXE_NAME: b"x", "../evil.txt": b"x"}), self.current)
|
||||
self.assertFalse((self.root / "evil.txt").exists())
|
||||
|
||||
def test_rejects_broken_zip_and_cleans_up(self):
|
||||
broken = self.share / "Tätigkeitsnachweis-Generator v1.4.0.zip"
|
||||
broken.write_bytes(b"no zip")
|
||||
with self.assertRaisesRegex(UpdateError, "keine gültige ZIP"):
|
||||
install_update(broken, self.current)
|
||||
self.assertEqual(sorted(p.name for p in self.root.iterdir()), sorted([self.current.name, "share"]))
|
||||
|
||||
def test_unreachable_share_gives_vpn_hint(self):
|
||||
with self.assertRaisesRegex(UpdateError, "VPN"):
|
||||
install_update(self.share / "Tätigkeitsnachweis-Generator v1.4.0.zip", self.current)
|
||||
|
||||
def test_refuses_existing_folder_without_program(self):
|
||||
(self.root / "Tätigkeitsnachweis-Generator v1.4.0").mkdir()
|
||||
with self.assertRaisesRegex(UpdateError, "existiert bereits"):
|
||||
install_update(self.make_zip(), self.current)
|
||||
|
||||
|
||||
class StartApplicationTests(unittest.TestCase):
|
||||
@unittest.skipUnless(sys.platform == "win32", "Windows-Prozessflags")
|
||||
def test_starts_new_exe_with_clean_bundle_environment(self):
|
||||
target = Path("C:/neu")
|
||||
environment = {"PATH": "x", "APP_BASE_DIR": "alt", "_MEIPASS2": "alt",
|
||||
"_PYI_APPLICATION_HOME_DIR": "alt", "STREAMLIT_SERVER_PORT": "8501"}
|
||||
with patch.dict(update_installer.os.environ, environment, clear=True), \
|
||||
patch.object(update_installer.subprocess, "Popen") as popen:
|
||||
update_installer.start_application(target)
|
||||
args, kwargs = popen.call_args
|
||||
self.assertEqual(args[0], [str(target / EXE_NAME)])
|
||||
self.assertEqual(kwargs["cwd"], target)
|
||||
self.assertEqual(kwargs["env"], {"PATH": "x", "PYINSTALLER_RESET_ENVIRONMENT": "1"})
|
||||
self.assertTrue(kwargs["creationflags"] & subprocess.CREATE_NEW_CONSOLE)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Richtet eine neue Programmversion neben der aktuellen Installation ein.
|
||||
|
||||
Ablauf von "Anwendung ersetzen":
|
||||
1. ZIP vom SMB-Laufwerk lokal zwischenspeichern und prüfen.
|
||||
2. In einen Zwischenordner neben der aktuellen Installation entpacken.
|
||||
3. input/ und output/ sowie eigene Template-PDFs übernehmen.
|
||||
4. Zwischenordner in "Tätigkeitsnachweis-Generator vX.Y.Z" umbenennen.
|
||||
5. Neue EXE starten; sie beendet die alte Instanz nach Rückfrage selbst.
|
||||
|
||||
Die alte Installation bleibt unverändert als Rückfallebene erhalten.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
EXE_NAME = "Tätigkeitsnachweis-Generator.exe"
|
||||
USER_DATA_DIRS = ("input", "output")
|
||||
SETTINGS_FILE = Path("config") / "settings.ini"
|
||||
|
||||
|
||||
class UpdateError(Exception):
|
||||
"""Fehler mit einer Meldung, die direkt angezeigt werden kann."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpdateResult:
|
||||
target_dir: Path
|
||||
already_installed: bool = False
|
||||
previous_settings: Path | None = None
|
||||
|
||||
|
||||
def can_replace_application() -> bool:
|
||||
"""Nur die gebaute Windows-EXE kann sich selbst ersetzen."""
|
||||
return sys.platform == "win32" and bool(getattr(sys, "frozen", False))
|
||||
|
||||
|
||||
def _check_members(package: zipfile.ZipFile) -> None:
|
||||
names = package.namelist()
|
||||
for name in names:
|
||||
path = PurePosixPath(name.replace("\\", "/"))
|
||||
if path.is_absolute() or ".." in path.parts or ":" in name:
|
||||
raise UpdateError(f"Das Update-Paket enthält einen ungültigen Pfad: {name}")
|
||||
if EXE_NAME not in names:
|
||||
raise UpdateError(f"Das Update-Paket enthält keine {EXE_NAME}.")
|
||||
if package.testzip() is not None:
|
||||
raise UpdateError("Das Update-Paket ist beschädigt. Bitte später erneut versuchen.")
|
||||
|
||||
|
||||
def _copy_user_data(current_dir: Path, staging_dir: Path) -> None:
|
||||
for folder in USER_DATA_DIRS:
|
||||
source = current_dir / folder
|
||||
if source.is_dir():
|
||||
shutil.copytree(source, staging_dir / folder, dirs_exist_ok=True)
|
||||
# Eigene Templates übernehmen, ohne die mitgelieferten zu überschreiben.
|
||||
templates = current_dir / "template"
|
||||
if templates.is_dir():
|
||||
(staging_dir / "template").mkdir(exist_ok=True)
|
||||
for path in templates.iterdir():
|
||||
destination = staging_dir / "template" / path.name
|
||||
if path.is_file() and not destination.exists():
|
||||
shutil.copy2(path, destination)
|
||||
|
||||
|
||||
def _keep_previous_settings(current_dir: Path, staging_dir: Path) -> Path | None:
|
||||
"""Neue settings.ini gilt; abweichende alte Einstellungen bleiben daneben erhalten."""
|
||||
old, new = current_dir / SETTINGS_FILE, staging_dir / SETTINGS_FILE
|
||||
if not old.is_file() or (new.is_file() and old.read_bytes() == new.read_bytes()):
|
||||
return None
|
||||
new.parent.mkdir(parents=True, exist_ok=True)
|
||||
backup = new.with_name("settings.vorherige-version.ini")
|
||||
shutil.copy2(old, backup)
|
||||
return backup
|
||||
|
||||
|
||||
def install_update(zip_path: Path, current_dir: Path) -> UpdateResult:
|
||||
"""Entpackt zip_path neben current_dir und übernimmt die Nutzerdaten."""
|
||||
current_dir = current_dir.resolve()
|
||||
target_dir = current_dir.parent / zip_path.stem
|
||||
if target_dir == current_dir:
|
||||
raise UpdateError("Diese Version ist bereits die laufende Installation.")
|
||||
if (target_dir / EXE_NAME).is_file():
|
||||
return UpdateResult(target_dir, already_installed=True)
|
||||
if target_dir.exists():
|
||||
raise UpdateError(
|
||||
f"Der Ordner {target_dir} existiert bereits, enthält aber kein Programm. "
|
||||
"Bitte den Ordner entfernen oder umbenennen und erneut versuchen.")
|
||||
|
||||
staging_dir = current_dir.parent / f".{zip_path.stem}.update"
|
||||
try:
|
||||
shutil.rmtree(staging_dir, ignore_errors=True)
|
||||
with tempfile.TemporaryDirectory(prefix="taetigkeitsnachweis-update-") as temporary:
|
||||
# Erst lokal kopieren: ein VPN-Abbruch trifft so nur die Kopie.
|
||||
local_zip = Path(temporary) / zip_path.name
|
||||
try:
|
||||
shutil.copyfile(zip_path, local_zip)
|
||||
except OSError as exc:
|
||||
raise UpdateError(
|
||||
"Das Update konnte nicht vom Netzlaufwerk geladen werden. "
|
||||
"Bitte VPN-Verbindung und Laufwerk J: prüfen.") from exc
|
||||
try:
|
||||
with zipfile.ZipFile(local_zip) as package:
|
||||
_check_members(package)
|
||||
package.extractall(staging_dir)
|
||||
except zipfile.BadZipFile as exc:
|
||||
raise UpdateError("Das Update-Paket ist keine gültige ZIP-Datei.") from exc
|
||||
_copy_user_data(current_dir, staging_dir)
|
||||
previous_settings = _keep_previous_settings(current_dir, staging_dir)
|
||||
staging_dir.rename(target_dir)
|
||||
except UpdateError:
|
||||
shutil.rmtree(staging_dir, ignore_errors=True)
|
||||
raise
|
||||
except OSError as exc:
|
||||
shutil.rmtree(staging_dir, ignore_errors=True)
|
||||
raise UpdateError(
|
||||
f"Die neue Version konnte nicht eingerichtet werden ({exc}). "
|
||||
f"Bitte Schreibrechte für {current_dir.parent} prüfen.") from exc
|
||||
if previous_settings is not None:
|
||||
previous_settings = target_dir / SETTINGS_FILE.parent / previous_settings.name
|
||||
return UpdateResult(target_dir, previous_settings=previous_settings)
|
||||
|
||||
|
||||
def start_application(target_dir: Path) -> None:
|
||||
"""Startet die neue EXE in einem eigenen Konsolenfenster wie per Doppelklick."""
|
||||
env = {key: value for key, value in os.environ.items()
|
||||
if key != "APP_BASE_DIR" and not key.startswith(("STREAMLIT_", "_MEIPASS", "_PYI_"))}
|
||||
# Sonst übernimmt die neue EXE Pfade aus dem Bundle der laufenden alten EXE.
|
||||
env["PYINSTALLER_RESET_ENVIRONMENT"] = "1"
|
||||
subprocess.Popen(
|
||||
[str(target_dir / EXE_NAME)], cwd=target_dir, env=env, close_fds=True,
|
||||
creationflags=subprocess.CREATE_NEW_CONSOLE | subprocess.CREATE_NEW_PROCESS_GROUP,
|
||||
)
|
||||
@@ -1 +1 @@
|
||||
1.3
|
||||
1.4
|
||||
@@ -37,11 +37,11 @@ REM ── Build ausführen ─────────────────
|
||||
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo [FEHLER] Build fehlgeschlagen!
|
||||
echo [FEHLER] Pipeline fehlgeschlagen. Details stehen oben.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Build abgeschlossen.
|
||||
echo Tests, Build und Deployment abgeschlossen.
|
||||
pause
|
||||
+95
-15
@@ -2,7 +2,8 @@
|
||||
|
||||
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/.
|
||||
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
|
||||
@@ -20,6 +21,8 @@ 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
|
||||
@@ -141,6 +144,20 @@ def update_changelog(
|
||||
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:
|
||||
@@ -177,6 +194,21 @@ def _build_sections(bump_type: str, message: Optional[str]) -> str:
|
||||
|
||||
# ── 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:
|
||||
@@ -196,14 +228,27 @@ def _prepare_build_dir() -> None:
|
||||
target = BUILD_OUTPUT_DIR / folder
|
||||
target.mkdir(exist_ok=True)
|
||||
|
||||
# Kopiere config-Dateien, falls noch nicht vorhanden
|
||||
# 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() and not (config_dst / f.name).exists():
|
||||
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():
|
||||
@@ -308,15 +353,24 @@ def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Build mit Semantic Versioning",
|
||||
)
|
||||
parser.add_argument(
|
||||
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",
|
||||
@@ -327,6 +381,8 @@ def main() -> None:
|
||||
# ── 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"
|
||||
|
||||
@@ -334,7 +390,7 @@ def main() -> None:
|
||||
|
||||
# Bump-Typ bestimmen
|
||||
bump_type = args.bump
|
||||
if not bump_type:
|
||||
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)")
|
||||
@@ -346,32 +402,56 @@ def main() -> None:
|
||||
print("\nAbgebrochen.")
|
||||
sys.exit(0)
|
||||
|
||||
new_version = bump_version(current, bump_type)
|
||||
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(f" Würde CHANGELOG.md aktualisieren: [{new_version}]")
|
||||
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
|
||||
|
||||
# ── CHANGELOG aktualisieren ──────────────────────────────
|
||||
print("\n📝 Changelog …")
|
||||
update_changelog(new_version, bump_type, args.message)
|
||||
|
||||
# ── Build ────────────────────────────────────────────────
|
||||
_ensure_pyinstaller()
|
||||
_prepare_build_dir()
|
||||
_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__":
|
||||
main()
|
||||
try:
|
||||
main()
|
||||
except (OSError, ValueError, subprocess.CalledProcessError) as exc:
|
||||
print(f"[FEHLER] Pipeline abgebrochen: {exc}")
|
||||
raise SystemExit(1)
|
||||
@@ -77,6 +77,7 @@ except ImportError:
|
||||
datas = [
|
||||
(str(_APP_DIR / "app.py"), "."),
|
||||
(str(_APP_DIR / "version_check.py"), "."),
|
||||
(str(_APP_DIR / "update_installer.py"), "."),
|
||||
(str(_APP_DIR / "CHANGELOG.md"), "."),
|
||||
(str(_APP_DIR / "src"), "src"),
|
||||
(str(_APP_DIR / "config"), "config"),
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
setlocal
|
||||
set "PYTHONUTF8=1"
|
||||
cd /d "%~dp0.."
|
||||
|
||||
if exist "0_application\.venv\Scripts\python.exe" (
|
||||
set "DEPLOY_PYTHON=0_application\.venv\Scripts\python.exe"
|
||||
) else if exist ".venv\Scripts\python.exe" (
|
||||
set "DEPLOY_PYTHON=.venv\Scripts\python.exe"
|
||||
) else (
|
||||
set "DEPLOY_PYTHON=python"
|
||||
)
|
||||
|
||||
"%DEPLOY_PYTHON%" "1_build-pipeline\deploy.py"
|
||||
set "DEPLOY_EXIT_CODE=%ERRORLEVEL%"
|
||||
if /I not "%~1"=="--no-pause" pause
|
||||
exit /b %DEPLOY_EXIT_CODE%
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Package and publish the last successful build to the SMB update directory."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
BUILD_DIR = PROJECT_ROOT / "2_build"
|
||||
DEPLOY_DIR = Path(r"j:\Fachbereiche\BO\Produktionsschule\Maßnahmeunterlagen\20_Personal\05_Tätigkeitsnachweise ESF\Nachweise-automatisiert-generieren")
|
||||
EXE_NAME = "Tätigkeitsnachweis-Generator.exe"
|
||||
MANIFEST_NAME = "release.json"
|
||||
PACKAGE_FILES = [
|
||||
EXE_NAME, "README.md", "CHANGELOG.md",
|
||||
"config/settings.ini", "config/input-fields.json", "config/output-fields.json",
|
||||
]
|
||||
|
||||
|
||||
def is_template_pdf(path: Path) -> bool:
|
||||
"""Nur *.pdf, die nicht mit '_' oder '.' beginnen, gelten als Template."""
|
||||
return path.is_file() and path.suffix.lower() == ".pdf" and not path.name.startswith(("_", "."))
|
||||
|
||||
|
||||
def template_files(base_dir: Path) -> list[str]:
|
||||
"""Relative Pfade aller Template-PDFs in base_dir/template (sortiert)."""
|
||||
folder = base_dir / "template"
|
||||
if not folder.is_dir():
|
||||
return []
|
||||
return [f"template/{p.name}" for p in sorted(folder.iterdir()) if is_template_pdf(p)]
|
||||
|
||||
|
||||
def package_files(build_dir: Path) -> list[str]:
|
||||
"""Alle Dateien des Release-Pakets: feste Dateien plus alle Template-PDFs."""
|
||||
return PACKAGE_FILES + template_files(build_dir)
|
||||
|
||||
|
||||
def normalize_version(version: str) -> str:
|
||||
if not re.fullmatch(r"\d+\.\d+(?:\.\d+)?", version):
|
||||
raise ValueError(f"Ungültige Versionsnummer: {version}")
|
||||
return ".".join(str(int(part)) for part in (version.split(".") + ["0"])[:3])
|
||||
|
||||
|
||||
def file_hash(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for block in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def write_release_manifest(build_dir: Path, version: str) -> None:
|
||||
"""Record the version and all payload hashes after a successful build."""
|
||||
manifest = {
|
||||
"version": normalize_version(version),
|
||||
"files": {name: file_hash(build_dir / name) for name in package_files(build_dir)},
|
||||
}
|
||||
(build_dir / MANIFEST_NAME).write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def validate_release(build_dir: Path) -> str:
|
||||
if not (build_dir / MANIFEST_NAME).is_file():
|
||||
raise ValueError("Kein bestätigter Build vorhanden. Bitte zuerst build.bat ausführen.")
|
||||
manifest = json.loads((build_dir / MANIFEST_NAME).read_text(encoding="utf-8"))
|
||||
version = normalize_version(manifest["version"])
|
||||
for name in package_files(build_dir):
|
||||
path = build_dir / name
|
||||
if not path.is_file() or file_hash(path) != manifest.get("files", {}).get(name):
|
||||
raise ValueError(f"Build-Datei fehlt oder wurde nach dem Build verändert: {name}. Bitte neu bauen.")
|
||||
return version
|
||||
|
||||
|
||||
def check_deploy_target(target_dir: Path) -> None:
|
||||
"""Never create the SMB destination: a missing mount must be an error."""
|
||||
try:
|
||||
if not target_dir.is_dir():
|
||||
raise OSError("Verzeichnis ist nicht verfügbar")
|
||||
# Actually access the directory; exists() alone does not check read access.
|
||||
with os.scandir(target_dir) as entries:
|
||||
next(entries, None)
|
||||
except OSError as exc:
|
||||
raise OSError(
|
||||
f"Deployment-Ziel nicht erreichbar: {target_dir}\n"
|
||||
"Bitte VPN-Verbindung und Netzlaufwerk J: prüfen und deploy.bat erneut starten."
|
||||
) from exc
|
||||
|
||||
|
||||
def deploy(build_dir: Path = BUILD_DIR, target_dir: Path = DEPLOY_DIR) -> Path:
|
||||
version = validate_release(build_dir)
|
||||
check_deploy_target(target_dir)
|
||||
archive_name = f"Tätigkeitsnachweis-Generator v{version}.zip"
|
||||
archive = build_dir / archive_name
|
||||
descriptor, temporary = tempfile.mkstemp(prefix="release-", suffix=".zip", dir=build_dir)
|
||||
os.close(descriptor)
|
||||
temporary = Path(temporary)
|
||||
try:
|
||||
with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as package:
|
||||
for name in package_files(build_dir):
|
||||
package.write(build_dir / name, name)
|
||||
for directory in ["input/", "output/"]:
|
||||
# Fixed timestamps keep repeated deployments byte-identical.
|
||||
package.writestr(zipfile.ZipInfo(directory, date_time=(2020, 1, 1, 0, 0, 0)), b"")
|
||||
with zipfile.ZipFile(temporary) as package:
|
||||
if package.testzip() is not None:
|
||||
raise ValueError("Das erzeugte ZIP-Paket ist beschädigt.")
|
||||
temporary.replace(archive)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
destination = target_dir / archive_name
|
||||
if destination.exists():
|
||||
if file_hash(destination) == file_hash(archive):
|
||||
print(f"[OK] Diese Version ist bereits unverändert bereitgestellt: {destination}")
|
||||
return destination
|
||||
raise FileExistsError(f"{destination} existiert bereits mit anderem Inhalt. Bitte eine neue Version bauen.")
|
||||
|
||||
# Write access is checked by creating the staging file on the actual share.
|
||||
remote_temporary = None
|
||||
try:
|
||||
descriptor, name = tempfile.mkstemp(prefix=".upload-", suffix=".tmp", dir=target_dir)
|
||||
os.close(descriptor)
|
||||
remote_temporary = Path(name)
|
||||
shutil.copyfile(archive, remote_temporary)
|
||||
if file_hash(remote_temporary) != file_hash(archive):
|
||||
raise OSError("Die Prüfsumme nach der SMB-Übertragung stimmt nicht überein.")
|
||||
# On Windows rename refuses to overwrite another published release.
|
||||
remote_temporary.rename(destination)
|
||||
except OSError as exc:
|
||||
raise OSError(
|
||||
"Deployment fehlgeschlagen. VPN-Verbindung und Schreibrechte prüfen. "
|
||||
"Der erfolgreiche lokale Build bleibt erhalten; erneut mit deploy.bat starten."
|
||||
) from exc
|
||||
finally:
|
||||
if remote_temporary is not None:
|
||||
try:
|
||||
remote_temporary.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass # The VPN may have disconnected during transfer.
|
||||
print(f"[OK] Deployment abgeschlossen: {destination}")
|
||||
return destination
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Letzten erfolgreichen Build auf dem SMB-Laufwerk bereitstellen")
|
||||
parser.parse_args()
|
||||
try:
|
||||
deploy()
|
||||
except (OSError, ValueError, KeyError) as exc:
|
||||
print(f"[FEHLER] {exc}")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in new issue
Block a user