feat: Wiederaufnahme aus working/, --check-config, feldtauglicher Updater (v0.6.0)

Datenverlust behoben:
- Nach hartem Stopp blieb das Original in working/ liegen und wurde nie
  wieder angefasst (_scan_existing sah nur incoming/). Es wird jetzt beim
  Start an Ort und Stelle wieder aufgegriffen, mit Kollisionsschutz gegen
  gleichnamige neue Scans; angefangene __ocr_-Fragmente werden geloescht.
- TimeoutStopSec 30 -> 300, damit laufendes OCR zu Ende laufen darf.

Config-Drift sichtbar gemacht:
- Neues --check-config (Exit 0 sauber / 1 Warnungen / 2 Fehler), das
  update.sh vor dem Neustart ueber alle Instanz-Configs laufen laesst.
- Warnungen fuer [ocr].timeout >= 900 (seit 0.4.0 pro SEITE) und gesetztes
  pdfa_level, beim Dienststart wie im Check.
- Unbekannte Config-Keys werden nicht mehr still verworfen, sondern genannt.

Updater feldtauglich:
- venv-Health-Check erkennt toten Symlink UND Versions-Drift gegen das
  System-Python; --rebuild-venv als ausdruecklicher Weg nach einem Debian-
  Major-Upgrade. Neubau ist ganz-oder-gar-nicht mit Rollback.
- apt-Pakete werden auch beim Update synchronisiert (Quelle: install.sh).
- Instanz-Erfassung inkl. activating/failed, Verifikation prueft is-failed
  und NRestarts statt sleep 1 + is-active.
- Backup enthaelt Configs, Unit, Drop-ins und pip-freeze.txt, liegt auf
  0600 und rotiert auf 5; schlaegt es fehl, bricht das Update vorher ab.
- ERR-Trap faehrt die vorher laufenden Instanzen wieder hoch.
- lxc-compat.conf wird beim Update nachgezogen.
- requirements.txt gepinnt (ocrmypdf 16.13.0, geprueft fuer Python 3.11+3.13).

