Skip to content

Commit bf2ba35

Browse files
lukischclaude
andcommitted
fix: Bugsweep Lauf #10 — 4 Bugs (#10-1 bis #10-4) behoben
Bug #10-1: QSystemTrayIcon.Trigger -> ActivationReason.Trigger (PySide6 6.4+) Bug #10-2: OSError-Guard in models._ingest_txt() write+replace Bug #10-3: OSError-Guard in settings.save() write+replace Bug #10-4: ASCII-Digraphe in user-facing return-Strings in ops.py Style: Docstrings/Kommentare in contextmenu/watcher/worker/paths/models ebenfalls auf echte Umlaute umgestellt (kein Bug per CLAUDE.md Code-Ausnahme). 4 Regressionstests ergaenzt (tests/test_bugsweep_regressions.py). 105/105 Tests gruen. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6ec659a commit bf2ba35

9 files changed

Lines changed: 142 additions & 37 deletions

File tree

src/cloudlockfixer/contextmenu.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
"""Explorer-Rechtsklick-Kontextmenue (Windows, HKCU) als Kaskaden-Untermenue
2-
'CloudLockFixer' fuer Ordner und Dateien. Eintraege rufen `clf gui-add`."""
1+
"""Explorer-Rechtsklick-Kontextmenü (Windows, HKCU) als Kaskaden-Untermenü
2+
'CloudLockFixer' für Ordner und Dateien. Einträge rufen `clf gui-add`."""
33
from __future__ import annotations
44

55
import sys

src/cloudlockfixer/models.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ class Step:
2727
op: StepType
2828
src: str
2929
arg: str = ""
30-
copied: bool = False # bei move/rename: Copy erfolgt, nur noch Quelle-Loeschen offen
30+
copied: bool = False # bei move/rename: Copy erfolgt, nur noch Quelle-Löschen offen
3131

3232
def describe(self) -> str:
3333
if self.op == "rename":
@@ -142,7 +142,7 @@ def _ingest_txt(self) -> None:
142142
try:
143143
text = self.txt_path.read_text(encoding="utf-8")
144144
except (OSError, UnicodeDecodeError):
145-
# Analog zu queue.json: korrupte Datei -> Ingest uebersprungen.
145+
# Analog zu queue.json: korrupte Datei -> Ingest übersprungen.
146146
return
147147
lines = text.splitlines()
148148
changed = False
@@ -167,10 +167,13 @@ def _ingest_txt(self) -> None:
167167
else:
168168
out.append(line)
169169
if changed:
170-
tmp = self.txt_path.with_suffix(".txt.tmp")
171-
tmp.write_text("\n".join(out) + "\n", encoding="utf-8")
172-
tmp.replace(self.txt_path)
173-
self._save_unlocked()
170+
try:
171+
tmp = self.txt_path.with_suffix(".txt.tmp")
172+
tmp.write_text("\n".join(out) + "\n", encoding="utf-8")
173+
tmp.replace(self.txt_path)
174+
self._save_unlocked()
175+
except OSError:
176+
pass # best-effort; nächster load()-Aufruf wiederholt den Versuch
174177

175178
def add(self, task: Task) -> Task:
176179
with self._lock:

src/cloudlockfixer/ops.py

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
am Cloud-Files-Filter (cldflt) mit Zugriff-verweigert/EXDEV, automatisch auf
55
copy+delete ausweichen (der MS-empfohlene, filter-sichere Workaround).
66
7-
Idempotenz fuer verzoegerte Retries: Wenn der Copy-Teil gelang, aber das
8-
Loeschen der Quelle (noch) gesperrt ist, merkt sich der Schritt das via
9-
`Step.copied` — der naechste Lauf loescht dann nur noch die Quelle und kopiert
7+
Idempotenz für verzögerte Retries: Wenn der Copy-Teil gelang, aber das
8+
Löschen der Quelle (noch) gesperrt ist, merkt sich der Schritt das via
9+
`Step.copied` — der nächste Lauf löscht dann nur noch die Quelle und kopiert
1010
NICHT erneut (kein Datenverlust, kein Doppel-Copy).
1111
"""
1212
from __future__ import annotations
@@ -50,9 +50,9 @@ def _force_writable(path: str | os.PathLike) -> None:
5050

5151
def _on_rm_error(func, path, _exc):
5252
"""rmtree-Fehlerbehandler: read-only-Attribut entfernen, dann erneut versuchen.
53-
Windows verweigert das Loeschen read-only markierter Dateien/Verzeichnisse mit
53+
Windows verweigert das Löschen read-only markierter Dateien/Verzeichnisse mit
5454
WinError 5 (z.B. .pytest_cache, .git/objects oder von OneDrive 'pinned'). Ohne
55-
diesen Handler bricht shutil.rmtree an solchen Eintraegen ab."""
55+
diesen Handler bricht shutil.rmtree an solchen Einträgen ab."""
5656
_force_writable(path)
5757
func(path)
5858

