Ctrl K
ComunidadGuías…

_programm_einrichten.py

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

Archivo original ↗Preguntar a KiBot
"""Richtet den Programmordner ein: Versionsordner, Startbaustein, aktuell.txt (Auto-Update, #1093).

Aufruf (vom Installer, `INSTALLIEREN.bat`, mit der Python-Laufzeit des Installers):

    python _programm_einrichten.py <entpackter Ordner> <Programmordner>

Exit 0 = eingerichtet. Exit 1 = es wurde NICHTS veraendert (der Grund steht auf der Ausgabe).

Was hier entsteht (siehe `src/bewerbungs_assistent_boot/__init__.py` fuer das Warum):

    <Programmordner>/
        python/                 (legt der Installer)
        boot/bewerbungs_assistent_boot/   der Startbaustein (hier kopiert, ersetzt eine aeltere Kopie)
        versions/<fassung>/     src/, start_dashboard.py, _selftest.py, manifest.json, .fertig
        start_dashboard.py      der Starter der Verknuepfung (aendert sich nie)
        aktuell.txt             zeigt auf <fassung> — als LETZTES geschrieben

Der alte Aufbau (`<Programmordner>/src`) wird entfernt, nachdem die neue Fassung steht. Die Datenbank wird
hier nicht angefasst; ihre Sicherung vor dem Update macht der Installer vorher, die Umstellung des
Datenstands geschieht beim ersten Start der neuen Fassung.

Nur Standardbibliothek: es laeuft in der Embeddable-Python-Laufzeit, bevor irgendein Paket installiert ist.
"""
from __future__ import annotations

import json
import os
import re
import shutil
import stat
import sys
from datetime import datetime, timezone
from pathlib import Path

KOPIERT_NICHT = shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo")


class Abbruch(Exception):
    """Der Aufbau konnte nicht eingerichtet werden; nichts wurde umgeschaltet."""


def _jetzt() -> str:
    return datetime.now(timezone.utc).isoformat(timespec="seconds")


def _version_aus(init: Path) -> str:
    try:
        text = init.read_text(encoding="utf-8-sig")
    except OSError as exc:
        raise Abbruch(f"{init} nicht lesbar: {exc}") from exc
    m = re.search(r'^__version__\s*=\s*"([^"]+)"', text, re.M)
    if not m:
        raise Abbruch(f"In {init} steht keine __version__.")
    return m.group(1)


def _loeschen(pfad: Path) -> None:
    """Ordner oder Datei weg; scheitert es (gesperrt), ist das ein Abbruch statt eines halben Zustands."""
    if not pfad.exists():
        return
    try:
        if pfad.is_dir():
            def _frei(fn, p, _info):
                os.chmod(p, stat.S_IWRITE)
                fn(p)
            shutil.rmtree(pfad, onerror=_frei)
        else:
            pfad.unlink()
    except OSError as exc:
        raise Abbruch(f"{pfad} laesst sich nicht entfernen (gesperrt?): {exc}") from exc
    if pfad.exists():
        raise Abbruch(f"{pfad} laesst sich nicht vollstaendig entfernen (Datei gesperrt?)")


def pth_anpassen(python_ordner: Path) -> bool:
    """`python*._pth`: `../boot` rein, `../src` raus, `import site` aktiv. Gibt True zurueck, wenn sich etwas aenderte.

    Mit einer ._pth-Datei ignoriert die Embeddable-Laufzeit die Umgebungsvariable PYTHONPATH — nur was hier steht,
    findet `python -m bewerbungs_assistent_boot`.
    """
    dateien = sorted(Path(python_ordner).glob("python*._pth"))
    if not dateien:
        return False
    geaendert = False
    for datei in dateien:
        roh = datei.read_bytes()
        crlf = b"\r\n" in roh
        text = roh.decode("utf-8-sig")
        zeilen = text.splitlines()
        neu = []
        for z in zeilen:
            s = z.strip()
            if s in ("../src", "..\\src"):
                continue
            if s == "#import site":
                z = "import site"
            neu.append(z)
        if not any(z.strip() == "../boot" for z in neu):
            neu.append("../boot")
        if not any(z.strip() == "import site" for z in neu):
            neu.append("import site")
        ergebnis = ("\r\n" if crlf else "\n").join(neu) + ("\r\n" if crlf else "\n")
        if ergebnis != text:
            datei.write_bytes(ergebnis.encode("utf-8"))
            geaendert = True
    return geaendert


