Ctrl K
ComunidadGuías…

_installer_aufraeumen.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""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 <Datenordner>        gibt fragen | immer | nie aus
    python _installer_aufraeumen.py plan <Installationsordner> <Fassung> <Programmordner> <Datenordner>
                                                                     zeigt, was geloescht wuerde (loescht nichts)
    python _installer_aufraeumen.py loeschen <Installationsordner> <Fassung> <Programmordner> <Datenordner>
                                                                     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-<Fassung>.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-<fassung>.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())