fix: Fehlerzaehlung, Upload-Fehler und ocr.timeout scharf (v0.4.0)
- [ocr].timeout wird als tesseract_timeout (pro Seite) an ocrmypdf durchgereicht; Default 1800 -> 300, 0 = ocrmypdf-Default - Exceptions nach dem OCR zaehlen als Fehler, Datei wird nach error/ gerettet - Fehlgeschlagene Uploads zaehlen als Fehler und loesen Fehler-Mail aus - name_mode wird im Preflight geprueft, nicht erst pro Datei - Fehlende [paths]-Sektion -> ConfigError mit klarer Meldung statt KeyError - Stabilitaets-Timeout zaehlt als Fehler (--once liefert Exit 1) - upload_folder nutzt shutil.copyfile statt read_bytes/write_bytes - OcrConfig.pdfa_level Default "2" -> "" (Ghostscript-Bug, Issue #3) - 35 neue Tests (92 gesamt), pytest.ini - AI_AGENT_BRIEFING.md auf Stand 0.4.0 gebracht Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+119
-27
@@ -15,7 +15,7 @@ from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
||||
from watchdog.observers import Observer
|
||||
|
||||
from .config import Config
|
||||
from .processor import ProcessResult, process_pdf
|
||||
from .processor import VALID_NAME_MODES, ProcessResult, _move_to_error, process_pdf
|
||||
from .uploaders import notify_email, upload_folder, upload_nextcloud, upload_sftp
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -72,7 +72,8 @@ def detect_ghostscript_version() -> str | None:
|
||||
return result.stdout.strip() or None
|
||||
|
||||
|
||||
def check_output_config(mode: str, archive_dir: str) -> None:
|
||||
def check_output_config(mode: str, archive_dir: str,
|
||||
name_mode: str = "prefix") -> None:
|
||||
"""Validiert die [output]-Section. Wirft PreflightError bei Problemen."""
|
||||
valid_modes = {"delete", "archive"}
|
||||
if mode not in valid_modes:
|
||||
@@ -84,6 +85,13 @@ def check_output_config(mode: str, archive_dir: str) -> None:
|
||||
raise PreflightError(
|
||||
"[output].original_on_success='archive' erfordert [output].archive_dir"
|
||||
)
|
||||
# Früh prüfen: sonst schlägt ein Tippfehler erst pro Datei zu — und zwar
|
||||
# NACH dem Move nach working/, wo die Datei dann liegen bleibt.
|
||||
if name_mode not in VALID_NAME_MODES:
|
||||
raise PreflightError(
|
||||
f"[output].name_mode={name_mode!r} ungültig. "
|
||||
f"Erlaubt: {sorted(VALID_NAME_MODES)}"
|
||||
)
|
||||
|
||||
|
||||
def check_preflight(pdfa_level: str = "") -> None:
|
||||
@@ -188,7 +196,8 @@ class HotfolderService:
|
||||
def run(self) -> None:
|
||||
check_preflight(self.cfg.ocr.pdfa_level)
|
||||
check_output_config(self.cfg.output.original_on_success,
|
||||
self.cfg.output.archive_dir)
|
||||
self.cfg.output.archive_dir,
|
||||
self.cfg.output.name_mode)
|
||||
self.ensure_dirs()
|
||||
self._scan_existing()
|
||||
|
||||
@@ -214,7 +223,8 @@ class HotfolderService:
|
||||
"""
|
||||
check_preflight(self.cfg.ocr.pdfa_level)
|
||||
check_output_config(self.cfg.output.original_on_success,
|
||||
self.cfg.output.archive_dir)
|
||||
self.cfg.output.archive_dir,
|
||||
self.cfg.output.name_mode)
|
||||
self.ensure_dirs()
|
||||
self._scan_existing()
|
||||
self._executor.shutdown(wait=True)
|
||||
@@ -258,39 +268,121 @@ class HotfolderService:
|
||||
|
||||
# ---- Processing ----
|
||||
|
||||
def _count_success(self) -> None:
|
||||
with self._lock:
|
||||
self._success_count += 1
|
||||
|
||||
def _count_error(self) -> None:
|
||||
with self._lock:
|
||||
self._error_count += 1
|
||||
|
||||
def _process(self, path: Path) -> None:
|
||||
if not _wait_until_stable(path):
|
||||
log.warning("Datei nicht stabilisiert, überspringe: %s", path)
|
||||
if not path.exists():
|
||||
# Datei wurde währenddessen entfernt — kein Fehlerfall
|
||||
log.info("Datei vor der Verarbeitung verschwunden: %s", path)
|
||||
return
|
||||
# Bewusst als Fehler zählen: sonst liefert --once trotz liegen
|
||||
# gebliebener Datei Exit 0.
|
||||
log.error(
|
||||
"Datei hat sich nicht stabilisiert (Timeout): %s — bleibt in %s "
|
||||
"liegen und wird beim nächsten Lauf erneut versucht",
|
||||
path, self.cfg.paths.incoming,
|
||||
)
|
||||
self._count_error()
|
||||
return
|
||||
if not path.exists():
|
||||
return
|
||||
|
||||
result: ProcessResult = process_pdf(
|
||||
src=path,
|
||||
working_dir=self.cfg.paths.working,
|
||||
outgoing_dir=self.cfg.paths.outgoing,
|
||||
error_dir=self.cfg.paths.error,
|
||||
ocr_cfg=self.cfg.ocr,
|
||||
vera_cfg=self.cfg.verapdf,
|
||||
output_cfg=self.cfg.output,
|
||||
)
|
||||
try:
|
||||
result: ProcessResult = process_pdf(
|
||||
src=path,
|
||||
working_dir=self.cfg.paths.working,
|
||||
outgoing_dir=self.cfg.paths.outgoing,
|
||||
error_dir=self.cfg.paths.error,
|
||||
ocr_cfg=self.cfg.ocr,
|
||||
vera_cfg=self.cfg.verapdf,
|
||||
output_cfg=self.cfg.output,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 - kein Fehler darf die Zählung umgehen
|
||||
log.exception("Unerwarteter Fehler bei der Verarbeitung von %s", path.name)
|
||||
self._count_error()
|
||||
self._rescue_to_error(path)
|
||||
self._notify(ProcessResult(
|
||||
path, self.cfg.paths.outgoing / path.name, False,
|
||||
f"unerwarteter Fehler: {e}",
|
||||
))
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
if result.success:
|
||||
self._success_count += 1
|
||||
else:
|
||||
self._error_count += 1
|
||||
if not result.success:
|
||||
self._count_error()
|
||||
self._notify(result)
|
||||
return
|
||||
|
||||
if result.success:
|
||||
self._dispatch_uploads(result.output)
|
||||
failed = self._dispatch_uploads(result.output)
|
||||
if failed:
|
||||
log.error(
|
||||
"Upload fehlgeschlagen (%s) für %s — das OCR selbst war "
|
||||
"erfolgreich, die Datei bleibt daher in %s liegen und wird "
|
||||
"NICHT nach error/ verschoben",
|
||||
", ".join(failed), result.output.name, result.output.parent,
|
||||
)
|
||||
self._count_error()
|
||||
self._notify_upload_failure(result, failed)
|
||||
return
|
||||
|
||||
self._count_success()
|
||||
self._notify(result)
|
||||
|
||||
def _dispatch_uploads(self, pdf: Path) -> None:
|
||||
upload_folder(pdf, self.cfg.folder, self.cfg.paths.outgoing)
|
||||
if self.cfg.nextcloud.enabled:
|
||||
upload_nextcloud(pdf, self.cfg.nextcloud)
|
||||
if self.cfg.sftp.enabled:
|
||||
upload_sftp(pdf, self.cfg.sftp)
|
||||
def _rescue_to_error(self, src: Path) -> None:
|
||||
"""Bringt eine Datei nach einer unerwarteten Exception ins error-Verzeichnis.
|
||||
|
||||
Die Datei kann je nach Abbruchzeitpunkt noch in incoming/ oder schon in
|
||||
working/ liegen. Der erste Treffer wird verschoben (keine Doppel-Moves),
|
||||
Fehler beim Verschieben werden nur geloggt.
|
||||
"""
|
||||
error_dir = self.cfg.paths.error
|
||||
for candidate in (src, self.cfg.paths.working / src.name):
|
||||
try:
|
||||
if not candidate.is_file():
|
||||
continue
|
||||
if candidate.parent.resolve() == error_dir.resolve():
|
||||
return # liegt bereits im error-Verzeichnis
|
||||
except OSError:
|
||||
continue
|
||||
_move_to_error(candidate, error_dir)
|
||||
return
|
||||
log.warning("Datei %s nach Fehler nicht mehr auffindbar — "
|
||||
"kein Verschieben nach error/ möglich", src.name)
|
||||
|
||||
def _dispatch_uploads(self, pdf: Path) -> list[str]:
|
||||
"""Schiebt das fertige PDF an alle Upload-Ziele.
|
||||
|
||||
Die uploader prüfen `cfg.enabled` jeweils selbst und liefern für
|
||||
deaktivierte Ziele True.
|
||||
|
||||
Returns:
|
||||
Namen der fehlgeschlagenen Ziele — leere Liste = alle erfolgreich.
|
||||
"""
|
||||
failed: list[str] = []
|
||||
if not upload_folder(pdf, self.cfg.folder, self.cfg.paths.outgoing):
|
||||
failed.append("folder")
|
||||
if not upload_nextcloud(pdf, self.cfg.nextcloud):
|
||||
failed.append("nextcloud")
|
||||
if not upload_sftp(pdf, self.cfg.sftp):
|
||||
failed.append("sftp")
|
||||
return failed
|
||||
|
||||
def _notify_upload_failure(self, result: ProcessResult, failed: list[str]) -> None:
|
||||
"""Fehler-Mail, wenn das OCR lief, aber mindestens ein Upload scheiterte."""
|
||||
subject = f"[pdf-ocr] FEHLER Upload: {result.source.name}"
|
||||
body = (
|
||||
f"OCR erfolgreich: {result.output}\n\n"
|
||||
f"Fehlgeschlagene Upload-Ziele: {', '.join(failed)}\n\n"
|
||||
f"Das OCR-PDF bleibt in {result.output.parent} liegen und wurde "
|
||||
"NICHT nach error/ verschoben. Details siehe Log.\n"
|
||||
)
|
||||
notify_email(self.cfg.email, subject, body, False)
|
||||
|
||||
def _notify(self, result: ProcessResult) -> None:
|
||||
if result.success:
|
||||
|
||||
Reference in New Issue
Block a user