Doku in Installation / Update / OS-Upgrade aufgeteilt (docs/).
135 Tests gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-22 22:04:04 +02:00
parent 2062476252
commit 3e24aa2ecd
20 changed files with 3095 additions and 347 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""PDF OCR Hotfolder — Scanner-PDFs automatisch durchsuchbar machen."""
__version__ = "0.5.0"
__version__ = "0.6.0"
+98 -2
View File
@@ -4,11 +4,24 @@ from __future__ import annotations
import argparse
import logging
import sys
import tomllib
from pathlib import Path
from . import __version__
from .config import ConfigError, load_config
from .service import HotfolderService, PreflightError
from .config import Config, ConfigError, config_warnings, load_config
from .service import (
HotfolderService,
PreflightError,
check_output_config,
check_preflight,
)
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:
@@ -19,6 +32,81 @@ def _setup_logging(level: str) -> None:
)
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: {cfg_path} ist kein gültiges TOML: {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)'}")
errors: list[str] = []
try:
check_preflight(cfg.ocr.pdfa_level)
print(" Preflight ok (tesseract, gs vorhanden).")
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",
@@ -29,6 +117,9 @@ def main() -> int:
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)
@@ -36,12 +127,17 @@ def main() -> int:
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
_setup_logging(cfg.log_level)
_log_config_warnings(cfg)
service = HotfolderService(cfg)
+98
View File
@@ -105,6 +105,18 @@ class Config:
sftp: SftpUpload
email: EmailNotify
log_level: str = "INFO"
# Einträge der TOML, die zu keiner Dataclass gehören (Tippfehler oder
# Optionen aus älteren Versionen). load_config() sammelt sie hier ein,
# statt sie stumm zu verwerfen — geloggt wird erst weiter oben, damit
# load_config() ohne konfiguriertes Logging benutzbar bleibt.
unknown_keys: list[str] = field(default_factory=list)
# Bekannte Sektionen — alles andere landet in Config.unknown_keys
_KNOWN_SECTIONS = ("paths", "ocr", "output", "verapdf", "upload", "notify", "logging")
_KNOWN_UPLOAD_TARGETS = ("folder", "nextcloud", "sftp")
_KNOWN_NOTIFY_TARGETS = ("email",)
_KNOWN_LOGGING_KEYS = ("level",)
def _section(data: dict[str, Any], *keys: str) -> dict[str, Any]:
@@ -130,6 +142,42 @@ def _require_path(p: dict[str, Any], key: str, cfg_path: Path) -> Path:
return Path(str(value))
def _unknown_in(data: dict[str, Any], keys: tuple[str, ...],
known: tuple[str, ...]) -> list[str]:
"""Listet alle Keys einer Sektion auf, die nicht in `known` stehen."""
label = ".".join(keys)
return [f"[{label}].{k}" for k in _section(data, *keys) if k not in known]
def _collect_unknown_keys(data: dict[str, Any]) -> list[str]:
"""Sammelt alle TOML-Einträge, die nirgends ausgewertet werden.
Dazu zählen Tippfehler (`[ocr].langauges`), Optionen aus älteren
Versionen und komplett unbekannte Sektionen. Die Reihenfolge entspricht
der Datei, damit die Meldung reproduzierbar bleibt.
"""
unknown: list[str] = []
for name, value in data.items():
if name not in _KNOWN_SECTIONS:
unknown.append(f"[{name}]" if isinstance(value, dict) else name)
unknown += _unknown_in(data, ("paths",), tuple(Paths.__annotations__))
unknown += _unknown_in(data, ("ocr",), tuple(OcrConfig.__annotations__))
unknown += _unknown_in(data, ("output",), tuple(OutputConfig.__annotations__))
unknown += _unknown_in(data, ("verapdf",), tuple(VeraPdfConfig.__annotations__))
for sub in _section(data, "upload"):
if sub not in _KNOWN_UPLOAD_TARGETS:
unknown.append(f"[upload.{sub}]")
unknown += _unknown_in(data, ("upload", "folder"), tuple(FolderUpload.__annotations__))
unknown += _unknown_in(data, ("upload", "nextcloud"), tuple(NextcloudUpload.__annotations__))
unknown += _unknown_in(data, ("upload", "sftp"), tuple(SftpUpload.__annotations__))
for sub in _section(data, "notify"):
if sub not in _KNOWN_NOTIFY_TARGETS:
unknown.append(f"[notify.{sub}]")
unknown += _unknown_in(data, ("notify", "email"), tuple(EmailNotify.__annotations__))
unknown += _unknown_in(data, ("logging",), _KNOWN_LOGGING_KEYS)
return unknown
def load_config(path: str | Path) -> Config:
path = Path(path)
with path.open("rb") as f:
@@ -171,4 +219,54 @@ def load_config(path: str | Path) -> Config:
paths=paths, ocr=ocr, output=output, verapdf=verapdf,
folder=folder, nextcloud=nextcloud, sftp=sftp, email=email,
log_level=log_level,
unknown_keys=_collect_unknown_keys(data),
)
# ---- Legacy- und Plausibilitätswarnungen ----
# [ocr].timeout war vor 0.4.0 ein (wirkungsloses) Gesamt-Timeout mit Default
# 1800. Ab diesem Wert gehen wir von einem Altwert aus.
LEGACY_TIMEOUT_THRESHOLD = 900
# Richtwert für das Seiten-Timeout seit 0.4.0
RECOMMENDED_PAGE_TIMEOUT = 300
def legacy_warnings(cfg: Config) -> list[str]:
"""Warnt vor Einträgen, deren Bedeutung sich geändert hat.
Die Meldungen werden sowohl beim Dienststart ins Log geschrieben als auch
von `--check-config` ausgegeben — deshalb steht der Text nur hier.
"""
out: list[str] = []
if cfg.ocr.timeout >= LEGACY_TIMEOUT_THRESHOLD:
out.append(
f"[ocr].timeout = {cfg.ocr.timeout}: Seit Version 0.4.0 sind das "
"Sekunden PRO SEITE (vorher ein wirkungsloses Gesamt-Timeout mit "
f"Default 1800). Ein Wert >= {LEGACY_TIMEOUT_THRESHOLD} stammt fast "
"sicher aus einer alten Config und lässt eine einzelne Seite "
f"unnötig lange laufen. Richtwert: {RECOMMENDED_PAGE_TIMEOUT}."
)
if cfg.ocr.pdfa_level:
out.append(
f"[ocr].pdfa_level = {cfg.ocr.pdfa_level!r}: PDF/A-Ausgabe ist "
"aktiv. Ghostscript 10.0.0-10.02.0 (Debian-12-Default) hat einen "
"Bug, der zusammen mit skip_text das OCR blockiert (Issue #3). Der "
"Preflight bricht ab, falls die installierte Ghostscript-Version "
"betroffen ist; ab 10.02.1 ist alles in Ordnung."
)
return out
def unknown_key_warnings(cfg: Config) -> list[str]:
"""Macht die beim Laden verworfenen Einträge sichtbar."""
return [
f"Unbekannter Config-Eintrag {key} — wird ignoriert (Tippfehler oder "
"Option aus einer älteren Version?)"
for key in cfg.unknown_keys
]
def config_warnings(cfg: Config) -> list[str]:
"""Alle Warnungen zu einer geladenen Config (Legacy + unbekannte Keys)."""
return legacy_warnings(cfg) + unknown_key_warnings(cfg)
+33 -5
View File
@@ -14,6 +14,10 @@ log = logging.getLogger(__name__)
# Erlaubte Werte für [output].name_mode — wird auch vom Preflight geprüft
VALID_NAME_MODES = ("prefix", "suffix", "none")
# Präfix der Zwischendatei, in die ocrmypdf schreibt. Bleibt sie nach einem
# harten Stopp in working/ liegen, ist sie ein unvollständiges Fragment.
OCR_TEMP_PREFIX = "__ocr_"
def build_output_name(src_name: str, mode: str, tag: str) -> str:
"""Erzeugt den Ziel-Dateinamen für ein OCR-PDF.
@@ -114,13 +118,29 @@ def process_pdf(
"""Verarbeitet eine einzelne PDF: move→OCR→validate→outgoing/error."""
out_name = build_output_name(src.name, output_cfg.name_mode, output_cfg.name_tag)
work_src = working_dir / src.name
work_out = working_dir / f"__ocr_{out_name}" # Temp-Name, damit er != src.name ist
work_out = working_dir / f"{OCR_TEMP_PREFIX}{out_name}" # Temp-Name, damit er != src.name ist
final_out = outgoing_dir / out_name
try:
shutil.move(str(src), str(work_src))
except OSError as e:
return ProcessResult(src, final_out, False, f"move to working failed: {e}")
if _is_same_file(src, work_src):
# Wiederaufnahme: die Datei liegt bereits in working/, weil ein
# früherer Lauf hart abgebrochen wurde. Kein zweiter Move — der würde
# die Datei bestenfalls auf sich selbst schieben.
log.warning("Wiederaufnahme aus %s: %s wird erneut per OCR verarbeitet",
working_dir, src.name)
elif work_src.exists():
# Gleicher Dateiname, andere Datei: ein Move würde den laufenden bzw.
# wiederaufgenommenen Vorgang in working/ stillschweigend überschreiben.
return ProcessResult(
src, final_out, False,
f"in {working_dir} liegt bereits eine andere Datei namens "
f"{src.name} — Original bleibt in {src.parent} liegen und wird "
"beim nächsten Lauf erneut versucht",
)
else:
try:
shutil.move(str(src), str(work_src))
except OSError as e:
return ProcessResult(src, final_out, False, f"move to working failed: {e}")
try:
run_ocr(work_src, work_out, ocr_cfg)
@@ -153,6 +173,14 @@ def process_pdf(
return ProcessResult(src, final_out, True, verapdf_passed=vera_ok)
def _is_same_file(a: Path, b: Path) -> bool:
"""Zeigen beide Pfade auf dieselbe Datei? (verträgt fehlende Dateien)"""
try:
return a.resolve() == b.resolve()
except OSError:
return False
def _dispose_original(work_src: Path, original_name: str, cfg: OutputConfig) -> None:
"""Entsorgt das Original laut [output].original_on_success — löschen oder archivieren.
+84 -4
View File
@@ -9,13 +9,20 @@ import subprocess
import threading
import time
from concurrent.futures import Future, ThreadPoolExecutor
from datetime import datetime
from pathlib import Path
from watchdog.events import FileSystemEvent, FileSystemEventHandler
from watchdog.observers import Observer
from .config import Config
from .processor import VALID_NAME_MODES, ProcessResult, _move_to_error, process_pdf
from .processor import (
OCR_TEMP_PREFIX,
VALID_NAME_MODES,
ProcessResult,
_move_to_error,
process_pdf,
)
from .uploaders import notify_email, upload_folder, upload_nextcloud, upload_sftp
log = logging.getLogger(__name__)
@@ -216,7 +223,7 @@ class HotfolderService:
self.shutdown()
def run_once(self) -> int:
"""Verarbeitet alle bereits im incoming-Ordner liegenden PDFs und beendet sich.
"""Verarbeitet alle bereits liegenden PDFs (incoming/ + working/) und beendet sich.
Returns:
Anzahl fehlgeschlagener PDFs (0 = alles ok).
@@ -243,11 +250,84 @@ class HotfolderService:
# ---- Queue ----
def _scan_existing(self) -> None:
"""Beim Start: bereits liegende PDFs aufgreifen."""
for p in self.cfg.paths.incoming.iterdir():
"""Beim Start: bereits liegende PDFs aufgreifen.
Zuerst working/ (abgebrochene Läufe, siehe `_scan_working`), danach
incoming/. Die Reihenfolge ist wichtig, damit eine Namenskollision
zwischen beiden Verzeichnissen aufgelöst ist, bevor die
incoming-Datei nach working/ will.
"""
self._scan_working()
for p in sorted(self.cfg.paths.incoming.iterdir()):
if _is_pdf(p):
self.enqueue(p)
def _scan_working(self) -> None:
"""Greift Dateien auf, die ein harter Stopp in working/ liegen ließ.
`process_pdf()` verschiebt das Original vor dem OCR nach working/.
Wird der Dienst dort abgeschossen (SIGKILL nach TimeoutStopSec),
bleibt es liegen und wurde bisher nie wieder angefasst — stiller
Datenverlust. Die Datei wird deshalb an Ort und Stelle
wiederaufgenommen; `process_pdf()` erkennt das und verschiebt sie
nicht erneut.
Die Zwischendateien des abgebrochenen OCR-Laufs (Präfix `__ocr_`)
sind unvollständige Fragmente: als Eingabe unbrauchbar und als
Ergebnis wertlos. Sie werden gelöscht, damit sie niemand für ein
fertiges PDF hält und damit der neue Lauf sauber startet.
"""
working = self.cfg.paths.working
if not working.is_dir():
return
for p in sorted(working.iterdir()):
if not p.is_file():
continue
if p.name.startswith(OCR_TEMP_PREFIX):
log.warning(
"Unvollständiges OCR-Fragment aus abgebrochenem Lauf "
"gefunden und gelöscht: %s", p,
)
try:
p.unlink()
except OSError:
log.exception("Konnte OCR-Fragment %s nicht löschen", p)
continue
if not _is_pdf(p):
continue
target = self._free_resume_name(p)
log.warning(
"Abgebrochener Lauf wird fortgesetzt: %s lag noch in %s "
"(Dienst wurde vermutlich hart gestoppt) — OCR startet neu",
target.name, working,
)
self.enqueue(target)
def _free_resume_name(self, p: Path) -> Path:
"""Entschärft eine Namenskollision zwischen working/ und incoming/.
Liegt in incoming/ eine gleichnamige (aber andere) Datei, würden beide
dieselbe working- und dieselbe outgoing-Datei beanspruchen. Die
wiederaufgenommene Datei bekommt deshalb einen Zeitstempel angehängt —
dann laufen beide durch, statt dass eine überschrieben wird.
"""
if not (self.cfg.paths.incoming / p.name).exists():
return p
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
renamed = p.with_name(f"{p.stem}_{ts}{p.suffix}")
try:
p.rename(renamed)
except OSError:
log.exception("Konnte %s nicht umbenennen — Wiederaufnahme unter "
"Originalnamen", p)
return p
log.warning(
"In %s liegt eine gleichnamige Datei %s — die wiederaufgenommene "
"Datei wurde nach %s umbenannt, damit sich beide nicht "
"überschreiben", self.cfg.paths.incoming, p.name, renamed.name,
)
return renamed
def enqueue(self, path: Path) -> None:
if not _is_pdf(path):
return