"""Der Werkzeug-Ordner auf diesem Rechner: lesen, ablegen, vergleichen. Das ist der Teil, den eine Weboberflaeche nicht kann, und der einzige Grund, warum es diesen Client gibt. Der Kniff gegen jedes Raten: Wir legen die Dateien selbst ab und merken uns dabei ihre Pruefsumme. Beim naechsten Start wird nachgerechnet — stimmt sie, wissen wir sicher, welche Fassung dort liegt. Stimmt sie nicht, hat jemand von Hand eingegriffen, und genau das wird gemeldet statt geschaetzt. """ from __future__ import annotations import hashlib import json from dataclasses import dataclass from enum import Enum from pathlib import Path # Liegt im Werkzeug-Ordner selbst. Damit zieht der Stand mit, wenn jemand den # Ordner verschiebt oder auf einen anderen Rechner kopiert. STANDSDATEI = ".bibliothek-stand.json" # Endung fuer die verdraengte Vorgaengerdatei. Ein laufendes Programm laesst sich # unter Windows umbenennen, aber nicht ueberschreiben — daher dieser Umweg. ALTLAST_ENDUNG = ".alt" class Zustand(str, Enum): FEHLT = "fehlt" """Nichts abgelegt — das Werkzeug ist auf diesem Rechner nicht vorhanden.""" AKTUELL = "aktuell" """Liegt da und entspricht genau dem, was die Bibliothek anbietet.""" VERALTET = "veraltet" """Liegt da, aber die Bibliothek bietet inzwischen eine andere Fassung an.""" FREMD = "fremd" """Liegt da, stammt aber nicht von uns — von Hand ersetzt oder veraendert.""" @dataclass class Befund: zustand: Zustand dateiname: str = "" abgelegteVersion: str = "" pfad: str = "" def pruefsumme(datei: Path) -> str: """SHA-256 im Strom — die Dateien sind teils dreistellig viele Megabyte gross.""" rechner = hashlib.sha256() with datei.open("rb") as offen: for brocken in iter(lambda: offen.read(1024 * 1024), b""): rechner.update(brocken) return rechner.hexdigest() class Werkzeugordner: def __init__(self, ordner: str | Path) -> None: self.ordner = Path(ordner) # --- Stand --- def _standsdatei(self) -> Path: return self.ordner / STANDSDATEI def _stand_lesen(self) -> dict[str, dict[str, str]]: datei = self._standsdatei() if not datei.exists(): return {} try: gelesen = json.loads(datei.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return {} return gelesen if isinstance(gelesen, dict) else {} def _stand_schreiben(self, stand: dict[str, dict[str, str]]) -> None: self.ordner.mkdir(parents=True, exist_ok=True) self._standsdatei().write_text( json.dumps(stand, indent=2, ensure_ascii=False), encoding="utf-8" ) # --- Abfrage --- def befund(self, software_id: int, angeboteneVersion: str) -> Befund: """Was liegt hier, und passt es zu dem, was die Bibliothek anbietet?""" vermerk = self._stand_lesen().get(str(software_id)) if not vermerk: return Befund(Zustand.FEHLT) datei = self.ordner / vermerk.get("dateiname", "") if not vermerk.get("dateiname") or not datei.exists(): return Befund(Zustand.FEHLT) try: tatsaechlich = pruefsumme(datei) except OSError: return Befund(Zustand.FREMD, vermerk.get("dateiname", ""), pfad=str(datei)) if tatsaechlich != vermerk.get("pruefsumme"): return Befund(Zustand.FREMD, vermerk["dateiname"], vermerk.get("version", ""), str(datei)) zustand = Zustand.AKTUELL if vermerk.get("version") == angeboteneVersion else Zustand.VERALTET return Befund(zustand, vermerk["dateiname"], vermerk.get("version", ""), str(datei)) # --- Ablegen --- def ablegen(self, software_id: int, quelle: Path, dateiname: str, version: str) -> Path: """Legt eine geholte Datei ab und vermerkt ihre Pruefsumme. Eine vorhandene gleichnamige Datei wird zuerst umbenannt statt geloescht: Laeuft das Programm gerade, laesst Windows das Umbenennen zu, das Ueberschreiben nicht. Der Rest wird beim naechsten Lauf weggeraeumt. """ self.ordner.mkdir(parents=True, exist_ok=True) ziel = self.ordner / dateiname if ziel.exists(): altlast = ziel.with_name(ziel.name + ALTLAST_ENDUNG) altlast.unlink(missing_ok=True) ziel.replace(altlast) quelle.replace(ziel) stand = self._stand_lesen() stand[str(software_id)] = { "dateiname": dateiname, "version": version, "pruefsumme": pruefsumme(ziel), } self._stand_schreiben(stand) return ziel def vergessen(self, software_id: int) -> None: """Nimmt einen Eintrag aus dem Stand — die Datei selbst bleibt liegen.""" stand = self._stand_lesen() if stand.pop(str(software_id), None) is not None: self._stand_schreiben(stand) def altlasten_aufraeumen(self) -> int: """Loescht verdraengte Vorgaengerdateien, sofern sie niemand mehr haelt.""" if not self.ordner.exists(): return 0 entfernt = 0 for datei in self.ordner.glob("*" + ALTLAST_ENDUNG): try: datei.unlink() entfernt += 1 except OSError: continue # laeuft noch — beim naechsten Mal wieder versuchen return entfernt