@@ -90,7 +90,7 @@ def _verify_copy(src: Path, dst: Path) -> bool:
9090
def _delete_path(p: Path) -> tuple[bool, str]:
9191
try:
9292
if not p.exists():
93-
return True, "bereits geloescht"
93+
return True, "bereits gelöscht"
9494
if p.is_dir():
9595
try:
9696
_rmtree(p)
@@ -99,7 +99,7 @@ def _delete_path(p: Path) -> tuple[bool, str]:
9999
# Bug 3: Verzeichnis mit gesperrter Innendatei
100100
ok, locked = _delete_dir_skip_locked(p)
101101
if ok:
102-
return True, "geloescht (gesperrte Innendateien uebersprungen)"
102+
return True, "gelöscht (gesperrte Innendateien übersprungen)"
103103
if locked:
104104
names = ", ".join(str(lp.name) for lp in locked[:3])
105105
return False, f"Innendatei(en) noch gesperrt (EBUSY): {names}"
@@ -115,9 +115,9 @@ def _delete_path(p: Path) -> tuple[bool, str]:
115115
except OSError:
116116
_force_writable(p) # read-only Datei -> beschreibbar machen
117117
p.unlink()
118-
return True, "geloescht"
118+
return True, "gelöscht"
119119
except OSError as e:
120-
return False, f"Loeschen fehlgeschlagen: {e}"
120+
return False, f"Löschen fehlgeschlagen: {e}"
121121

122122

123123
def _delete_dir_skip_locked(p: Path) -> tuple[bool, list[Path]]:
@@ -165,8 +165,8 @@ def _delete_dir_skip_locked(p: Path) -> tuple[bool, list[Path]]:
165165

166166

167167
def _do_move(src: Path, dst: Path) -> tuple[bool, str, bool]:
168-
"""Verschiebt src -> dst. Rueckgabe: (ok, msg, copied).
169-
`copied`=True heisst: dst ist vollstaendig erstellt (egal ob Quelle schon weg).
168+
"""Verschiebt src -> dst. Rückgabe: (ok, msg, copied).
169+
`copied`=True heißt: dst ist vollständig erstellt (egal ob Quelle schon weg).
170170
Ziel-existiert (mit anderem Inhalt als 'schon erledigt') = Konflikt -> Fehler."""
171171
if not src.exists() and dst.exists():
172172
return True, "bereits verschoben", True
@@ -199,12 +199,12 @@ def _do_move(src: Path, dst: Path) -> tuple[bool, str, bool]:
199199

200200
if not _verify_copy(src, dst):
201201
_delete_path(dst)
202-
return False, "Copy unvollstaendig (Verify fehlgeschlagen)", False
202+
return False, "Copy unvollständig (Verify fehlgeschlagen)", False
203203

204204
ok, msg = _delete_path(src)
205205
if ok:
206206
return True, "verschoben (copy+delete)", True
207-
return False, f"Kopiert, aber Quelle noch gesperrt — Retry spaeter ({msg})", True
207+
return False, f"Kopiert, aber Quelle noch gesperrt — Retry später ({msg})", True
208208

209209

210210
# ── Standalone-API (fuer direkte Nutzung/Tests) ─────────────────────
@@ -251,7 +251,7 @@ def execute_step(step: Step) -> tuple[bool, str]:
251251

252252

253253
def execute_chain(task: Task) -> bool:
254-
"""Fuehrt die Kette ab task.step_index aus. Schritt N nur nach Erfolg N-1."""
254+
"""Führt die Kette ab task.step_index aus. Schritt N nur nach Erfolg N-1."""
255255
for i in range(task.step_index, len(task.chain)):
256256
ok, msg = execute_step(task.chain[i])
257257
if ok:

src/cloudlockfixer/paths.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Zentrale Pfade (Datenverzeichnis, Log, Launcher fuer Registry-Befehle)."""
1+
"""Zentrale Pfade (Datenverzeichnis, Log, Launcher für Registry-Befehle)."""
22
from __future__ import annotations
33

