feat: stille Datenverlust-Pfade geschlossen, gemeinsame Shell-Lib (v0.7.0)
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>
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
"""PDF OCR Hotfolder — Scanner-PDFs automatisch durchsuchbar machen."""
|
||||
|
||||
__version__ = "0.6.3"
|
||||
__version__ = "0.7.0"
|
||||
|
||||
@@ -27,13 +27,32 @@ 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):
|
||||
@@ -55,7 +74,7 @@ def check_config(cfg_path: Path) -> int:
|
||||
print(f"FEHLER: {e}", file=sys.stderr)
|
||||
return CHECK_ERROR
|
||||
except tomllib.TOMLDecodeError as e:
|
||||
print(f"FEHLER: {cfg_path} ist kein gültiges TOML: {e}", file=sys.stderr)
|
||||
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)
|
||||
@@ -76,10 +95,19 @@ def check_config(cfg_path: Path) -> int:
|
||||
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)
|
||||
print(" Preflight ok (tesseract, gs vorhanden, Ghostscript-Version "
|
||||
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))
|
||||
@@ -142,6 +170,18 @@ def main() -> int:
|
||||
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)
|
||||
|
||||
@@ -156,13 +196,15 @@ def main() -> int:
|
||||
return 1 if errors > 0 else 0
|
||||
|
||||
try:
|
||||
service.run()
|
||||
# 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:
|
||||
pass
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -131,6 +131,29 @@ def _section(data: dict[str, Any], *keys: str) -> dict[str, Any]:
|
||||
return cur if isinstance(cur, dict) else {}
|
||||
|
||||
|
||||
def _require_absolute(value: str, label: str, cfg_path: Path,
|
||||
beispiel: str) -> None:
|
||||
"""Weist relative Pfadangaben zurück.
|
||||
|
||||
Ein relativer Pfad wird gegen das Arbeitsverzeichnis des Prozesses
|
||||
aufgelöst — bei der systemd-Unit also gegen `WorkingDirectory`
|
||||
(/opt/pdf-ocr-hotfolder). `incoming = "in"` legte damit stillschweigend
|
||||
/opt/pdf-ocr-hotfolder/in an: der Scanner schreibt woanders hin als der
|
||||
Dienst schaut, und niemand sieht einen Fehler. Absolute Pfade sind die
|
||||
einzige sinnvolle Angabe; install.sh erzeugt ohnehin nur solche.
|
||||
"""
|
||||
if not value or Path(value).is_absolute():
|
||||
return
|
||||
raise ConfigError(
|
||||
f"{cfg_path}: {label} = {value!r} ist ein relativer Pfad. Hier sind "
|
||||
f"nur absolute Pfade zulässig — ein relativer würde gegen das "
|
||||
f"Arbeitsverzeichnis des Dienstes aufgelöst "
|
||||
f"(WorkingDirectory, also z.B. /opt/pdf-ocr-hotfolder/{value}) und "
|
||||
f"nicht gegen das Verzeichnis, in dem die Config liegt. "
|
||||
f'Bitte absolut angeben, z.B. "{beispiel}".'
|
||||
)
|
||||
|
||||
|
||||
def _require_path(p: dict[str, Any], key: str, cfg_path: Path) -> Path:
|
||||
"""Holt einen Pflicht-Pfad aus der [paths]-Sektion.
|
||||
|
||||
@@ -144,7 +167,10 @@ def _require_path(p: dict[str, Any], key: str, cfg_path: Path) -> Path:
|
||||
f'{key} = "/var/lib/pdf-ocr-hotfolder/{key}" '
|
||||
f"— siehe config.example.toml."
|
||||
)
|
||||
return Path(str(value))
|
||||
value = str(value)
|
||||
_require_absolute(value, f"In der Sektion [paths] der Eintrag '{key}'",
|
||||
cfg_path, f"/var/lib/pdf-ocr-hotfolder/{key}")
|
||||
return Path(value)
|
||||
|
||||
|
||||
def _unknown_in(data: dict[str, Any], keys: tuple[str, ...],
|
||||
@@ -218,6 +244,13 @@ def load_config(path: str | Path) -> Config:
|
||||
email = EmailNotify(**{k: v for k, v in _section(data, "notify", "email").items()
|
||||
if k in EmailNotify.__annotations__})
|
||||
|
||||
# Dieselbe Regel wie für [paths]: beides sind Verzeichnisse, in die der
|
||||
# Dienst schreibt, und beide wären relativ aufgelöst schlicht falsch.
|
||||
_require_absolute(str(output.archive_dir), "[output].archive_dir", path,
|
||||
"/var/lib/pdf-ocr-hotfolder/archive")
|
||||
_require_absolute(str(folder.target), "[upload.folder].target", path,
|
||||
"/srv/scans/fertig")
|
||||
|
||||
log_level = _section(data, "logging").get("level", "INFO")
|
||||
|
||||
return Config(
|
||||
|
||||
+200
-37
@@ -2,9 +2,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from .config import OcrConfig, OutputConfig, VeraPdfConfig
|
||||
@@ -45,6 +47,27 @@ def build_output_name(src_name: str, mode: str, tag: str) -> str:
|
||||
raise ValueError(f"Unbekannter name_mode: {mode!r}")
|
||||
|
||||
|
||||
class VeraPdfUnavailable(RuntimeError):
|
||||
"""veraPDF konnte nicht befragt werden — Programm fehlt, startet nicht, Timeout.
|
||||
|
||||
Ausdrücklich KEIN inhaltliches Urteil über die PDF. Der Unterschied ist
|
||||
existenziell: ein nicht startbares veraPDF, das wie ein FAIL behandelt
|
||||
wird, schiebt JEDES OCR-Ergebnis nach error/ und entsorgt das Original
|
||||
laut [output].original_on_success — bei dessen Default `delete` also
|
||||
Scan für Scan die Vorlage, während der Dienst als "läuft" dasteht.
|
||||
"""
|
||||
|
||||
|
||||
# veraPDF schreibt mit `--format text` pro Datei eine Zeile, die mit dem
|
||||
# Urteil beginnt. Steht in der Ausgabe weder PASS noch FAIL, hat veraPDF gar
|
||||
# nichts geprüft (fehlendes Java, kaputter Wrapper, falsches Flavour) — das
|
||||
# ist kein "nicht konform", sondern ein fehlendes Urteil.
|
||||
_VERAPDF_VERDICTS = ("PASS", "FAIL")
|
||||
|
||||
# Sekunden, die veraPDF pro Datei laufen darf
|
||||
VERAPDF_TIMEOUT = 300
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessResult:
|
||||
source: Path
|
||||
@@ -52,6 +75,9 @@ class ProcessResult:
|
||||
success: bool
|
||||
error: str = ""
|
||||
verapdf_passed: bool | None = None
|
||||
# Gesetzt, wenn der Durchlauf erfolgreich war, aber etwas Nennenswertes
|
||||
# danebenlief (aktuell: das Original ließ sich nicht entsorgen).
|
||||
warning: str = ""
|
||||
|
||||
|
||||
def run_ocr(src: Path, dst: Path, cfg: OcrConfig) -> None:
|
||||
@@ -86,24 +112,65 @@ def run_ocr(src: Path, dst: Path, cfg: OcrConfig) -> None:
|
||||
log.info("OCR done: %s", dst.name)
|
||||
|
||||
|
||||
def resolve_verapdf_binary(binary: str) -> str | None:
|
||||
"""Sucht das veraPDF-Programm und prüft, ob es ausführbar ist.
|
||||
|
||||
Beide Schreibweisen sind zulässig: ein Pfad (`/opt/verapdf/verapdf`, der
|
||||
Default) wird direkt geprüft, ein nackter Name (`verapdf`) im PATH
|
||||
gesucht.
|
||||
|
||||
Returns:
|
||||
Der aufrufbare Pfad oder None.
|
||||
"""
|
||||
if not binary:
|
||||
return None
|
||||
if os.sep in binary:
|
||||
p = Path(binary)
|
||||
return str(p) if p.is_file() and os.access(p, os.X_OK) else None
|
||||
return shutil.which(binary)
|
||||
|
||||
|
||||
def run_verapdf(pdf: Path, cfg: VeraPdfConfig) -> bool:
|
||||
"""Validiert PDF/A mit veraPDF (CLI). Gibt True zurück, wenn konform."""
|
||||
"""Validiert PDF/A mit veraPDF (CLI). Gibt True zurück, wenn konform.
|
||||
|
||||
Returns:
|
||||
True = konform (PASS), False = nicht konform (FAIL).
|
||||
|
||||
Raises:
|
||||
VeraPdfUnavailable: veraPDF ließ sich nicht befragen. Das ist kein
|
||||
FAIL — siehe Klassen-Docstring.
|
||||
"""
|
||||
if not cfg.enabled:
|
||||
return True
|
||||
if not Path(cfg.binary).exists():
|
||||
log.warning("veraPDF binary nicht gefunden: %s", cfg.binary)
|
||||
return False
|
||||
binary = resolve_verapdf_binary(cfg.binary)
|
||||
if binary is None:
|
||||
raise VeraPdfUnavailable(
|
||||
f"[verapdf].binary = {cfg.binary!r} existiert nicht oder ist nicht "
|
||||
"ausführbar"
|
||||
)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[cfg.binary, "--flavour", cfg.flavour, "--format", "text", str(pdf)],
|
||||
capture_output=True, text=True, timeout=300,
|
||||
[binary, "--flavour", cfg.flavour, "--format", "text", str(pdf)],
|
||||
capture_output=True, text=True, timeout=VERAPDF_TIMEOUT,
|
||||
)
|
||||
ok = result.returncode == 0 and "PASS" in result.stdout
|
||||
log.info("veraPDF %s: %s", "PASS" if ok else "FAIL", pdf.name)
|
||||
return ok
|
||||
except subprocess.TimeoutExpired:
|
||||
log.error("veraPDF Timeout: %s", pdf.name)
|
||||
return False
|
||||
except subprocess.TimeoutExpired as e:
|
||||
raise VeraPdfUnavailable(
|
||||
f"veraPDF hat für {pdf.name} nach {VERAPDF_TIMEOUT} s nicht "
|
||||
"geantwortet"
|
||||
) from e
|
||||
except OSError as e:
|
||||
raise VeraPdfUnavailable(f"veraPDF ({binary}) nicht startbar: {e}") from e
|
||||
|
||||
if not any(v in result.stdout for v in _VERAPDF_VERDICTS):
|
||||
ausgabe = (result.stdout + result.stderr).strip().replace("\n", " ")
|
||||
raise VeraPdfUnavailable(
|
||||
f"veraPDF ({binary}) hat kein Urteil geliefert "
|
||||
f"(Exit {result.returncode}): {ausgabe[:300] or '(keine Ausgabe)'}"
|
||||
)
|
||||
|
||||
ok = result.returncode == 0 and "PASS" in result.stdout
|
||||
log.info("veraPDF %s: %s", "PASS" if ok else "FAIL", pdf.name)
|
||||
return ok
|
||||
|
||||
|
||||
def process_pdf(
|
||||
@@ -151,7 +218,24 @@ def process_pdf(
|
||||
|
||||
vera_ok: bool | None = None
|
||||
if vera_cfg.enabled:
|
||||
vera_ok = run_verapdf(work_out, vera_cfg)
|
||||
try:
|
||||
vera_ok = run_verapdf(work_out, vera_cfg)
|
||||
except VeraPdfUnavailable as e:
|
||||
# Kein Urteil über die Datei — also darf auch nichts entsorgt
|
||||
# werden. Original UND OCR-Ergebnis gehen nach error/; das
|
||||
# Original bleibt damit unabhängig von
|
||||
# [output].original_on_success erhalten.
|
||||
log.error(
|
||||
"veraPDF nicht aufrufbar (%s) — %s wird NICHT als ungültig "
|
||||
"gewertet: Original und OCR-Ergebnis liegen in %s, das "
|
||||
"Original wurde weder gelöscht noch archiviert. "
|
||||
"[verapdf].binary prüfen (--check-config)",
|
||||
e, src.name, error_dir,
|
||||
)
|
||||
_move_to_error(work_out, error_dir)
|
||||
_move_to_error(work_src, error_dir)
|
||||
return ProcessResult(src, final_out, False,
|
||||
f"veraPDF nicht aufrufbar: {e}")
|
||||
if not vera_ok:
|
||||
# Das OCR-Ergebnis ist unbrauchbar und wandert nach error/. Das
|
||||
# Original wird aber NICHT bedingungslos gelöscht: es folgt derselben
|
||||
@@ -168,9 +252,26 @@ def process_pdf(
|
||||
"verapdf validation failed", verapdf_passed=False)
|
||||
|
||||
outgoing_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Liegt in outgoing/ schon eine Datei desselben Namens (Scanner liefert
|
||||
# denselben Dateinamen ein zweites Mal, oder das Vorgängerergebnis wurde
|
||||
# noch nicht abgeholt), würde der move sie kommentarlos überschreiben.
|
||||
# Stattdessen derselbe Zeitstempel-Ausweg wie im Archiv.
|
||||
final_out = _collision_free_path(final_out)
|
||||
if final_out.name != out_name:
|
||||
log.warning(
|
||||
"In %s liegt bereits eine Datei %s — das neue OCR-Ergebnis wird "
|
||||
"als %s abgelegt, damit das ältere nicht überschrieben wird",
|
||||
outgoing_dir, out_name, final_out.name,
|
||||
)
|
||||
shutil.move(str(work_out), str(final_out))
|
||||
_dispose_original(work_src, src.name, output_cfg)
|
||||
return ProcessResult(src, final_out, True, verapdf_passed=vera_ok)
|
||||
# Scheitert die Entsorgung des Originals (Platte voll, read-only), ist der
|
||||
# Durchlauf trotzdem gelungen: das fertige PDF liegt bereits in outgoing/.
|
||||
# Der Fehler darf ihn deshalb nicht entwerten — sonst unterbleibt der
|
||||
# Upload und das Ergebnis bleibt liegen. Er wird als Warnung
|
||||
# weitergereicht und landet in der Benachrichtigung.
|
||||
warning = _dispose_original(work_src, src.name, output_cfg)
|
||||
return ProcessResult(src, final_out, True, verapdf_passed=vera_ok,
|
||||
warning=warning)
|
||||
|
||||
|
||||
def _is_same_file(a: Path, b: Path) -> bool:
|
||||
@@ -181,41 +282,103 @@ def _is_same_file(a: Path, b: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _dispose_original(work_src: Path, original_name: str, cfg: OutputConfig) -> None:
|
||||
def _collision_free_path(dest: Path) -> Path:
|
||||
"""Weicht einem schon belegten Zielnamen per Zeitstempel-Suffix aus.
|
||||
|
||||
Einheitlich für outgoing/ und Archiv: `scan.pdf` wird zu
|
||||
`scan_20260923-081500.pdf`. Ist auch der Zeitstempel-Name belegt (zwei
|
||||
Dateien innerhalb derselben Sekunde, z.B. bei mehreren Workern), wird
|
||||
zusätzlich hochgezählt — sonst überschriebe der anschließende `move` doch
|
||||
wieder still.
|
||||
|
||||
Der Rest bleibt unverändert: existiert das Ziel nicht, kommt es
|
||||
unverändert zurück.
|
||||
"""
|
||||
if not dest.exists():
|
||||
return dest
|
||||
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
candidate = dest.with_name(f"{dest.stem}_{ts}{dest.suffix}")
|
||||
counter = 2
|
||||
while candidate.exists():
|
||||
candidate = dest.with_name(f"{dest.stem}_{ts}-{counter}{dest.suffix}")
|
||||
counter += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def _dispose_original(work_src: Path, original_name: str, cfg: OutputConfig) -> str:
|
||||
"""Entsorgt das Original laut [output].original_on_success — löschen oder archivieren.
|
||||
|
||||
Wird nach erfolgreichem OCR aufgerufen und ebenso, wenn veraPDF die
|
||||
Validierung ablehnt: auch dann soll `archive` das Original erhalten.
|
||||
|
||||
Wirft bewusst NICHT: zum Aufrufzeitpunkt liegt das fertige PDF schon in
|
||||
outgoing/. Eine Exception von hier würde den gelungenen Durchlauf im
|
||||
Catch-all des Service in einen Fehler verwandeln — mitsamt
|
||||
ausgefallenem Upload.
|
||||
|
||||
Returns:
|
||||
Leerer String = erledigt. Sonst die Fehlermeldung (bereits geloggt).
|
||||
"""
|
||||
if not work_src.exists():
|
||||
return
|
||||
return ""
|
||||
mode = cfg.original_on_success
|
||||
if mode == "delete":
|
||||
work_src.unlink(missing_ok=True)
|
||||
return
|
||||
if mode == "archive":
|
||||
if not cfg.archive_dir:
|
||||
log.error("original_on_success=archive aber archive_dir ist leer — lösche stattdessen")
|
||||
work_src.unlink(missing_ok=True)
|
||||
return
|
||||
if mode == "archive" and cfg.archive_dir:
|
||||
archive = Path(cfg.archive_dir)
|
||||
archive.mkdir(parents=True, exist_ok=True)
|
||||
dest = archive / original_name
|
||||
# Bei Namens-Kollision mit Timestamp umbenennen
|
||||
if dest.exists():
|
||||
from datetime import datetime
|
||||
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
dest = archive / f"{dest.stem}_{ts}{dest.suffix}"
|
||||
shutil.move(str(work_src), str(dest))
|
||||
try:
|
||||
archive.mkdir(parents=True, exist_ok=True)
|
||||
# Bei Namens-Kollision mit Timestamp umbenennen (gleicher Weg wie
|
||||
# für das Ergebnis in outgoing/)
|
||||
dest = _collision_free_path(archive / original_name)
|
||||
shutil.move(str(work_src), str(dest))
|
||||
except OSError as e:
|
||||
return _disposal_failed(work_src, original_name,
|
||||
f"nicht nach {archive} archiviert", e)
|
||||
log.info("Original archiviert: %s", dest)
|
||||
return
|
||||
log.warning("Unbekannter original_on_success=%r — lösche stattdessen", mode)
|
||||
work_src.unlink(missing_ok=True)
|
||||
return ""
|
||||
|
||||
if mode == "archive":
|
||||
log.error("original_on_success=archive aber archive_dir ist leer — "
|
||||
"lösche stattdessen")
|
||||
elif mode != "delete":
|
||||
log.warning("Unbekannter original_on_success=%r — lösche stattdessen", mode)
|
||||
try:
|
||||
work_src.unlink(missing_ok=True)
|
||||
except OSError as e:
|
||||
return _disposal_failed(work_src, original_name, "nicht gelöscht", e)
|
||||
return ""
|
||||
|
||||
|
||||
def _disposal_failed(work_src: Path, original_name: str, was: str,
|
||||
exc: OSError) -> str:
|
||||
"""Einheitliche Meldung, wenn das Original nicht entsorgt werden konnte."""
|
||||
msg = (
|
||||
f"Original {original_name} konnte {was} werden ({exc}). Das OCR-PDF ist "
|
||||
f"fertig und wird normal ausgeliefert, das Original liegt aber "
|
||||
f"weiterhin in {work_src.parent} — es wird beim nächsten Start dort "
|
||||
f"aufgegriffen und ein zweites Mal durch das OCR geschickt. Bitte "
|
||||
f"{work_src} von Hand aufräumen und die Ursache beheben "
|
||||
f"(Plattenplatz, Schreibrechte)."
|
||||
)
|
||||
log.error("%s", msg)
|
||||
return msg
|
||||
|
||||
|
||||
def _move_to_error(p: Path, error_dir: Path) -> None:
|
||||
"""Verschiebt eine Datei ins error-Verzeichnis, ohne dort etwas zu überschreiben.
|
||||
|
||||
Scheitert dieselbe `scan.pdf` zweimal, ersetzte die zweite bisher still die
|
||||
erste — dieselbe Datenverlust-Klasse wie in outgoing/. Deshalb derselbe
|
||||
Zeitstempel-Ausweg über `_collision_free_path()`.
|
||||
"""
|
||||
error_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = _collision_free_path(error_dir / p.name)
|
||||
if dest.name != p.name:
|
||||
log.warning(
|
||||
"In %s liegt bereits eine Datei %s — die neue wird als %s abgelegt, "
|
||||
"damit die ältere nicht überschrieben wird",
|
||||
error_dir, p.name, dest.name,
|
||||
)
|
||||
try:
|
||||
shutil.move(str(p), str(error_dir / p.name))
|
||||
shutil.move(str(p), str(dest))
|
||||
except OSError:
|
||||
log.exception("Konnte %s nicht in error-Verzeichnis verschieben", p)
|
||||
|
||||
@@ -22,6 +22,7 @@ from .processor import (
|
||||
ProcessResult,
|
||||
_move_to_error,
|
||||
process_pdf,
|
||||
resolve_verapdf_binary,
|
||||
)
|
||||
from .uploaders import notify_email, upload_folder, upload_nextcloud, upload_sftp
|
||||
|
||||
@@ -32,6 +33,13 @@ class PreflightError(RuntimeError):
|
||||
"""Erforderliche externe Binaries fehlen."""
|
||||
|
||||
|
||||
# Exit-Code, mit dem sich der Dienst bei totem watchdog-Observer beendet.
|
||||
# Bewusst NICHT 2: die Unit setzt RestartPreventExitStatus=2 für Config- und
|
||||
# Preflight-Fehler, die ein Neustart nicht heilt. Ein toter Observer soll
|
||||
# dagegen genau das — neu starten, damit der inotify-Watch neu aufgesetzt wird.
|
||||
EXIT_OBSERVER_DEAD = 3
|
||||
|
||||
|
||||
# Pflicht-Binaries für ocrmypdf
|
||||
_REQUIRED_BINARIES = ("tesseract", "gs")
|
||||
|
||||
@@ -140,13 +148,47 @@ def check_output_config(mode: str, archive_dir: str,
|
||||
)
|
||||
|
||||
|
||||
def check_preflight(pdfa_level: str = "", skip_text: bool = False) -> None:
|
||||
def check_verapdf_binary(enabled: bool, binary: str) -> None:
|
||||
"""Prüft das in [verapdf].binary konfigurierte Programm — wenn aktiviert.
|
||||
|
||||
Ohne diese Prüfung ist ein Tippfehler im Pfad der gefährlichste Fehler des
|
||||
ganzen Dienstes: `run_verapdf()` findet das Programm für JEDE Datei nicht,
|
||||
das OCR-Ergebnis wandert nach error/, und `_dispose_original()` löscht bei
|
||||
`original_on_success = "delete"` (dem Default) das Original. Scan für Scan
|
||||
verschwinden so die Vorlagen, während die Unit als `active (running)`
|
||||
dasteht.
|
||||
"""
|
||||
if not enabled:
|
||||
return
|
||||
if not binary:
|
||||
raise PreflightError(
|
||||
"[verapdf].enabled = true, aber [verapdf].binary ist leer. "
|
||||
"Entweder den Pfad zum veraPDF-Programm eintragen oder "
|
||||
"[verapdf].enabled = false setzen."
|
||||
)
|
||||
if resolve_verapdf_binary(binary) is None:
|
||||
raise PreflightError(
|
||||
f"[verapdf].enabled = true, aber [verapdf].binary = {binary!r} "
|
||||
"existiert nicht oder ist nicht ausführbar. Der Dienst startet "
|
||||
"bewusst nicht: ein nicht aufrufbares veraPDF würde sonst jede "
|
||||
"einzelne PDF als ungültig werten, das OCR-Ergebnis nach error/ "
|
||||
"schieben und das Original laut [output].original_on_success "
|
||||
"entsorgen. Pfad korrigieren (chmod +x nicht vergessen) oder "
|
||||
"[verapdf].enabled = false setzen."
|
||||
)
|
||||
|
||||
|
||||
def check_preflight(pdfa_level: str = "", skip_text: bool = False,
|
||||
verapdf_enabled: bool = False,
|
||||
verapdf_binary: str = "") -> None:
|
||||
"""Prüft externe Abhängigkeiten.
|
||||
|
||||
- Tesseract und Ghostscript müssen im PATH sein
|
||||
- Die Ghostscript-Version wird gegen den bekannten 10.0.0–10.02.0 Bug
|
||||
geprüft, und zwar genau unter der Bedingung, unter der ocrmypdf selbst
|
||||
abbricht (siehe `_gs_block_reason`).
|
||||
- Ist [verapdf].enabled gesetzt, muss auch das dort konfigurierte
|
||||
Programm vorhanden und ausführbar sein (siehe `check_verapdf_binary`).
|
||||
|
||||
Wirft PreflightError bei fehlenden Binaries oder unsicherem Ghostscript.
|
||||
"""
|
||||
@@ -161,6 +203,8 @@ def check_preflight(pdfa_level: str = "", skip_text: bool = False) -> None:
|
||||
if reason:
|
||||
raise PreflightError(reason)
|
||||
|
||||
check_verapdf_binary(verapdf_enabled, verapdf_binary)
|
||||
|
||||
|
||||
def _gs_block_reason(pdfa_level: str, skip_text: bool) -> str | None:
|
||||
"""Liefert die Fehlermeldung, wenn ocrmypdf mit diesem Ghostscript abbricht.
|
||||
@@ -298,11 +342,20 @@ class HotfolderService:
|
||||
|
||||
# ---- Lifecycle ----
|
||||
|
||||
def run(self) -> None:
|
||||
check_preflight(self.cfg.ocr.pdfa_level, self.cfg.ocr.skip_text)
|
||||
def _preflight(self) -> None:
|
||||
check_preflight(self.cfg.ocr.pdfa_level, self.cfg.ocr.skip_text,
|
||||
self.cfg.verapdf.enabled, self.cfg.verapdf.binary)
|
||||
check_output_config(self.cfg.output.original_on_success,
|
||||
self.cfg.output.archive_dir,
|
||||
self.cfg.output.name_mode)
|
||||
|
||||
def run(self) -> int:
|
||||
"""Startet den Dienst und läuft, bis gestoppt wird.
|
||||
|
||||
Returns:
|
||||
0 bei regulärem Stopp (SIGTERM/SIGINT), sonst `EXIT_OBSERVER_DEAD`.
|
||||
"""
|
||||
self._preflight()
|
||||
self.ensure_dirs()
|
||||
self._scan_existing()
|
||||
|
||||
@@ -315,21 +368,49 @@ class HotfolderService:
|
||||
signal.signal(signal.SIGINT, lambda *_: self._stop.set())
|
||||
|
||||
try:
|
||||
while not self._stop.is_set():
|
||||
self._stop.wait(1.0)
|
||||
return self._wait_loop()
|
||||
finally:
|
||||
self.shutdown()
|
||||
|
||||
def _wait_loop(self) -> int:
|
||||
"""Hauptschleife: wartet auf den Stopp und bewacht den Observer.
|
||||
|
||||
Stirbt der watchdog-Observer im Betrieb (erschöpftes
|
||||
inotify-Watch-Limit, ersetztes oder neu gemountetes Verzeichnis),
|
||||
blieb die Unit bisher `active (running)` und verarbeitete nichts mehr:
|
||||
kein Log, keine Mail, niemand merkt es. Für einen Hotfolder ist das
|
||||
der schlechteste denkbare Zustand. Deshalb wird der Observer
|
||||
sekündlich mitgeprüft und der Dienst im Ernstfall mit
|
||||
`EXIT_OBSERVER_DEAD` beendet, damit systemd ihn per
|
||||
`Restart=on-failure` neu startet und den Watch neu aufsetzt.
|
||||
"""
|
||||
while not self._stop.is_set():
|
||||
self._stop.wait(1.0)
|
||||
if self._stop.is_set():
|
||||
# Regulärer Stopp — hier darf kein Fehlalarm entstehen, auch
|
||||
# wenn der Observer planmäßig schon gestoppt wurde.
|
||||
break
|
||||
if self._observer is not None and not self._observer.is_alive():
|
||||
log.error(
|
||||
"Der Verzeichnis-Watch auf %s ist gestorben — es werden "
|
||||
"KEINE neuen Dateien mehr erkannt. Mögliche Ursachen: "
|
||||
"erschöpftes inotify-Watch-Limit "
|
||||
"(fs.inotify.max_user_watches), ersetztes oder neu "
|
||||
"gemountetes Verzeichnis. Der Dienst beendet sich mit "
|
||||
"Exit %d, damit systemd ihn neu startet und der Watch "
|
||||
"neu aufgesetzt wird.",
|
||||
self.cfg.paths.incoming, EXIT_OBSERVER_DEAD,
|
||||
)
|
||||
return EXIT_OBSERVER_DEAD
|
||||
return 0
|
||||
|
||||
def run_once(self) -> int:
|
||||
"""Verarbeitet alle bereits liegenden PDFs (incoming/ + working/) und beendet sich.
|
||||
|
||||
Returns:
|
||||
Anzahl fehlgeschlagener PDFs (0 = alles ok).
|
||||
"""
|
||||
check_preflight(self.cfg.ocr.pdfa_level, self.cfg.ocr.skip_text)
|
||||
check_output_config(self.cfg.output.original_on_success,
|
||||
self.cfg.output.archive_dir,
|
||||
self.cfg.output.name_mode)
|
||||
self._preflight()
|
||||
self.ensure_dirs()
|
||||
self._scan_existing()
|
||||
self._executor.shutdown(wait=True)
|
||||
@@ -356,9 +437,32 @@ class HotfolderService:
|
||||
incoming-Datei nach working/ will.
|
||||
"""
|
||||
self._scan_working()
|
||||
fremd: list[str] = []
|
||||
for p in sorted(self.cfg.paths.incoming.iterdir()):
|
||||
if _is_pdf(p):
|
||||
self.enqueue(p)
|
||||
elif p.is_file():
|
||||
fremd.append(p.name)
|
||||
self._report_non_pdf(fremd)
|
||||
|
||||
def _report_non_pdf(self, names: list[str]) -> None:
|
||||
"""Meldet einmalig, wie viele Fremddateien in incoming/ liegen.
|
||||
|
||||
Alles ohne .pdf-Endung wird ignoriert und sammelte sich bisher stumm
|
||||
an — Scanner-Fehlablagen, abgebrochene Uploads, Thumbnails. Eine
|
||||
Sammelmeldung beim Start-Scan, keine Zeile pro Datei und nichts im
|
||||
laufenden Betrieb: das soll auffallen, nicht spammen.
|
||||
"""
|
||||
if not names:
|
||||
return
|
||||
beispiele = ", ".join(names[:3])
|
||||
if len(names) > 3:
|
||||
beispiele += f", … (+{len(names) - 3} weitere)"
|
||||
log.warning(
|
||||
"In %s liegen %d Datei(en) ohne .pdf-Endung — sie werden nicht "
|
||||
"verarbeitet und bleiben dort liegen: %s",
|
||||
self.cfg.paths.incoming, len(names), beispiele,
|
||||
)
|
||||
|
||||
def _scan_working(self) -> None:
|
||||
"""Greift Dateien auf, die ein harter Stopp in working/ liegen ließ.
|
||||
@@ -563,6 +667,19 @@ class HotfolderService:
|
||||
notify_email(self.cfg.email, subject, body, False)
|
||||
|
||||
def _notify(self, result: ProcessResult) -> None:
|
||||
if result.success and result.warning:
|
||||
# Erfolgreich verarbeitet, aber das Original blieb liegen. Der
|
||||
# Durchlauf zählt als Erfolg (das PDF ist fertig und ausgeliefert),
|
||||
# die Mail geht aber als Nicht-Erfolg raus, damit sie auch bei
|
||||
# [notify.email].on = "errors" zugestellt wird — sonst wäre das
|
||||
# genau wieder ein stiller Fehlerpfad.
|
||||
subject = f"[pdf-ocr] OK mit Warnung: {result.source.name}"
|
||||
body = (
|
||||
f"Datei verarbeitet: {result.output}\n\n"
|
||||
f"ACHTUNG: {result.warning}\n"
|
||||
)
|
||||
notify_email(self.cfg.email, subject, body, False)
|
||||
return
|
||||
if result.success:
|
||||
subject = f"[pdf-ocr] OK: {result.source.name}"
|
||||
body = f"Datei verarbeitet: {result.output}\n"
|
||||
|
||||
@@ -13,6 +13,7 @@ import paramiko
|
||||
import requests
|
||||
|
||||
from .config import EmailNotify, FolderUpload, NextcloudUpload, SftpUpload
|
||||
from .processor import _collision_free_path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -26,6 +27,15 @@ def upload_folder(pdf: Path, cfg: FolderUpload, default_target: Path) -> bool:
|
||||
try:
|
||||
if pdf.resolve() == dest.resolve():
|
||||
return True
|
||||
# Gleichnamige Datei im Ziel wurde bisher kommentarlos ersetzt.
|
||||
# Derselbe Zeitstempel-Ausweg wie in outgoing/, archive/ und error/.
|
||||
dest = _collision_free_path(dest)
|
||||
if dest.name != pdf.name:
|
||||
log.warning(
|
||||
"In %s liegt bereits eine Datei %s — die Kopie wird als %s "
|
||||
"abgelegt, damit die ältere nicht überschrieben wird",
|
||||
target, pdf.name, dest.name,
|
||||
)
|
||||
# copyfile statt read_bytes/write_bytes: große PDFs nicht komplett
|
||||
# in den Speicher laden
|
||||
shutil.copyfile(pdf, dest)
|
||||
|
||||
Reference in New Issue
Block a user