"""Der Installer räumt sich selbst auf: entpackter Ordner und ZIP (Auto-Update, #1093, Anforderung 14). Wer von Hand aktualisiert (ZIP laden, entpacken, `INSTALLIEREN.bat`), hinterlässt bisher beides: das ZIP im Downloads-Ordner und den entpackten Ordner. Am Ende bietet der Installer an, beides zu löschen; ob er fragt, es immer tut oder nie, steht in den Einstellungen (`installer_aufraeumen`: fragen, immer, nie; Vorgabe fragen). Aufruf aus `INSTALLIEREN.bat`: python _installer_aufraeumen.py einstellung gibt fragen | immer | nie aus python _installer_aufraeumen.py plan zeigt, was geloescht wuerde (loescht nichts) python _installer_aufraeumen.py loeschen startet das Loeschen im Hintergrund **Was gelöscht wird, ist eng gefasst** — ein Fehltreffer hier loescht Dateien des Menschen: * der Installationsordner nur, wenn er wirklich ein entpackter Installer ist (`INSTALLIEREN.bat`, `_programm_einrichten.py` und `src/bewerbungs_assistent/__init__.py` liegen darin), kein Git-Arbeitsordner ist (`.git`), nicht der Programm- oder Datenordner ist, keinen davon enthaelt, nicht in einem davon liegt und nicht das Home- oder Laufwerks-Wurzelverzeichnis ist; * ZIP-Dateien nur mit dem Namen `PBP-.zip` (Gross-/Kleinschreibung egal), im Ordner NEBEN dem Installationsordner oder in `Downloads`, und nur, wenn in ihnen `INSTALLIEREN.bat` steht. Das Loeschen laeuft in einem eigenen Prozess, der wiederholt versucht: solange das Fenster des Installers offen ist, ist sein Ordner gesperrt. Es gibt nach zehn Minuten auf und merkt sich nichts. Nur Standardbibliothek: es laeuft in der Embeddable-Python-Laufzeit des Installers. """ from __future__ import annotations import json import os import shutil import sqlite3 import stat import subprocess import sys import time import zipfile from pathlib import Path EINSTELLUNGEN = ("fragen", "immer", "nie") ERKENNUNGSDATEIEN = ("INSTALLIEREN.bat", "_programm_einrichten.py", "src/bewerbungs_assistent/__init__.py") VERSUCHE = 200 PAUSE_S = 3.0 def einstellung(daten_ordner) -> str: """Die Einstellung `installer_aufraeumen` aus der Datenbank (nur lesend); bei jedem Zweifel `fragen`.""" db = Path(daten_ordner) / "pbp.db" if not db.is_file(): return "fragen" try: conn = sqlite3.connect(f"file:{db.as_posix()}?mode=ro", uri=True, timeout=3) try: zeile = conn.execute("SELECT value FROM settings WHERE key='installer_aufraeumen'").fetchone() finally: conn.close() wert = json.loads(zeile[0]) if zeile else "fragen" except Exception: # noqa: BLE001 — gesperrt, kaputt, fremd: dann fragen, nie ungefragt loeschen return "fragen" return wert if wert in EINSTELLUNGEN else "fragen" def _gleich_oder_darin(a: Path, b: Path) -> bool: """Ist a gleich b oder liegt a in b?""" try: a.resolve().relative_to(b.resolve()) return True except (ValueError, OSError): return False def installationsordner_pruefen(basis, programm, daten): """(True, "") oder (False, Grund): ist `basis` ein entpackter Installer, den man loeschen darf?""" basis, programm, daten = Path(basis), Path(programm), Path(daten) try: if not basis.is_dir(): return False, "Der Installationsordner existiert nicht." for name in ERKENNUNGSDATEIEN: if not (basis / name).is_file(): return False, f"{name} fehlt: das ist kein entpackter Installer." if (basis / ".git").exists(): return False, "Das ist ein Git-Arbeitsordner (Entwicklung), kein entpackter Installer." for tabu, name in ((programm, "der Programmordner"), (daten, "der Datenordner")): if _gleich_oder_darin(basis, tabu) or _gleich_oder_darin(tabu, basis): return False, f"Der Installationsordner ist oder enthaelt {name}." if _gleich_oder_darin(Path.home(), basis): # der Ordner IST das Benutzerverzeichnis oder enthaelt es return False, "Der Installationsordner ist oder enthaelt das Benutzerverzeichnis." if basis.resolve().parent == basis.resolve(): return False, "Das ist die Wurzel eines Laufwerks." except OSError as exc: return False, f"Nicht pruefbar: {exc}" return True, "" def zip_kandidaten(basis, fassung: str) -> list: """ZIP-Dateien `PBP-.zip` neben dem Installationsordner und in Downloads, die den Installer enthalten.""" basis = Path(basis) ordner = [basis.resolve().parent, Path.home() / "Downloads"] soll = f"pbp-{fassung}.zip".lower() gefunden = [] for o in ordner: try: for p in o.iterdir(): if p.is_file() and p.name.lower() == soll and p not in gefunden: try: with zipfile.ZipFile(p) as zf: if any(n.replace("\\", "/").rstrip("/").endswith("INSTALLIEREN.bat") for n in zf.namelist()): gefunden.append(p) except (zipfile.BadZipFile, OSError): continue except OSError: continue return gefunden def plan(basis, fassung: str, programm, daten) -> dict: ok, grund = installationsordner_pruefen(basis, programm, daten) return {"ordner": str(Path(basis)) if ok else None, "grund": grund, "zips": [str(p) for p in zip_kandidaten(basis, fassung)] if ok else []} def _freigeben(fn, pfad, _info): """Schreibschutz aufheben und noch einmal. Scheitert auch das, bleibt der Rest fuer den naechsten Durchgang. PP24 (Testlauf 11.10.2026): hier stand kein Fangnetz. Haelt ein Fenster (Explorer, Konsole) den Ordner selbst, wirft das zweite `os.rmdir` `PermissionError` (WinError 32) -- aus dieser Rueckruffunktion heraus, durch `rmtree` und `lauf` bis in den Prozess hinein. Der Helfer starb im ersten Versuch; ein leerer Ordner und seine Kopie im Temp-Ordner blieben liegen, obwohl er 200 Versuche im Abstand von 3 s vorsieht. """ try: # Unter Windows nimmt chmod nur den Schreibschutz weg. Unter POSIX hiesse S_IWRITE bei einem Ordner "nur schreiben": # er wuerde unlesbar, und der naechste Durchgang scheiterte schon am Oeffnen (`fn` waere dann `os.open`, ohne Flags). os.chmod(pfad, stat.S_IRWXU if os.path.isdir(pfad) else stat.S_IREAD | stat.S_IWRITE) fn(pfad) except Exception: # noqa: BLE001 -- ein Fehlschlag hier ist ein Grund zu warten, kein Grund zu sterben pass def _loeschen_versuchen(ordner, zips) -> bool: """Ein Durchgang; True, wenn alles weg ist. Wirft nie: ein gehaltener Ordner ist ein Grund zu warten, kein Fehler.""" for z in zips: try: Path(z).unlink(missing_ok=True) except OSError: pass if ordner and Path(ordner).exists(): try: shutil.rmtree(ordner, onerror=_freigeben) except OSError: pass return not (ordner and Path(ordner).exists()) and not any(Path(z).exists() for z in zips) def lauf(ordner, zips, *, versuche: int = VERSUCHE, pause: float = PAUSE_S) -> bool: """Wiederholt, bis der Ordner weg ist (das Fenster des Installers sperrt ihn, bis es zu ist).""" for i in range(versuche): if _loeschen_versuchen(ordner, zips): return True time.sleep(pause) return False def loeschen_starten(basis, fassung: str, programm, daten) -> dict: """Prueft, plant und startet das Loeschen in einem abgetrennten Prozess. Gibt den Plan zurueck.""" p = plan(basis, fassung, programm, daten) if not p["ordner"]: return p nutzlast = json.dumps({"ordner": p["ordner"], "zips": p["zips"]}) kwargs = {} if sys.platform == "win32": kwargs["creationflags"] = 0x00000008 | 0x08000000 | 0x00000200 # DETACHED | NO_WINDOW | NEW_PROCESS_GROUP else: kwargs["start_new_session"] = True # Der Helfer laeuft NICHT aus dem Ordner, den er loescht: eine Kopie dieser Datei im Temp-Ordner. import tempfile kopie = Path(tempfile.gettempdir()) / "pbp_installer_aufraeumen.py" shutil.copy2(__file__, kopie) subprocess.Popen([sys.executable, str(kopie), "lauf", nutzlast], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=tempfile.gettempdir(), **kwargs) return p def _ausgabe_absichern(): """Die Ausgabe nennt Pfade, und die tragen den Benutzernamen. Geht sie in eine Datei (Installer-Protokoll), gilt die Zeichentabelle des Rechners; ein Zeichen ausserhalb davon (ł, ş, griechisch) darf das Drucken nicht zum Absturz bringen (L44).""" for strom in (sys.stdout, sys.stderr): try: strom.reconfigure(errors="backslashreplace") except Exception: pass def main(argv=None) -> int: _ausgabe_absichern() a = list(sys.argv[1:] if argv is None else argv) if len(a) == 2 and a[0] == "einstellung": print(einstellung(a[1])) return 0 if len(a) == 5 and a[0] in ("plan", "loeschen"): try: p = plan(a[1], a[2], a[3], a[4]) if a[0] == "plan" else loeschen_starten(a[1], a[2], a[3], a[4]) except OSError as exc: # #1219: die Bat sagt "wird geloescht" erst nach dem Exit-Code; ein Temp-Ordner ohne Schreibrecht oder ein # nicht startbarer Prozess ist ein Fehlschlag mit Grund, kein Traceback. print(json.dumps({"ordner": "", "zips": [], "fehler": str(exc)}, ensure_ascii=False)) return 1 print(json.dumps(p, ensure_ascii=False)) return 0 if p["ordner"] else 1 if len(a) == 2 and a[0] == "lauf": nutz = json.loads(a[1]) ergebnis = 0 if lauf(nutz["ordner"], nutz["zips"]) else 1 # PP23 (#1170): die Kopie dieser Datei im Temp-Ordner blieb liegen. Python liest die Quelle beim Start vollstaendig und haelt sie # nicht offen — eine laufende .py-Datei laesst sich auch unter Windows loeschen. if Path(__file__).name == "pbp_installer_aufraeumen.py": try: Path(__file__).unlink() except OSError: pass return ergebnis print("Aufruf: siehe Kopf dieser Datei.", file=sys.stderr) return 2 if __name__ == "__main__": sys.exit(main())