44
import os

src/cloudlockfixer/settings.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,13 @@ def load() -> dict:
2828

2929
def save(settings: dict) -> None:
3030
p = _path()
31-
tmp = p.with_suffix(".json.tmp")
32-
tmp.write_text(json.dumps(settings, ensure_ascii=False, indent=2),
33-
encoding="utf-8")
34-
tmp.replace(p)
31+
try:
32+
tmp = p.with_suffix(".json.tmp")
33+
tmp.write_text(json.dumps(settings, ensure_ascii=False, indent=2),
34+
encoding="utf-8")
35+
tmp.replace(p)
36+
except OSError:
37+
pass
3538

3639

3740
def resolve_language(cfg: dict) -> str:

src/cloudlockfixer/tray.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ def _build_menu(self) -> None:
173173
triggered=self.app.quit))
174174

175175
def _on_activated(self, reason):
176-
if reason == QSystemTrayIcon.Trigger:
176+
if reason == QSystemTrayIcon.ActivationReason.Trigger:
177177
self.menu.popup(self.tray.geometry().center())
178178

179179
# ── Aktionen ────────────────────────────────────────────────────

src/cloudlockfixer/watcher.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
"""Praeventiv-Waechter (P3, opt-in).
1+
"""Präventiv-Wächter (P3, opt-in).
22
3-
Beobachtet die Aenderungsrate in *konfigurierten* Ordnern (NICHT dem ganzen
4-
OneDrive-Baum — das wuerde Online-only-Placeholder hydratisieren = ungewollte
5-
Downloads). Bei hoher Aktivitaet wird der Sync-Client pausiert; nach einer
3+
Beobachtet die Änderungsrate in *konfigurierten* Ordnern (NICHT dem ganzen
4+
OneDrive-Baum — das würde Online-only-Placeholder hydratisieren = ungewollte
5+
Downloads). Bei hoher Aktivität wird der Sync-Client pausiert; nach einer
66
Ruhephase (Cooldown) wieder gestartet — so entstehen Locks gar nicht erst.
77
88
Die Entscheidungslogik (`decide`) ist rein/zustandsbehaftet und testbar; das
9-
Zaehlen ist bounded + stat-only (liest nur Metadaten, hydratisiert nicht).
9+
Zählen ist bounded + stat-only (liest nur Metadaten, hydratisiert nicht).
1010
"""
1111
from __future__ import annotations
1212

@@ -91,7 +91,7 @@ def tick(self) -> str:
9191
log.info("Preventive watcher: %s paused (high change rate).",
9292
self.provider.name)
9393
else:
94-
self._paused_by_us = False # pause() gescheitert — kein Resume noetig
94+
self._paused_by_us = False # pause() gescheitert — kein Resume nötig
9595
else:
9696
self._paused_by_us = False
9797
elif action == "resume":

src/cloudlockfixer/worker.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
"""Worker: arbeitet die Queue ab.
22
33
Standard ist non-disruptiv: copy+delete funktioniert auch ohne den Sync-Client
4-
zu beenden. Erst wenn ein Task mehrfach haengt (oder force_pause), wird der
5-
zustaendige Sync-Provider fuer den Lauf pausiert (M2) und danach wieder gestartet.
4+
zu beenden. Erst wenn ein Task mehrfach hängt (oder force_pause), wird der
5+
zuständige Sync-Provider für den Lauf pausiert (M2) und danach wieder gestartet.
66
"""
77
from __future__ import annotations
88

@@ -70,7 +70,7 @@ def run_once(queue: Queue, force_pause: bool = False) -> dict:
7070
summary["done"] += 1
7171
log.info("Task %s completed.", t.id)
7272
else:
73-
t.status = "pending" # bleibt fuer naechsten Lauf
73+
t.status = "pending" # bleibt für nächsten Lauf
7474
summary["failed_again"] += 1
7575
log.warning("Task %s still open: %s", t.id, t.last_error)
7676
queue.save()

tests/test_bugsweep_regressions.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""Regressionstests: Bugsweep Lauf #10 — CloudLockFixer."""
2+
from __future__ import annotations
3+
4+
import ast
5+
from pathlib import Path
6+
7+
SRC = Path(__file__).resolve().parents[1] / "src" / "cloudlockfixer"
8+
9+
10+
# ---------------------------------------------------------------------------
11+
# Bug #10-1: QSystemTrayIcon.Trigger statt QSystemTrayIcon.ActivationReason.Trigger
12+
# ---------------------------------------------------------------------------
13+
14+
def test_tray_uses_activation_reason_enum():
15+
"""_on_activated() muss ActivationReason.Trigger nutzen, nicht deprecated .Trigger (Bug #10-1)."""
16+
src = (SRC / "tray.py").read_text(encoding="utf-8")
17+
tree = ast.parse(src)
18+
for node in ast.walk(tree):
19+
if not (isinstance(node, ast.FunctionDef) and node.name == "_on_activated"):
20+
continue
21+
for sub in ast.walk(node):
22+
if not isinstance(sub, ast.Compare):
23+
continue
24+
for comp in sub.comparators:
25+
if isinstance(comp, ast.Attribute) and comp.attr == "Trigger":
26+
val = comp.value
27+
if isinstance(val, ast.Attribute) and val.attr == "ActivationReason":
28+
return # korrekte Form gefunden
29+
raise AssertionError(
30+
"Bug #10-1: QSystemTrayIcon.Trigger verwendet — "
31+
"muss QSystemTrayIcon.ActivationReason.Trigger sein (PySide6 6.4+)"
32+
)
33+
# kein Compare mit .Trigger gefunden — ebenfalls OK (kein Vergleich)
34+
35+
36+
# ---------------------------------------------------------------------------
37+
# Bug #10-2: _ingest_txt() kein try/except OSError bei write_text/replace
38+
# ---------------------------------------------------------------------------
39+
40+
def test_ingest_txt_survives_oserror(tmp_path):
41+
"""_ingest_txt() darf bei OSError beim Schreiben nicht abstürzen (Bug #10-2)."""
42+
import sys
43+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
44+
from unittest.mock import patch
45+
from cloudlockfixer.models import Queue
46+
47+
q = Queue(tmp_path)
48+
txt = tmp_path / "queue.txt"
49+
txt.write_text("delete /some/path\n", encoding="utf-8")
50+
51+
with patch("pathlib.Path.write_text", side_effect=OSError("locked")):
52+
try:
53+
q.load()
54+
except OSError:
55+
raise AssertionError("_ingest_txt() muss OSError intern abfangen (Bug #10-2)")
56+
57+
58+
# ---------------------------------------------------------------------------
59+
# Bug #10-3: settings.save() kein try/except OSError
60+
# ---------------------------------------------------------------------------
61+
62+
def test_settings_save_survives_oserror(tmp_path):
63+
"""settings.save() darf bei OSError nicht abstürzen (Bug #10-3)."""
64+
import sys
65+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
66+
from unittest.mock import patch
67+
import cloudlockfixer.settings as s
68+
69+
with patch("pathlib.Path.write_text", side_effect=OSError("disk full")):
70+
try:
71+
s.save({"interval_min": 60})
72+
except OSError:
73+
raise AssertionError("settings.save() muss OSError intern abfangen (Bug #10-3)")
74+
75+
76+
# ---------------------------------------------------------------------------
77+
# Bug #10-4: ops.py User-facing Strings mit ASCII-Digraphen
78+
# ---------------------------------------------------------------------------
79+
80+
def test_ops_no_digraphs_in_return_strings():
81+
"""ops.py return-Strings dürfen keine ASCII-Digraphe enthalten (Bug #10-4)."""
82+
import re
83+
src = (SRC / "ops.py").read_text(encoding="utf-8")
84+
tree = ast.parse(src)
85+
digraph_re = re.compile(
86+
r"\b(?:geloescht|uebersprungen|loeschen|unvollstaendig|spaeter|"
87+
r"fuer|rueckgabe|fuehrt|verzoegert|naechste)\b",
88+
re.IGNORECASE,
89+
)
90+
violations = []
91+
for node in ast.walk(tree):
92+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
93+
m = digraph_re.search(node.value)
94+
if m:
95+
violations.append(f"Z.{node.lineno}: '{m.group()}'")
96+
assert not violations, (
97+
"ASCII-Digraphe in ops.py Strings (Bug #10-4) — echte Umlaute verwenden:\n"
98+
+ "\n".join(violations)
99+
)

0 commit comments

Comments
 (0)