feat(drives): V2-4 (Teil 2) — main.py laedt unter Windows, Treiber gemessen
Ampel / ampel (push) Failing after 46s

WAS: rippy/drives/__init__.py waehlt den Treiber fuer DIESE Maschine.
main.py und prescan.py fragen ihn, statt fest den Linux-Treiber zu
importieren. Damit ist der Blocker fuer den nativen Windows-Betrieb weg.

GEMESSEN, vorher (frische Python-3.12-Umgebung unter Windows):
  >>> import main
  ModuleNotFoundError: No module named 'fcntl'

GEMESSEN, nachher:
  main.py laedt unter Windows: JA
    Treiber:   rippy.drives.windows
    Routen:    61
    Laufwerke: ['\\.\G:']

Der Import stand in rippy/drives/linux.py -> detection.py -> fcntl. Genau
dafuer gibt es den Port: Der Aufrufer sagt WAS, nicht WIE. test_treiberwahl.py
haelt ausserdem fest, dass BEIDE Treiber denselben Satz Funktionen anbieten —
sonst faellt eine fehlende erst im Betrieb als AttributeError auf, und zwar
auf der anderen Plattform.

DER TREIBER AN ECHTER HARDWARE (LG BU40N extern, Laufwerk G:, LEER):
  GetDriveTypeW("G:\\")              -> 5 (DRIVE_CDROM)
  list_optical_devices()             -> ['\\.\G:']
  CreateFileW(r"\.\G:")             -> Handle 380
  CHECK_VERIFY2 bei leerem Laufwerk  -> ERROR_NOT_READY (21)
  drive_status()                     -> 1 (CDS_NO_DISC)
  detect_disc_type()                 -> 'no_disc'
  device_info()['status']            -> 'empty'
  MEDIA_REMOVAL (entriegeln)         -> Erfolg,   0,1 ms
  EJECT_MEDIA                        -> Erfolg, 664,8 ms

Die 664,8 ms sind der Beleg, dass wirklich etwas passiert ist: Ein
abgelehnter Steuercode kommt in rund 2 ms zurueck.

BEFUND, DER EIN PLACEBO VERHINDERT HAT: IOCTL_STORAGE_LOAD_MEDIA (Schublade
einziehen) antwortet auf diesem Laufwerk mit ERROR_INVALID_FUNCTION. Bei
flachen und externen Laufwerken ist das die Regel. Deshalb gibt es bewusst
KEINE einziehen()-Funktion — ein Knopf "Schublade schliessen", der auf der
Haelfte aller Laufwerke stillschweigend nichts tut, waere genau die Sorte
Placebo, die in Etappe 19 aufgeraeumt wurde.

Dazu eine DeprecationWarning behoben: '\G' im Docstring ist keine gueltige
Escape-Sequenz und waere in einer kuenftigen Python-Version ein harter Fehler.

NOCH NICHT gemessen: das Verhalten MIT eingelegter Disc (Typ-Erkennung ueber
die Groesse, und ein Auswurf, bei dem wirklich etwas drin ist).