def einrichten(basis, app) -> dict:
    """Legt die Fassung in `app/versions/<fassung>` an und schaltet `aktuell.txt` um. Wirft `Abbruch`."""
    basis, app = Path(basis), Path(app)
    src = basis / "src"
    if not (src / "bewerbungs_assistent" / "__init__.py").is_file():
        raise Abbruch(f"{src} enthaelt kein Programm (bewerbungs_assistent fehlt).")
    if not (src / "bewerbungs_assistent_boot" / "__init__.py").is_file():
        raise Abbruch(f"{src} enthaelt den Startbaustein nicht (bewerbungs_assistent_boot fehlt).")
    for name in ("start_dashboard.py", "_selftest.py"):
        if not (basis / name).is_file():
            raise Abbruch(f"{basis / name} fehlt.")
    launcher = basis / "installer" / "boot" / "start_dashboard_launcher.py"
    if not launcher.is_file():
        raise Abbruch(f"{launcher} fehlt.")

    sys.path.insert(0, str(src))
    import bewerbungs_assistent_boot as boot

    version = _version_aus(src / "bewerbungs_assistent" / "__init__.py")
    if boot.sortschluessel(version) is None:
        raise Abbruch(f"'{version}' ist keine gueltige Versionsnummer.")

    app.mkdir(parents=True, exist_ok=True)
    versionen = app / boot.VERSIONEN
    versionen.mkdir(exist_ok=True)
    ziel = versionen / version
    neu = versionen / f"{version}.neu"
    bericht = {"version": version}

    try:
        _loeschen(neu)
        shutil.copytree(src, neu / "src", ignore=KOPIERT_NICHT)
        for name in ("start_dashboard.py", "_selftest.py"):
            shutil.copy2(basis / name, neu / name)
        (neu / "manifest.json").write_text(json.dumps({
            "format": 1, "version": version, "installiert_mit": "installer", "erstellt": _jetzt()}, indent=1),
            encoding="utf-8")

        aktuell_vorher = boot.lese_aktuell(app)
        _loeschen(ziel)                       # dieselbe Fassung noch einmal installieren: ersetzen
        os.replace(neu, ziel)
        (ziel / boot.FERTIG).write_text(json.dumps({"version": version, "zeit": _jetzt(), "von": "installer"}), encoding="utf-8")

        # Startbaustein und Starter: eine neuere Kopie ersetzt die aeltere
        boot_ziel = app / "boot" / "bewerbungs_assistent_boot"
        _loeschen(boot_ziel)
        shutil.copytree(src / "bewerbungs_assistent_boot", boot_ziel, ignore=KOPIERT_NICHT)
        shutil.copy2(launcher, app / "start_dashboard.py")

        # Der alte Aufbau: der Code lag in <Programmordner>/src
        _loeschen(app / "src")
        for datei in ("_selftest.py",):       # lag frueher neben dem Starter
            alt = app / datei
            if alt.is_file():
                alt.unlink()
        pth_anpassen(app / "python")

        # Zustand: eine bewusste Installation setzt die Vorgeschichte der Fassung zurueck
        status = boot.lese_status(app)
        bestaetigt = status.get("bestaetigt")
        vorherige = bestaetigt if bestaetigt and bestaetigt != version and boot.fassung_gueltig(app, bestaetigt) else aktuell_vorher
        if vorherige and vorherige != version and boot.fassung_gueltig(app, vorherige):
            status["vorherige"] = vorherige
        status["gescheitert"] = [v for v in status.get("gescheitert", []) if v != version]
        status.pop("rueckgang", None)
        status.pop("start", None)
        status["letzte_installation"] = {"version": version, "zeit": _jetzt(), "von": "installer"}
        boot.schreibe_status(app, status)

        boot.schreibe_atomar(app / boot.AKTUELL, version + "\n")          # als LETZTES
    except BaseException:
        shutil.rmtree(neu, ignore_errors=True)
        raise
    bericht["vorherige"] = vorherige if vorherige and vorherige != version else None
    bericht["aelter_als_vorher"] = bool(aktuell_vorher and boot.sortschluessel(version) < boot.sortschluessel(aktuell_vorher))
    return bericht


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()
    argv = list(sys.argv[1:] if argv is None else argv)
    if len(argv) != 2:
        print("Aufruf: python _programm_einrichten.py <entpackter Ordner> <Programmordner>", file=sys.stderr)
        return 1
    try:
        bericht = einrichten(argv[0], argv[1])
    except Abbruch as exc:
        print(f"[FEHLER] {exc}")
        return 1
    except Exception as exc:  # noqa: BLE001
        print(f"[FEHLER] Unerwartet: {type(exc).__name__}: {exc}")
        return 1
    print(f"[OK] Version {bericht['version']} eingerichtet")
    if bericht.get("aelter_als_vorher"):
        print("[INFO] Das ist eine AELTERE Version als die bisher installierte. Die Datenbank kann neuer sein: "
              "dann schreibt PBP vorerst nichts mehr und sagt, was zu tun ist.")
    return 0


if __name__ == "__main__":
    sys.exit(main())