fix(windows): Cover, Arbeitsverzeichnis und die zurueckgelassenen _MEI-Ordner
Drei Befunde des Commanders vom 28.08.2026, alle drei gemessen.
## 1. „Was ist mit dem Cover auf Windows Rippy?"
Es gab keins, weil es keinen Treffer gab. `_scan_video` verlangte woertliche
Gleichheit:
if movie.get("title", "").lower() == kandidat.lower():
An seiner Disc gemessen:
'Evangelion 2.22' 1 Treffer Evangelion: 2.0 You Can (Not) Advance
'Evangelion' 20 Treffer irgendein Evangelion
Der EINZIGE Treffer auf den vollen Disc-Titel war der richtige Film, mit
Poster — und wurde verworfen. Jetzt zaehlt die SPEZIFITAET der Anfrage: Wer
auf den vollen Disc-Titel hoechstens drei Treffer bekommt, hat gefragt wie
jemand, der weiss was er sucht. Ergebnis an derselben Disc:
Evangelion: 2.0 You Can (Not) Advance / 2009 / 80 % / Poster + dt. Text
## 2. „Warum heisst das hier noch container platte? … Waere es moeglich das
## Arbeitsverzeichnis zu aendern? momentan geht das nicht."
Beide Haelften gehen auf EINE Zeile zurueck: `MEDIA_ROOT = "/app/media"` war
zugleich Vorgabe UND Pfadgrenze. Auf Windows gibt es den Ordner nicht:
* `/storage-targets` fing den OSError und gab still [] zurueck — die Auswahl
hatte genau einen Eintrag. Das ist „momentan geht das nicht".
* Dessen Text war fest verdrahtet „(Container-Platte)".
* `/browse` antwortete auf jeden Pfad mit 422.
Die Grenze faellt nicht weg, sie wird betriebsabhaengig: Container und
Kopflos-Betrieb bedienen ein Netz, die native App den Menschen davor.
Gemessen auf seinem PC: 7 Laufwerke zur Auswahl, X/Y/Z mit je 2,2 TB frei.
## 3. „Failed to remove temporary directory: …_MEI0000b0882"
GEMESSEN: 20 zurueckgelassene _MEI-Ordner mit 1,1 GB. Ursache: `Popen` ohne
`env=` reicht PyInstallers Auspack-Zeiger an die Kinder weiter (--dienst und
--oeffnen). Beide laufen dann im Ordner des Elternprozesses, der sich zuerst
beendet und ihn loeschen will. Waere das TEILWEISE geglueckt, haetten Dienst
und Fenster mitten im Betrieb ihre Dateien verloren.
Nebenbefund: test_setup_fenster scheiterte in jeder Umgebung ohne pywebview.
794 Tests gruen, ruff sauber.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
11001a443e
commit
35370fd552
+99
-2
@@ -131,14 +131,74 @@ def auskunft(werte: dict, container: bool = None,
|
||||
# Im Image steckt alles fest; ein Update ist ein Rebuild. Nur die
|
||||
# native Installation kann ihre Werkzeuge selbst pflegen.
|
||||
"werkzeuge_verwalten": not container,
|
||||
# Darf die Oberfläche außerhalb der Medien-Wurzel blättern?
|
||||
"frei_blaettern": frei_blaettern(werte, container),
|
||||
},
|
||||
# Was in der Oberfläche als Vorgabe stehen soll. Ein Feld, das mit
|
||||
# `/app/media` vorbelegt ist, ist auf Windows schlicht falsch.
|
||||
"ablage_vorgabe": ablage_vorgabe(werte, container, system),
|
||||
"arbeits_vorgabe": arbeits_vorgabe(werte, container, system),
|
||||
"hilfe_befehl": "docker compose -p rippy ps" if container else "",
|
||||
}
|
||||
|
||||
|
||||
def frei_blaettern(werte: dict, container: bool = None) -> bool:
|
||||
"""Darf die Oberfläche das ganze Dateisystem sehen? (Befund 28.08.2026)
|
||||
|
||||
## Warum diese Frage überhaupt gestellt werden muss
|
||||
|
||||
`MEDIA_ROOT = "/app/media"` war in `main.py` zugleich Vorgabe UND
|
||||
Pfadgrenze: `/browse`, `/storage-targets` und jede Datei-Auslieferung
|
||||
prüften `unter_wurzel(pfad, MEDIA_ROOT)`.
|
||||
|
||||
Im Container ist das genau richtig. Die API hängt dort im Netz, und eine
|
||||
Weboberfläche, die jeden Pfad des Wirts ausliefern kann, ist ein Loch.
|
||||
|
||||
Auf dem Windows-PC des Commanders war dieselbe Zeile gleich doppelt
|
||||
falsch: Den Ordner `/app/media` gibt es nicht (also war die Liste der
|
||||
Arbeitsverzeichnisse LEER — sein Befund „momentan geht das nicht"), und
|
||||
sein Ziel ist eine UNC-Freigabe, die unter gar keiner lokalen Wurzel
|
||||
liegt.
|
||||
|
||||
Die Unterscheidung ist nicht „Windows" — sie ist **wer hört zu**. Ein
|
||||
Container und der Kopflos-Betrieb bedienen ein Netz; die native App
|
||||
bedient den Menschen, der vor dem Rechner sitzt und dessen eigene Ordner
|
||||
das sind. Deshalb: kein Container UND kein verteilter Betrieb.
|
||||
"""
|
||||
container = im_container() if container is None else container
|
||||
return not container and modus(werte, container) == "standalone"
|
||||
|
||||
|
||||
def medien_wurzel(werte: dict, container: bool = None,
|
||||
plattform_name: str = None) -> str:
|
||||
"""Die Wurzel, unter der Rippy ablegt — im Container `/app/media`.
|
||||
|
||||
Eigener Name statt `ablage_vorgabe`, weil die Rolle eine andere ist: Die
|
||||
Vorgabe füllt ein Eingabefeld, die Wurzel begrenzt Pfade. Dass beide
|
||||
denselben Ort meinen, ist Absicht und kein Zufall.
|
||||
"""
|
||||
return ablage_vorgabe(werte, im_container() if container is None else container,
|
||||
plattform(plattform_name))
|
||||
|
||||
|
||||
def arbeits_vorgabe(werte: dict, container: bool = None,
|
||||
plattform_name: str = None) -> str:
|
||||
"""Wohin die Rohdaten wandern, wenn niemand etwas anderes wählt.
|
||||
|
||||
Der Roh-Rip einer 4K-UHD ist bis zu 100 GB groß. Wo der landet, ist keine
|
||||
Nebensache — am 25.07.2026 lief damit die Container-Platte voll.
|
||||
"""
|
||||
werte = werte or {}
|
||||
container = im_container() if container is None else container
|
||||
system = plattform(plattform_name)
|
||||
eigen = (werte.get("storage", {}) or {}).get("temp")
|
||||
if eigen:
|
||||
return eigen
|
||||
if container:
|
||||
return "/app/temp"
|
||||
return pfade.verbinden(ablage_vorgabe(werte, container, system), "_arbeit")
|
||||
|
||||
|
||||
def naechster_vorhandener(pfad: str, existiert=None) -> str:
|
||||
"""Der naechste vorhandene Ordner nach oben — siehe `rippy.pfade`."""
|
||||
return pfade.naechster_vorhandener(pfad, existiert)
|
||||
@@ -165,14 +225,13 @@ def platz_orte(werte: dict, container: bool = None,
|
||||
werte = werte or {}
|
||||
container = im_container() if container is None else container
|
||||
system = plattform(plattform_name)
|
||||
lager = werte.get("storage", {}) or {}
|
||||
|
||||
if container:
|
||||
return [{"name": "Media (/app/media)", "pfad": "/app/media"},
|
||||
{"name": "Arbeitsverzeichnis (/app/temp)", "pfad": "/app/temp"}]
|
||||
|
||||
ablage = ablage_vorgabe(werte, container, system)
|
||||
temp = lager.get("temp") or pfade.verbinden(ablage, "_arbeit")
|
||||
temp = arbeits_vorgabe(werte, container, system)
|
||||
orte = [{"name": "Ablage", "pfad": ablage}]
|
||||
# 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.
|
||||
@@ -185,6 +244,44 @@ def platz_orte(werte: dict, container: bool = None,
|
||||
return orte
|
||||
|
||||
|
||||
#: Rückgabewerte von GetDriveTypeW, die als Ablage taugen (learn.microsoft.com
|
||||
#: /windows/win32/api/fileapi/nf-fileapi-getdrivetypew): 3 = feste Platte,
|
||||
#: 4 = Netzlaufwerk. NICHT dabei: 2 (Wechselmedium — ein USB-Stick als
|
||||
#: Arbeitsverzeichnis für 100 GB Rohdaten ist eine Falle) und 5 (CD/DVD —
|
||||
#: dort liegt die Disc, die gerade gerippt wird).
|
||||
LAUFWERKSARTEN = (3, 4)
|
||||
|
||||
|
||||
def windows_laufwerke(art=None, buchstaben=None) -> list:
|
||||
"""Welche Laufwerksbuchstaben taugen als Ablage? — gemessen, nicht geraten.
|
||||
|
||||
Warum das gebraucht wird: Auf Windows ist die Antwort auf „wohin mit 100 GB
|
||||
Rohdaten" fast immer *ein anderes Laufwerk*. Ohne diese Liste stand im
|
||||
Auswahlfeld nur ein einziger Eintrag, und der Commander konnte nichts
|
||||
ändern.
|
||||
|
||||
`art` und `buchstaben` sind einspritzbar, damit das auf dem Linux-Runner
|
||||
der Ampel prüfbar bleibt — dort gibt es kein `GetDriveTypeW`.
|
||||
"""
|
||||
if buchstaben is None:
|
||||
buchstaben = [c + ":\\" for c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"]
|
||||
if art is None:
|
||||
try:
|
||||
import ctypes
|
||||
art = ctypes.WinDLL("kernel32").GetDriveTypeW
|
||||
art.argtypes = [ctypes.c_wchar_p]
|
||||
except (OSError, AttributeError):
|
||||
return []
|
||||
gefunden = []
|
||||
for pfad in buchstaben:
|
||||
try:
|
||||
if art(pfad) in LAUFWERKSARTEN:
|
||||
gefunden.append(pfad)
|
||||
except OSError:
|
||||
continue
|
||||
return gefunden
|
||||
|
||||
|
||||
def ablage_vorgabe(werte: dict, container: bool, system: str) -> str:
|
||||
"""Wohin Rippy standardmäßig ablegt — je Betrieb ein anderer Ort."""
|
||||
eigen = ((werte or {}).get("storage", {}) or {}).get("medien", "")
|
||||
|
||||
+224
-123
@@ -1,123 +1,224 @@
|
||||
"""Kind-Prozesse ohne Fenster — und die Ausgabe ohne Konsole.
|
||||
|
||||
Ein Konsolenfenster laesst sich in einem Test nicht ansehen. Geprueft wird
|
||||
deshalb das, woran es beim ersten Anlauf gescheitert ist: die ENTSCHEIDUNG,
|
||||
wohin die Ausgabe geht, wenn es keine Konsole gibt.
|
||||
|
||||
## Warum das nicht Nebensache ist
|
||||
|
||||
Die EXE wird seit dem 28.08.2026 als Fenster-Programm gebaut, damit beim
|
||||
Doppelklick keine schwarze Box aufgeht. Damit ist `sys.stdout` aber `None`.
|
||||
Ohne Umleitung wuerde jede Bibliothek, die auf `stderr` schreibt, mit
|
||||
`AttributeError: 'NoneType' object has no attribute 'write'` sterben — mitten
|
||||
im Start, und nirgends stuende etwas. Genau der stille Fehlschlag, vor dem
|
||||
AGENTS.md warnt.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from rippy.platform import winlauf
|
||||
|
||||
nur_windows = pytest.mark.skipif(os.name != "nt", reason="nur unter Windows")
|
||||
|
||||
|
||||
# ── Kind-Prozesse ───────────────────────────────────────────────────────
|
||||
def test_flag_ist_auf_windows_gesetzt_und_sonst_null():
|
||||
"""Auf Linux ist CREATE_NO_WINDOW nicht vorhanden; `creationflags=0` wird
|
||||
dort akzeptiert und ignoriert. So laeuft derselbe Code auf beiden Seiten."""
|
||||
if os.name == "nt":
|
||||
assert winlauf.OHNE_FENSTER == subprocess.CREATE_NO_WINDOW
|
||||
else:
|
||||
assert winlauf.OHNE_FENSTER == 0
|
||||
|
||||
|
||||
# ── Gibt es ueberhaupt eine Ausgabe? ────────────────────────────────────
|
||||
def test_fehlende_standardausgabe_wird_erkannt(monkeypatch):
|
||||
"""Der Fall in der fertigen EXE: PyInstaller setzt `sys.stdout` auf None."""
|
||||
monkeypatch.setattr(sys, "stdout", None)
|
||||
assert winlauf.ohne_konsole() is True
|
||||
|
||||
|
||||
def test_ein_strom_ohne_dateinummer_zaehlt_auch_als_ohne_konsole(monkeypatch):
|
||||
"""pytest ersetzt stdout durch einen Auffang-Puffer. Der hat kein
|
||||
`fileno` — und ein Meldungsfenster waere in einem Testlauf das Letzte,
|
||||
was jemand gebrauchen kann."""
|
||||
class Puffer:
|
||||
write = staticmethod(lambda _: None)
|
||||
|
||||
monkeypatch.setattr(sys, "stdout", Puffer())
|
||||
assert winlauf.ohne_konsole() is True
|
||||
|
||||
|
||||
def test_mit_echter_ausgabe_ist_alles_in_ordnung(monkeypatch, tmp_path):
|
||||
datei = tmp_path / "echt.txt"
|
||||
with open(datei, "w", encoding="utf-8") as f:
|
||||
monkeypatch.setattr(sys, "stdout", f)
|
||||
assert winlauf.ohne_konsole() is False
|
||||
|
||||
|
||||
# ── Die Protokolldatei ──────────────────────────────────────────────────
|
||||
def test_protokoll_liegt_bei_rippy():
|
||||
"""Nicht im TEMP: Was Rippy ueber sich aufschreibt, gehoert zu Rippy und
|
||||
verschwindet beim Deinstallieren mit."""
|
||||
pfad = winlauf.protokolldatei(os.path.join("C:" + os.sep, "Basis"))
|
||||
assert pfad.endswith(os.path.join("Rippy", "rippy.log"))
|
||||
assert pfad.startswith(os.path.join("C:" + os.sep, "Basis"))
|
||||
|
||||
|
||||
def test_umleiten_schreibt_wirklich(tmp_path, monkeypatch):
|
||||
ziel = str(tmp_path / "unterordner" / "rippy.log")
|
||||
vorher_out, vorher_err = sys.stdout, sys.stderr
|
||||
try:
|
||||
assert winlauf.ausgabe_umleiten(ziel) == ziel
|
||||
print("eine Zeile mit Umlaut: ä")
|
||||
sys.stdout.flush()
|
||||
finally:
|
||||
sys.stdout, sys.stderr = vorher_out, vorher_err
|
||||
assert "eine Zeile mit Umlaut" in open(ziel, encoding="utf-8").read()
|
||||
|
||||
|
||||
def test_umleiten_haengt_an_statt_zu_ueberschreiben(tmp_path):
|
||||
"""Sonst waere nach jedem Neustart das Protokoll des letzten Fehlers weg
|
||||
— also genau das, was man dann sucht."""
|
||||
ziel = str(tmp_path / "rippy.log")
|
||||
vorher_out, vorher_err = sys.stdout, sys.stderr
|
||||
try:
|
||||
winlauf.ausgabe_umleiten(ziel)
|
||||
print("erster Start")
|
||||
sys.stdout.flush()
|
||||
winlauf.ausgabe_umleiten(ziel)
|
||||
print("zweiter Start")
|
||||
sys.stdout.flush()
|
||||
finally:
|
||||
sys.stdout, sys.stderr = vorher_out, vorher_err
|
||||
inhalt = open(ziel, encoding="utf-8").read()
|
||||
assert "erster Start" in inhalt and "zweiter Start" in inhalt
|
||||
|
||||
|
||||
def test_unbeschreibbarer_ort_laesst_den_start_nicht_platzen(monkeypatch):
|
||||
"""Lieber ins Nichts schreiben als beim Start sterben. Ein Programm, das
|
||||
wegen seiner Protokolldatei nicht hochkommt, ist schlimmer als eines
|
||||
ohne Protokoll."""
|
||||
def geht_nicht(*a, **k):
|
||||
raise OSError("kein Platz")
|
||||
|
||||
monkeypatch.setattr(winlauf.os, "makedirs", geht_nicht)
|
||||
vorher_out, vorher_err = sys.stdout, sys.stderr
|
||||
try:
|
||||
winlauf.ausgabe_umleiten("/gibt/es/nicht/rippy.log")
|
||||
print("das darf nicht werfen")
|
||||
finally:
|
||||
sys.stdout, sys.stderr = vorher_out, vorher_err
|
||||
|
||||
|
||||
# ── Das Meldungsfenster ─────────────────────────────────────────────────
|
||||
def test_ohne_windows_kein_meldungsfenster():
|
||||
if os.name == "nt":
|
||||
pytest.skip("prueft das Verhalten auf Nicht-Windows")
|
||||
assert winlauf.meldung_zeigen("egal") is False
|
||||
assert winlauf.an_elternkonsole_haengen() is False
|
||||
"""Kind-Prozesse ohne Fenster — und die Ausgabe ohne Konsole.
|
||||
|
||||
Ein Konsolenfenster laesst sich in einem Test nicht ansehen. Geprueft wird
|
||||
deshalb das, woran es beim ersten Anlauf gescheitert ist: die ENTSCHEIDUNG,
|
||||
wohin die Ausgabe geht, wenn es keine Konsole gibt.
|
||||
|
||||
## Warum das nicht Nebensache ist
|
||||
|
||||
Die EXE wird seit dem 28.08.2026 als Fenster-Programm gebaut, damit beim
|
||||
Doppelklick keine schwarze Box aufgeht. Damit ist `sys.stdout` aber `None`.
|
||||
Ohne Umleitung wuerde jede Bibliothek, die auf `stderr` schreibt, mit
|
||||
`AttributeError: 'NoneType' object has no attribute 'write'` sterben — mitten
|
||||
im Start, und nirgends stuende etwas. Genau der stille Fehlschlag, vor dem
|
||||
AGENTS.md warnt.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from rippy.platform import winlauf
|
||||
|
||||
nur_windows = pytest.mark.skipif(os.name != "nt", reason="nur unter Windows")
|
||||
|
||||
|
||||
# ── Kind-Prozesse ───────────────────────────────────────────────────────
|
||||
def test_flag_ist_auf_windows_gesetzt_und_sonst_null():
|
||||
"""Auf Linux ist CREATE_NO_WINDOW nicht vorhanden; `creationflags=0` wird
|
||||
dort akzeptiert und ignoriert. So laeuft derselbe Code auf beiden Seiten."""
|
||||
if os.name == "nt":
|
||||
assert winlauf.OHNE_FENSTER == subprocess.CREATE_NO_WINDOW
|
||||
else:
|
||||
assert winlauf.OHNE_FENSTER == 0
|
||||
|
||||
|
||||
# ── Gibt es ueberhaupt eine Ausgabe? ────────────────────────────────────
|
||||
def test_fehlende_standardausgabe_wird_erkannt(monkeypatch):
|
||||
"""Der Fall in der fertigen EXE: PyInstaller setzt `sys.stdout` auf None."""
|
||||
monkeypatch.setattr(sys, "stdout", None)
|
||||
assert winlauf.ohne_konsole() is True
|
||||
|
||||
|
||||
def test_ein_strom_ohne_dateinummer_zaehlt_auch_als_ohne_konsole(monkeypatch):
|
||||
"""pytest ersetzt stdout durch einen Auffang-Puffer. Der hat kein
|
||||
`fileno` — und ein Meldungsfenster waere in einem Testlauf das Letzte,
|
||||
was jemand gebrauchen kann."""
|
||||
class Puffer:
|
||||
write = staticmethod(lambda _: None)
|
||||
|
||||
monkeypatch.setattr(sys, "stdout", Puffer())
|
||||
assert winlauf.ohne_konsole() is True
|
||||
|
||||
|
||||
def test_mit_echter_ausgabe_ist_alles_in_ordnung(monkeypatch, tmp_path):
|
||||
datei = tmp_path / "echt.txt"
|
||||
with open(datei, "w", encoding="utf-8") as f:
|
||||
monkeypatch.setattr(sys, "stdout", f)
|
||||
assert winlauf.ohne_konsole() is False
|
||||
|
||||
|
||||
# ── Die Protokolldatei ──────────────────────────────────────────────────
|
||||
def test_protokoll_liegt_bei_rippy():
|
||||
"""Nicht im TEMP: Was Rippy ueber sich aufschreibt, gehoert zu Rippy und
|
||||
verschwindet beim Deinstallieren mit."""
|
||||
pfad = winlauf.protokolldatei(os.path.join("C:" + os.sep, "Basis"))
|
||||
assert pfad.endswith(os.path.join("Rippy", "rippy.log"))
|
||||
assert pfad.startswith(os.path.join("C:" + os.sep, "Basis"))
|
||||
|
||||
|
||||
def test_umleiten_schreibt_wirklich(tmp_path, monkeypatch):
|
||||
ziel = str(tmp_path / "unterordner" / "rippy.log")
|
||||
vorher_out, vorher_err = sys.stdout, sys.stderr
|
||||
try:
|
||||
assert winlauf.ausgabe_umleiten(ziel) == ziel
|
||||
print("eine Zeile mit Umlaut: ä")
|
||||
sys.stdout.flush()
|
||||
finally:
|
||||
sys.stdout, sys.stderr = vorher_out, vorher_err
|
||||
assert "eine Zeile mit Umlaut" in open(ziel, encoding="utf-8").read()
|
||||
|
||||
|
||||
def test_umleiten_haengt_an_statt_zu_ueberschreiben(tmp_path):
|
||||
"""Sonst waere nach jedem Neustart das Protokoll des letzten Fehlers weg
|
||||
— also genau das, was man dann sucht."""
|
||||
ziel = str(tmp_path / "rippy.log")
|
||||
vorher_out, vorher_err = sys.stdout, sys.stderr
|
||||
try:
|
||||
winlauf.ausgabe_umleiten(ziel)
|
||||
print("erster Start")
|
||||
sys.stdout.flush()
|
||||
winlauf.ausgabe_umleiten(ziel)
|
||||
print("zweiter Start")
|
||||
sys.stdout.flush()
|
||||
finally:
|
||||
sys.stdout, sys.stderr = vorher_out, vorher_err
|
||||
inhalt = open(ziel, encoding="utf-8").read()
|
||||
assert "erster Start" in inhalt and "zweiter Start" in inhalt
|
||||
|
||||
|
||||
def test_unbeschreibbarer_ort_laesst_den_start_nicht_platzen(monkeypatch):
|
||||
"""Lieber ins Nichts schreiben als beim Start sterben. Ein Programm, das
|
||||
wegen seiner Protokolldatei nicht hochkommt, ist schlimmer als eines
|
||||
ohne Protokoll."""
|
||||
def geht_nicht(*a, **k):
|
||||
raise OSError("kein Platz")
|
||||
|
||||
monkeypatch.setattr(winlauf.os, "makedirs", geht_nicht)
|
||||
vorher_out, vorher_err = sys.stdout, sys.stderr
|
||||
try:
|
||||
winlauf.ausgabe_umleiten("/gibt/es/nicht/rippy.log")
|
||||
print("das darf nicht werfen")
|
||||
finally:
|
||||
sys.stdout, sys.stderr = vorher_out, vorher_err
|
||||
|
||||
|
||||
# ── Das Meldungsfenster ─────────────────────────────────────────────────
|
||||
def test_ohne_windows_kein_meldungsfenster():
|
||||
if os.name == "nt":
|
||||
pytest.skip("prueft das Verhalten auf Nicht-Windows")
|
||||
assert winlauf.meldung_zeigen("egal") is False
|
||||
assert winlauf.an_elternkonsole_haengen() is False
|
||||
|
||||
|
||||
# ── Der Auspack-Ordner (Befund 28.08.2026) ──────────────────────────────
|
||||
#
|
||||
# Commander, mit Bildschirmfoto:
|
||||
#
|
||||
# Failed to remove temporary directory:
|
||||
# C:\Users\TobisPC\AppData\Local\Temp\_MEI0000b0882
|
||||
#
|
||||
# „Und manchmal kommt dieser fehler."
|
||||
#
|
||||
# GEMESSEN in seinem Temp-Ordner: 20 zurueckgelassene _MEI-Ordner, zusammen
|
||||
# 1,1 GB. Der aus der Meldung liess sich hinterher anstandslos loeschen — die
|
||||
# Sperre war also voruebergehend, es ist ein Wettlauf.
|
||||
#
|
||||
# Ursache: `starte_hintergrund` und `starte_fensterprozess` starten Rippy.exe
|
||||
# erneut. `Popen` ohne `env=` reicht PyInstallers Auspack-Zeiger weiter, also
|
||||
# laufen beide Kinder im Ordner des Elternprozesses. Der beendet sich zuerst,
|
||||
# will loeschen — und die Kinder halten die DLLs offen.
|
||||
|
||||
|
||||
def test_der_auspack_zeiger_wird_dem_kind_NICHT_mitgegeben():
|
||||
"""Der Test, der 1,1 GB Reste verhindert haette."""
|
||||
eltern = {"PATH": "/usr/bin", "_MEIPASS2": "/tmp/_MEI123",
|
||||
"_PYI_APPLICATION_HOME_DIR": "/tmp/_MEI123",
|
||||
"_PYI_ARCHIVE_FILE": "/x/Rippy.exe",
|
||||
"_PYI_PARENT_PROCESS_LEVEL": "1"}
|
||||
kind = winlauf.umgebung_ohne_bundle(eltern)
|
||||
for name in winlauf.PYI_ZEIGER:
|
||||
assert name not in kind, "%s wuerde das Kind in den Ordner der Eltern schicken" % name
|
||||
assert kind["PATH"] == "/usr/bin", "der Rest der Umgebung muss bleiben"
|
||||
|
||||
|
||||
def test_die_elternumgebung_wird_nicht_veraendert():
|
||||
"""Ein `del os.environ[...]` haette den eigenen Prozess beschaedigt."""
|
||||
eltern = {"_MEIPASS2": "/tmp/_MEI123"}
|
||||
winlauf.umgebung_ohne_bundle(eltern)
|
||||
assert eltern == {"_MEIPASS2": "/tmp/_MEI123"}
|
||||
|
||||
|
||||
def test_beide_startwege_reichen_den_zeiger_nicht_weiter():
|
||||
"""Der Waechter: Wer kuenftig einen dritten Startweg baut, muss `env=`
|
||||
setzen. Sonst kommen die Reste zurueck, und zwar still."""
|
||||
import re
|
||||
|
||||
quelle = os.path.join(os.path.dirname(__file__), "..", "windows_app.py")
|
||||
with open(os.path.abspath(quelle), encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
stellen = [m.start() for m in re.finditer(r"subprocess\.Popen\(\[programm", text)]
|
||||
assert len(stellen) == 2, "Startwege haben sich geaendert: %d gefunden" % len(stellen)
|
||||
for stelle in stellen:
|
||||
aufruf = text[stelle:stelle + 300]
|
||||
assert "env=umgebung_ohne_bundle()" in aufruf, (
|
||||
"Dieser Start reicht den Auspack-Zeiger weiter:\n" + aufruf[:120])
|
||||
|
||||
|
||||
def test_aufraeumen_laesst_einen_BENUTZTEN_ordner_unangetastet(tmp_path):
|
||||
"""Die wichtigste Eigenschaft: Ein laufender Rippy darf nichts verlieren.
|
||||
|
||||
Ein blindes `rmtree(ignore_errors=True)` haette ihm die halbe Bibliothek
|
||||
weggeraeumt, bevor es an der gesperrten DLL scheitert.
|
||||
"""
|
||||
lebt = tmp_path / "_MEI111111"
|
||||
lebt.mkdir()
|
||||
(lebt / "python312.dll").write_bytes(b"MZ")
|
||||
(lebt / "wichtig.pyd").write_bytes(b"x")
|
||||
|
||||
def gesperrt(pfad):
|
||||
raise OSError(32, "Datei wird von einem anderen Prozess verwendet")
|
||||
|
||||
assert winlauf.reste_aufraeumen(str(tmp_path), eigener="",
|
||||
jetzt_loeschen=gesperrt) == 0
|
||||
assert (lebt / "wichtig.pyd").exists(), "an einem benutzten Ordner wird NICHTS angefasst"
|
||||
|
||||
|
||||
def test_aufraeumen_entfernt_die_reste(tmp_path):
|
||||
for name in ("_MEI000035002", "_MEI0000b0882"):
|
||||
ordner = tmp_path / name
|
||||
ordner.mkdir()
|
||||
(ordner / "python312.dll").write_bytes(b"MZ")
|
||||
(ordner / "base_library.zip").write_bytes(b"PK")
|
||||
|
||||
assert winlauf.reste_aufraeumen(str(tmp_path), eigener="") == 2
|
||||
assert not list(tmp_path.glob("_MEI*"))
|
||||
|
||||
|
||||
def test_aufraeumen_raeumt_den_EIGENEN_ordner_nicht_weg(tmp_path):
|
||||
"""Sonst saegte Rippy waehrend des Startens an seinem eigenen Ast."""
|
||||
eigen = tmp_path / "_MEI999999"
|
||||
eigen.mkdir()
|
||||
(eigen / "python312.dll").write_bytes(b"MZ")
|
||||
|
||||
assert winlauf.reste_aufraeumen(str(tmp_path), eigener=str(eigen)) == 0
|
||||
assert eigen.exists()
|
||||
|
||||
|
||||
def test_aufraeumen_ignoriert_fremde_ordner(tmp_path):
|
||||
"""Was nicht `_MEI` heisst, geht uns nichts an."""
|
||||
(tmp_path / "wichtige-daten").mkdir()
|
||||
assert winlauf.reste_aufraeumen(str(tmp_path), eigener="") == 0
|
||||
assert (tmp_path / "wichtige-daten").exists()
|
||||
|
||||
@@ -71,6 +71,99 @@ MB_ICONINFORMATION = 0x40
|
||||
MB_ICONERROR = 0x10
|
||||
|
||||
|
||||
#: PyInstaller schreibt den Auspack-Ordner in die eigene Umgebung. Namen je
|
||||
#: nach Fassung: `_MEIPASS2` bis 5.x, ab 6.x die drei `_PYI_*`-Variablen
|
||||
#: (pyinstaller/PyInstaller/loader/pyiboot01_bootstrap.py). Hier stehen alle,
|
||||
#: damit ein Fassungswechsel den Fehler nicht stillschweigend zurückholt.
|
||||
PYI_ZEIGER = ("_MEIPASS2", "_PYI_ARCHIVE_FILE", "_PYI_APPLICATION_HOME_DIR",
|
||||
"_PYI_PARENT_PROCESS_LEVEL")
|
||||
|
||||
|
||||
def umgebung_ohne_bundle(basis: dict = None) -> dict:
|
||||
"""Die eigene Umgebung, aber ohne PyInstallers Auspack-Zeiger.
|
||||
|
||||
## Der Befund des Commanders (28.08.2026)
|
||||
|
||||
Failed to remove temporary directory:
|
||||
C:\\Users\\TobisPC\\AppData\\Local\\Temp\\_MEI0000b0882
|
||||
|
||||
„Und manchmal kommt dieser fehler." — Manchmal stimmt: Es ist ein
|
||||
Wettlauf.
|
||||
|
||||
## Was gemessen wurde
|
||||
|
||||
In seinem Temp-Ordner lagen **20 zurückgelassene `_MEI`-Ordner mit
|
||||
zusammen 1,1 GB**. Einer davon war der aus der Meldung, und er ließ sich
|
||||
hinterher anstandslos löschen — die Sperre war also vorübergehend.
|
||||
|
||||
## Die Ursache
|
||||
|
||||
Eine Onefile-EXE packt sich beim Start nach `%TEMP%\\_MEIxxxxxx` aus und
|
||||
räumt beim Beenden auf. Den Ordner findet der Prozess über eine
|
||||
Umgebungsvariable, die PyInstaller sich selbst setzt.
|
||||
|
||||
`starte_hintergrund` und `starte_fensterprozess` starten `Rippy.exe`
|
||||
erneut — mit `--dienst` und mit `--oeffnen`, denn Tray und Fenster
|
||||
brauchen je einen eigenen Haupt-Thread. `subprocess.Popen` ohne `env=`
|
||||
reicht die ganze Umgebung weiter, also **auch diesen Zeiger**. Die beiden
|
||||
Kinder packen daraufhin gar nichts mehr aus: Sie laufen im Ordner des
|
||||
Elternprozesses.
|
||||
|
||||
Der Eltern-Prozess beendet sich als Erster und will seinen Ordner
|
||||
löschen. Die Kinder haben die DLLs darin noch offen — Windows verweigert.
|
||||
Meldung. Und weil danach niemand mehr zuständig ist, bleibt der Ordner
|
||||
für immer liegen; beim nächsten Start derselbe Ablauf.
|
||||
|
||||
Das war nicht nur unschön: Wäre das Löschen TEILWEISE geglückt, hätten
|
||||
Dienst und Fenster mitten im Betrieb ihre eigenen Dateien verloren.
|
||||
|
||||
Ohne den Zeiger packt sich jedes Kind seinen eigenen Ordner aus und räumt
|
||||
ihn selbst wieder weg. Das kostet je Start etwa eine Sekunde und ein paar
|
||||
Dutzend MB kurzzeitig — deutlich billiger als 1,1 GB Reste.
|
||||
"""
|
||||
umgebung = dict(os.environ if basis is None else basis)
|
||||
for name in PYI_ZEIGER:
|
||||
umgebung.pop(name, None)
|
||||
return umgebung
|
||||
|
||||
|
||||
def reste_aufraeumen(ordner: str = None, eigener: str = None,
|
||||
jetzt_loeschen=None) -> int:
|
||||
"""Räumt zurückgelassene `_MEI`-Ordner weg. Gibt die Anzahl zurück.
|
||||
|
||||
Der Netzfang für alles, was der Zeiger-Fix nicht mehr erzeugt, aber schon
|
||||
liegen ließ — und für abgestürzte Läufe, die es immer geben wird.
|
||||
|
||||
## Warum das gefahrlos ist
|
||||
|
||||
Ein LAUFENDER Rippy hat seine `python312.dll` offen, und Windows lässt
|
||||
eine offene Datei nicht löschen. Deshalb wird genau die zuerst versucht:
|
||||
Geht sie nicht weg, gehört der Ordner einem lebenden Prozess, und es wird
|
||||
**nichts weiter angefasst**. Ein blindes `rmtree(ignore_errors=True)`
|
||||
hätte einem laufenden Rippy die halbe Bibliothek unter den Füßen
|
||||
weggeräumt, bevor es an der gesperrten DLL scheitert.
|
||||
"""
|
||||
import glob
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
ordner = ordner or tempfile.gettempdir()
|
||||
eigener = eigener if eigener is not None else getattr(sys, "_MEIPASS", "")
|
||||
weg = 0
|
||||
for pfad in glob.glob(os.path.join(ordner, "_MEI*")):
|
||||
if not os.path.isdir(pfad) or os.path.normcase(pfad) == os.path.normcase(eigener or "\0"):
|
||||
continue
|
||||
wache = [d for d in glob.glob(os.path.join(pfad, "python3*.dll"))]
|
||||
try:
|
||||
for datei in wache:
|
||||
(jetzt_loeschen or os.remove)(datei)
|
||||
except OSError:
|
||||
continue # In Benutzung — Finger weg vom ganzen Ordner.
|
||||
shutil.rmtree(pfad, ignore_errors=True)
|
||||
weg += 1
|
||||
return weg
|
||||
|
||||
|
||||
def ohne_konsole() -> bool:
|
||||
"""Läuft dieser Prozess ohne Standardausgabe?
|
||||
|
||||
|
||||
+600
-584
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,132 @@
|
||||
"""Wo liegt die Ablage, wo die Rohdaten — und wer darf wohin blaettern?
|
||||
|
||||
## Der Befund des Commanders (28.08.2026)
|
||||
|
||||
> „Warum heißt das hier noch container platte? Er holt sich das
|
||||
> Arbeitsverzeichnis ja von der Installation. Wäre es möglich das
|
||||
> Arbeitsverzeichnis zu ändern? momentan geht das nicht."
|
||||
|
||||
Beide Haelften desselben Fehlers, und beide gehen auf EINE Zeile zurueck:
|
||||
|
||||
MEDIA_ROOT = "/app/media"
|
||||
|
||||
Sie war zugleich Vorgabe UND Pfadgrenze. Auf einem Windows-PC gibt es den
|
||||
Ordner nicht:
|
||||
|
||||
* `/storage-targets` machte `os.listdir("/app/media")`, fing den `OSError`
|
||||
und gab still `[]` zurueck. Im Auswahlfeld stand genau ein Eintrag — eine
|
||||
Auswahl ohne Auswahl. **Das ist „momentan geht das nicht".**
|
||||
* Der Text dieses einen Eintrags war fest verdrahtet „(Container-Platte)".
|
||||
**Das ist „warum heisst das hier noch container platte".**
|
||||
* `/browse` prueft `unter_wurzel(pfad, MEDIA_ROOT)` — auf Windows also 422
|
||||
fuer jeden Pfad. Der Ordner-Browser konnte gar nichts anzeigen.
|
||||
|
||||
Kein Absturz, keine Meldung. Nur eine Oberflaeche, die stillschweigend
|
||||
nichts kann.
|
||||
|
||||
## Warum die Grenze nicht einfach faellt
|
||||
|
||||
Im Container ist sie richtig. Die API haengt dort im Netz, und eine
|
||||
Weboberflaeche, die jeden Pfad des Wirts ausliefert, ist ein Loch. Die
|
||||
Unterscheidung ist deshalb nicht „Windows", sondern **wer zuhoert**: Container
|
||||
und Kopflos-Betrieb bedienen ein Netz, die native App den Menschen davor.
|
||||
|
||||
Alles hier ist eingespritzt — kein Container, kein Windows, keine Platte
|
||||
noetig.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from rippy import betrieb
|
||||
|
||||
|
||||
CONTAINER = {"profil": "standalone"}
|
||||
WINDOWS = {"profil": "standalone"}
|
||||
VERTEILT = {"profil": "api", "queue": {"treiber": "celery"}}
|
||||
|
||||
|
||||
# ── Wer darf frei blaettern ─────────────────────────────────────────────
|
||||
def test_im_container_gilt_die_wurzel():
|
||||
"""Die API haengt dort im Netz — freies Blaettern waere ein Loch."""
|
||||
assert betrieb.frei_blaettern(CONTAINER, container=True) is False
|
||||
|
||||
|
||||
def test_die_native_app_darf_frei_blaettern():
|
||||
"""Sie bedient den Menschen, der vor dem Rechner sitzt. Sein Ziel ist
|
||||
eine UNC-Freigabe, die unter gar keiner lokalen Wurzel liegt."""
|
||||
assert betrieb.frei_blaettern(WINDOWS, container=False) is True
|
||||
|
||||
|
||||
def test_der_verteilte_betrieb_darf_NICHT_frei_blaettern():
|
||||
"""Kopflos (V2-6) laeuft nativ, bedient aber ein Netz. Der Fall, an dem
|
||||
ein blosses `not container` falsch waere."""
|
||||
assert betrieb.frei_blaettern(VERTEILT, container=False) is False
|
||||
|
||||
|
||||
# ── Wo liegt was ────────────────────────────────────────────────────────
|
||||
def test_im_container_bleibt_alles_wie_es_war():
|
||||
"""Der Docker-Weg darf sich durch diese Reparatur NICHT aendern."""
|
||||
assert betrieb.medien_wurzel({}, container=True) == "/app/media"
|
||||
assert betrieb.arbeits_vorgabe({}, container=True) == "/app/temp"
|
||||
|
||||
|
||||
def test_auf_windows_kommt_der_ort_aus_der_installation():
|
||||
"""Genau das, was der Commander erwartet hat: „Er holt sich das
|
||||
Arbeitsverzeichnis ja von der Installation."""
|
||||
werte = {"storage": {"medien": "D:\\Rippy\\Medien"}}
|
||||
assert betrieb.medien_wurzel(werte, container=False,
|
||||
plattform_name="win32") == "D:\\Rippy\\Medien"
|
||||
assert betrieb.arbeits_vorgabe(werte, container=False,
|
||||
plattform_name="win32") == "D:\\Rippy\\Medien\\_arbeit"
|
||||
|
||||
|
||||
def test_ein_eigenes_arbeitsverzeichnis_gewinnt():
|
||||
"""Wer es eingestellt hat, bekommt es — auf ein anderes Laufwerk zeigend."""
|
||||
werte = {"storage": {"medien": "D:\\Rippy", "temp": "E:\\Roh"}}
|
||||
assert betrieb.arbeits_vorgabe(werte, container=False,
|
||||
plattform_name="win32") == "E:\\Roh"
|
||||
|
||||
|
||||
def test_die_auskunft_nennt_beide_orte():
|
||||
"""Ohne `arbeits_vorgabe` in der Antwort muesste die Oberflaeche wieder
|
||||
raten — und genau daraus wurde „Container-Platte"."""
|
||||
antwort = betrieb.auskunft({"storage": {"medien": "D:\\Rippy"}},
|
||||
container=False, plattform_name="win32")
|
||||
assert antwort["ablage_vorgabe"] == "D:\\Rippy"
|
||||
assert antwort["arbeits_vorgabe"] == "D:\\Rippy\\_arbeit"
|
||||
assert antwort["kann"]["frei_blaettern"] is True
|
||||
|
||||
|
||||
def test_die_auskunft_im_container_nennt_die_container_orte():
|
||||
antwort = betrieb.auskunft({}, container=True, plattform_name="linux")
|
||||
assert antwort["ablage_vorgabe"] == "/app/media"
|
||||
assert antwort["arbeits_vorgabe"] == "/app/temp"
|
||||
assert antwort["kann"]["frei_blaettern"] is False
|
||||
|
||||
|
||||
# ── Die Laufwerke ───────────────────────────────────────────────────────
|
||||
def test_nur_feste_platten_und_netzlaufwerke():
|
||||
"""Ein CD-Laufwerk als Arbeitsverzeichnis waere Unsinn — dort liegt die
|
||||
Disc, die gerade gerippt wird. Ein USB-Stick fuer 100 GB eine Falle."""
|
||||
arten = {"C:\\": 3, "D:\\": 3, "E:\\": 5, "F:\\": 2, "Z:\\": 4}
|
||||
gefunden = betrieb.windows_laufwerke(art=lambda p: arten.get(p, 1),
|
||||
buchstaben=list(arten))
|
||||
assert gefunden == ["C:\\", "D:\\", "Z:\\"]
|
||||
|
||||
|
||||
def test_ohne_windows_gibt_es_keine_laufwerke():
|
||||
"""Auf dem Linux-Runner der Ampel gibt es kein GetDriveTypeW. Das muss
|
||||
eine leere Liste sein, kein Fehler."""
|
||||
assert betrieb.windows_laufwerke(art=None, buchstaben=[]) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("werte,container,erwartet", [
|
||||
({}, True, 2), # Media + /app/temp
|
||||
({"storage": {"medien": "D:\\M"}}, False, 1), # gleiches Laufwerk
|
||||
({"storage": {"medien": "D:\\M", "temp": "E:\\R"}}, False, 2),
|
||||
])
|
||||
def test_platz_orte_zaehlen_nicht_doppelt(werte, container, erwartet):
|
||||
"""Zweimal dieselbe Zahl unter zwei Namen sieht aus wie zwei Auskuenfte
|
||||
und ist eine."""
|
||||
orte = betrieb.platz_orte(werte, container=container, plattform_name="win32")
|
||||
assert len(orte) == erwartet
|
||||
+193
-192
@@ -1,192 +1,193 @@
|
||||
"""Der Betriebsmodus und seine Faehigkeiten.
|
||||
|
||||
## Warum es diese Tests gibt
|
||||
|
||||
Commander am 28.08.2026, zum Windows-Fenster:
|
||||
|
||||
Worker erreichbar: 0 von 1
|
||||
Kein Worker antwortet — Pruefen: docker compose ps
|
||||
Container-Platte: unbekannt
|
||||
Freigaben: keine eingehaengt
|
||||
|
||||
Kein Satz davon ergibt auf einem Windows-PC einen Sinn. Die Oberflaeche hatte
|
||||
nie erfahren, worauf sie laeuft — also hat sie angenommen.
|
||||
|
||||
Alle Pruefungen hier sind eingespritzt: Jeder Betriebsfall ist pruefbar, ohne
|
||||
ihn herzustellen. Das ist der Punkt — sonst waere „laeuft im Container" nur
|
||||
auf einem Container-Rechner testbar, und genau dort faellt der Windows-Fehler
|
||||
nie auf.
|
||||
"""
|
||||
|
||||
from rippy import betrieb
|
||||
|
||||
DOCKER = {"profil": "api", "queue": {"treiber": "celery", "broker": "redis://x"}}
|
||||
WINDOWS = {"profil": "standalone", "queue": {"treiber": "lokal"}}
|
||||
|
||||
|
||||
# ── Plattform ───────────────────────────────────────────────────────────
|
||||
def test_plattform_wird_zu_einem_klaren_namen():
|
||||
assert betrieb.plattform("win32") == "windows"
|
||||
assert betrieb.plattform("linux") == "linux"
|
||||
assert betrieb.plattform("darwin") == "macos"
|
||||
|
||||
|
||||
def test_unbekannte_plattform_gilt_als_linux():
|
||||
"""FreeBSD und Verwandte verhalten sich wie Linux — und ein Rueckfall auf
|
||||
etwas Bekanntes ist besser als ein leeres Feld."""
|
||||
assert betrieb.plattform("freebsd14") == "linux"
|
||||
|
||||
|
||||
# ── Container ───────────────────────────────────────────────────────────
|
||||
def test_container_wird_gemessen_nicht_geraten():
|
||||
"""`/.dockerenv` legt Docker selbst an — verlaesslicher als eine
|
||||
Umgebungsvariable, die jeder setzen und vergessen kann."""
|
||||
assert betrieb.im_container(lambda p: p == "/.dockerenv") is True
|
||||
assert betrieb.im_container(lambda p: False) is False
|
||||
|
||||
|
||||
# ── Modus ───────────────────────────────────────────────────────────────
|
||||
def test_celery_heisst_verteilt():
|
||||
assert betrieb.modus(DOCKER) == "verteilt"
|
||||
|
||||
|
||||
def test_lokale_queue_heisst_standalone():
|
||||
assert betrieb.modus(WINDOWS) == "standalone"
|
||||
|
||||
|
||||
def test_ohne_angaben_gilt_standalone():
|
||||
"""Der harmlosere Rueckfall: Ein standalone-Rippy zeigt nichts an, was es
|
||||
nicht gibt. Andersherum entstuenden die Phantom-Worker wieder."""
|
||||
assert betrieb.modus({}) == "standalone"
|
||||
|
||||
|
||||
# ── Die Faehigkeiten — hier sass der gemeldete Fehler ───────────────────
|
||||
def test_windows_hat_KEINE_externen_worker():
|
||||
"""DER Befund: „Worker erreichbar: 0 von 1 — Kein Worker antwortet."
|
||||
Unter Windows rippt Rippy selbst; es gibt keinen zweiten, auf den man
|
||||
warten koennte. Die Meldung war nicht nur unpassend, sie war falsch."""
|
||||
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
||||
assert a["kann"]["externe_worker"] is False
|
||||
|
||||
|
||||
def test_windows_kennt_KEINE_container_pfade():
|
||||
"""`/app/media` ist ein Pfad IM Container. Auf Windows heisst der Ordner
|
||||
anders und liegt woanders — ein damit vorbelegtes Feld ist schlicht
|
||||
falsch."""
|
||||
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
||||
assert a["kann"]["container_pfade"] is False
|
||||
assert "/app/" not in a["ablage_vorgabe"]
|
||||
|
||||
|
||||
def test_windows_haengt_keine_freigaben_ein():
|
||||
"""Unter Windows gibt man einen UNC-Pfad an, fertig. Eine Maske zum
|
||||
Einhaengen waere eine Bedienung, die ins Leere fuehrt."""
|
||||
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
||||
assert a["kann"]["freigaben_einhaengen"] is False
|
||||
|
||||
|
||||
def test_windows_darf_seine_werkzeuge_selbst_pflegen():
|
||||
"""Im Image steckt alles fest, ein Update ist ein Rebuild. Nur die native
|
||||
Installation kann MakeMKV/HandBrake selbst beschaffen."""
|
||||
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
||||
assert a["kann"]["werkzeuge_verwalten"] is True
|
||||
|
||||
|
||||
def test_docker_behaelt_alles_was_es_hatte():
|
||||
"""Die Windows-Reparatur darf dem Docker-Betrieb nichts wegnehmen."""
|
||||
a = betrieb.auskunft(DOCKER, container=True, plattform_name="linux")
|
||||
assert a["kann"]["externe_worker"] is True
|
||||
assert a["kann"]["container_pfade"] is True
|
||||
assert a["kann"]["freigaben_einhaengen"] is True
|
||||
assert a["ablage_vorgabe"] == "/app/media"
|
||||
assert a["hilfe_befehl"] == "docker compose -p rippy ps"
|
||||
|
||||
|
||||
def test_der_docker_hinweis_taucht_ausserhalb_von_docker_NICHT_auf():
|
||||
"""„Pruefen: docker compose ps" auf einem Windows-PC ist ein Rat, den
|
||||
niemand befolgen kann."""
|
||||
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
||||
assert a["hilfe_befehl"] == ""
|
||||
|
||||
|
||||
def test_docker_allinone_hat_container_aber_keine_externen_worker():
|
||||
"""Der Fall, an dem ein blosser Modus-Name zerbrechen wuerde: EIN
|
||||
Container, der alles selbst macht. Er hat Container-Pfade, aber niemanden,
|
||||
der von aussen Jobs uebernimmt."""
|
||||
a = betrieb.auskunft({"profil": "standalone", "queue": {"treiber": "lokal"}},
|
||||
container=True, plattform_name="linux")
|
||||
assert a["kann"]["container_pfade"] is True
|
||||
assert a["kann"]["externe_worker"] is False
|
||||
|
||||
|
||||
# ── Die Ablage-Vorgabe ──────────────────────────────────────────────────
|
||||
def test_eingestellte_ablage_schlaegt_jede_vorgabe():
|
||||
a = betrieb.auskunft({"storage": {"medien": "D:\\Filme"}},
|
||||
container=False, plattform_name="win32")
|
||||
assert a["ablage_vorgabe"] == "D:\\Filme"
|
||||
|
||||
|
||||
def test_windows_vorgabe_liegt_beim_nutzer(monkeypatch):
|
||||
monkeypatch.setenv("USERPROFILE", "C:\\Users\\Test")
|
||||
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
||||
assert a["ablage_vorgabe"].startswith("C:\\Users\\Test")
|
||||
assert a["ablage_vorgabe"].endswith("Rippy")
|
||||
|
||||
|
||||
# ── Der Vertrag als Ganzes ──────────────────────────────────────────────
|
||||
def test_die_auskunft_ist_vollstaendig():
|
||||
"""Ein fehlendes Feld waere im UI ein `undefined` — und `undefined` ist
|
||||
in JavaScript falsch, also verschwaende die Oberflaeche stillschweigend
|
||||
einen Bereich. Lieber hier rot."""
|
||||
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
||||
assert set(a) == {"modus", "plattform", "im_container", "kann",
|
||||
"ablage_vorgabe", "hilfe_befehl"}
|
||||
assert set(a["kann"]) == {"externe_worker", "freigaben_einhaengen",
|
||||
"container_pfade", "werkzeuge_verwalten"}
|
||||
assert all(isinstance(v, bool) for v in a["kann"].values())
|
||||
|
||||
|
||||
# ── Wo Platz gemessen wird ──────────────────────────────────────────────
|
||||
def test_windows_misst_NICHT_in_app_media():
|
||||
"""DER Befund: Im Windows-Fenster stand „Platz fuer Rippy: unbekannt".
|
||||
|
||||
`main.py` mass fest an `/app/media` und `/app/temp`. Beide gibt es unter
|
||||
Windows nicht, `shutil.disk_usage` warf, die Liste blieb leer — und aus
|
||||
einer Nichtauskunft wurde eine Anzeige, die wie eine Auskunft aussieht.
|
||||
"""
|
||||
orte = betrieb.platz_orte(WINDOWS, container=False, plattform_name="win32")
|
||||
assert orte, "es muss mindestens ein Ort gemessen werden"
|
||||
assert all("/app/" not in o["pfad"] for o in orte), orte
|
||||
|
||||
|
||||
def test_docker_misst_weiter_die_container_pfade():
|
||||
orte = betrieb.platz_orte(DOCKER, container=True, plattform_name="linux")
|
||||
assert [o["pfad"] for o in orte] == ["/app/media", "/app/temp"]
|
||||
|
||||
|
||||
def test_gleiches_laufwerk_wird_nicht_doppelt_gezaehlt():
|
||||
"""Zweimal dieselbe Zahl unter zwei Namen sieht aus wie zwei Auskuenfte
|
||||
und ist eine."""
|
||||
orte = betrieb.platz_orte({"storage": {"medien": r"D:\Filme"}},
|
||||
container=False, plattform_name="win32")
|
||||
assert len(orte) == 1, orte
|
||||
|
||||
|
||||
def test_zweites_laufwerk_bekommt_eine_eigene_zeile():
|
||||
orte = betrieb.platz_orte(
|
||||
{"storage": {"medien": r"D:\Filme", "temp": r"E:\Arbeit"}},
|
||||
container=False, plattform_name="win32")
|
||||
assert len(orte) == 2, orte
|
||||
|
||||
|
||||
def test_noch_nicht_angelegter_ordner_faellt_auf_das_laufwerk_zurueck():
|
||||
"""Frisch installiert gibt es den Ablage-Ordner noch nicht. Der Nutzer
|
||||
will trotzdem wissen, ob auf dem LAUFWERK Platz ist."""
|
||||
da = r"C:\Users\Test"
|
||||
assert betrieb.naechster_vorhandener(
|
||||
r"C:\Users\Test\Videos\Rippy", existiert=lambda p: p == da) == da
|
||||
|
||||
|
||||
def test_wenn_gar_nichts_existiert_wird_nichts_behauptet():
|
||||
assert betrieb.naechster_vorhandener(r"Z:\gibt\es\nicht",
|
||||
existiert=lambda p: False) == ""
|
||||
"""Der Betriebsmodus und seine Faehigkeiten.
|
||||
|
||||
## Warum es diese Tests gibt
|
||||
|
||||
Commander am 28.08.2026, zum Windows-Fenster:
|
||||
|
||||
Worker erreichbar: 0 von 1
|
||||
Kein Worker antwortet — Pruefen: docker compose ps
|
||||
Container-Platte: unbekannt
|
||||
Freigaben: keine eingehaengt
|
||||
|
||||
Kein Satz davon ergibt auf einem Windows-PC einen Sinn. Die Oberflaeche hatte
|
||||
nie erfahren, worauf sie laeuft — also hat sie angenommen.
|
||||
|
||||
Alle Pruefungen hier sind eingespritzt: Jeder Betriebsfall ist pruefbar, ohne
|
||||
ihn herzustellen. Das ist der Punkt — sonst waere „laeuft im Container" nur
|
||||
auf einem Container-Rechner testbar, und genau dort faellt der Windows-Fehler
|
||||
nie auf.
|
||||
"""
|
||||
|
||||
from rippy import betrieb
|
||||
|
||||
DOCKER = {"profil": "api", "queue": {"treiber": "celery", "broker": "redis://x"}}
|
||||
WINDOWS = {"profil": "standalone", "queue": {"treiber": "lokal"}}
|
||||
|
||||
|
||||
# ── Plattform ───────────────────────────────────────────────────────────
|
||||
def test_plattform_wird_zu_einem_klaren_namen():
|
||||
assert betrieb.plattform("win32") == "windows"
|
||||
assert betrieb.plattform("linux") == "linux"
|
||||
assert betrieb.plattform("darwin") == "macos"
|
||||
|
||||
|
||||
def test_unbekannte_plattform_gilt_als_linux():
|
||||
"""FreeBSD und Verwandte verhalten sich wie Linux — und ein Rueckfall auf
|
||||
etwas Bekanntes ist besser als ein leeres Feld."""
|
||||
assert betrieb.plattform("freebsd14") == "linux"
|
||||
|
||||
|
||||
# ── Container ───────────────────────────────────────────────────────────
|
||||
def test_container_wird_gemessen_nicht_geraten():
|
||||
"""`/.dockerenv` legt Docker selbst an — verlaesslicher als eine
|
||||
Umgebungsvariable, die jeder setzen und vergessen kann."""
|
||||
assert betrieb.im_container(lambda p: p == "/.dockerenv") is True
|
||||
assert betrieb.im_container(lambda p: False) is False
|
||||
|
||||
|
||||
# ── Modus ───────────────────────────────────────────────────────────────
|
||||
def test_celery_heisst_verteilt():
|
||||
assert betrieb.modus(DOCKER) == "verteilt"
|
||||
|
||||
|
||||
def test_lokale_queue_heisst_standalone():
|
||||
assert betrieb.modus(WINDOWS) == "standalone"
|
||||
|
||||
|
||||
def test_ohne_angaben_gilt_standalone():
|
||||
"""Der harmlosere Rueckfall: Ein standalone-Rippy zeigt nichts an, was es
|
||||
nicht gibt. Andersherum entstuenden die Phantom-Worker wieder."""
|
||||
assert betrieb.modus({}) == "standalone"
|
||||
|
||||
|
||||
# ── Die Faehigkeiten — hier sass der gemeldete Fehler ───────────────────
|
||||
def test_windows_hat_KEINE_externen_worker():
|
||||
"""DER Befund: „Worker erreichbar: 0 von 1 — Kein Worker antwortet."
|
||||
Unter Windows rippt Rippy selbst; es gibt keinen zweiten, auf den man
|
||||
warten koennte. Die Meldung war nicht nur unpassend, sie war falsch."""
|
||||
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
||||
assert a["kann"]["externe_worker"] is False
|
||||
|
||||
|
||||
def test_windows_kennt_KEINE_container_pfade():
|
||||
"""`/app/media` ist ein Pfad IM Container. Auf Windows heisst der Ordner
|
||||
anders und liegt woanders — ein damit vorbelegtes Feld ist schlicht
|
||||
falsch."""
|
||||
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
||||
assert a["kann"]["container_pfade"] is False
|
||||
assert "/app/" not in a["ablage_vorgabe"]
|
||||
|
||||
|
||||
def test_windows_haengt_keine_freigaben_ein():
|
||||
"""Unter Windows gibt man einen UNC-Pfad an, fertig. Eine Maske zum
|
||||
Einhaengen waere eine Bedienung, die ins Leere fuehrt."""
|
||||
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
||||
assert a["kann"]["freigaben_einhaengen"] is False
|
||||
|
||||
|
||||
def test_windows_darf_seine_werkzeuge_selbst_pflegen():
|
||||
"""Im Image steckt alles fest, ein Update ist ein Rebuild. Nur die native
|
||||
Installation kann MakeMKV/HandBrake selbst beschaffen."""
|
||||
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
||||
assert a["kann"]["werkzeuge_verwalten"] is True
|
||||
|
||||
|
||||
def test_docker_behaelt_alles_was_es_hatte():
|
||||
"""Die Windows-Reparatur darf dem Docker-Betrieb nichts wegnehmen."""
|
||||
a = betrieb.auskunft(DOCKER, container=True, plattform_name="linux")
|
||||
assert a["kann"]["externe_worker"] is True
|
||||
assert a["kann"]["container_pfade"] is True
|
||||
assert a["kann"]["freigaben_einhaengen"] is True
|
||||
assert a["ablage_vorgabe"] == "/app/media"
|
||||
assert a["hilfe_befehl"] == "docker compose -p rippy ps"
|
||||
|
||||
|
||||
def test_der_docker_hinweis_taucht_ausserhalb_von_docker_NICHT_auf():
|
||||
"""„Pruefen: docker compose ps" auf einem Windows-PC ist ein Rat, den
|
||||
niemand befolgen kann."""
|
||||
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
||||
assert a["hilfe_befehl"] == ""
|
||||
|
||||
|
||||
def test_docker_allinone_hat_container_aber_keine_externen_worker():
|
||||
"""Der Fall, an dem ein blosser Modus-Name zerbrechen wuerde: EIN
|
||||
Container, der alles selbst macht. Er hat Container-Pfade, aber niemanden,
|
||||
der von aussen Jobs uebernimmt."""
|
||||
a = betrieb.auskunft({"profil": "standalone", "queue": {"treiber": "lokal"}},
|
||||
container=True, plattform_name="linux")
|
||||
assert a["kann"]["container_pfade"] is True
|
||||
assert a["kann"]["externe_worker"] is False
|
||||
|
||||
|
||||
# ── Die Ablage-Vorgabe ──────────────────────────────────────────────────
|
||||
def test_eingestellte_ablage_schlaegt_jede_vorgabe():
|
||||
a = betrieb.auskunft({"storage": {"medien": "D:\\Filme"}},
|
||||
container=False, plattform_name="win32")
|
||||
assert a["ablage_vorgabe"] == "D:\\Filme"
|
||||
|
||||
|
||||
def test_windows_vorgabe_liegt_beim_nutzer(monkeypatch):
|
||||
monkeypatch.setenv("USERPROFILE", "C:\\Users\\Test")
|
||||
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
||||
assert a["ablage_vorgabe"].startswith("C:\\Users\\Test")
|
||||
assert a["ablage_vorgabe"].endswith("Rippy")
|
||||
|
||||
|
||||
# ── Der Vertrag als Ganzes ──────────────────────────────────────────────
|
||||
def test_die_auskunft_ist_vollstaendig():
|
||||
"""Ein fehlendes Feld waere im UI ein `undefined` — und `undefined` ist
|
||||
in JavaScript falsch, also verschwaende die Oberflaeche stillschweigend
|
||||
einen Bereich. Lieber hier rot."""
|
||||
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
||||
assert set(a) == {"modus", "plattform", "im_container", "kann",
|
||||
"ablage_vorgabe", "arbeits_vorgabe", "hilfe_befehl"}
|
||||
assert set(a["kann"]) == {"externe_worker", "freigaben_einhaengen",
|
||||
"container_pfade", "werkzeuge_verwalten",
|
||||
"frei_blaettern"}
|
||||
assert all(isinstance(v, bool) for v in a["kann"].values())
|
||||
|
||||
|
||||
# ── Wo Platz gemessen wird ──────────────────────────────────────────────
|
||||
def test_windows_misst_NICHT_in_app_media():
|
||||
"""DER Befund: Im Windows-Fenster stand „Platz fuer Rippy: unbekannt".
|
||||
|
||||
`main.py` mass fest an `/app/media` und `/app/temp`. Beide gibt es unter
|
||||
Windows nicht, `shutil.disk_usage` warf, die Liste blieb leer — und aus
|
||||
einer Nichtauskunft wurde eine Anzeige, die wie eine Auskunft aussieht.
|
||||
"""
|
||||
orte = betrieb.platz_orte(WINDOWS, container=False, plattform_name="win32")
|
||||
assert orte, "es muss mindestens ein Ort gemessen werden"
|
||||
assert all("/app/" not in o["pfad"] for o in orte), orte
|
||||
|
||||
|
||||
def test_docker_misst_weiter_die_container_pfade():
|
||||
orte = betrieb.platz_orte(DOCKER, container=True, plattform_name="linux")
|
||||
assert [o["pfad"] for o in orte] == ["/app/media", "/app/temp"]
|
||||
|
||||
|
||||
def test_gleiches_laufwerk_wird_nicht_doppelt_gezaehlt():
|
||||
"""Zweimal dieselbe Zahl unter zwei Namen sieht aus wie zwei Auskuenfte
|
||||
und ist eine."""
|
||||
orte = betrieb.platz_orte({"storage": {"medien": r"D:\Filme"}},
|
||||
container=False, plattform_name="win32")
|
||||
assert len(orte) == 1, orte
|
||||
|
||||
|
||||
def test_zweites_laufwerk_bekommt_eine_eigene_zeile():
|
||||
orte = betrieb.platz_orte(
|
||||
{"storage": {"medien": r"D:\Filme", "temp": r"E:\Arbeit"}},
|
||||
container=False, plattform_name="win32")
|
||||
assert len(orte) == 2, orte
|
||||
|
||||
|
||||
def test_noch_nicht_angelegter_ordner_faellt_auf_das_laufwerk_zurueck():
|
||||
"""Frisch installiert gibt es den Ablage-Ordner noch nicht. Der Nutzer
|
||||
will trotzdem wissen, ob auf dem LAUFWERK Platz ist."""
|
||||
da = r"C:\Users\Test"
|
||||
assert betrieb.naechster_vorhandener(
|
||||
r"C:\Users\Test\Videos\Rippy", existiert=lambda p: p == da) == da
|
||||
|
||||
|
||||
def test_wenn_gar_nichts_existiert_wird_nichts_behauptet():
|
||||
assert betrieb.naechster_vorhandener(r"Z:\gibt\es\nicht",
|
||||
existiert=lambda p: False) == ""
|
||||
|
||||
@@ -352,10 +352,12 @@ def ui_auspacken(ziel: str) -> str:
|
||||
|
||||
def starte_hintergrund(programm: str) -> None:
|
||||
"""Startet Rippy als Hintergrundprozess — ohne aufblitzendes Fenster."""
|
||||
from rippy.platform.winlauf import OHNE_FENSTER
|
||||
from rippy.platform.winlauf import OHNE_FENSTER, umgebung_ohne_bundle
|
||||
|
||||
# `env=` ist hier kein Beiwerk: Ohne die Bereinigung liefe dieses Kind im
|
||||
# Auspack-Ordner des Elternprozesses. Begruendung in winlauf.py.
|
||||
subprocess.Popen([programm, "--dienst"], creationflags=OHNE_FENSTER,
|
||||
close_fds=True)
|
||||
close_fds=True, env=umgebung_ohne_bundle())
|
||||
|
||||
|
||||
def starte_fensterprozess(programm: str = None) -> None:
|
||||
@@ -370,10 +372,11 @@ def starte_fensterprozess(programm: str = None) -> None:
|
||||
Fenster ist der Client davor. Beide heissen im Taskmanager „Rippy.exe",
|
||||
und das Fenster laesst sich schliessen, ohne den Dienst mitzureissen.
|
||||
"""
|
||||
from rippy.platform.winlauf import OHNE_FENSTER
|
||||
from rippy.platform.winlauf import OHNE_FENSTER, umgebung_ohne_bundle
|
||||
|
||||
subprocess.Popen([programm or _eigene_datei(), "--oeffnen"],
|
||||
creationflags=OHNE_FENSTER, close_fds=True)
|
||||
creationflags=OHNE_FENSTER, close_fds=True,
|
||||
env=umgebung_ohne_bundle())
|
||||
|
||||
|
||||
def deinstallieren(still: bool = False) -> None:
|
||||
@@ -584,6 +587,18 @@ class Dienst:
|
||||
return False
|
||||
|
||||
|
||||
def _reste_wegraeumen() -> None:
|
||||
"""Zurueckgelassene Auspack-Ordner entfernen. Darf nie stoeren."""
|
||||
try:
|
||||
from rippy.platform.winlauf import reste_aufraeumen
|
||||
|
||||
anzahl = reste_aufraeumen()
|
||||
if anzahl:
|
||||
melden(" %d zurueckgelassene Auspack-Ordner entfernt." % anzahl)
|
||||
except Exception: # noqa: BLE001
|
||||
pass # Aufraeumen ist Kuer. Es darf den Dienst nicht kosten.
|
||||
|
||||
|
||||
def dienst_starten(port: int = STANDARD_PORT) -> int:
|
||||
"""Server im Hintergrund-Thread, Tray-Symbol im Vordergrund.
|
||||
|
||||
@@ -591,6 +606,13 @@ def dienst_starten(port: int = STANDARD_PORT) -> int:
|
||||
laufen, sonst bekommt es keine Fensternachrichten und das Symbol
|
||||
erscheint nie.
|
||||
"""
|
||||
# Reste frueherer Laeufe wegraeumen — nebenher, damit der Start nicht
|
||||
# darauf wartet. Beim Commander lagen 20 solcher Ordner mit 1,1 GB
|
||||
# herum (Befund 28.08.2026); die Ursache ist mit `umgebung_ohne_bundle`
|
||||
# behoben, das Liegengebliebene raeumt niemand sonst weg.
|
||||
threading.Thread(target=_reste_wegraeumen, daemon=True,
|
||||
name="rippy-aufraeumen").start()
|
||||
|
||||
dienst = Dienst(port)
|
||||
threading.Thread(target=dienst.server_starten, daemon=True,
|
||||
name="rippy-server").start()
|
||||
|
||||
Reference in New Issue
Block a user