"""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())