GEMESSEN: ruff sauber, 429 Tests gruen + 13 uebersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-28 09:55:15 +02:00
co-authored by Claude Opus 5
parent b723fee3a2
commit 3d7f3b1680
5 changed files with 177 additions and 14 deletions
+47
View File
@@ -1,4 +1,51 @@
"""Laufwerks-Schicht: Erkennung, Disc-Status, Verriegeln, Auswurf.
Der EINZIGE Ort im Projekt mit ioctl- bzw. Win32-Aufrufen (KONZEPT-V2.md § 5).
## Wer wählt den Treiber aus
`treiber()`. Und zwar EINMAL, hier — nicht an jeder Aufrufstelle.
Bis Etappe V2-4 importierte die API `rippy.drives.linux` direkt. Das war
solange harmlos, wie Rippy nur in Containern lief. Für den nativen
Windows-Betrieb ist es der Blocker: `linux.py` zieht über `detection.py` das
Modul `fcntl` nach, und das gibt es unter Windows nicht. Gemessen am
28.08.2026 in einer frischen Python-3.12-Umgebung:
>>> import main
ModuleNotFoundError: No module named 'fcntl'
Genau dafür ist der Port `rippy.ports.Drives` da: Der Aufrufer sagt, WAS er
will, und bekommt den Treiber, der auf DIESER Maschine passt.
## Warum der Import in der Funktion steht
Ein `import` auf Modulebene würde beide Treiber laden — und damit unter
Windows wieder `fcntl` verlangen. Der Import muss also genau dann passieren,
wenn klar ist, welcher gebraucht wird.
"""
import sys
def treiber(plattform: str = None):
"""Der Laufwerks-Treiber für diese Maschine.
`plattform` ist nur zum Testen da (Werte wie `sys.platform`): So lässt
sich die AUSWAHL prüfen, ohne die Maschine zu wechseln.
Beide Treiber bieten dieselben Namen an — `list_optical_devices`,
`device_info`, `drive_status`, `detect_disc_type`, `disc_size_bytes`,
`eject`, `auswerfen_versuchen`, `verriegeln`. Wer hier etwas ergänzt,
ergänzt es in BEIDEN; `test_treiberwahl.py` wacht darüber.
"""
plattform = plattform if plattform is not None else sys.platform
if plattform.startswith("win"):
from rippy.drives import windows
return windows
from rippy.drives import linux
return linux
def ist_windows(plattform: str = None) -> bool:
return (plattform if plattform is not None else sys.platform).startswith("win")
+74
View File
@@ -0,0 +1,74 @@
"""Die Treiberwahl — und die Zusage, dass beide Treiber dasselbe können.
## Warum das ein eigener Test ist
Wenn ein Treiber eine Funktion hat und der andere nicht, merkt das niemand
beim Bauen. Es fällt erst auf der anderen Plattform auf, mitten im Betrieb,
als `AttributeError` — und dann steht Rippy.
Genau das war bis V2-4 der Zustand: Die API importierte `rippy.drives.linux`
direkt, und unter Windows scheiterte schon der Import von `main.py` mit
`ModuleNotFoundError: No module named 'fcntl'` (am 28.08.2026 in einer
frischen Python-3.12-Umgebung gemessen).
"""
import sys
import pytest
from rippy import drives
# Was jeder Treiber können MUSS. Wer hier etwas ergänzt, ergänzt es in beiden
# Treibern — sonst wird dieser Test rot, und zwar auf jeder Plattform.
PFLICHT = (
"list_optical_devices",
"device_info",
"drive_status",
"disc_status",
"disc_size_bytes",
"detect_disc_type",
"eject",
"auswerfen_versuchen",
"auswerfen_mit_grund",
"verriegeln",
)
def test_windows_bekommt_den_windows_treiber():
assert drives.treiber("win32").__name__.endswith("windows")
def test_linux_bekommt_den_linux_treiber():
assert drives.treiber("linux").__name__.endswith("linux")
def test_unbekannte_plattform_bekommt_linux():
"""macOS und BSD landen bei Linux — die ioctls sind dort nicht identisch,
aber ein Treiber, der ehrlich scheitert, ist besser als gar keiner. Der
macOS-Treiber steht in KONZEPT-V2.md § 5 als eigener Punkt."""
assert drives.treiber("darwin").__name__.endswith("linux")
def test_ohne_angabe_gilt_diese_maschine():
erwartet = "windows" if sys.platform.startswith("win") else "linux"
assert drives.treiber().__name__.endswith(erwartet)
def test_ist_windows():
assert drives.ist_windows("win32") is True
assert drives.ist_windows("linux") is False
@pytest.mark.parametrize("name", PFLICHT)
def test_windows_treiber_kann_alles_pflichtgemaesse(name):
assert callable(getattr(drives.treiber("win32"), name, None)), (
f"Dem Windows-Treiber fehlt {name}(). Das faellt sonst erst im "
"Betrieb als AttributeError auf — auf der anderen Plattform."
)
@pytest.mark.skipif(sys.platform.startswith("win"),
reason="der Linux-Treiber braucht fcntl")
@pytest.mark.parametrize("name", PFLICHT)
def test_linux_treiber_kann_alles_pflichtgemaesse(name):
assert callable(getattr(drives.treiber("linux"), name, None))
+33 -6
View File
@@ -1,4 +1,4 @@
"""Laufwerks-Treiber für Windows. Erfüllt `rippy.ports.Drives`.
r"""Laufwerks-Treiber für Windows. Erfüllt `rippy.ports.Drives`.
Dieselben Verträge wie `linux.py`, nur mit Win32 statt ioctl:
@@ -30,15 +30,42 @@ Listen im UI geleert.
## Was hier geprüft ist und was nicht
**Geprüft, ohne Laufwerk:** die Steuercodes (`test_win_ioctl.py` rechnet sie
**Ohne Laufwerk geprüft:** die Steuercodes (`test_win_ioctl.py` rechnet sie
gegen die Dokumentation), der Ablauf des Auswurfs, die Fehlerunterscheidung,
die Typ-Zuordnung — alles über eine eingespritzte Win32-Nachbildung
(`test_windows.py`). Diese Tests laufen auf jeder Plattform.
**NICHT geprüft:** dass echte Hardware sich so verhält. Dafür braucht es ein
angestecktes Laufwerk und eine Disc. Solange das nicht gemessen ist, gilt
dieser Treiber als GEBAUT, nicht als BEWIESEN — die Unterscheidung steht so
in AGENTS.md („Beweisen statt behaupten").
**An echter Hardware gemessen (28.08.2026, LG BU40N extern am Windows-PC,
Laufwerk G:, LEER):**
GetDriveTypeW("G:\") -> 5 (DRIVE_CDROM)
list_optical_devices() -> ['\.\G:']
CreateFileW(r"\.\G:") -> Handle 380
CHECK_VERIFY2 bei leerem Laufwerk -> ERROR_NOT_READY (21)
drive_status() -> 1 (CDS_NO_DISC)
detect_disc_type() -> 'no_disc'
device_info()['status'] -> 'empty'
MEDIA_REMOVAL (entriegeln) -> Erfolg, 0,1 ms
EJECT_MEDIA -> Erfolg, 664,8 ms
Die 664,8 ms sind der eigentliche Beleg, dass etwas passiert ist: Ein
abgelehnter Steuercode kommt in rund 2 ms zurück (siehe die Fehler oben).
Eine mechanische Bewegung dauert.
**⚠️ DIESES LAUFWERK KANN DIE SCHUBLADE NICHT SELBST EINZIEHEN.**
`IOCTL_STORAGE_LOAD_MEDIA` antwortet mit `ERROR_INVALID_FUNCTION` (1) — bei
flachen und extern angeschlossenen Laufwerken ist das die Regel, nicht die
Ausnahme. Deshalb gibt es hier bewusst KEINE `einziehen()`-Funktion: Ein Knopf
„Schublade schließen", der auf der Hälfte aller Laufwerke stillschweigend
nichts tut, wäre genau die Sorte Placebo, die in Etappe 19 aufgeräumt wurde.
Die Konstante steht in `win_ioctl.py` — wer sie je benutzt, muss den Fehlschlag
sichtbar machen.
**NOCH NICHT gemessen:** das Verhalten MIT eingelegter Disc — also die
Typ-Erkennung (DVD/BD/UHD über Größe) und der Auswurf einer wirklich
eingelegten Disc. Bei leerem Laufwerk meldet `CHECK_VERIFY2` vorher wie
nachher „kein Medium"; der Auswurf gilt damit zu Recht als geglückt, beweist
aber nicht, dass sich die Schublade bewegt hat.
"""
import sys