Update ohne Neustart, wenn nur Oberflaechendateien wechseln
Bisher verlangte jedes Update einen Home-Assistant-Neustart. Noetig ist er aber nur fuer Python-Code: HA importiert die Module einmal beim Start und haengt danach mit lebenden Objekten daran. frontend/ dagegen wird direkt von der Platte ausgeliefert (StaticPathConfig, cache_headers=False) - eine ersetzte .js ist sofort wirksam, es braucht nur ein Neuladen im Browser. aktualisierung.neustart_noetig() vergleicht die alte gegen die neue Fassung ueber sha256 je Datei und meldet "kein Neustart" nur, wenn JEDE Abweichung unter frontend/ liegt oder die manifest.json ist. Alles andere - .py, services.yaml, translations/, vorlage/ - gilt als neustartpflichtig, auch wo es das im Einzelfall nicht waere. Die Schieflage ist Absicht: ein faelschlich ausgelassener Neustart laesst neuen Python-Code nie anlaufen, und der Fehler wird woanders gesucht. manifest.json ist ausgenommen, weil sich seine Versionsnummer bei jeder Veroeffentlichung aendert - sonst waere die Unterscheidung wertlos. Weil HA das Manifest fuer die Laufzeit festhaelt (loader.py: hass.data[ DATA_INTEGRATIONS]), liest version_von_platte() die Nummer direkt von der Platte; koordinator.version_neu_lesen() zieht sie nach einem Update ohne Neustart nach, damit Panel und App die neue Fassung auch anzeigen. Panel und App zeigen im Neustart-freien Fall "Seite neu laden" statt "Installation abschliessen - Jetzt neu starten". Neun neue Testfaelle fuer die Unterscheidung, 23 Tests in der Aktualisierungs-Suite gruen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -216,9 +216,18 @@ export interface IntegrationUpdateAngabe {
|
|||||||
verfuegbar: boolean;
|
verfuegbar: boolean;
|
||||||
version: string | null;
|
version: string | null;
|
||||||
geprueft_am: string;
|
geprueft_am: string;
|
||||||
/** true unmittelbar nach einer erfolgreichen Installation - dann ist ein
|
/** true unmittelbar nach einer erfolgreichen Installation. */
|
||||||
Neustart von Home Assistant nötig, damit die neue Fassung geladen wird. */
|
|
||||||
installiert: boolean;
|
installiert: boolean;
|
||||||
|
/** Ob nach der Installation wirklich ein Home-Assistant-Neustart nötig ist.
|
||||||
|
|
||||||
|
Der Updater entscheidet das am Datei-Vergleich: betrifft das Update nur
|
||||||
|
`frontend/`, reicht ein Neuladen der Seite - die Oberflächendateien
|
||||||
|
werden direkt von der Platte ausgeliefert. Nur geänderter Python-Code
|
||||||
|
braucht einen Neustart, weil Home Assistant seine Module einmal beim
|
||||||
|
Start importiert.
|
||||||
|
|
||||||
|
Fehlt das Feld (ältere Integration), gilt "Neustart nötig". */
|
||||||
|
neustart_noetig?: boolean;
|
||||||
fehler: string | null;
|
fehler: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -796,6 +796,21 @@ export function Einstellungen({
|
|||||||
Home Assistant startet neu …
|
Home Assistant startet neu …
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
) : integrationUpdate?.installiert && integrationUpdate.neustart_noetig === false ? (
|
||||||
|
/* Nur Oberflächendateien geändert - die liegen als statische Dateien
|
||||||
|
auf der Platte und werden ohne lange Cache-Vorgabe ausgeliefert.
|
||||||
|
Ein Neuladen genügt, Home Assistant muss nicht neu starten. */
|
||||||
|
<>
|
||||||
|
<p className="dm-erklaerung">
|
||||||
|
Version {integrationUpdate.version} installiert - nur Oberflächendateien, kein
|
||||||
|
Neustart nötig.
|
||||||
|
</p>
|
||||||
|
<div className="dm-knopfreihe">
|
||||||
|
<ActionButton variant="primary" onClick={() => window.location.reload()}>
|
||||||
|
App neu laden
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : integrationUpdate?.installiert ? (
|
) : integrationUpdate?.installiert ? (
|
||||||
<>
|
<>
|
||||||
<p className="dm-erklaerung">
|
<p className="dm-erklaerung">
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import datetime
|
import datetime
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -178,6 +179,112 @@ async def version_pruefen(hass: HomeAssistant, token: str, eigene_version: str)
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Dateien, deren Aenderung KEINEN Neustart braucht.
|
||||||
|
#
|
||||||
|
# frontend/ wird direkt von der Platte ausgeliefert (StaticPathConfig mit
|
||||||
|
# cache_headers=False, siehe __init__.py) - eine ersetzte .js oder .css ist
|
||||||
|
# sofort wirksam, es braucht nur ein Neuladen im Browser. Das gilt auch fuer
|
||||||
|
# frontend/app/, das OTA-Buendel der Companion-App.
|
||||||
|
#
|
||||||
|
# manifest.json steht dabei, weil sich seine Versionsnummer bei JEDER
|
||||||
|
# Veroeffentlichung aendert - ohne diese Ausnahme waere jedes Update ein
|
||||||
|
# Neustart-Update, und die Unterscheidung waere wertlos. Home Assistant haelt
|
||||||
|
# das Manifest fuer die Laufzeit fest (loader.py: hass.data[DATA_INTEGRATIONS]),
|
||||||
|
# die von HA selbst angezeigte Versionsnummer bleibt also bis zum naechsten
|
||||||
|
# Neustart die alte. Das ist kosmetisch; unsere eigene Anzeige liest die
|
||||||
|
# Version direkt von der Platte (siehe __init__.py).
|
||||||
|
OHNE_NEUSTART = ("frontend/",)
|
||||||
|
OHNE_NEUSTART_DATEIEN = ("manifest.json",)
|
||||||
|
|
||||||
|
|
||||||
|
def version_von_platte() -> str | None:
|
||||||
|
"""Die Versionsnummer aus der manifest.json neben diesem Modul.
|
||||||
|
|
||||||
|
Home Assistant haelt das Manifest fuer die Laufzeit fest (loader.py:
|
||||||
|
`cache = hass.data[DATA_INTEGRATIONS]`) - nach einem Update meldet
|
||||||
|
`async_get_integration()` also weiter die alte Nummer, selbst wenn der
|
||||||
|
Config-Eintrag neu geladen wird. Fuer ein Update ohne Neustart braucht es
|
||||||
|
deshalb eine Quelle, die wirklich von der Platte liest.
|
||||||
|
|
||||||
|
`None`, wenn die Datei fehlt oder unlesbar ist - dann bleibt der Aufrufer
|
||||||
|
bei dem Wert, den er schon hat, statt eine erfundene Nummer zu zeigen."""
|
||||||
|
pfad = os.path.join(INTEGRATIONSORDNER, "manifest.json")
|
||||||
|
try:
|
||||||
|
with open(pfad, encoding="utf-8") as datei:
|
||||||
|
return json.load(datei).get("version") or None
|
||||||
|
except (OSError, ValueError) as fehler:
|
||||||
|
_LOGGER.warning("manifest.json nicht lesbar (%s)", fehler)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _dateihashes(ordner: str) -> dict[str, str]:
|
||||||
|
"""Alle Dateien unter `ordner` als {relativer Pfad: sha256}.
|
||||||
|
|
||||||
|
__pycache__ bleibt aussen vor: es entsteht beim Laufen und sagt nichts
|
||||||
|
ueber die ausgelieferte Fassung."""
|
||||||
|
aus: dict[str, str] = {}
|
||||||
|
for wurzel, ordnerliste, dateien in os.walk(ordner):
|
||||||
|
ordnerliste[:] = [o for o in ordnerliste if o != "__pycache__"]
|
||||||
|
for name in dateien:
|
||||||
|
pfad = os.path.join(wurzel, name)
|
||||||
|
rel = os.path.relpath(pfad, ordner).replace(os.sep, "/")
|
||||||
|
hasher = hashlib.sha256()
|
||||||
|
with open(pfad, "rb") as datei:
|
||||||
|
for block in iter(lambda: datei.read(65536), b""):
|
||||||
|
hasher.update(block)
|
||||||
|
aus[rel] = hasher.hexdigest()
|
||||||
|
return aus
|
||||||
|
|
||||||
|
|
||||||
|
def neustart_noetig(alter_ordner: str, neuer_ordner: str) -> bool:
|
||||||
|
"""Ob die neue Fassung einen Home-Assistant-Neustart braucht.
|
||||||
|
|
||||||
|
Bewusst pessimistisch: gemeldet wird `False` nur, wenn JEDE geaenderte,
|
||||||
|
hinzugekommene oder entfallene Datei unter frontend/ liegt oder die
|
||||||
|
manifest.json ist. Alles andere - .py, services.yaml, translations/,
|
||||||
|
vorlage/ - gilt als neustartpflichtig, auch wenn es das im Einzelfall
|
||||||
|
vielleicht nicht waere.
|
||||||
|
|
||||||
|
Der Grund fuer die Schieflage: sagt diese Funktion faelschlich "kein
|
||||||
|
Neustart noetig", laeuft neuer Python-Code nie an, und der Fehler wird an
|
||||||
|
einer ganz anderen Stelle gesucht. Ein ueberfluessiger Neustart kostet
|
||||||
|
dagegen eine Minute. Im Zweifel also Neustart.
|
||||||
|
|
||||||
|
Laesst sich der alte Ordner nicht lesen, gilt ebenfalls Neustart."""
|
||||||
|
try:
|
||||||
|
alt = _dateihashes(alter_ordner)
|
||||||
|
neu = _dateihashes(neuer_ordner)
|
||||||
|
except OSError as fehler:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Konnte alte und neue Fassung nicht vergleichen (%s) - Neustart angenommen.",
|
||||||
|
fehler,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
geaendert = {
|
||||||
|
pfad
|
||||||
|
for pfad in set(alt) | set(neu)
|
||||||
|
if alt.get(pfad) != neu.get(pfad)
|
||||||
|
}
|
||||||
|
ohne_neustart = {
|
||||||
|
pfad
|
||||||
|
for pfad in geaendert
|
||||||
|
if pfad.startswith(OHNE_NEUSTART) or pfad in OHNE_NEUSTART_DATEIEN
|
||||||
|
}
|
||||||
|
rest = sorted(geaendert - ohne_neustart)
|
||||||
|
if rest:
|
||||||
|
_LOGGER.info(
|
||||||
|
"Update braucht einen Neustart - geaendert ausserhalb von frontend/: %s",
|
||||||
|
", ".join(rest[:8]) + (" …" if len(rest) > 8 else ""),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
_LOGGER.info(
|
||||||
|
"Update betrifft nur die Oberflaeche (%s Datei(en)) - kein Neustart noetig.",
|
||||||
|
len(geaendert),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def update_installieren(hass: HomeAssistant, token: str) -> dict[str, Any]:
|
async def update_installieren(hass: HomeAssistant, token: str) -> dict[str, Any]:
|
||||||
"""Lädt das komplette Repo-Archiv und delegiert Entpacken/Prüfen/Tauschen
|
"""Lädt das komplette Repo-Archiv und delegiert Entpacken/Prüfen/Tauschen
|
||||||
an eine blockierende Funktion im Executor - Datei- und Zip-Operationen
|
an eine blockierende Funktion im Executor - Datei- und Zip-Operationen
|
||||||
@@ -202,10 +309,10 @@ async def update_installieren(hass: HomeAssistant, token: str) -> dict[str, Any]
|
|||||||
except Exception as fehler:
|
except Exception as fehler:
|
||||||
raise AktualisierungsFehler(f"Gitea nicht erreichbar: {fehler}") from fehler
|
raise AktualisierungsFehler(f"Gitea nicht erreichbar: {fehler}") from fehler
|
||||||
|
|
||||||
neue_version = await hass.async_add_executor_job(
|
ergebnis = await hass.async_add_executor_job(
|
||||||
entpacken_pruefen_tauschen, zip_bytes, INTEGRATIONSORDNER, _STAGING_ORDNER, _BACKUP_ORDNER
|
entpacken_pruefen_tauschen, zip_bytes, INTEGRATIONSORDNER, _STAGING_ORDNER, _BACKUP_ORDNER
|
||||||
)
|
)
|
||||||
return {"version": neue_version}
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
def entpacken_pruefen_tauschen(
|
def entpacken_pruefen_tauschen(
|
||||||
@@ -277,6 +384,10 @@ def entpacken_pruefen_tauschen(
|
|||||||
if "__pycache__" in ordner:
|
if "__pycache__" in ordner:
|
||||||
shutil.rmtree(os.path.join(wurzel, "__pycache__"), ignore_errors=True)
|
shutil.rmtree(os.path.join(wurzel, "__pycache__"), ignore_errors=True)
|
||||||
|
|
||||||
|
# Vergleich VOR dem Tausch - danach gibt es die alte Fassung unter diesem
|
||||||
|
# Pfad nicht mehr.
|
||||||
|
neustart = neustart_noetig(integrationsordner, staging_ordner)
|
||||||
|
|
||||||
if os.path.isdir(backup_ordner):
|
if os.path.isdir(backup_ordner):
|
||||||
shutil.rmtree(backup_ordner)
|
shutil.rmtree(backup_ordner)
|
||||||
try:
|
try:
|
||||||
@@ -315,4 +426,4 @@ def entpacken_pruefen_tauschen(
|
|||||||
f"Tausch fehlgeschlagen, alte Fassung wiederhergestellt: {fehler}"
|
f"Tausch fehlgeschlagen, alte Fassung wiederhergestellt: {fehler}"
|
||||||
) from fehler
|
) from fehler
|
||||||
|
|
||||||
return neue_version
|
return {"version": neue_version, "neustart_noetig": neustart}
|
||||||
|
|||||||
@@ -344,23 +344,41 @@ def registrieren(hass: HomeAssistant, k: Koordinator) -> None:
|
|||||||
await k.app_version_veroeffentlichen()
|
await k.app_version_veroeffentlichen()
|
||||||
|
|
||||||
async def update_installieren(_aufruf: ServiceCall) -> None:
|
async def update_installieren(_aufruf: ServiceCall) -> None:
|
||||||
"""Lädt die neueste Fassung von Gitea und ersetzt den
|
"""Laedt die neueste Fassung von Gitea und ersetzt den
|
||||||
Integrationsordner. Home Assistant muss danach von Hand neu
|
Integrationsordner.
|
||||||
gestartet werden - kein automatischer Reload aus dem eigenen,
|
|
||||||
noch laufenden Aufruf heraus, siehe aktualisierung.py."""
|
Ob danach ein Neustart noetig ist, entscheidet der Updater am
|
||||||
|
Datei-Vergleich (aktualisierung.neustart_noetig): betrifft das Update
|
||||||
|
nur frontend/, reicht ein Neuladen der Seite - die Oberflaechen-Dateien
|
||||||
|
werden direkt von der Platte ausgeliefert (StaticPathConfig mit
|
||||||
|
cache_headers=False). Nur geaenderter Python-Code braucht wirklich
|
||||||
|
einen Neustart, weil Home Assistant seine Module einmal beim Start
|
||||||
|
importiert und danach mit lebenden Objekten daran haengt.
|
||||||
|
|
||||||
|
Kein automatischer Reload aus dem eigenen, noch laufenden Aufruf
|
||||||
|
heraus - siehe aktualisierung.py."""
|
||||||
token = k.entry.options.get(CONF_GITEA_TOKEN, "")
|
token = k.entry.options.get(CONF_GITEA_TOKEN, "")
|
||||||
jetzt = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
jetzt = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||||
try:
|
try:
|
||||||
ergebnis = await aktualisierung.update_installieren(hass, token)
|
ergebnis = await aktualisierung.update_installieren(hass, token)
|
||||||
|
neustart = bool(ergebnis.get("neustart_noetig", True))
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"Update installiert: Version %s - Home Assistant muss neu gestartet werden.",
|
"Update installiert: Version %s - %s",
|
||||||
ergebnis["version"],
|
ergebnis["version"],
|
||||||
|
"Home Assistant muss neu gestartet werden."
|
||||||
|
if neustart
|
||||||
|
else "nur Oberflaechen-Dateien, ein Neuladen der Seite genuegt.",
|
||||||
)
|
)
|
||||||
|
if not neustart:
|
||||||
|
# Ohne Neustart bleibt Home Assistants zwischengespeichertes
|
||||||
|
# Manifest stehen - die neue Nummer muss von der Platte kommen.
|
||||||
|
await k.version_neu_lesen()
|
||||||
k.letzte_update_pruefung = {
|
k.letzte_update_pruefung = {
|
||||||
"verfuegbar": False,
|
"verfuegbar": False,
|
||||||
"version": ergebnis["version"],
|
"version": ergebnis["version"],
|
||||||
"geprueft_am": jetzt,
|
"geprueft_am": jetzt,
|
||||||
"installiert": True,
|
"installiert": True,
|
||||||
|
"neustart_noetig": neustart,
|
||||||
"fehler": None,
|
"fehler": None,
|
||||||
}
|
}
|
||||||
except aktualisierung.AktualisierungsFehler as fehler:
|
except aktualisierung.AktualisierungsFehler as fehler:
|
||||||
@@ -370,6 +388,7 @@ def registrieren(hass: HomeAssistant, k: Koordinator) -> None:
|
|||||||
"version": (k.letzte_update_pruefung or {}).get("version"),
|
"version": (k.letzte_update_pruefung or {}).get("version"),
|
||||||
"geprueft_am": jetzt,
|
"geprueft_am": jetzt,
|
||||||
"installiert": False,
|
"installiert": False,
|
||||||
|
"neustart_noetig": True,
|
||||||
"fehler": str(fehler),
|
"fehler": str(fehler),
|
||||||
}
|
}
|
||||||
await k.app_version_veroeffentlichen()
|
await k.app_version_veroeffentlichen()
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"version":"2026.9.5.22","sha256":"a83dcedbf835def7fedebdb55e136ec5cf6cd1e046bbe02b41a21477a4801547","bytes":324556,"gebaut":"2026-09-05T10:46:04Z"}
|
{"version":"2026.9.5.23","sha256":"3ff0de25e28b5cf00a9015ec3af3e27f0e4d0ae8a13eb9e443dd47100ebc9d1e","bytes":324637,"gebaut":"2026-09-05T11:29:36Z"}
|
||||||
Binary file not shown.
@@ -3743,6 +3743,18 @@ function vEinst() {
|
|||||||
if (laeuft === "installieren") return spinner("Installiere …");
|
if (laeuft === "installieren") return spinner("Installiere …");
|
||||||
if (laeuft === "neustart") return spinner("Home Assistant startet neu …");
|
if (laeuft === "neustart") return spinner("Home Assistant startet neu …");
|
||||||
if (u && u.installiert) {
|
if (u && u.installiert) {
|
||||||
|
/* Ob ein Neustart noetig ist, entscheidet der Updater am
|
||||||
|
Datei-Vergleich (aktualisierung.neustart_noetig). Betrifft das
|
||||||
|
Update nur frontend/, reicht ein Neuladen der Seite: die Dateien
|
||||||
|
werden direkt von der Platte ausgeliefert, und zwar mit
|
||||||
|
cache_headers=False - der Browser holt sie beim naechsten Laden
|
||||||
|
ohnehin frisch. Nur geaenderter Python-Code braucht wirklich einen
|
||||||
|
Neustart (Abschnitt CZ in AGENTS.md). */
|
||||||
|
if (u.neustart_noetig === false) {
|
||||||
|
return `<span style="display:block;margin-top:8px;color:var(--fg2);font-size:13px;line-height:1.6">
|
||||||
|
Version ${esc(u.version)} installiert - nur Oberflächendateien, kein Neustart nötig.</span>
|
||||||
|
<button class="aktion primaer" style="margin-top:14px" data-update="neuladen">Seite neu laden</button>`;
|
||||||
|
}
|
||||||
return `<span style="display:block;margin-top:8px;color:var(--fg2);font-size:13px;line-height:1.6">
|
return `<span style="display:block;margin-top:8px;color:var(--fg2);font-size:13px;line-height:1.6">
|
||||||
Version ${esc(u.version)} installiert - die neue Fassung wird erst nach einem Neustart
|
Version ${esc(u.version)} installiert - die neue Fassung wird erst nach einem Neustart
|
||||||
geladen.</span>
|
geladen.</span>
|
||||||
@@ -6693,6 +6705,8 @@ function ereignisseVerdrahten() {
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
.finally(() => { INTEGRATION_UPDATE_LAEUFT = null; render(); scrollWiederherstellen(); });
|
.finally(() => { INTEGRATION_UPDATE_LAEUFT = null; render(); scrollWiederherstellen(); });
|
||||||
|
} else if (aktion === "neuladen") {
|
||||||
|
location.reload();
|
||||||
} else if (aktion === "neustart") {
|
} else if (aktion === "neustart") {
|
||||||
INTEGRATION_UPDATE_LAEUFT = "neustart"; render();
|
INTEGRATION_UPDATE_LAEUFT = "neustart"; render();
|
||||||
HASS.callService("homeassistant", "restart", {}).catch((err) => {
|
HASS.callService("homeassistant", "restart", {}).catch((err) => {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ from homeassistant.helpers.start import async_at_started
|
|||||||
from homeassistant.helpers.storage import Store
|
from homeassistant.helpers.storage import Store
|
||||||
|
|
||||||
from . import (
|
from . import (
|
||||||
|
aktualisierung,
|
||||||
batterie,
|
batterie,
|
||||||
belege,
|
belege,
|
||||||
bilder,
|
bilder,
|
||||||
@@ -681,6 +682,19 @@ class Koordinator:
|
|||||||
return None
|
return None
|
||||||
return {**info, "url": BUENDEL_URL}
|
return {**info, "url": BUENDEL_URL}
|
||||||
|
|
||||||
|
async def version_neu_lesen(self) -> None:
|
||||||
|
"""Liest die Versionsnummer erneut von der Platte.
|
||||||
|
|
||||||
|
Nach einem Update OHNE Neustart hat sich die manifest.json geaendert,
|
||||||
|
Home Assistants zwischengespeicherte Fassung aber nicht. Ohne diesen
|
||||||
|
Schritt zeigten Panel und App nach einem reinen Oberflaechen-Update
|
||||||
|
weiter die alte Nummer - und der Nutzer haette keinen Anhalt, ob das
|
||||||
|
Update wirklich angekommen ist."""
|
||||||
|
neu = await self.hass.async_add_executor_job(aktualisierung.version_von_platte)
|
||||||
|
if neu and neu != self.version:
|
||||||
|
_LOGGER.info("Version nach Update: %s -> %s", self.version, neu)
|
||||||
|
self.version = neu
|
||||||
|
|
||||||
async def app_version_veroeffentlichen(self) -> None:
|
async def app_version_veroeffentlichen(self) -> None:
|
||||||
buendel = await self.hass.async_add_executor_job(self._buendel_lesen)
|
buendel = await self.hass.async_add_executor_job(self._buendel_lesen)
|
||||||
bildstaende = await bilder.staende(
|
bildstaende = await bilder.staende(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "audi_dashboard",
|
"domain": "audi_dashboard",
|
||||||
"name": "Audi Dashboard",
|
"name": "Audi Dashboard",
|
||||||
"version": "2026.9.5.22",
|
"version": "2026.9.5.23",
|
||||||
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
|
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
|
||||||
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
|
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
|
||||||
"codeowners": [
|
"codeowners": [
|
||||||
|
|||||||
@@ -69,10 +69,12 @@ class ErfolgreicherTausch(unittest.TestCase):
|
|||||||
"neue_datei.py": b"# neue Fassung\n",
|
"neue_datei.py": b"# neue Fassung\n",
|
||||||
"frontend/app.js": b"// panel\n",
|
"frontend/app.js": b"// panel\n",
|
||||||
})
|
})
|
||||||
version = a.entpacken_pruefen_tauschen(
|
ergebnis = a.entpacken_pruefen_tauschen(
|
||||||
zip_bytes, self.integration, self.staging, self.backup
|
zip_bytes, self.integration, self.staging, self.backup
|
||||||
)
|
)
|
||||||
self.assertEqual(version, "2099.1.1.1")
|
self.assertEqual(ergebnis["version"], "2099.1.1.1")
|
||||||
|
# Das Testarchiv enthaelt .py-Dateien, also neustartpflichtig.
|
||||||
|
self.assertTrue(ergebnis["neustart_noetig"])
|
||||||
|
|
||||||
# Live-Ordner trägt jetzt die neue Fassung.
|
# Live-Ordner trägt jetzt die neue Fassung.
|
||||||
with open(os.path.join(self.integration, "manifest.json"), "rb") as f:
|
with open(os.path.join(self.integration, "manifest.json"), "rb") as f:
|
||||||
@@ -188,6 +190,73 @@ class AbgelehnteFaelle(unittest.TestCase):
|
|||||||
self._unangetastet()
|
self._unangetastet()
|
||||||
|
|
||||||
|
|
||||||
|
class NeustartNoetig(unittest.TestCase):
|
||||||
|
"""Die Unterscheidung "braucht das einen Neustart?" ist bewusst schief.
|
||||||
|
|
||||||
|
Sagt sie faelschlich "nein", laeuft neuer Python-Code nie an und der Fehler
|
||||||
|
wird woanders gesucht. Ein ueberfluessiger Neustart kostet eine Minute.
|
||||||
|
Deshalb: nur frontend/ und manifest.json duerfen ohne Neustart wechseln.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.basis = tempfile.mkdtemp()
|
||||||
|
self.alt = os.path.join(self.basis, "alt")
|
||||||
|
self.neu = os.path.join(self.basis, "neu")
|
||||||
|
for ordner in (self.alt, self.neu):
|
||||||
|
os.makedirs(os.path.join(ordner, "frontend"))
|
||||||
|
self._schreiben(ordner, "manifest.json", '{"version": "1"}')
|
||||||
|
self._schreiben(ordner, "__init__.py", "x = 1\n")
|
||||||
|
self._schreiben(ordner, "frontend/app.js", "alert(1)\n")
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
shutil.rmtree(self.basis, ignore_errors=True)
|
||||||
|
|
||||||
|
def _schreiben(self, ordner, rel, inhalt):
|
||||||
|
pfad = os.path.join(ordner, *rel.split("/"))
|
||||||
|
os.makedirs(os.path.dirname(pfad), exist_ok=True)
|
||||||
|
with open(pfad, "w", encoding="utf-8") as datei:
|
||||||
|
datei.write(inhalt)
|
||||||
|
|
||||||
|
def test_nichts_geaendert_braucht_keinen_neustart(self):
|
||||||
|
self.assertFalse(a.neustart_noetig(self.alt, self.neu))
|
||||||
|
|
||||||
|
def test_nur_frontend_braucht_keinen_neustart(self):
|
||||||
|
self._schreiben(self.neu, "frontend/app.js", "alert(2)\n")
|
||||||
|
self._schreiben(self.neu, "frontend/neu.css", "body{}\n")
|
||||||
|
self.assertFalse(a.neustart_noetig(self.alt, self.neu))
|
||||||
|
|
||||||
|
def test_manifest_allein_braucht_keinen_neustart(self):
|
||||||
|
# Die Versionsnummer aendert sich bei JEDER Veroeffentlichung - ohne
|
||||||
|
# diese Ausnahme waere jedes Update ein Neustart-Update.
|
||||||
|
self._schreiben(self.neu, "manifest.json", '{"version": "2"}')
|
||||||
|
self._schreiben(self.neu, "frontend/app.js", "alert(2)\n")
|
||||||
|
self.assertFalse(a.neustart_noetig(self.alt, self.neu))
|
||||||
|
|
||||||
|
def test_geaenderte_python_datei_braucht_neustart(self):
|
||||||
|
self._schreiben(self.neu, "__init__.py", "x = 2\n")
|
||||||
|
self.assertTrue(a.neustart_noetig(self.alt, self.neu))
|
||||||
|
|
||||||
|
def test_neue_python_datei_braucht_neustart(self):
|
||||||
|
self._schreiben(self.neu, "neu.py", "y = 1\n")
|
||||||
|
self.assertTrue(a.neustart_noetig(self.alt, self.neu))
|
||||||
|
|
||||||
|
def test_entfallene_python_datei_braucht_neustart(self):
|
||||||
|
os.remove(os.path.join(self.neu, "__init__.py"))
|
||||||
|
self.assertTrue(a.neustart_noetig(self.alt, self.neu))
|
||||||
|
|
||||||
|
def test_services_yaml_braucht_neustart(self):
|
||||||
|
# Nicht weil es zwingend waere, sondern weil die Regel pessimistisch ist.
|
||||||
|
self._schreiben(self.neu, "services.yaml", "dienst:\n")
|
||||||
|
self.assertTrue(a.neustart_noetig(self.alt, self.neu))
|
||||||
|
|
||||||
|
def test_pycache_zaehlt_nicht(self):
|
||||||
|
self._schreiben(self.neu, "__pycache__/x.cpython-312.pyc", "egal")
|
||||||
|
self.assertFalse(a.neustart_noetig(self.alt, self.neu))
|
||||||
|
|
||||||
|
def test_unlesbarer_ordner_gilt_als_neustart(self):
|
||||||
|
self.assertTrue(a.neustart_noetig(os.path.join(self.basis, "gibtsnicht"), self.neu))
|
||||||
|
|
||||||
|
|
||||||
class VersionsOrdnung(unittest.TestCase):
|
class VersionsOrdnung(unittest.TestCase):
|
||||||
"""Nur eine NACHWEISLICH neuere Fassung ist ein Update.
|
"""Nur eine NACHWEISLICH neuere Fassung ist ein Update.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user