cd803a3dfe
Vor dem Rollout durchgesehen und die verbliebenen Stellen geschlossen, an denen etwas schiefgehen konnte, ohne dass es irgendwo sichtbar wurde. Datenverlust: - veraPDF: das in [verapdf].binary konfigurierte Programm wird im Preflight geprueft. Bisher galt bei falschem Pfad JEDE Datei als "nicht konform" — Ergebnis nach error/, Original geloescht (Default delete). run_verapdf() trennt jetzt ausserdem ein echtes FAIL-Urteil von einer Stoerung (VeraPdfUnavailable: nicht startbar, abgestuerzt, kein PASS/FAIL in der Ausgabe). Bei Stoerung wandern Original UND Ergebnis nach error/, das Original wird nicht entsorgt. - Gleichnamige Dateien wurden in outgoing/, error/ und beim Ordner-Upload mit abweichendem target kommentarlos ueberschrieben. Jetzt Zeitstempel daneben, mit Warnung; ProcessResult.output traegt den echten Pfad. Robustheit: - Kaputtes oder nicht lesbares TOML beim Start: Exit 2 statt Traceback. - RestartPreventExitStatus=2 in der Unit — Exit 2 (Config/Preflight) laeuft nicht mehr endlos neu, die Instanz bleibt sichtbar failed stehen. - Toter watchdog-Observer wird erkannt: Exit 3, systemd setzt den Watch neu auf. Vorher blieb die Unit "active" und verarbeitete nichts mehr. - Relative Pfade in [paths]/archive_dir/target sind ein Config-Fehler statt still unter /opt zu landen. - Fehler beim Archivieren entwertet den Durchlauf nicht mehr: Upload und Mail laufen, Sichtbarkeit ueber log.error + "OK mit Warnung"-Mail. - Nicht-PDFs in incoming/ werden beim Start-Scan gesammelt gemeldet. - Logging explizit nach stdout (die Doku versprach das schon). Struktur: - Neue lib/common.sh, von install.sh und update.sh gesourct. Die doppelte venv_is_healthy() gibt es nur noch einmal, in der gruendlichen Fassung — die schlanke in install.sh haette eine nach einem Distro-Sprung kaputte venv als gesund durchgewunken (nachgewiesen). - install.sh warnt in Containern, wenn systemd-journald nicht laeuft. Doku: Dateisystem-Festlegung (ext4/xfs/zfs, kein CIFS/NFS wegen inotify), Debian 13 in LXC auf Proxmox scheitert an journald (243/CREDENTIALS, AppArmor blockiert sd-mkdcreds) inkl. Abhilfe, echte Speicher-Messwerte, Exit-Code-Tabelle. 254 Tests gruen (vorher 152). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
212 lines
7.6 KiB
Python
212 lines
7.6 KiB
Python
"""CLI-Entrypoint."""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import logging
|
|
import sys
|
|
import tomllib
|
|
from pathlib import Path
|
|
|
|
from . import __version__
|
|
from .config import Config, ConfigError, config_warnings, load_config
|
|
from .service import (
|
|
HotfolderService,
|
|
PreflightError,
|
|
check_output_config,
|
|
check_preflight,
|
|
detect_ghostscript_version,
|
|
detect_ocrmypdf_version,
|
|
)
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# Exit-Codes von --check-config (werden vom Updater ausgewertet)
|
|
CHECK_OK = 0
|
|
CHECK_WARN = 1
|
|
CHECK_ERROR = 2
|
|
|
|
|
|
def _setup_logging(level: str) -> None:
|
|
# stream explizit auf stdout: der Default von basicConfig() ist stderr,
|
|
# README und docs/INSTALLATION.md versprechen aber stdout. Für journald
|
|
# ist das egal, für den dort beschriebenen Vordergrund-Notbehelf und für
|
|
# jeden, der die Ausgabe weiterleitet, nicht.
|
|
logging.basicConfig(
|
|
level=getattr(logging, level.upper(), logging.INFO),
|
|
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
|
datefmt="%Y-%m-%d %H:%M:%S",
|
|
stream=sys.stdout,
|
|
)
|
|
|
|
|
|
def _toml_error_text(cfg_path: Path, exc: tomllib.TOMLDecodeError) -> str:
|
|
"""Formuliert die Meldung für kaputtes TOML — mit Zeile/Spalte, wenn möglich.
|
|
|
|
`TOMLDecodeError.lineno`/`.colno` gibt es erst ab Python 3.14. Auf Debian
|
|
12 (Python 3.11) fehlen die Attribute, dort steht die Position nur im
|
|
Meldungstext ("... (at line 3, column 12)") — deshalb `getattr` statt
|
|
direktem Zugriff.
|
|
"""
|
|
lineno = getattr(exc, "lineno", None)
|
|
colno = getattr(exc, "colno", None)
|
|
pos = f" (Zeile {lineno}, Spalte {colno})" if lineno is not None else ""
|
|
return f"{cfg_path} ist kein gültiges TOML{pos}: {exc}"
|
|
|
|
|
|
def _log_config_warnings(cfg: Config) -> None:
|
|
"""Schreibt Legacy- und Unbekannt-Warnungen beim Dienststart ins Log."""
|
|
for warning in config_warnings(cfg):
|
|
log.warning("Config-Warnung: %s", warning)
|
|
|
|
|
|
def check_config(cfg_path: Path) -> int:
|
|
"""Lädt und prüft die Config, ohne irgendetwas zu verarbeiten.
|
|
|
|
Returns:
|
|
0 = alles sauber, 1 = nur Warnungen, 2 = Fehler (Config unbrauchbar
|
|
oder Preflight scheitert).
|
|
"""
|
|
print(f"Prüfe Konfiguration: {cfg_path}")
|
|
|
|
try:
|
|
cfg = load_config(cfg_path)
|
|
except ConfigError as e:
|
|
print(f"FEHLER: {e}", file=sys.stderr)
|
|
return CHECK_ERROR
|
|
except tomllib.TOMLDecodeError as e:
|
|
print(f"FEHLER: {_toml_error_text(cfg_path, e)}", file=sys.stderr)
|
|
return CHECK_ERROR
|
|
except OSError as e:
|
|
print(f"FEHLER: {cfg_path} nicht lesbar: {e}", file=sys.stderr)
|
|
return CHECK_ERROR
|
|
|
|
print(" Config gelesen.")
|
|
for label, path in (("incoming", cfg.paths.incoming),
|
|
("outgoing", cfg.paths.outgoing),
|
|
("working ", cfg.paths.working),
|
|
("error ", cfg.paths.error)):
|
|
hint = "" if path.is_dir() else " (existiert noch nicht, "\
|
|
"wird beim Start angelegt)"
|
|
print(f" {label} = {path}{hint}")
|
|
print(f" OCR-Sprachen = {cfg.ocr.languages}")
|
|
print(f" Seiten-Timeout= {cfg.ocr.timeout} s")
|
|
print(f" PDF/A-Level = {cfg.ocr.pdfa_level or '(aus)'}")
|
|
print(f" skip_text = {'an' if cfg.ocr.skip_text else 'aus'}")
|
|
print(f" ocrmypdf = {detect_ocrmypdf_version() or '(nicht installiert)'}")
|
|
print(f" Ghostscript = {detect_ghostscript_version() or '(nicht gefunden)'}")
|
|
|
|
if cfg.verapdf.enabled:
|
|
print(f" veraPDF = {cfg.verapdf.binary} (Flavour "
|
|
f"{cfg.verapdf.flavour})")
|
|
else:
|
|
print(" veraPDF = (aus)")
|
|
|
|
errors: list[str] = []
|
|
try:
|
|
check_preflight(cfg.ocr.pdfa_level, cfg.ocr.skip_text,
|
|
cfg.verapdf.enabled, cfg.verapdf.binary)
|
|
print(" Preflight ok (tesseract, gs"
|
|
+ (", veraPDF" if cfg.verapdf.enabled else "")
|
|
+ " vorhanden, Ghostscript-Version "
|
|
"passt zu ocrmypdf + [ocr]-Einstellungen).")
|
|
except PreflightError as e:
|
|
errors.append(str(e))
|
|
try:
|
|
check_output_config(cfg.output.original_on_success,
|
|
cfg.output.archive_dir,
|
|
cfg.output.name_mode)
|
|
print(" [output]-Sektion ok.")
|
|
except PreflightError as e:
|
|
errors.append(str(e))
|
|
|
|
warnings = config_warnings(cfg)
|
|
if warnings:
|
|
print(f"\n{len(warnings)} Warnung(en):")
|
|
for w in warnings:
|
|
print(f" WARNUNG: {w}")
|
|
|
|
if errors:
|
|
print(f"\n{len(errors)} Fehler:", file=sys.stderr)
|
|
for e in errors:
|
|
print(f" FEHLER: {e}", file=sys.stderr)
|
|
print("\nErgebnis: Config unbrauchbar — der Dienst würde nicht starten.",
|
|
file=sys.stderr)
|
|
return CHECK_ERROR
|
|
|
|
if warnings:
|
|
print("\nErgebnis: Config nutzbar, aber mit Warnungen.")
|
|
return CHECK_WARN
|
|
|
|
print("\nErgebnis: Config sauber.")
|
|
return CHECK_OK
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(
|
|
prog="pdf-ocr-hotfolder",
|
|
description="Wandelt eingehende PDFs per OCR in durchsuchbare PDFs um.",
|
|
)
|
|
parser.add_argument("--config", "-c", default="/etc/pdf-ocr-hotfolder/config.toml",
|
|
help="Pfad zur Konfigurationsdatei (TOML)")
|
|
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
parser.add_argument("--once", action="store_true",
|
|
help="Nur bestehende Dateien verarbeiten und beenden")
|
|
parser.add_argument("--check-config", action="store_true", dest="check_config",
|
|
help="Config nur prüfen, nichts verarbeiten "
|
|
"(Exit 0 = sauber, 1 = Warnungen, 2 = Fehler)")
|
|
args = parser.parse_args()
|
|
|
|
cfg_path = Path(args.config)
|
|
if not cfg_path.exists():
|
|
print(f"Config nicht gefunden: {cfg_path}", file=sys.stderr)
|
|
return 2
|
|
|
|
if args.check_config:
|
|
# Hat Vorrang vor --once: es wird nichts verarbeitet.
|
|
return check_config(cfg_path)
|
|
|
|
try:
|
|
cfg = load_config(cfg_path)
|
|
except ConfigError as e:
|
|
print(f"FEHLER: {e}", file=sys.stderr)
|
|
return 2
|
|
except tomllib.TOMLDecodeError as e:
|
|
# Ohne diesen Zweig endet ein Tippfehler in der Config (unbalancierte
|
|
# Anführungszeichen o.ä.) beim Dienststart in einem nackten Traceback.
|
|
# Dieselbe Behandlung wie in --check-config: verständliche Meldung,
|
|
# Exit 2 = Config-Fehler.
|
|
print(f"FEHLER: {_toml_error_text(cfg_path, e)}", file=sys.stderr)
|
|
print("Der Dienst startet nicht. Config korrigieren und mit "
|
|
"--check-config gegenprüfen.", file=sys.stderr)
|
|
return 2
|
|
except OSError as e:
|
|
print(f"FEHLER: {cfg_path} nicht lesbar: {e}", file=sys.stderr)
|
|
return 2
|
|
_setup_logging(cfg.log_level)
|
|
_log_config_warnings(cfg)
|
|
|
|
service = HotfolderService(cfg)
|
|
|
|
if args.once:
|
|
try:
|
|
errors = service.run_once()
|
|
except PreflightError as e:
|
|
print(f"FEHLER: {e}", file=sys.stderr)
|
|
return 2
|
|
return 1 if errors > 0 else 0
|
|
|
|
try:
|
|
# run() liefert 0 bei regulärem Stopp und EXIT_OBSERVER_DEAD, wenn der
|
|
# Verzeichnis-Watch gestorben ist — Letzteres muss nach außen
|
|
# durchschlagen, sonst startet systemd den Dienst nicht neu.
|
|
return service.run()
|
|
except PreflightError as e:
|
|
print(f"FEHLER: {e}", file=sys.stderr)
|
|
return 2
|
|
except KeyboardInterrupt:
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|