fix: Pfad-Regeln an EINE Stelle — dreimal dieselbe Falle war zweimal zu viel
Ampel / ampel (push) Successful in 54s

Ampel-Lauf zu b47d215 rot, drei Fehlschlaege, alle mein eigener Code:

    platz_orte             os.path.splitdrive kannte auf Linux kein "D:"
                           -> zwei Laufwerke galten als eines
    naechster_vorhandener  os.path.dirname zerlegte C:\Users\Test\... nicht
    pruefe_windows         die Plattform-Pruefung stand VOR der eingespritzten
                           Version -> pruefe_windows(22631) gab auf Linux FEHLER

`os.path` richtet sich nach der Maschine, auf der es laeuft. Das ist fast
immer richtig -- und an jeder Stelle falsch, an der ueber Pfade einer ANDEREN
Maschine gerechnet wird. Genau das war heute schon zweimal da:

    tools/katalog.py         C:\Program Files (x86)\MakeMKV/makemkvcon64.exe
    platform/verknuepfungen  dirname() einer .lnk gab einen leeren String

Zweimal einzeln repariert, beim dritten Mal gehoert es an EINE Stelle:
`rippy/pfade.py` mit `ist_windows_pfad`, `verbinden`, `ordner_von`,
`laufwerk_von`, `gleiches_laufwerk`, `naechster_vorhandener`. Die Regel steht
dort im Kopf: **Der Pfad entscheidet, nicht der Rechner.** katalog,
verknuepfungen, betrieb und einrichtung benutzen jetzt alle dasselbe.

Und `pruefe_windows` beachtet einen eingespritzten Wert wieder. Ein
Einspritzpunkt, der ignoriert wird, ist keiner -- dann sind die Tests
daneben, die ihn benutzen.

