Server-Bibliothek
vor 18 Std. 98bce1b0da675715b2e5482c0ad61ba21135f62f
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
"""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