Add in-app self-update via git (Hilfe menu)

New Updater class + "Hilfe" menubar:
- "Nach Update suchen": git fetch + count of new upstream commits.
- Quiet background check on start; surfaces via status bar + menu label.
- Install: git merge --ff-only, pip install if requirements.txt changed,
  then restart via os.execv. data/ is git-ignored and untouched.
- Fast-forward only; diverged history or offline -> clear message, no action.
- Inert unless run from a git clone with git on PATH.

Also: "Version…" menu item shows the installed commit.

Verified against throwaway git repos (check / ff-update / diverged / no-op /
non-clone) and via GUI build on Python 3.14 / Tk 9.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 22:06:51 +02:00
parent e3bbd4d264
commit 1bed723363
7 changed files with 330 additions and 10 deletions
+238
View File
@@ -12,6 +12,8 @@ import math
import os import os
import re import re
import shutil import shutil
import subprocess
import sys
import threading import threading
import time import time
import webbrowser import webbrowser
@@ -451,6 +453,90 @@ def generate_map(store: DataStore) -> Path:
return MAP_PATH return MAP_PATH
# ── Selbst-Update (git) ──────────────────────────────────────────────────────
class UpdateError(Exception):
"""Ein Schritt beim Aktualisieren ist fehlgeschlagen (Text ist benutzertauglich)."""
class Updater:
"""Aktualisiert die App per ``git`` nur wenn sie aus einem Git-Klon läuft.
Fährt bewusst konservativ: es wird ausschließlich per *fast-forward* auf den
Stand des Servers gezogen. Lokale Änderungen oder abweichende Historie führen
zu einer klaren Fehlermeldung statt zu einem automatischen Merge.
"""
def __init__(self, base_dir: Path) -> None:
self.base_dir = base_dir
self.available = bool(shutil.which("git")) and (base_dir / ".git").is_dir()
def _git(self, *args: str, timeout: int = 30) -> subprocess.CompletedProcess:
return subprocess.run(
["git", "-C", str(self.base_dir), *args],
capture_output=True, text=True, timeout=timeout,
)
def _upstream_ref(self) -> str:
r = self._git("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
return r.stdout.strip() or "origin/main"
def current_version(self) -> str:
r = self._git("log", "-1", "--format=%h · %cs")
return r.stdout.strip() or "unbekannt"
def check(self) -> int:
"""Anzahl neuer Commits auf dem Server. Wirft ``UpdateError`` bei Problemen."""
f = self._git("fetch", "--quiet", timeout=60)
if f.returncode != 0:
raise UpdateError(f.stderr.strip() or "Server nicht erreichbar (git fetch).")
ref = self._upstream_ref()
c = self._git("rev-list", "--count", f"HEAD..{ref}")
if c.returncode != 0:
raise UpdateError(c.stderr.strip() or "Versionsvergleich fehlgeschlagen.")
return int(c.stdout.strip() or "0")
def update(self) -> bool:
"""Zieht den neuen Stand (fast-forward) und installiert ggf. neue Pakete.
Gibt ``True`` zurück, wenn tatsächlich etwas aktualisiert wurde.
"""
f = self._git("fetch", "--quiet", timeout=60)
if f.returncode != 0:
raise UpdateError(f.stderr.strip() or "Server nicht erreichbar (git fetch).")
ref = self._upstream_ref()
before = self._git("rev-parse", "HEAD").stdout.strip()
m = self._git("merge", "--ff-only", ref, timeout=60)
if m.returncode != 0:
raise UpdateError(
(m.stderr.strip() or "Automatisches Update nicht möglich.")
+ "\n\nBitte Patrick Bescheid geben."
)
after = self._git("rev-parse", "HEAD").stdout.strip()
if before == after:
return False
changed = self._git("diff", "--name-only", before, after).stdout.split()
if "requirements.txt" in changed:
self._install_requirements()
log.info("Update: %s%s", before[:9], after[:9])
return True
def _install_requirements(self) -> None:
req = self.base_dir / "requirements.txt"
if not req.exists():
return
r = subprocess.run(
[sys.executable, "-m", "pip", "install", "-r", str(req)],
capture_output=True, text=True, timeout=600,
)
if r.returncode != 0:
raise UpdateError(
"Neue Programmbibliotheken konnten nicht installiert werden:\n"
+ (r.stderr.strip()[-400:] or "pip-Fehler")
)
# ── Bearbeiten-Dialog ───────────────────────────────────────────────────────── # ── Bearbeiten-Dialog ─────────────────────────────────────────────────────────
@@ -612,6 +698,9 @@ class App(ttk.Window):
self.geocoder.start() self.geocoder.start()
self._service_error_shown = False self._service_error_shown = False
self.updater = Updater(BASE_DIR)
self._update_busy = False
self._queue: list[dict] = [] self._queue: list[dict] = []
self._next_id = 0 self._next_id = 0
self._status_after_id: str | None = None self._status_after_id: str | None = None
@@ -623,6 +712,9 @@ class App(ttk.Window):
self.protocol("WM_DELETE_WINDOW", self._on_close) self.protocol("WM_DELETE_WINDOW", self._on_close)
log.info("App gestartet (%d Einträge).", self.store.total_count()) log.info("App gestartet (%d Einträge).", self.store.total_count())
if self.updater.available:
threading.Thread(target=self._auto_check_update, daemon=True).start()
# ── Aussehen ────────────────────────────────────────────────────────────── # ── Aussehen ──────────────────────────────────────────────────────────────
def _scale_fonts(self) -> None: def _scale_fonts(self) -> None:
@@ -684,6 +776,150 @@ class App(ttk.Window):
except OSError: except OSError:
log.warning("Fensterposition konnte nicht gespeichert werden.") log.warning("Fensterposition konnte nicht gespeichert werden.")
# ── Menüleiste & Selbst-Update ───────────────────────────────────────────
def _build_menubar(self) -> None:
menubar = tk.Menu(self)
help_menu = tk.Menu(menubar, tearoff=0)
if self.updater.available:
self._update_menu = help_menu
self._update_menu_index = 0
help_menu.add_command(
label="Nach Update suchen", command=lambda: self._check_update(manual=True)
)
help_menu.add_separator()
else:
self._update_menu = None
help_menu.add_command(label="Version…", command=self._show_version)
menubar.add_cascade(label="Hilfe", menu=help_menu)
self.configure(menu=menubar)
def _show_version(self) -> None:
if self.updater.available:
info = f"Installierte Version:\n{self.updater.current_version()}"
else:
info = "Version: unbekannt (kein Git-Klon Update über das Menü nicht möglich)."
messagebox.showinfo("DRK Blutspende Arbeitsorte", info, parent=self)
def _auto_check_update(self) -> None:
"""Stiller Hintergrund-Check beim Start meldet sich nur, wenn es etwas gibt."""
time.sleep(2)
try:
n = self.updater.check()
except Exception as exc: # offline o. Ä. nicht stören
log.info("Automatischer Update-Check übersprungen: %s", exc)
return
if n > 0:
self.after(0, lambda: self._on_update_available(n, manual=False))
def _check_update(self, *, manual: bool) -> None:
if self._update_busy:
return
if not self.updater.available:
messagebox.showinfo(
"Update", "Diese Installation kann sich nicht selbst aktualisieren.",
parent=self,
)
return
self._update_busy = True
self._set_status("Suche nach Updates…", sticky=True)
def work() -> None:
try:
n = self.updater.check()
err = None
except Exception as exc:
n, err = 0, str(exc)
self.after(0, self._on_update_checked, n, err, manual)
threading.Thread(target=work, daemon=True).start()
def _on_update_checked(self, n: int, err: str | None, manual: bool) -> None:
self._update_busy = False
if err is not None:
self._set_status("Update-Prüfung fehlgeschlagen.", "warning")
if manual:
messagebox.showwarning(
"Update",
f"Es konnte nicht nach Updates gesucht werden:\n\n{err}\n\n"
"Ist der Computer mit dem Internet verbunden?",
parent=self,
)
return
if n == 0:
self._set_status("Die App ist auf dem neuesten Stand.", "success")
if manual:
messagebox.showinfo("Update", "Die App ist auf dem neuesten Stand.", parent=self)
return
self._on_update_available(n, manual=manual)
def _on_update_available(self, n: int, *, manual: bool) -> None:
if self._update_menu is not None:
self._update_menu.entryconfigure(
self._update_menu_index, label=f"Update installieren ({n} verfügbar)"
)
self._set_status(
f"{n} Aktualisierung(en) verfügbar Menü „Hilfe“ Update installieren.",
"info", sticky=True,
)
count = f"{n} Aktualisierung" + ("en" if n != 1 else "")
if messagebox.askyesno(
"Update verfügbar",
f"Es gibt {count} für diese App.\n\n"
"Jetzt installieren? Die App wird dabei kurz neu gestartet.\n"
"Deine gespeicherten Einträge bleiben unverändert.",
parent=self,
):
self._do_update()
def _do_update(self) -> None:
if self._update_busy:
return
self._update_busy = True
self._set_status("App wird aktualisiert bitte warten…", sticky=True)
def work() -> None:
try:
changed = self.updater.update()
err = None
except Exception as exc:
changed, err = False, str(exc)
self.after(0, self._on_update_done, changed, err)
threading.Thread(target=work, daemon=True).start()
def _on_update_done(self, changed: bool, err: str | None) -> None:
self._update_busy = False
if err is not None:
self._set_status("Update fehlgeschlagen.", "error")
messagebox.showerror("Update fehlgeschlagen", err, parent=self)
return
if not changed:
self._set_status("Nichts zu aktualisieren.", "info")
return
messagebox.showinfo(
"Update installiert",
"Die Aktualisierung wurde installiert. Die App startet jetzt neu.",
parent=self,
)
self._restart()
def _restart(self) -> None:
log.info("Neustart nach Update.")
self._save_window_state()
self.geocoder.stop()
try:
os.chdir(BASE_DIR)
os.execv(sys.executable, [sys.executable, str(Path(__file__).resolve())])
except OSError:
log.exception("Neustart fehlgeschlagen.")
messagebox.showwarning(
"Neustart nötig",
"Bitte die App einmal schließen und neu öffnen, "
"damit das Update wirksam wird.",
parent=self,
)
def _on_geocode_service_error(self) -> None: def _on_geocode_service_error(self) -> None:
"""Einmalige Warnung, wenn der Standortdienst wiederholt nicht erreichbar ist.""" """Einmalige Warnung, wenn der Standortdienst wiederholt nicht erreichbar ist."""
self._set_status( self._set_status(
@@ -704,6 +940,8 @@ class App(ttk.Window):
# ── UI-Aufbau ───────────────────────────────────────────────────────────── # ── UI-Aufbau ─────────────────────────────────────────────────────────────
def _build_ui(self) -> None: def _build_ui(self) -> None:
self._build_menubar()
# Kopfzeile mit DRK-Rot # Kopfzeile mit DRK-Rot
header = ttk.Frame(self, bootstyle="danger", padding=(14, 8)) header = ttk.Frame(self, bootstyle="danger", padding=(14, 8))
header.pack(fill=X, side=TOP) header.pack(fill=X, side=TOP)
+1 -1
View File
@@ -31,4 +31,4 @@ code) and plots them on an interactive map.
- **UI language:** German; DRK-red header, enlarged font, colour-coded status bar, window position remembered - **UI language:** German; DRK-red header, enlarged font, colour-coded status bar, window position remembered
- **Persistence:** atomic CSV writes + rotating backups in `data/backups/`; log at `data/app.log` - **Persistence:** atomic CSV writes + rotating backups in `data/backups/`; log at `data/app.log`
- **Tests:** non-GUI smoke checks only (not yet a committed `tests/` suite) - **Tests:** non-GUI smoke checks only (not yet a committed `tests/` suite)
- **Version control:** git initialized 2026-09-07 - **Version control:** git initialized 2026-09-07; in-app self-update (`git` fast-forward + restart) via the Hilfe menu
+29 -1
View File
@@ -12,7 +12,8 @@ Everything lives in [`app.py`](../app.py). There is no package structure.
| `parse_date()` / `parse_coord()` / helpers | Module-level pure functions for normalizing user input (date → ISO, coordinate strings → float, `"City (PLZ)"` splitting). Shared by the entry tab and `EditDialog`. | | `parse_date()` / `parse_coord()` / helpers | Module-level pure functions for normalizing user input (date → ISO, coordinate strings → float, `"City (PLZ)"` splitting). Shared by the entry tab and `EditDialog`. |
| `generate_map()` | Builds `karte.html` from `DataStore.get_map_data()` with Folium. | | `generate_map()` | Builds `karte.html` from `DataStore.get_map_data()` with Folium. |
| `EditDialog` | `tk.Toplevel` modal dialog for editing one row. Returns its result via `self.result`. | | `EditDialog` | `tk.Toplevel` modal dialog for editing one row. Returns its result via `self.result`. |
| `App` | `ttkbootstrap.Window`. Builds the UI, owns the `DataStore`, the `GeocoderWorker`, and the in-memory `_queue` list for Tab 1. | | `Updater` | Wraps `git` for self-update. `available` is true only from a git clone with `git` on `PATH`. `check()` → new-commit count (`fetch` + `rev-list`); `update()``fetch` + `merge --ff-only` + conditional `pip install`. All git calls are `subprocess.run` with timeouts. Raises `UpdateError` (user-facing German text) on any failure. |
| `App` | `ttkbootstrap.Window`. Builds the UI + menubar, owns the `DataStore`, the `GeocoderWorker`, the `Updater`, and the in-memory `_queue` list for Tab 1. |
## Data flow ## Data flow
@@ -71,6 +72,33 @@ user clicks "Alle speichern" -> _on_save_all()
app is open, the next in-app save overwrites those changes (but the pre-write app is open, the next in-app save overwrites those changes (but the pre-write
backup captures them). backup captures them).
## Self-update flow
```
App start ─► daemon thread: sleep 2s ─► Updater.check()
(fetch + count) └─ on error: log only, stay quiet
N > 0 ─► self.after(0, …) ─► status hint + menu label
+ "Update verfügbar?" dialog
Hilfe ▸ Nach Update suchen ─► worker thread ─► Updater.check()
error ─► warning dialog (offline?)
N = 0 ─► "aktuell" dialog
N > 0 ─► "jetzt installieren?" dialog
install ─► worker thread ─► Updater.update() (fetch, ff-only merge, pip)
success + changed ─► info dialog ─► _restart()
save window state,
stop geocoder,
os.chdir(BASE_DIR),
os.execv(python, [python, app.py])
ff-only fails / pip fails ─► error dialog, no restart
```
Everything network- or subprocess-bound runs off the UI thread; results are
marshalled back with `self.after(0, …)` (same rule as the geocoder).
## Logging ## Logging
`_setup_logging()` (called from `__main__`) attaches a `RotatingFileHandler` to `_setup_logging()` (called from `__main__`) attaches a `RotatingFileHandler` to
+20
View File
@@ -2,6 +2,26 @@
Notable changes to the app. Newest first. Notable changes to the app. Newest first.
## 2026-09-07 — built-in self-update
New **Hilfe** menu:
- **Nach Update suchen** `git fetch` + count of new commits on the server.
- **Version…** shows the installed commit (`<hash> · <date>`).
On start, a quiet background check runs; if updates exist the status bar says so
and the menu item becomes "Update installieren (N verfügbar)".
Installing runs `git merge --ff-only` (fast-forward only never an automatic
merge), re-runs `pip install -r requirements.txt` if that file changed, then
restarts the app via `os.execv`. User data (`data/`) is git-ignored and
untouched. If the local checkout has diverged or the network is down, it shows a
clear message and does nothing.
Only active when the app runs from a **git clone** with `git` installed;
otherwise the menu just shows "Version: unbekannt". New `Updater` class,
`app.py`.
## 2026-09-07 — map tiles switched to OpenStreetMap ## 2026-09-07 — map tiles switched to OpenStreetMap
`folium.Map(tiles="CartoDB positron")` began showing an "API KEY REQUIRED" `folium.Map(tiles="CartoDB positron")` began showing an "API KEY REQUIRED"
+13 -3
View File
@@ -212,9 +212,19 @@ Second review, focused on the interface. Items 112 implemented; 13 deferred.
Full custom DRK-red *theme* (recolouring `primary` etc.) was **not** done — the Full custom DRK-red *theme* (recolouring `primary` etc.) was **not** done — the
red header bar gives the branding without fighting ttkbootstrap's theme system. red header bar gives the branding without fighting ttkbootstrap's theme system.
## Self-update (2026-09-07) — ✅ done
**Hilfe ▸ Nach Update suchen** / quiet check on start / restart via `os.execv`.
`git merge --ff-only` only, `pip install` if `requirements.txt` changed, `data/`
untouched. Needs a git clone + `git` installed (`Updater.available`).
Open follow-ups: no rollback if a pushed update is broken (mitigated by
`--ff-only` from a branch Patrick controls + pip errors caught pre-restart);
`run.sh` / `.desktop` still not committed (item 5).
## Suggested order of remaining work ## Suggested order of remaining work
1. Initial git commit (item 4), then decide on committing `orte.csv`. 1. Initial git commit (item 4), then decide on committing `orte.csv`.
2. Pinned deps + `run.sh` / `.desktop` launcher in the repo (item 5). 2. **Set up the git remote** the updater pulls from, and pin all deps (item 5).
3. Stable `id` column (item 7 of the first review). 3. `run.sh` + `.desktop` launcher in the repo (item 5).
4. Formalize tests (item 10); offline map (item 9) only if offline use becomes real. 4. Stable `id` column (item 7 of the first review).
5. Formalize tests (item 10); offline map (item 9) only if offline use becomes real.
+3
View File
@@ -1,5 +1,8 @@
# Overview what the app does # Overview what the app does
There is a **Hilfe** menu with "Nach Update suchen" (self-update via `git`, see
[setup.md](setup.md#updating)) and "Version…".
The app is a single window: a **DRK-red header bar**, two tabs, and a The app is a single window: a **DRK-red header bar**, two tabs, and a
**colour-coded status bar** at the bottom (grey = neutral, green = success, **colour-coded status bar** at the bottom (grey = neutral, green = success,
orange = warning, red = error; transient messages fade back to neutral after orange = warning, red = error; transient messages fade back to neutral after
+26 -5
View File
@@ -7,16 +7,24 @@ during install.
## 1. System packages ## 1. System packages
Tkinter is not bundled with the system Python on Mint and must be installed Tkinter is not bundled with the system Python on Mint and must be installed
separately: separately. `git` is needed for the in-app updater:
```bash ```bash
sudo apt update sudo apt update
sudo apt install python3-tk python3-venv python3-pip sudo apt install python3-tk python3-venv python3-pip git
``` ```
## 2. Get the code ## 2. Get the code
Put the project folder somewhere stable, e.g. `~/Apps/DRK_Blutspende_Orte`. **Clone it** (don't download a zip) the in-app "Nach Update suchen" only works
from a git clone:
```bash
mkdir -p ~/Apps && cd ~/Apps
git clone <repo-url> DRK_Blutspende_Orte
```
Put it somewhere stable, e.g. `~/Apps/DRK_Blutspende_Orte`.
## 3. Create a virtual environment and install dependencies ## 3. Create a virtual environment and install dependencies
@@ -94,13 +102,26 @@ that. The macOS CommandLineTools Python used during development ships Tk 8.5 and
## Updating ## Updating
**From inside the app:** menu **Hilfe ▸ Nach Update suchen**. The app also
checks quietly on start and offers the update if there is one. It fast-forwards
to the server version, reinstalls dependencies if `requirements.txt` changed,
and restarts itself. `data/` is never touched.
For this to work the app must run from a **git clone** (step 2) whose `.venv`
was made with the same Python it runs on, and `git` must be installed. The
updater only ever fast-forwards if the local copy has been changed by hand it
refuses and says to get in touch.
**Manually** (equivalent):
```bash ```bash
cd ~/Apps/DRK_Blutspende_Orte cd ~/Apps/DRK_Blutspende_Orte
git pull # once the project is in git git pull --ff-only
.venv/bin/pip install -r requirements.txt .venv/bin/pip install -r requirements.txt
``` ```
## Data location ## Data location
All state is in `data/orte.csv` next to `app.py`. To back up the app, copy that All state is in `data/orte.csv` next to `app.py`. To back up the app, copy that
one file. To move to a new laptop, copy the whole folder and redo steps 1 & 3. one file (plus `data/backups/` if you want the history). To move to a new
laptop, clone the repo again and redo steps 1 & 3; copy `data/orte.csv` across.