Sieben neue Tests in test_pfade.py, die BEIDE Zweige pruefen und deshalb
ueberall laufen. Das war der eigentliche Mangel: Jede dieser drei Fallen war
nur auf der jeweils anderen Plattform sichtbar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-28 14:00:34 +02:00
co-authored by Claude Opus 5
parent b47d21510d
commit 151376aee3
6 changed files with 180 additions and 60 deletions
+10 -20
View File
@@ -61,6 +61,8 @@ Wo es geht, wird nachgesehen statt geglaubt:
import os import os
import sys import sys
from rippy import pfade
# Docker legt diese Datei in jedem Container an. Sie ist das verlässlichste # Docker legt diese Datei in jedem Container an. Sie ist das verlässlichste
# Kennzeichen, das ohne Zusatzpakete zu haben ist — verlässlicher als eine # Kennzeichen, das ohne Zusatzpakete zu haben ist — verlässlicher als eine
# Umgebungsvariable, die jeder setzen (und vergessen) kann. # Umgebungsvariable, die jeder setzen (und vergessen) kann.
@@ -138,24 +140,8 @@ def auskunft(werte: dict, container: bool = None,
def naechster_vorhandener(pfad: str, existiert=None) -> str: def naechster_vorhandener(pfad: str, existiert=None) -> str:
"""Der nächste Ordner nach oben, den es WIRKLICH gibt. "" wenn keiner. """Der naechste vorhandene Ordner nach oben — siehe `rippy.pfade`."""
return pfade.naechster_vorhandener(pfad, existiert)
Frisch installiert gibt es den Ablage-Ordner noch nicht — `disk_usage`
wirft dann, und im Dashboard stand „unbekannt". Der Nutzer will aber
wissen, ob auf dem LAUFWERK Platz ist, und das lässt sich beantworten.
"""
existiert = existiert or os.path.isdir
pfad = (pfad or "").rstrip("\\/")
gesehen = set()
while pfad and pfad not in gesehen:
if existiert(pfad):
return pfad
gesehen.add(pfad)
eltern = os.path.dirname(pfad)
if eltern == pfad: # Wurzel erreicht
break
pfad = eltern
return ""
def platz_orte(werte: dict, container: bool = None, def platz_orte(werte: dict, container: bool = None,
@@ -186,11 +172,15 @@ def platz_orte(werte: dict, container: bool = None,
{"name": "Arbeitsverzeichnis (/app/temp)", "pfad": "/app/temp"}] {"name": "Arbeitsverzeichnis (/app/temp)", "pfad": "/app/temp"}]
ablage = ablage_vorgabe(werte, container, system) ablage = ablage_vorgabe(werte, container, system)
temp = lager.get("temp") or os.path.join(ablage, "_arbeit") temp = lager.get("temp") or pfade.verbinden(ablage, "_arbeit")
orte = [{"name": "Ablage", "pfad": ablage}] orte = [{"name": "Ablage", "pfad": ablage}]
# Nur wenn es ein ANDERES Laufwerk ist, lohnt eine zweite Zeile. Zweimal # Nur wenn es ein ANDERES Laufwerk ist, lohnt eine zweite Zeile. Zweimal
# dieselbe Zahl unter zwei Namen sieht aus wie zwei Auskünfte und ist eine. # dieselbe Zahl unter zwei Namen sieht aus wie zwei Auskünfte und ist eine.
if os.path.splitdrive(temp)[0].lower() != os.path.splitdrive(ablage)[0].lower(): #
# Ueber `pfade`, nicht ueber `os.path`: Auf dem Linux-Runner der Ampel
# kannte `splitdrive` kein `D:` und hielt zwei verschiedene Laufwerke
# fuer eines.
if not pfade.gleiches_laufwerk(temp, ablage):
orte.append({"name": "Arbeitsverzeichnis", "pfad": temp}) orte.append({"name": "Arbeitsverzeichnis", "pfad": temp})
return orte return orte
+11 -4
View File
@@ -63,8 +63,15 @@ def _eintrag(name, zustand, text, abhilfe=""):
# ── Die einzelnen Prüfungen ───────────────────────────────────────────── # ── Die einzelnen Prüfungen ─────────────────────────────────────────────
def pruefe_windows(version=None) -> dict: def pruefe_windows(version=None) -> dict:
"""Windows 10 oder neuer. Darunter gibt es kein WebView2.""" """Windows 10 oder neuer. Darunter gibt es kein WebView2.
if os.name != "nt":
⚠️ Ein eingespritzter `version`-Wert wird BEACHTET, auch auf Linux. Vorher
stand die Plattform-Prüfung davor, und damit war jeder eingespritzte Wert
wirkungslos: Auf dem Linux-Runner der Ampel gab `pruefe_windows(22631)`
ein FEHLER zurück, obwohl der Test genau eine gültige Build-Nummer
übergeben hatte. Ein Einspritzpunkt, der ignoriert wird, ist keiner.
"""
if version is None and os.name != "nt":
return _eintrag("Betriebssystem", FEHLER, "Kein Windows", return _eintrag("Betriebssystem", FEHLER, "Kein Windows",
"Für Linux gibt es die Docker- und die Headless-Fassung.") "Für Linux gibt es die Docker- und die Headless-Fassung.")
bau = version if version is not None else sys.getwindowsversion().build bau = version if version is not None else sys.getwindowsversion().build
@@ -96,10 +103,10 @@ def pruefe_webview(version=None) -> dict:
def pruefe_platz(ziel: str, frei_mb=None) -> dict: def pruefe_platz(ziel: str, frei_mb=None) -> dict:
"""Genug Platz am Zielort? Gemessen am nächsten vorhandenen Ordner.""" """Genug Platz am Zielort? Gemessen am nächsten vorhandenen Ordner."""
from rippy import betrieb from rippy import pfade
if frei_mb is None: if frei_mb is None:
pfad = betrieb.naechster_vorhandener(ziel) pfad = pfade.naechster_vorhandener(ziel)
if not pfad: if not pfad:
return _eintrag("Speicherplatz", FEHLER, return _eintrag("Speicherplatz", FEHLER,
"Der Ort ist nicht erreichbar: %s" % ziel, "Der Ort ist nicht erreichbar: %s" % ziel,
+94
View File
@@ -0,0 +1,94 @@
r"""Pfade nach IHREN Regeln behandeln, nicht nach denen der laufenden Maschine.
## Warum es dieses Modul gibt (28.08.2026, zweimal bezahlt)
`os.path` richtet sich nach der Plattform, auf der es gerade läuft. Das ist
fast immer richtig — und in diesem Projekt an drei Stellen falsch, weil dort
über Pfade einer ANDEREN Maschine gerechnet wird:
* `tools/katalog.py` baut Windows-Installationsorte (`C:\Program Files\…`).
Auf dem Linux-Runner der Ampel wurde daraus
`C:\Program Files (x86)\MakeMKV/makemkvcon64.exe` — ein Pfad, den es auf
keiner Maschine gibt. Fünf Tests rot, und zwar NUR auf Linux.
* `platform/verknuepfungen.py` leitet den Arbeitsordner einer `.lnk` ab. Eine
`.lnk` zeigt IMMER auf einen Windows-Pfad, auch wenn der Code gerade auf
Linux läuft. `os.path.dirname` gab dort einen leeren String.
* `betrieb.py` vergleicht Laufwerksbuchstaben und sucht das nächste
vorhandene Elternverzeichnis. Auf Linux kannte `splitdrive` kein `D:`, und
`dirname` zerlegte `C:\Users\Test\…` nicht.
Dreimal dasselbe Muster, dreimal einzeln repariert. Beim dritten Mal gehört
es an EINE Stelle — sonst kommt es ein viertes Mal wieder.
## Die Regel
**Der Pfad entscheidet, nicht der Rechner.** Ein Laufwerksbuchstabe oder ein
Backslash heißt Windows; alles andere heißt POSIX.
"""
import ntpath
import posixpath
def ist_windows_pfad(pfad: str) -> bool:
r"""`C:\…` oder irgendein Backslash — dann ist es ein Windows-Pfad."""
pfad = pfad or ""
return "\\" in pfad or (len(pfad) > 1 and pfad[1] == ":")
def _modul(pfad: str):
return ntpath if ist_windows_pfad(pfad) else posixpath
def verbinden(basis: str, *teile) -> str:
r"""Pfadteile mit dem Trenner der BASIS verbinden.
Achtung, Windows-Eigenheit: `verbinden("D:", "Rippy")` ergibt `D:Rippy`,
NICHT `D:\Rippy`. `D:` ohne Backslash bedeutet „der aktuelle Ordner auf
Laufwerk D" — `ntpath.join` setzt deshalb absichtlich keinen Trenner. Das
ist richtig so, auch wenn es überrascht.
"""
return _modul(basis).join(basis, *teile)
def ordner_von(datei: str) -> str:
"""Der Ordner einer Datei — nach dem Trenner der DATEI."""
return _modul(datei).dirname(datei)
def laufwerk_von(pfad: str) -> str:
r"""Der Laufwerksteil (`C:`, `\\server\freigabe`) — leer bei POSIX-Pfaden.
Für die Frage „liegen zwei Pfade auf demselben Laufwerk". Zwei leere
Ergebnisse heißen dabei „beide POSIX" und damit ebenfalls: dasselbe.
"""
return _modul(pfad).splitdrive(pfad)[0]
def gleiches_laufwerk(a: str, b: str) -> bool:
"""Liegen beide auf demselben Laufwerk? (ohne Rücksicht auf Groß/Klein)"""
return laufwerk_von(a).lower() == laufwerk_von(b).lower()
def naechster_vorhandener(pfad: str, existiert=None) -> str:
"""Der nächste Ordner nach oben, den es WIRKLICH gibt. "" wenn keiner.
Frisch installiert gibt es den Ablage-Ordner noch nicht — `disk_usage`
wirft dann, und im Dashboard stand „unbekannt". Der Nutzer will aber
wissen, ob auf dem LAUFWERK Platz ist, und das lässt sich beantworten.
"""
import os
existiert = existiert or os.path.isdir
modul = _modul(pfad)
pfad = (pfad or "").rstrip("\\/")
gesehen = set()
while pfad and pfad not in gesehen:
if existiert(pfad):
return pfad
gesehen.add(pfad)
eltern = modul.dirname(pfad)
if eltern == pfad: # Wurzel erreicht
break
pfad = eltern
return ""
+3 -2
View File
@@ -33,10 +33,11 @@ der Browser auf; läuft er nicht, wird er zuerst gestartet. Ein Symbol, das
beim zweiten Klick einen zweiten Server startet, wäre eine Falle. beim zweiten Klick einen zweiten Server startet, wäre eine Falle.
""" """
import ntpath
import os import os
import subprocess import subprocess
from rippy import pfade
def shell_ordner(name: str) -> str: def shell_ordner(name: str) -> str:
"""Der ECHTE Pfad eines Windows-Sonderordners („Desktop", „Programs"). """Der ECHTE Pfad eines Windows-Sonderordners („Desktop", „Programs").
@@ -90,7 +91,7 @@ def skript(lnk: str, ziel: str, argumente: str = "", icon: str = "",
# C:\Programme\Rippy\Rippy.exe einen LEEREN String zurueck, und der # C:\Programme\Rippy\Rippy.exe einen LEEREN String zurueck, und der
# Test schlug nur dort fehl. # Test schlug nur dort fehl.
zeilen.append("$s.WorkingDirectory = '%s'" zeilen.append("$s.WorkingDirectory = '%s'"
% _ps_text(arbeitsordner or ntpath.dirname(ziel))) % _ps_text(arbeitsordner or pfade.ordner_von(ziel)))
zeilen.append("$s.Save()") zeilen.append("$s.Save()")
return "; ".join(zeilen) return "; ".join(zeilen)
+54
View File
@@ -0,0 +1,54 @@
"""Pfade nach IHREN Regeln, nicht nach denen der laufenden Maschine.
Dreimal dieselbe Falle in drei Dateien (katalog, verknuepfungen, betrieb) —
jedes Mal nur auf der ANDEREN Plattform sichtbar. Diese Tests laufen ueberall
und pruefen beide Zweige, damit es kein viertes Mal gibt.
"""
from rippy import pfade
B = chr(92)
def test_windows_pfad_wird_erkannt():
assert pfade.ist_windows_pfad("C:" + B + "Users") is True
assert pfade.ist_windows_pfad("D:") is True
assert pfade.ist_windows_pfad("/usr/local/bin") is False
assert pfade.ist_windows_pfad("") is False
assert pfade.ist_windows_pfad(None) is False
def test_verbinden_folgt_dem_pfad():
assert pfade.verbinden("C:" + B + "Program Files", "MakeMKV") == \
"C:" + B + "Program Files" + B + "MakeMKV"
assert pfade.verbinden("/usr/local/bin", "makemkvcon") == \
"/usr/local/bin/makemkvcon"
def test_ordner_von_folgt_dem_pfad():
assert pfade.ordner_von("C:" + B + "R" + B + "x.exe") == "C:" + B + "R"
assert pfade.ordner_von("/opt/r/x") == "/opt/r"
def test_laufwerke_werden_unterschieden():
"""Auf dem Linux-Runner kannte `os.path.splitdrive` kein `D:` und hielt
zwei verschiedene Laufwerke fuer eines."""
assert pfade.gleiches_laufwerk("D:" + B + "Filme", "E:" + B + "Arbeit") is False
assert pfade.gleiches_laufwerk("D:" + B + "Filme", "d:" + B + "Anderes") is True
assert pfade.gleiches_laufwerk("/a", "/b") is True
def test_naechster_vorhandener_geht_nach_oben():
da = "C:" + B + "Users" + B + "Test"
assert pfade.naechster_vorhandener(
da + B + "Videos" + B + "Rippy", existiert=lambda p: p == da) == da
def test_naechster_vorhandener_auf_posix():
assert pfade.naechster_vorhandener(
"/srv/rippy/media", existiert=lambda p: p == "/srv") == "/srv"
def test_wenn_gar_nichts_existiert_wird_nichts_behauptet():
assert pfade.naechster_vorhandener("Z:" + B + "nix",
existiert=lambda p: False) == ""
+8 -34
View File
@@ -38,12 +38,12 @@ Uninstall-Zweig der Registry (`DisplayVersion`). Das ist kein Notbehelf,
sondern die einzige Stelle, an der sie unter Windows überhaupt steht. sondern die einzige Stelle, an der sie unter Windows überhaupt steht.
""" """
import ntpath
import os import os
import posixpath
import shutil import shutil
import subprocess import subprocess
from rippy import pfade
# ── Die Werkzeuge ─────────────────────────────────────────────────────── # ── Die Werkzeuge ───────────────────────────────────────────────────────
# `programme` Dateinamen, in dieser Reihenfolge probiert (64 Bit zuerst). # `programme` Dateinamen, in dieser Reihenfolge probiert (64 Bit zuerst).
# `orte` Bekannte Installationsordner, mit %UMGEBUNGSVARIABLEN%. # `orte` Bekannte Installationsordner, mit %UMGEBUNGSVARIABLEN%.
@@ -86,38 +86,12 @@ def werkzeug_ordner(basis: str = None) -> str:
return os.path.join(os.path.expanduser("~"), ".local", "share", "rippy", "tools") return os.path.join(os.path.expanduser("~"), ".local", "share", "rippy", "tools")
def verbinden(basis: str, *teile) -> str: # Die drei Pfad-Helfer stehen in `rippy.pfade` — dreimal dieselbe Falle in
r"""Pfadteile mit dem Trenner der BASIS verbinden, nicht dem des Systems. # drei Dateien war zweimal zu viel. Hier bleiben nur die Namen, damit die
# Aufrufe unten lesbar bleiben.
## Warum nicht `os.path.join` (Ampel rot 28.08.2026, Läufe 181184) verbinden = pfade.verbinden
ordner_von = pfade.ordner_von
`os.path.join` nimmt immer den Trenner der laufenden Maschine. Auf dem _sieht_nach_windows_aus = pfade.ist_windows_pfad
Linux-Runner der Ampel wurde aus `C:\Program Files (x86)\MakeMKV` und
`makemkvcon64.exe` dann:
C:\Program Files (x86)\MakeMKV/makemkvcon64.exe
Ein Pfad, den es auf keiner Maschine gibt. Fünf Tests sind daran
gescheitert — und zwar NUR auf Linux, weshalb es hier lange niemand sah.
Ein Werkzeug-Pfad beschreibt einen Ort auf der Maschine, die ihn
NENNT — nicht auf der, die gerade rechnet. Deshalb entscheidet die
Basis: Laufwerksbuchstabe oder Backslash heißt Windows.
"""
modul = ntpath if _sieht_nach_windows_aus(basis) else posixpath
return modul.join(basis, *teile)
def _sieht_nach_windows_aus(pfad: str) -> bool:
"""`C:\\…` oder irgendein Backslash — dann ist es ein Windows-Pfad."""
pfad = pfad or ""
return "\\" in pfad or (len(pfad) > 1 and pfad[1] == ":")
def ordner_von(datei: str) -> str:
"""Der Ordner einer Datei — nach dem Trenner der DATEI (siehe `verbinden`)."""
modul = ntpath if _sieht_nach_windows_aus(datei) else posixpath
return modul.dirname(datei)
def _entfalten(pfad: str, umgebung: dict) -> str: def _entfalten(pfad: str, umgebung: dict) -> str: