fix(windows): Vollstaendiger Rundgang durch die Docker-Reste
Ampel / ampel (push) Successful in 1m20s

Commander: „Bro, du musst alles was rippy jetzt im code hat für Windows
Bauen! Jeden pfad, alles wo die tools drauf zugreifen. Diese Rippy version
MUSS 100% Windows Kompatibel sein. Prüfe bitte den kompletten Quellcode nach
Docker Resten."

Systematisch gesucht statt Fundstelle fuer Fundstelle: feste POSIX-Pfade,
Linux-Programme, POSIX-eigene Aufrufe, `shutil.which`, `posixpath` auf echten
Pfaden, Container-Texte. Sechs echte Fehler dabei.

## 1. `/dev/{name}` in drei Endpunkten — der schwerste

Das UI ruft `/devices/{id}/eject`, `/scan-tracks` und `/tracks` mit der
Kennung aus der Geraeteliste auf, unter Windows also `G`. Gebaut wurde daraus
`/dev/G` — steht in keiner Laufwerksliste. **Auswerfen und „Disc scannen"
antworteten unter Windows IMMER mit 404**, ohne dass irgendwo stand, warum.

Hin- und Rueckweg gehoeren zusammen: Beide Treiber haben jetzt `kennung()`
und `pfad_zu_kennung()`. Wer die Kennung vergibt, loest sie auch auf.

## 2. `os.path.isdir("/app")` — zum zweiten Mal

Nach `caps.py` (heute frueh) auch in `ablauf.py`: Der eigenstaendige
Windows-Rippy hielt sich fuer einen FREMDEN Worker und haette sich selbst
vorgeworfen, Container-Pfade nicht zu erreichen — auf einer Maschine ohne
Container. Die Entscheidung ist jetzt einspritzbar; vorher hing der Test
daran, ob es einen Ordner `/app` gibt.

## 3. `shutil.which` in `schluessel.py`

Ausgerechnet im Modul, das es NUR unter Windows gibt: Es suchte makemkvcon im
PATH, wo unter Windows nie ein Programm aus „Programme" steht. Die
Schluessel-Automatik fuer 4K-UHD lief damit nie an.

## 4. `posixpath.join` auf echten Pfaden

`rohdaten.py` baute `C:\Roh/datei.mkv` — gemischte Trenner, die im UI falsch
aussehen und jeden Vergleich brechen.

## 5. Container-Pfad in einer Nutzermeldung

„Roh-Datei bleibt in /app/temp erhalten" nennt jetzt den echten Ordner. Wer
die Datei retten will, sucht sonst am falschen Ort.

## 6. Container-Pfade als UI-Vorbelegung

Rip-Dialog und `useBetrieb` starteten mit `/app/media`, bis die Antwort da
war. Leer ist ehrlicher: Es behauptet nichts.

## Und HandBrakes „Code 0"

Code 0 heisst ERFOLG. Rippy meldete trotzdem „fehlgeschlagen", weil die Datei
nicht am erwarteten Ort lag: **HandBrake bestimmt den Container aus dem
PRESET, nicht aus der Endung** — ein MP4-Preset schreibt `.mp4` neben das
verlangte `.mkv`. Jetzt erzwingt `--format` den Container passend zur Endung
(an HandBrake 1.11.2 gegengeprueft), und falls doch etwas daneben liegt, wird
es gefunden statt weggeworfen.

## Der Waechter

`test_keine_container_reste.py` prueft mechanisch, dass im Windows-Weg kein
Container-Pfad ohne Begruendung steht. Die Ausnahmen stehen namentlich mit
Grund da (Linux-Zweige, benannte Rueckfaelle) — und ein zweiter Test wirft
jede Ausnahme raus, die niemand mehr braucht.

Ueber den Tokenizer, nicht ueber „faengt mit Anfuehrungszeichen an": Der
erste Anlauf blieb prompt an seinem eigenen `r\"\"\"`-Docstring haengen.

887 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-29 16:14:10 +02:00
co-authored by Claude Opus 5
parent 1a529f4e75
commit 1b84ec2a45
11 changed files with 384 additions and 23 deletions
+28 -7
View File
@@ -1473,12 +1473,35 @@ async def storage_targets():
return await asyncio.to_thread(sammle)
def geraetepfad(name: str) -> str:
"""Die Kennung aus der URL zum echten Geraetepfad — oder 404.
## Der Befund (29.08.2026)
Hier stand an DREI Stellen:
device_path = f"/dev/{name}"
Das UI ruft diese Endpunkte mit der Kennung aus der Geraeteliste auf —
unter Windows also `G`. Gebaut wurde daraus `/dev/G`, und das steht in
keiner Laufwerksliste. **Auswerfen und „Disc scannen" antworteten unter
Windows also immer mit 404**, ohne dass irgendwo stuende, warum.
Aufgeloest wird jetzt vom TREIBER: Er vergibt die Kennung (`kennung`),
also loest er sie auch wieder auf. Hin- und Rueckweg koennen damit nicht
mehr auseinanderlaufen.
"""
pfad = device_discovery.pfad_zu_kennung(name)
if not pfad:
raise HTTPException(status_code=404,
detail=f"Laufwerk {name} nicht gefunden")
return pfad
@app.post("/devices/{name}/eject")
async def eject_device(name: str):
"""Wirft die Disc aus. Verweigert, wenn auf dem Gerät gerade ein Job läuft."""
device_path = f"/dev/{name}"
if device_path not in device_discovery.list_optical_devices():
raise HTTPException(status_code=404, detail=f"Laufwerk {device_path} nicht gefunden")
device_path = geraetepfad(name)
if await asyncio.to_thread(db.has_active_job, device_path):
raise HTTPException(
status_code=409, detail="Auf diesem Laufwerk läuft gerade ein Job"
@@ -1498,9 +1521,7 @@ async def scan_tracks_starten(name: str):
Läuft als Worker-Task (nur der hat makemkvcon + Laufwerk); das UI pollt
GET /devices/{name}/tracks. Dauert je nach Disc 20120 s.
"""
device_path = f"/dev/{name}"
if device_path not in device_discovery.list_optical_devices():
raise HTTPException(status_code=404, detail=f"Laufwerk {device_path} nicht gefunden")
device_path = geraetepfad(name)
if await asyncio.to_thread(db.has_active_job, device_path):
raise HTTPException(status_code=409, detail="Auf diesem Laufwerk läuft gerade ein Job")
@@ -1512,7 +1533,7 @@ async def scan_tracks_starten(name: str):
@app.get("/devices/{name}/tracks")
async def scan_tracks_ergebnis(name: str):
"""Ergebnis des Titel-Scans (Polling-Ziel des UI)."""
device_path = f"/dev/{name}"
device_path = geraetepfad(name)
daten = await asyncio.to_thread(db.get_settings, f"tracks:{device_path}")
if not daten:
return {"status": "none"}
+7 -2
View File
@@ -38,9 +38,10 @@ Verwechslungsgefahr gibt es dabei nicht: Roh-Verzeichnisse heißen exakt wie die
Job-ID (vollständige UUID), fertige Ablagen heißen `Titel (Jahr) [kurz-id]`.
"""
import posixpath
import subprocess
from rippy import pfade as _pfade
# Container-Standard für Roh-Rips (RAW_DIR im Worker). Bleibt als Rueckfall
# stehen — die WURZELN dieses Betriebs liefert `wurzeln()`.
RAW_STANDARD = "/app/temp/raw"
@@ -246,7 +247,11 @@ def groesse(pfade: list, listdir, isfile, getsize) -> tuple:
except OSError:
continue
for name in namen:
voll = posixpath.join(pfad, name)
# `pfade.verbinden` statt posixpath: Auf Windows ist `pfad`
# ein echter Windows-Pfad, und `posixpath.join` baute daraus
# `C:\Roh/datei.mkv` — gemischte Trenner, die im UI falsch
# aussehen und jeden Vergleich brechen (Befund 29.08.2026).
voll = _pfade.verbinden(pfad, name)
try:
if isfile(voll):
bytes_gesamt += getsize(voll)
+10 -6
View File
@@ -162,11 +162,15 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
// auf einem Windows-PC eine Ortsangabe fuer einen Ort, den es nicht gibt.
const betrieb = useBetrieb()
const [targets, setTargets] = useState<TargetConfig[]>([
{ id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', isActive: true },
{ id: '2', name: 'Serien', path: '/app/media/series', type: 'series', isActive: true },
{ id: '3', name: 'Musik', path: '/app/media/music', type: 'music', isActive: true },
])
/*
* Leer starten statt mit Container-Pfaden (Befund 29.08.2026).
*
* Hier standen `/app/media/movies` & Co. als Anfangswert. Auf einem
* Windows-PC gibt es die nicht — und fuer den Augenblick zwischen Oeffnen
* des Dialogs und der Antwort von /settings stand dort ein Pfad, den es
* nirgends gibt. Ein leerer Wert ist ehrlicher: Er behauptet nichts.
*/
const [targets, setTargets] = useState<TargetConfig[]>([])
const [browsePath, setBrowsePath] = useState<string>('')
const [browseParent, setBrowseParent] = useState<string | null>(null)
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
@@ -223,7 +227,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
.split(',').map((t: string) => t.trim().toLowerCase()).filter(Boolean)
setStandardAudio(codes(s.audioSprachen))
setStandardUntertitel(codes(s.untertitelSprachen))
const basis = s.outputDir || betrieb.ablage_vorgabe || '/app/media'
const basis = s.outputDir || betrieb.ablage_vorgabe
setTargets([
{ id: '1', name: 'Filme', path: `${basis}/${s.movieDir || 'movies'}`, type: 'movies', isActive: true },
{ id: '2', name: 'Serien', path: `${basis}/${s.seriesDir || 'series'}`, type: 'series', isActive: true },
+5 -2
View File
@@ -77,8 +77,11 @@ const VORGABE: Betrieb = {
werkzeuge_verwalten: false,
frei_blaettern: false,
},
ablage_vorgabe: '/app/media',
arbeits_vorgabe: '/app/temp',
// Leer statt Container-Pfade: Bis /betrieb geantwortet hat, WISSEN wir den
// Ort nicht. Ein leeres Feld sagt das; „/app/media" behauptet etwas, das
// auf einem Windows-PC falsch ist (Befund 29.08.2026).
ablage_vorgabe: '',
arbeits_vorgabe: '',
hilfe_befehl: 'docker compose -p rippy ps',
geladen: false,
}
+21 -3
View File
@@ -867,7 +867,8 @@ def erster_vorhandener_ordner(pfad: str, isdir=None, dirname=None) -> str:
def _erreichbarkeit_pruefen(raw_container: str, raw_lokal: str,
final_container: str, final_lokal: str) -> str:
final_container: str, final_lokal: str,
fremd=None) -> str:
"""Kann DIESER Worker Quelle und Ziel überhaupt sehen? Klartext oder "".
Befund 25.07.2026 am Job 95afdc89, live: Das gezielte Routing an den
@@ -889,7 +890,20 @@ def _erreichbarkeit_pruefen(raw_container: str, raw_lokal: str,
Quelle — sonst scheitert es erst beim Schreiben, nach Stunden Rechenzeit.
"""
mapping = os.getenv("RIPPY_PATH_MAP", "")
fremder_worker = not os.path.isdir("/app")
# ⚠️ Hier stand `not os.path.isdir("/app")` (Befund 29.08.2026) — dasselbe
# Muster wie in `caps.py`. Im Image stimmt es; auf einem Windows-PC gibt
# es `/app` nicht, und der eigenstaendige Rippy hielt sich fuer einen
# FREMDEN Worker. Er haette sich dann selbst vorgeworfen, die
# Container-Pfade nicht zu erreichen — auf einer Maschine ohne Container.
#
# `fremd` ist einspritzbar, damit BEIDE Faelle ueberall pruefbar sind:
# Vorher haing der Test daran, ob es einen Ordner `/app` gibt, und war
# damit an den laufenden Rechner gebunden.
if fremd is None:
from caps import _extern
fremd = _extern() == "ja"
fremder_worker = fremd
for zweck, container, lokal in (
("die Quelle (Rohdaten)", raw_container, raw_lokal),
@@ -1067,7 +1081,11 @@ def komprimieren(job_id: str, raw_dir: str, final_dir: str):
"status": "error",
"error": (
f"Kompression fehlgeschlagen bei {os.path.basename(quelle)}: "
f"{hb.get('error')} — Roh-Datei bleibt in /app/temp erhalten"
# Den ECHTEN Ordner nennen, nicht „/app/temp" (Befund
# 29.08.2026). Auf Windows gibt es den nicht, und wer die
# Roh-Datei retten will, sucht sonst am falschen Ort.
f"{hb.get('error')} — Roh-Datei bleibt unter "
f"{os.path.dirname(quelle)} erhalten"
),
}
_job_abschliessen(job_id, ergebnis)
+69
View File
@@ -476,6 +476,12 @@ def preset_fuer(disc_type: str, einstellungen: dict) -> str:
return (einstellungen.get("transcodePreset") or "").strip() or DEFAULT_HB_PRESET
#: Endung -> HandBrake-Container. Am mitgelieferten HandBrake 1.11.2
#: gegengeprueft (`--help`, Abschnitt `-f, --format`).
FORMATE = {".mkv": "av_mkv", ".mp4": "av_mp4", ".m4v": "av_mp4",
".mov": "av_mov", ".webm": "av_webm"}
def build_handbrake_cmd(input_path: str, output_path: str,
preset: str = DEFAULT_HB_PRESET,
audio_sprachen=None, untertitel_sprachen=None) -> list:
@@ -510,6 +516,25 @@ def build_handbrake_cmd(input_path: str, output_path: str,
"--output", output_path,
"--preset", preset,
]
# Den Container zur Endung erzwingen (Befund 29.08.2026).
#
# Commander: „Kompression fehlgeschlagen bei title_t00.mkv: HandBrake
# endete mit Code 0"
#
# Code 0 heisst bei HandBrake ERFOLG — und trotzdem lag am erwarteten Ort
# keine Datei. Der Grund: **HandBrake bestimmt den Container aus dem
# Preset, nicht aus der Endung.** Steht ein MP4-Preset ein, schreibt es
# `title_t00.mp4` neben das verlangte `title_t00.mkv`, meldet „Output
# format changed" — und beendet sich mit 0. Rippy sah an seiner Stelle
# nichts und nannte das „fehlgeschlagen".
#
# `-f/--format` ist der dokumentierte Schalter dafuer (an dem
# mitgelieferten HandBrake 1.11.2 gegengeprueft: av_mp4, av_mov, av_mkv,
# av_webm). Damit sind Endung und Container EINE Entscheidung statt zwei,
# die auseinanderlaufen koennen.
format_name = FORMATE.get(os.path.splitext(output_path)[1].lower())
if format_name:
befehl += ["--format", format_name]
audio = [s for s in (audio_sprachen or []) if s]
if audio:
befehl += ["--audio-lang-list", ",".join(audio)]
@@ -579,6 +604,23 @@ def sprachliste(wert) -> list:
return sauber
def _datei_daneben(erwartet: str) -> str:
"""Dieselbe Datei mit anderer Endung im selben Ordner — oder "".
HandBrake waehlt den Container nach dem Preset. Passt er nicht zur
verlangten Endung, liegt das Ergebnis unter demselben Namen mit anderer
Endung daneben (Befund 29.08.2026). Gesucht wird nur in den Endungen, die
HandBrake ueberhaupt schreiben kann.
"""
ordner = os.path.dirname(erwartet)
stamm = os.path.splitext(os.path.basename(erwartet))[0]
for endung in FORMATE:
kandidat = os.path.join(ordner, stamm + endung)
if kandidat != erwartet and os.path.isfile(kandidat):
return kandidat
return ""
def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET,
progress_cb=None, abbruch_cb=None,
audio_sprachen=None, untertitel_sprachen=None) -> dict:
@@ -659,6 +701,33 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=
if process.returncode == 0 and os.path.exists(output_path):
return {"status": "success", "output_path": output_path}
# Code 0, aber die Datei fehlt: HandBrake hat sie woanders hingeschrieben.
#
# Commander 29.08.2026: „Kompression fehlgeschlagen bei title_t00.mkv:
# HandBrake endete mit Code 0". Code 0 heisst ERFOLG — HandBrake meldet
# Fehler notorisch nicht ueber den Rueckgabewert. Der haeufigste Fall ist
# der Container: Er kommt aus dem PRESET, nicht aus der Endung. Ein
# MP4-Preset schreibt `title_t00.mp4` neben das verlangte `.mkv`.
#
# Verhindert wird das jetzt mit `--format` (siehe build_handbrake_cmd).
# Dieser Zweig bleibt trotzdem: Er FINDET die Datei, statt einen
# erfolgreichen Lauf wegzuwerfen — und sagt, was passiert ist.
if process.returncode == 0:
daneben = _datei_daneben(output_path)
if daneben:
return {"status": "success", "output_path": daneben,
"hinweis": "HandBrake hat %s geschrieben statt %s "
"(der Container kommt aus dem Preset)."
% (os.path.basename(daneben),
os.path.basename(output_path))}
return {
"status": "error",
"error": ("HandBrake meldet Erfolg, aber es ist keine Datei "
"entstanden. Meist ist der Zielordner nicht "
"beschreibbar: %s" % os.path.dirname(output_path)),
"return_code": 0,
}
if falsches_preset:
return {
"status": "error",
+8 -1
View File
@@ -101,7 +101,14 @@ def makemkvcon_pfad() -> str:
for pfad in fest:
if os.path.isfile(pfad):
return pfad
return shutil.which("makemkvcon64") or shutil.which("makemkvcon") or ""
# ⚠️ `shutil.which` sucht NUR im PATH (Befund 29.08.2026). Unter Windows
# liegt MakeMKV in „Programme" und steht dort nie — ausgerechnet in dem
# Modul, das es NUR unter Windows gibt. Die Schluessel-Automatik lief
# damit nie an. Der Werkzeug-Katalog kennt die echten Orte.
from rippy.tools import katalog
return (katalog.finden("makemkv")
or shutil.which("makemkvcon64") or shutil.which("makemkvcon") or "")
def parse_laufwerke(ausgabe: str) -> list:
+9
View File
@@ -84,6 +84,11 @@ def _pruefen(monkeypatch, vorhandene, mapping, im_container=False):
return tasks._erreichbarkeit_pruefen(
"/app/media/rippy/job1", "\\\\NAS\\rippy\\job1",
"/app/media/rippy/movies/Akira (1988)", "\\\\NAS\\rippy\\movies\\Akira (1988)",
# „Fremd" heisst: Rippy laeuft woanders als dieser Worker. Bis zum
# 29.08.2026 wurde das an der Existenz von `/app` festgemacht — damit
# hing dieser Test am laufenden Rechner, und unter Windows hielt sich
# der eigenstaendige Rippy fuer einen fremden Worker.
fremd=not im_container,
)
@@ -124,6 +129,10 @@ def test_nicht_uebersetzter_pfad_beschuldigt_sehr_wohl_die_karte(monkeypatch):
meldung = tasks._erreichbarkeit_pruefen(
"/app/media/rippy/job1", "/app/media/rippy/job1",
"/app/media/movies/X", "/app/media/movies/X",
# Ausdruecklich ein FREMDER Worker — nur den betrifft die Pfad-Karte.
# Vorher ergab sich das aus „es gibt kein /app", und genau daran hing
# der Test am laufenden Rechner (Befund 29.08.2026).
fremd=True,
)
assert "deckt diesen Pfad aber nicht ab" in meldung
assert "von keinem Eintrag übersetzt" in meldung
+19 -1
View File
@@ -229,6 +229,24 @@ def read_sys_attr(device_name: str, attr: str) -> str:
return ""
def kennung(device_path: str) -> str:
"""Der kurze Name eines Laufwerks — `/dev/sr0` wird zu `sr0`.
Gegenstueck zu `pfad_zu_kennung`. Begruendung im Windows-Treiber: Hin-
und Rueckweg muessen zusammenpassen, sonst antworten die Endpunkte mit
404 (Befund 29.08.2026).
"""
return os.path.basename(device_path)
def pfad_zu_kennung(name: str, geraete=None) -> str:
"""Kennung -> Geraetepfad, oder "" wenn es dieses Laufwerk nicht gibt."""
for pfad in (geraete if geraete is not None else list_optical_devices()):
if pfad == name or kennung(pfad) == name:
return pfad
return ""
def device_info(device_path: str) -> dict:
"""Baut den Geräte-Eintrag fürs UI: Name aus /sys, Disc-Status per ioctl.
@@ -247,7 +265,7 @@ def device_info(device_path: str) -> dict:
drive_status,
)
name = os.path.basename(device_path)
name = kennung(device_path)
vendor = read_sys_attr(name, "vendor")
model = read_sys_attr(name, "model")
+29 -1
View File
@@ -436,6 +436,34 @@ def geraeteangaben(geraet: str, api=None) -> dict:
}
def kennung(geraet: str) -> str:
r"""Der kurze Name eines Laufwerks — `\.\G:` wird zu `G`.
## Warum das eine benannte Funktion ist (Befund 29.08.2026)
In `main.py` stand an DREI Stellen der Rueckweg als feste Zeile:
device_path = f"/dev/{name}"
Das UI ruft diese Endpunkte mit der Kennung aus der Geraeteliste auf
unter Windows also `G`. Gebaut wurde daraus `/dev/G`, und das steht in
keiner Laufwerksliste. Ergebnis: **Auswerfen und Disc scannen" antworten
unter Windows immer mit 404**, ohne dass irgendwo stuende, warum.
Hin und zurueck muessen zusammenpassen. Deshalb gehoert beides zum
Treiber: Er vergibt die Kennung, und er loest sie wieder auf.
"""
return geraet.rstrip(":").rsplit("\\", 1)[-1].rstrip(":")
def pfad_zu_kennung(name: str, geraete=None) -> str:
"""Kennung -> Geraetepfad, oder "" wenn es dieses Laufwerk nicht gibt."""
for pfad in (geraete if geraete is not None else list_optical_devices()):
if pfad == name or kennung(pfad) == name:
return pfad
return ""
def device_info(geraet: str, api=None) -> dict:
"""Der Geräte-Eintrag fürs UI — gleiche Felder wie beim Linux-Treiber.
@@ -459,7 +487,7 @@ def device_info(geraet: str, api=None) -> dict:
except OSError:
status = "unknown"
buchstabe = geraet.rstrip(":").rsplit("\\", 1)[-1].rstrip(":")
buchstabe = kennung(geraet)
angaben = _angaben_gemerkt(geraet, api)
modell = " ".join(t for t in (angaben.get("hersteller"),
angaben.get("modell")) if t)
+179
View File
@@ -0,0 +1,179 @@
"""Der Wächter gegen Container-Annahmen im Windows-Betrieb.
## Warum es diesen Test gibt (Commander, 29.08.2026)
> Bro, du musst alles was rippy jetzt im code hat für Windows Bauen! Jeden
> pfad, alles wo die tools drauf zugreifen. Diese Rippy version MUSS 100%
> Windows Kompatibel sein. Prüfe bitte den kompletten Quellcode nach Docker
> Resten."
Der Rundgang dazu hat an einem Tag gefunden:
/app/media als Pfadgrenze -> Auswahl blieb leer, Browser tot
/app/temp/raw als Arbeitsordner -> 436 MB landeten in F:\\app\\temp
/root/.MakeMKV fuer den Beta-Key -> Key wurde nie gelesen
/dev/{name} in drei Endpunkten -> Auswerfen und Disc-Scan gaben 404
os.path.isdir("/app") zweimal -> Rippy hielt sich fuer fremd
shutil.which fuer makemkvcon -> Schluessel-Automatik lief nie an
posixpath auf echten Pfaden -> C:\\Roh/datei.mkv
Jeder einzelne Fall sah gleich aus: kein Absturz, keine Meldung, nur etwas,
das still nicht tat, was es sollte. Genau deshalb steht hier ein MECHANISCHER
Waechter und keine Merkregel.
## Die Regel
In allem, was der Windows-Betrieb laedt, darf ein Container- oder Linux-Pfad
nur stehen, wenn er in einem Zweig steht, der auf Windows nicht laeuft. Diese
Zweige stehen unten namentlich in `ERLAUBT` mit Begruendung. Wer eine neue
Fundstelle hinzufuegt, muss sie dort begruenden; das ist der eigentliche
Zweck.
"""
import io
import os
import re
import pytest
WURZEL = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
#: Was der Windows-Betrieb wirklich laedt.
GEPRUEFT = ("src/rippy", "docker/api", "docker/worker")
#: Module, die es unter Windows GAR NICHT gibt — dort sind Linux-Pfade richtig.
NUR_LINUX = (
"src/rippy/drives/linux.py", # der Linux-Treiber
"docker/api/mounts.py", # Freigaben einhaengen macht der Wirt
)
#: Begruendete Ausnahmen: Datei -> warum der Pfad dort stehen DARF.
ERLAUBT = {
"src/rippy/betrieb.py":
"Container-Zweige von ablage_vorgabe/arbeits_vorgabe/platz_orte — "
"sie laufen nur mit container=True",
"src/rippy/config.py":
"Linux-Zweig von standard_datenbankpfad (Windows: %LOCALAPPDATA%)",
"src/rippy/daemon.py":
"Linux-Zweig von standard_konfigpfad (Windows: %PROGRAMDATA%)",
"src/rippy/rip/makemkv_daten.py":
"Linux-Zweig von daten_verzeichnis (Windows: %USERPROFILE%\\.MakeMKV)",
"docker/api/config.py":
"Pydantic-Vorgaben ohne Leser — kein Aufrufer im Quellcode",
"docker/api/main.py":
"MEDIA_ROOT als benannter Rueckfall; die Wurzel liefert medien_wurzel()",
"docker/api/rohdaten.py":
"RAW_STANDARD/MEDIA_ROOT als Rueckfall; die Wurzeln liefert wurzeln()",
"docker/api/prescan/prescan.py":
"Linux-Zweig von disc_wurzel (Windows haengt die Disc selbst ein) "
"und die Audio-CD-Werkzeuge, die es unter Windows nicht gibt",
"docker/worker/ablauf.py":
"RAW_DIR/MEDIA_ROOT als Rueckfall; die Wurzeln liefert _betriebs_wurzeln()",
"docker/worker/ripping.py":
"RIP_OUTPUT_DIR als Rueckfall und die Audio-CD-Werkzeuge (Linux-only)",
}
PFAD_MUSTER = re.compile(r'["\'](/(?:app|root|dev|mnt|srv|etc|var|usr)(?:/[^"\']*)?)["\']')
def _dateien():
for teil in GEPRUEFT:
for ordner, unter, namen in os.walk(os.path.join(WURZEL, teil)):
unter[:] = [u for u in unter if u != "__pycache__"]
for name in namen:
if not name.endswith(".py") or name.startswith("test_"):
continue
voll = os.path.join(ordner, name)
rel = os.path.relpath(voll, WURZEL).replace("\\", "/")
if rel in NUR_LINUX:
continue
yield rel, voll
def _code_zeilen(voll: str):
"""Zeilen OHNE Kommentare und Docstrings — nur was wirklich laeuft.
Ueber den TOKENIZER, nicht ueber faengt mit Anfuehrungszeichen an": Ein
Docstring kann `r\"\"\"` oder `f\"\"\"` heissen, und der erste Anlauf dieses
Tests ist prompt an seinem eigenen `r\"\"\"`-Docstring haengengeblieben.
Python weiss selbst am besten, was Code ist und was Text.
"""
import tokenize
with tokenize.open(voll) as f:
try:
marken = list(tokenize.generate_tokens(f.readline))
except (tokenize.TokenError, IndentationError, SyntaxError):
return
# Zeilen, in denen echter Code steht (alles ausser Kommentar, Text,
# Einrueckung und Zeilenende).
egal = {tokenize.COMMENT, tokenize.STRING, tokenize.NL, tokenize.NEWLINE,
tokenize.INDENT, tokenize.DEDENT, tokenize.ENDMARKER}
zeilen = {}
for marke in marken:
if marke.type in egal:
continue
zeilen[marke.start[0]] = marke.line.rstrip("\n")
# Zeichenketten IM Code (kein Docstring) muessen mit — dort stehen die
# Pfade. Erkennbar daran, dass in derselben Zeile auch Code steht.
for marke in marken:
if marke.type == tokenize.STRING and marke.start[0] in zeilen:
zeilen[marke.start[0]] = marke.line.rstrip("\n")
for nr in sorted(zeilen):
yield nr, zeilen[nr]
def test_kein_unbegruendeter_container_pfad():
"""DER Waechter. Neue Fundstelle -> hier begruenden oder beheben."""
offen = []
for rel, voll in _dateien():
for nr, zeile in _code_zeilen(voll):
treffer = PFAD_MUSTER.search(zeile)
if treffer and rel not in ERLAUBT:
offen.append("%s:%d %s" % (rel, nr, treffer.group(1)))
assert not offen, (
"Container-Pfad im Windows-Betrieb ohne Begruendung:\n "
+ "\n ".join(offen)
+ "\n\nEntweder fuer Windows umschreiben (betrieb.medien_wurzel, "
"rippy.pfade) oder in ERLAUBT begruenden.")
def test_die_ausnahmen_sind_noch_noetig():
"""Eine Ausnahme, die niemand mehr braucht, ist eine Unwahrheit im Test."""
ungenutzt = []
for rel in ERLAUBT:
voll = os.path.join(WURZEL, rel)
if not os.path.isfile(voll):
ungenutzt.append(rel + " (Datei gibt es nicht mehr)")
continue
if not any(PFAD_MUSTER.search(z) for _, z in _code_zeilen(voll)):
ungenutzt.append(rel + " (kein Container-Pfad mehr drin)")
assert not ungenutzt, "Ausnahme ueberfluessig:\n " + "\n ".join(ungenutzt)
@pytest.mark.parametrize("modul,name", [
("src/rippy/drives/windows.py", "kennung"),
("src/rippy/drives/windows.py", "pfad_zu_kennung"),
("src/rippy/drives/linux.py", "kennung"),
("src/rippy/drives/linux.py", "pfad_zu_kennung"),
])
def test_beide_treiber_koennen_die_kennung_in_beide_richtungen(modul, name):
"""Hin und zurueck muessen zusammenpassen.
`main.py` baute den Geraetepfad aus der Kennung selbst zusammen
(`/dev/{name}`) unter Windows heisst das Laufwerk aber `\\\\.\\G:`.
Auswerfen und Disc scannen" antworteten deshalb IMMER mit 404.
"""
text = io.open(os.path.join(WURZEL, modul), encoding="utf-8",
errors="replace").read()
assert "def %s(" % name in text
def test_niemand_baut_den_geraetepfad_mehr_selbst():
"""Der konkrete Fehler: `f"/dev/{name}"` in drei Endpunkten."""
voll = os.path.join(WURZEL, "docker/api/main.py")
gebaut = [nr for nr, z in _code_zeilen(voll) if 'f"/dev/{' in z]
assert not gebaut, (
"main.py:%s baut den Geraetepfad selbst — device_discovery."
"pfad_zu_kennung() benutzen" % gebaut)