Compare commits
2
Commits
971459c9bf
...
7d63063024
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d63063024 | ||
|
|
2aad2c9d99 |
No files matched your search
@@ -61,3 +61,7 @@ $RECYCLE.BIN/
|
|||||||
*.vsix
|
*.vsix
|
||||||
3_Archiv
|
3_Archiv
|
||||||
2_build
|
2_build
|
||||||
|
|
||||||
|
# Python-Test- und Build-Artefakte
|
||||||
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
@@ -7,6 +7,22 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.3.2] – 2026-10-07
|
||||||
|
|
||||||
|
### 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
|
## [1.3] – 2026-08-11
|
||||||
|
|
||||||
### Hinzugefügt
|
### 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
|
Dieser Aufruf erfolgt aus dem Projekt-Hauptordner. Bei fehlgeschlagenen Tests
|
||||||
liefert das Skript einen Fehlercode und führt die übrigen Tests trotzdem aus.
|
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
|
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
|
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,
|
von Januar bis Mai 2026 mit zehn Seiten, Antragsnummer, Name, Stellenanteil,
|
||||||
Monat, Funktion, Tätigkeit, Ort und korrekten Monatsenddaten
|
Monat, Funktion, Tätigkeit, Ort und korrekten Monatsenddaten
|
||||||
- Neustartlogik unter Windows mit simulierten Prozessen
|
- 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.
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
_CONFIRM_SCRIPT = r'''
|
_CONFIRM_SCRIPT = r'''
|
||||||
@@ -25,9 +26,18 @@ def stop_existing_server(port: int = 8501) -> bool:
|
|||||||
if os.name != "nt":
|
if os.name != "nt":
|
||||||
raise RuntimeError("Der kontrollierte Server-Neustart unterstützt nur Windows.")
|
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.
|
# 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_PORT=str(port),
|
||||||
APP_RESTART_VENV=str(Path(app_path).parent / ".venv" / "Scripts"))
|
APP_RESTART_VENV=str(Path(app_path).parent / ".venv" / "Scripts"))
|
||||||
script = _CONFIRM_SCRIPT + r'''
|
script = _CONFIRM_SCRIPT + r'''
|
||||||
@@ -47,11 +57,15 @@ foreach ($processId in $processIds) {
|
|||||||
if (-not $process) { continue }
|
if (-not $process) { continue }
|
||||||
$isExe = $process.Name -eq 'Tätigkeitsnachweis-Generator.exe'
|
$isExe = $process.Name -eq 'Tätigkeitsnachweis-Generator.exe'
|
||||||
$command = $process.CommandLine
|
$command = $process.CommandLine
|
||||||
$appArgument = '(?i)\bstreamlit(?:\.exe)?"?\s+run\s+"?' +
|
$matchesApp = $false
|
||||||
[regex]::Escape($env:APP_RESTART_PATH) + '"?(?:\s|$)'
|
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
|
$isDev = $process.Name -match '^(python(?:w)?(?:\d+(?:\.\d+)*)?(?:w)?|streamlit)\.exe$' -and
|
||||||
$command -match '(?i)\bstreamlit(?:\.exe)?"?\s+run\s+' -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
|
($command -match '(?i)\bstreamlit(?:\.exe)?"?\s+run\s+"?app\.py"?(?:\s|$)' -and
|
||||||
[IO.Path]::GetDirectoryName($process.ExecutablePath) -eq $env:APP_RESTART_VENV))
|
[IO.Path]::GetDirectoryName($process.ExecutablePath) -eq $env:APP_RESTART_VENV))
|
||||||
if (-not ($isExe -or $isDev)) {
|
if (-not ($isExe -or $isDev)) {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ echo ""
|
|||||||
echo "[INFO] Führe End-to-End-Tests aus…"
|
echo "[INFO] Führe End-to-End-Tests aus…"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
"$VENV_DIR/bin/python" -m pytest tests/test_e2e.py -v
|
"$VENV_DIR/bin/python" -m pytest tests -v
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
read -p "Drücke Enter zum Beenden..."
|
read -p "Drücke Enter zum Beenden..."
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"""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)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def release(tmp_path):
|
||||||
|
directory = tmp_path / "build"
|
||||||
|
directory.mkdir()
|
||||||
|
for name in deploy.PACKAGE_FILES:
|
||||||
|
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 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 + ["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()
|
||||||
@@ -13,14 +13,18 @@ import server_lifecycle
|
|||||||
|
|
||||||
@unittest.skipUnless(os.name == "nt", "Windows process management")
|
@unittest.skipUnless(os.name == "nt", "Windows process management")
|
||||||
class ServerLifecycleTests(unittest.TestCase):
|
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 = {}
|
captured = {}
|
||||||
|
|
||||||
def capture(command, **kwargs):
|
def capture(command, **kwargs):
|
||||||
captured.update(script=command[-1], env=kwargs["env"])
|
captured.update(script=command[-1], env=kwargs["env"])
|
||||||
return subprocess.CompletedProcess(command, 0, "[]", "")
|
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()
|
server_lifecycle.stop_existing_server()
|
||||||
|
|
||||||
setup = r'''
|
setup = r'''
|
||||||
@@ -140,6 +144,32 @@ function Confirm-ServerRestart {
|
|||||||
self.assertNotIn("CONFIRM_CALLED", result.stderr)
|
self.assertNotIn("CONFIRM_CALLED", result.stderr)
|
||||||
self.assertNotIn("STOP_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):
|
def test_unrelated_server_is_preserved(self):
|
||||||
result = self.simulate(r'''[pscustomobject]@{
|
result = self.simulate(r'''[pscustomobject]@{
|
||||||
Name='python.exe'; CommandLine='streamlit run app.py';
|
Name='python.exe'; CommandLine='streamlit run app.py';
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1.3
|
1.3.2
|
||||||
@@ -37,11 +37,11 @@ REM ── Build ausführen ─────────────────
|
|||||||
|
|
||||||
if errorlevel 1 (
|
if errorlevel 1 (
|
||||||
echo.
|
echo.
|
||||||
echo [FEHLER] Build fehlgeschlagen!
|
echo [FEHLER] Pipeline fehlgeschlagen. Details stehen oben.
|
||||||
pause
|
pause
|
||||||
exit /b 1
|
exit /b 1
|
||||||
)
|
)
|
||||||
|
|
||||||
echo.
|
echo.
|
||||||
echo Build abgeschlossen.
|
echo Tests, Build und Deployment abgeschlossen.
|
||||||
pause
|
pause
|
||||||
+68
-15
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
Liest die aktuelle Version aus CHANGELOG.md, bump't sie gemäß
|
Liest die aktuelle Version aus CHANGELOG.md, bump't sie gemäß
|
||||||
Benutzereingabe (major/minor/patch), aktualisiert das Changelog
|
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:
|
Verwendung:
|
||||||
python build.py # interaktiver Modus
|
python build.py # interaktiver Modus
|
||||||
@@ -20,6 +21,8 @@ from datetime import date
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from deploy import deploy, write_release_manifest
|
||||||
|
|
||||||
# ── Pfade ───────────────────────────────────────────────────────
|
# ── Pfade ───────────────────────────────────────────────────────
|
||||||
BUILD_PIPELINE_DIR = Path(__file__).resolve().parent
|
BUILD_PIPELINE_DIR = Path(__file__).resolve().parent
|
||||||
PROJECT_ROOT = BUILD_PIPELINE_DIR.parent
|
PROJECT_ROOT = BUILD_PIPELINE_DIR.parent
|
||||||
@@ -177,6 +180,21 @@ def _build_sections(bump_type: str, message: Optional[str]) -> str:
|
|||||||
|
|
||||||
# ── Build ───────────────────────────────────────────────────────
|
# ── 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:
|
def _ensure_pyinstaller() -> None:
|
||||||
"""Stellt sicher, dass PyInstaller installiert ist."""
|
"""Stellt sicher, dass PyInstaller installiert ist."""
|
||||||
try:
|
try:
|
||||||
@@ -196,14 +214,20 @@ def _prepare_build_dir() -> None:
|
|||||||
target = BUILD_OUTPUT_DIR / folder
|
target = BUILD_OUTPUT_DIR / folder
|
||||||
target.mkdir(exist_ok=True)
|
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_src = APP_DIR / "config"
|
||||||
config_dst = BUILD_OUTPUT_DIR / "config"
|
config_dst = BUILD_OUTPUT_DIR / "config"
|
||||||
if config_src.exists():
|
if config_src.exists():
|
||||||
for f in config_src.iterdir():
|
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(f, config_dst / f.name)
|
||||||
|
|
||||||
|
shutil.copy2(CHANGELOG_PATH, BUILD_OUTPUT_DIR / "CHANGELOG.md")
|
||||||
|
shutil.copy2(
|
||||||
|
APP_DIR / "template" / "Tätigkeitsnachweise Stellenförderung.pdf",
|
||||||
|
BUILD_OUTPUT_DIR / "template" / "Tätigkeitsnachweise Stellenförderung.pdf",
|
||||||
|
)
|
||||||
|
|
||||||
# README für input-Ordner
|
# README für input-Ordner
|
||||||
input_readme = BUILD_OUTPUT_DIR / "input" / "_DATEIEN_HIER_ABLEGEN_.txt"
|
input_readme = BUILD_OUTPUT_DIR / "input" / "_DATEIEN_HIER_ABLEGEN_.txt"
|
||||||
if not input_readme.exists():
|
if not input_readme.exists():
|
||||||
@@ -308,11 +332,16 @@ def main() -> None:
|
|||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Build mit Semantic Versioning",
|
description="Build mit Semantic Versioning",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
version_options = parser.add_mutually_exclusive_group()
|
||||||
|
version_options.add_argument(
|
||||||
"--bump",
|
"--bump",
|
||||||
choices=["major", "minor", "patch"],
|
choices=["major", "minor", "patch"],
|
||||||
help="Version bump-Typ (ohne Angabe: interaktiv)",
|
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(
|
parser.add_argument(
|
||||||
"--message", "-m",
|
"--message", "-m",
|
||||||
help="Changelog-Beschreibung für diese Version",
|
help="Changelog-Beschreibung für diese Version",
|
||||||
@@ -327,6 +356,8 @@ def main() -> None:
|
|||||||
# ── Version ermitteln ────────────────────────────────────
|
# ── Version ermitteln ────────────────────────────────────
|
||||||
current = get_current_version()
|
current = get_current_version()
|
||||||
if not current:
|
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.")
|
print("❌ Keine Version in CHANGELOG.md gefunden. Beginne mit 0.1.")
|
||||||
current = "0.1"
|
current = "0.1"
|
||||||
|
|
||||||
@@ -334,7 +365,7 @@ def main() -> None:
|
|||||||
|
|
||||||
# Bump-Typ bestimmen
|
# Bump-Typ bestimmen
|
||||||
bump_type = args.bump
|
bump_type = args.bump
|
||||||
if not bump_type:
|
if not bump_type and not args.no_bump:
|
||||||
print("\nWelche Art von Änderung?")
|
print("\nWelche Art von Änderung?")
|
||||||
print(" [1] major – inkompatible API-Änderungen")
|
print(" [1] major – inkompatible API-Änderungen")
|
||||||
print(" [2] minor – neue Funktionalität (abwärtskompatibel)")
|
print(" [2] minor – neue Funktionalität (abwärtskompatibel)")
|
||||||
@@ -346,32 +377,54 @@ def main() -> None:
|
|||||||
print("\nAbgebrochen.")
|
print("\nAbgebrochen.")
|
||||||
sys.exit(0)
|
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}")
|
print(f"🚀 Neue Version: {new_version}")
|
||||||
|
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
print("\n[Trockenlauf – kein Build]")
|
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")
|
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
|
return
|
||||||
|
|
||||||
# ── CHANGELOG aktualisieren ──────────────────────────────
|
_run_tests()
|
||||||
print("\n📝 Changelog …")
|
|
||||||
update_changelog(new_version, bump_type, args.message)
|
|
||||||
|
|
||||||
# ── Build ────────────────────────────────────────────────
|
|
||||||
_ensure_pyinstaller()
|
|
||||||
_prepare_build_dir()
|
|
||||||
|
|
||||||
|
previous_changelog = _read_changelog()
|
||||||
try:
|
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)
|
||||||
|
_prepare_build_dir()
|
||||||
exe_path = _run_pyinstaller()
|
exe_path = _run_pyinstaller()
|
||||||
final_exe = _copy_to_build(exe_path)
|
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:
|
finally:
|
||||||
_cleanup()
|
_cleanup()
|
||||||
|
|
||||||
_save_version(new_version)
|
_save_version(new_version)
|
||||||
_print_summary(new_version, final_exe)
|
_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__":
|
if __name__ == "__main__":
|
||||||
main()
|
try:
|
||||||
|
main()
|
||||||
|
except (OSError, ValueError, subprocess.CalledProcessError) as exc:
|
||||||
|
print(f"[FEHLER] Pipeline abgebrochen: {exc}")
|
||||||
|
raise SystemExit(1)
|
||||||
@@ -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,143 @@
|
|||||||
|
"""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",
|
||||||
|
"template/Tätigkeitsnachweise Stellenförderung.pdf",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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 / 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:
|
||||||
|
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:
|
||||||
|
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