diff --git a/AI_AGENT_BRIEFING.md b/AI_AGENT_BRIEFING.md index 84f6fd6..56cb8e6 100644 --- a/AI_AGENT_BRIEFING.md +++ b/AI_AGENT_BRIEFING.md @@ -1,8 +1,8 @@ # AI Agent Briefing — PDF OCR Hotfolder -**Zuletzt aktualisiert:** 2026-04-08 -**Version:** 0.2.0 -**Status:** Multi-Instanz-Support, nicht produktiv getestet +**Zuletzt aktualisiert:** 2026-09-22 +**Version:** 0.4.0 +**Status:** Multi-Instanz-Betrieb, Preflight-Checks und Fehlerzählung vorhanden, Test-Suite grün (92 pytest-Tests). Ein Produktiv-Einsatz ist im Repo (README/CHANGELOG) nicht dokumentiert — die bisherigen Fixes stammen aus Issues #1–#6, nicht aus einem belegten Dauerbetrieb. ## 🎯 Projektziel @@ -13,16 +13,28 @@ Eingehende gescannte PDFs werden automatisch durch OCR (ocrmypdf + Tesseract) in ``` pdf-ocr-hotfolder/ ├── pdf_ocr_hotfolder/ -│ ├── __init__.py # Versionsstring -│ ├── __main__.py # CLI-Entrypoint (argparse, --once, --config) -│ ├── config.py # TOML-Loader, Dataclasses -│ ├── service.py # Hauptservice (watchdog + ThreadPool) -│ ├── processor.py # ocrmypdf + veraPDF -│ └── uploaders.py # folder, nextcloud (WebDAV), sftp, email +│ ├── __init__.py # Versionsstring (__version__) +│ ├── __main__.py # CLI (argparse: --config, --once, --version); Exit 0/1/2 +│ ├── config.py # TOML-Loader, Dataclasses, ConfigError +│ ├── service.py # HotfolderService (watchdog + ThreadPool), Preflight, Zähler +│ ├── processor.py # ocrmypdf-Call, veraPDF, Ausgabename, Original-Entsorgung +│ └── uploaders.py # folder, nextcloud (WebDAV), sftp, E-Mail-Notify +├── tests/ # pytest-Suite (92 Tests, ocrmypdf wird gemockt) +│ ├── conftest.py # Fixtures tmp_config / dummy_pdf +│ ├── test_config_errors.py +│ ├── test_error_counting.py +│ ├── test_ghostscript_version.py +│ ├── test_ocr_timeout.py +│ ├── test_once_exit_code.py +│ ├── test_output_naming.py +│ ├── test_preflight.py +│ └── test_upload_folder.py ├── systemd/ -│ └── pdf-ocr-hotfolder@.service # systemd Template-Unit (Instanz = %i) +│ ├── pdf-ocr-hotfolder@.service # Template-Unit (Instanz = %i) +│ └── lxc-compat.conf # Drop-in-Vorlage: Hardening für LXC abschalten +├── pytest.ini # testpaths = tests ├── config.example.toml -├── install.sh # Interaktiver Installer +├── install.sh # Interaktiver Installer + Instanz-Manager ├── update.sh # Update aus Repo ├── requirements.txt ├── VERSION @@ -35,24 +47,27 @@ pdf-ocr-hotfolder/ | Komponente | Technologie | |------------|-------------| | Sprache | Python 3.11+ (für `tomllib` aus stdlib) | -| OCR | `ocrmypdf` (als Library, nicht via Subprozess) | +| OCR | `ocrmypdf` (als Library, nicht via Subprozess; Import ist lazy) | | Engine | Tesseract | | Watcher | `watchdog` | | HTTP | `requests` (Nextcloud WebDAV) | | SFTP | `paramiko` | | Email | `smtplib` (stdlib) | -| Service | systemd | +| Tests | `pytest` | +| Service | systemd (Template-Unit) | ## 🖥️ Installations-Layout (Multi-Instanz) | Pfad | Inhalt | |------|--------| | `/opt/pdf-ocr-hotfolder/` | Code + venv (für alle Instanzen gemeinsam) | +| `/opt/pdf-ocr-hotfolder/.repo_path` | Pfad zum Repo, aus dem installiert wurde (nutzt `update.sh`) | | `/etc/pdf-ocr-hotfolder/.toml` | Config pro Instanz (mode 640, root:) | | `/etc/systemd/system/pdf-ocr-hotfolder@.service` | Template-Unit | +| `/etc/systemd/system/pdf-ocr-hotfolder@.service.d/lxc-compat.conf` | Drop-in für Container (optional) | | `/etc/systemd/system/pdf-ocr-hotfolder@.service.d/user.conf` | Drop-in für abweichenden User (optional) | | `/var/lib/pdf-ocr-hotfolder//{incoming,working,outgoing,error}/` | Daten pro Instanz | -| `/var/log/pdf-ocr-hotfolder/` | Logs | +| `/var/log/pdf-ocr-hotfolder/` | vom Installer angelegt; der Service selbst loggt nach stdout → journald | | `/var/backups/pdf-ocr-hotfolder/` | Update-Backups | ## 👤 Service-User @@ -68,9 +83,10 @@ pdf-ocr-hotfolder/ `install.sh` ist gleichzeitig **Installer und Instanz-Manager**: - Erster Lauf: Basis-Install + erste Instanz anlegen (Pflicht) -- Folgender Lauf: Basis-Install wird übersprungen, bestehende Instanzen werden gelistet, weitere Instanzen können ergänzt werden -- Eingaben pro Instanz: Name (`[a-z0-9-]+`), Basis-Pfad (default `/var/lib/pdf-ocr-hotfolder/`), Service-User -- `config.toml` wird aus `config.example.toml` mit sed-substituierten Pfaden generiert +- Folgender Lauf: Basis-Install wird übersprungen (erkannt an `venv` + Template-Unit), bestehende Instanzen werden gelistet, weitere Instanzen können ergänzt werden +- Eingaben pro Instanz: Name (`[a-z0-9][a-z0-9-]*`), Basis-Pfad (default `/var/lib/pdf-ocr-hotfolder/`), Service-User +- Basis-Install prüft zusätzlich die Ghostscript-Version und bietet auf Debian 12 bookworm-backports an; erkennt Container (`systemd-detect-virt --container`) und bietet das LXC-Drop-in an +- `.toml` wird aus `config.example.toml` mit sed-substituierten Pfaden generiert - Instanz wird sofort `enable --now` gestartet Manuelles Löschen einer Instanz: @@ -85,29 +101,65 @@ systemctl daemon-reload ## 🔄 Update-Verhalten `update.sh`: -1. Ermittelt alle aktiven `pdf-ocr-hotfolder@*.service` Units -2. Stoppt diese -3. Backup nach `/var/backups/pdf-ocr-hotfolder/` +1. Findet das Repo (eigenes Verzeichnis oder `/opt/pdf-ocr-hotfolder/.repo_path`) +2. Ermittelt alle **aktiven** `pdf-ocr-hotfolder@*.service` Units und stoppt sie +3. Backup nach `/var/backups/pdf-ocr-hotfolder/` (tar.gz, ohne venv/`__pycache__`) 4. Kopiert Code + requirements + VERSION + config.example aus dem Repo 5. `pip install --upgrade` im venv 6. Aktualisiert Template-Unit + `daemon-reload` -7. Startet alle zuvor aktiven Instanzen wieder -8. Exit 1 wenn eine Instanz nicht mehr hochkommt +7. Setzt den Code-Eigentümer auf den User, dem `venv` gehört (default `pdfocr`) +8. Startet alle zuvor aktiven Instanzen wieder, Exit 1 wenn eine nicht mehr hochkommt -Config-Dateien werden **nie** überschrieben. +Config-Dateien werden **nie** überschrieben. Das Repo muss erhalten bleiben — `update.sh` kopiert daraus. + +## ⚙️ Konfiguration (Überblick) + +Vollständiges Beispiel mit Kommentaren: `config.example.toml`. Sektionen: + +| Sektion | Zweck | +|---------|-------| +| `[paths]` | `incoming`, `outgoing`, `working`, `error` — **Pflicht**, fehlt einer → `ConfigError` + Exit 2 | +| `[ocr]` | `languages`, `jobs`, `skip_text`, `oversample`, `pdfa_level`, `deskew`, `clean`, `max_workers`, `timeout` (Sekunden **pro Seite**) | +| `[output]` | `name_mode` (`prefix`/`suffix`/`none`), `name_tag`, `original_on_success` (`delete`/`archive`), `archive_dir` | +| `[verapdf]` | `enabled`, `binary`, `flavour` — optionale PDF/A-Validierung per CLI | +| `[upload.folder]` | `enabled`, `target` (leer = `[paths].outgoing`, dann No-op) | +| `[upload.nextcloud]` | `enabled`, `url`, `username`, `password`, `remote_path`, `verify_ssl` | +| `[upload.sftp]` | `enabled`, `host`, `port`, `username`, `key_file`, `password`, `remote_path` | +| `[notify.email]` | `enabled`, SMTP-Daten, `from_addr`, `to_addrs`, `on` = `always`/`errors`/`never` | +| `[logging]` | `level` = DEBUG/INFO/WARNING/ERROR | + +Unbekannte Keys in einer Sektion werden beim Laden **still verworfen** (`config.py` filtert gegen die Dataclass-Annotationen) — Tippfehler in Key-Namen fallen also nicht auf. ## 🔄 Verarbeitungs-Flow -1. `watchdog` triggert auf Datei-Event in `incoming/` -2. `_wait_until_stable()` wartet, bis Datei nicht mehr wächst (Scanner schreibt mehrmals) -3. Move nach `working/` -4. `ocrmypdf.ocr()` als **Library-Call** (kein Subprozess-Start pro PDF — schneller) -5. Optional: veraPDF-Validierung (CLI-Subprozess) -6. Move nach `outgoing/` als `OCR_.pdf` -7. Aktive Upload-Targets ausführen (folder/nextcloud/sftp) -8. Optional E-Mail-Notify +**Beim Start (`run()` wie `run_once()`), vor allem anderen:** +1. `check_preflight()` — `tesseract` und `gs` müssen im PATH sein; ist `pdfa_level` gesetzt, wird zusätzlich die Ghostscript-Version gegen den 10.0.0–10.02.0-Bug geprüft +2. `check_output_config()` — validiert `original_on_success`, `archive_dir` (Pflicht bei `archive`) und `name_mode` +3. Scheitert eines davon → `PreflightError`, CLI beendet sich mit **Exit-Code 2** (ebenso bei kaputter/fehlender Config) -Fehler → Move nach `error/`, Service läuft weiter (kein `exit 1` wie im alten Bash-Tool). +**Pro Datei:** +1. `watchdog` triggert auf `created`/`moved`/`closed` in `incoming/` (beim Start greift `_scan_existing()` bereits liegende PDFs auf) +2. `_wait_until_stable()` wartet, bis die Datei nicht mehr wächst (max. ~60s) +3. Move nach `working/` +4. `ocrmypdf.ocr()` als **Library-Call** (kein Subprozess-Start pro PDF) +5. Optional: veraPDF-Validierung (CLI-Subprozess) +6. Move nach `outgoing/` unter dem laut `[output]` gebauten Namen (`build_output_name()`: `prefix`/`suffix`/`none` + `name_tag` — das harte `OCR_`-Präfix aus 0.1.0 ist nur noch der Default) +7. Original in `working/` wird laut `original_on_success` **gelöscht** oder nach `archive_dir` **archiviert** (Kollision → Timestamp-Suffix) +8. Aktive Upload-Targets ausführen (folder/nextcloud/sftp) +9. E-Mail-Notify je nach `[notify.email].on` + +**Fehlerbehandlung (Stand 0.4.0):** + +| Fehlerfall | Zählt als Fehler | Wo liegt die Datei danach | +|------------|------------------|----------------------------| +| Stabilitäts-Check läuft in den Timeout | ja | bleibt in `incoming/`, wird beim nächsten Lauf erneut versucht | +| Datei verschwindet vor der Verarbeitung | nein | — | +| OCR wirft (ocrmypdf) | ja | `error/` | +| veraPDF FAIL | ja | OCR-Ergebnis nach `error/`, Original wird gelöscht | +| Beliebige Exception aus `process_pdf()` (z.B. `shutil.move` nach `outgoing/`) | ja | `_rescue_to_error()` sucht in `incoming/` und `working/` und verschiebt nach `error/` | +| Mindestens ein Upload-Ziel schlägt fehl | ja | PDF bleibt **bewusst in `outgoing/`** (das OCR war ja erfolgreich), Fehler-Mail nennt die Ziele | + +Der Service läuft in allen Fällen weiter (kein `exit 1` wie im alten Bash-Tool). Im `--once`-Modus liefert die CLI **Exit-Code 1**, sobald `error_count > 0` ist, sonst 0. ## 🧠 Performance-Entscheidungen @@ -116,8 +168,17 @@ Fehler → Move nach `error/`, Service läuft weiter (kein `exit 1` wie im alten - **`--jobs` an ocrmypdf**: Tesseract parallelisiert Seiten innerhalb eines PDFs - **`skip_text=True`**: bereits OCR-haltige Seiten werden nicht neu verarbeitet - **Stabilitäts-Check** statt magic-file `new` (alte Bash-Krücke) +- **`upload_folder()` nutzt `shutil.copyfile()`** statt `read_bytes()`/`write_bytes()` — große PDFs landen nicht komplett im RAM - veraPDF nur wenn `enabled=true` (JVM-Start ist teuer) +## ⚠️ Fallstricke + +- **Ghostscript 10.0.0–10.02.0 zerschießt OCR.** Das ist der Debian-12-Default. In Kombination aus `[ocr].pdfa_level` + `skip_text = true` blockiert ocrmypdf komplett (Issue #3). Deshalb ist `pdfa_level = ""` der sichere Default, und der Preflight bricht mit Exit 2 ab, wenn `pdfa_level` gesetzt **und** die GS-Version betroffen ist. Abhilfe: Ghostscript ≥ 10.02.1 aus bookworm-backports (der Installer bietet das an). +- **`[ocr].timeout` ist ein Timeout PRO SEITE**, kein Gesamt-Timeout pro PDF. Der Wert geht als `tesseract_timeout` (ocrmypdf-Option `--tesseract-timeout`) durch; ocrmypdf kennt kein Dokument-Timeout. Wer noch den alten Default `1800` in einer Config stehen hat, gibt Tesseract 30 Minuten **je Seite** — Richtwert ist 300. Ein durchgereichtes `0` würde ocrmypdf dazu bringen, OCR **still zu überspringen**, deshalb wird bei `0` (oder negativ) gar nichts übergeben und der ocrmypdf-Default greift. +- **systemd-Hardening bricht in LXC-Containern** (`Error 226/NAMESPACE` durch `PrivateTmp`, `ProtectSystem` usw., Issue #4). Gegenmittel ist das Drop-in `systemd/lxc-compat.conf` nach `/etc/systemd/system/pdf-ocr-hotfolder@.service.d/`; der Installer erkennt Container via `systemd-detect-virt --container` und bietet es an. +- **Das Paket wird nicht pip-installiert, sondern nach `/opt/pdf-ocr-hotfolder` kopiert.** Gestartet wird per `python -m pdf_ocr_hotfolder`, gefunden wird das Modul nur über das Arbeitsverzeichnis — `WorkingDirectory=/opt/pdf-ocr-hotfolder` in der Unit ist daher Pflicht, nicht Kosmetik (Issue #5). +- **Klartext-Passwörter in der Instanz-Config**: SMTP-, Nextcloud- und SFTP-Zugangsdaten stehen unverschlüsselt in `/etc/pdf-ocr-hotfolder/.toml`. Deshalb `chmod 640` und `chown root:`, und `/etc/pdf-ocr-hotfolder` selbst `750 root:pdfocr`. Beim Debuggen nicht versehentlich in ein Ticket oder Log kopieren. + ## 🛠️ Entwicklung Lokaler Test ohne Installation: @@ -130,9 +191,17 @@ cp config.example.toml /tmp/config.toml python -m pdf_ocr_hotfolder --config /tmp/config.toml ``` +Tests (aus dem Repo-Root, `pytest.ini` setzt `testpaths = tests`): +```bash +pytest # aktuell 92 Tests +``` + +`ocrmypdf` muss dafür **nicht** installiert sein: der Import in `processor.py` ist lazy, und `tests/test_ocr_timeout.py` schiebt ein Dummy-Modul in `sys.modules`. Die übrigen Tests mocken `process_pdf` bzw. arbeiten nur auf Config-Ebene. + ## 📋 Roadmap / TODO -- [ ] Tests (`pytest`) für `processor` und `uploaders` +- [x] Tests (`pytest`) für `processor` und `uploaders` — 92 Tests +- [ ] Test-Lücken schließen: der watchdog-Eventpfad (`_Handler`/`Observer`) wird nirgends getestet, `run_verapdf()` ebenso wenig, und `run_ocr()` nur gegen ein gemocktes ocrmypdf — es gibt keinen Test mit einer echten PDF-Datei. Auch `upload_nextcloud()` und `upload_sftp()` sind ungetestet (nur `upload_folder()`). - [ ] Prometheus-Metriken (verarbeitete PDFs, Fehlerquote, Laufzeit) - [ ] CLI-Subkommandos: `pdf-ocr-hotfolder reprocess ` - [ ] Optional: S3/MinIO Upload-Target diff --git a/CHANGELOG.md b/CHANGELOG.md index 60ab2fe..d63a6c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,63 @@ # Changelog +## [0.4.0] - 2026-09-22 + +### Added +- `[ocr].timeout` ist jetzt wirksam: der Wert wird als `tesseract_timeout` + (Sekunden pro Seite) an ocrmypdf durchgereicht. Bisher war der Key zwar + dokumentiert, wurde aber nirgends gelesen. +- `check_output_config()` validiert zusätzlich `[output].name_mode`. Ein Tippfehler + führt jetzt beim Start zum Abbruch mit Exit-Code 2, statt erst pro Datei + zuzuschlagen — und zwar bisher **nach** dem Verschieben nach `working/`, + wo die Datei dann liegen blieb. +- Neue Exception `ConfigError` in `pdf_ocr_hotfolder.config` — fehlende + `[paths]`-Sektion oder ein fehlender Pfad-Eintrag liefern eine deutsche + Fehlermeldung mit Datei- und Key-Nennung statt eines nackten `KeyError`-Tracebacks. + Die CLI bricht damit sauber mit Exit-Code 2 ab. +- `pytest.ini` mit `testpaths = tests`, damit `pytest` aus dem Repo-Root läuft. +- 35 neue Tests: Fehlerzählung (Exception, Upload, Stabilitäts-Timeout), + Config-Fehlermeldungen, `tesseract_timeout`-Durchreichung (ocrmypdf gemockt) + und `upload_folder()`. + +### Changed +- **`[ocr].timeout` hat eine neue Bedeutung — für bestehende Installationen relevant!** + Der Wert ist kein (nie implementiertes) Gesamt-Timeout pro PDF mehr, sondern + das Limit **pro Seite** für Tesseract. Der Default sinkt entsprechend von + `1800` auf `300`. Wer den alten Wert `1800` in seiner `config.toml` stehen hat, + gibt Tesseract damit 30 Minuten **je Seite** — bitte auf einen Seiten-Wert + anpassen (Richtwert 300). + `0` bedeutet "kein eigenes Limit": der Wert wird dann gar nicht erst + durchgereicht, weil ocrmypdf `tesseract_timeout=0` als "OCR komplett + überspringen" interpretiert. +- `_dispatch_uploads()` liefert jetzt die Namen der fehlgeschlagenen Upload-Ziele + zurück; die doppelte `enabled`-Prüfung (Service + Uploader) ist entfallen — + die Uploader prüfen das selbst. +- `upload_folder()` kopiert mit `shutil.copyfile()` statt + `read_bytes()`/`write_bytes()` — große PDFs landen nicht mehr komplett im + Speicher. Die Selbst-Ziel-Erkennung bleibt unverändert. + +### Fixed +- `OcrConfig.pdfa_level` hatte im Code noch den Default `"2"`, obwohl + `config.example.toml` seit 0.2.2 bewusst `""` setzt (Ghostscript-Bug, Issue #3). + Eine Config ohne `[ocr]`-Sektion bzw. ohne den Key lief damit ungewollt in + PDF/A. Default im Code jetzt ebenfalls `""`. +- Eine Exception **nach** dem OCR (z.B. ein fehlgeschlagener + `shutil.move()` nach `outgoing/`) wurde nur im Worker-Callback geloggt. + `error_count` blieb 0 und `--once` lieferte trotz Fehlschlag Exit-Code 0. + Jede Exception aus `process_pdf()` zählt jetzt als Fehler, wird geloggt, + löst eine Fehler-Mail aus und die Datei wandert — soweit noch auffindbar + (`incoming/` oder `working/`) — nach `error/`. +- Fehlgeschlagene Uploads waren folgenlos: die Rückgabewerte der Uploader wurden + verworfen, es ging sogar eine Erfolgs-Mail raus. Jetzt zählt mindestens ein + fehlgeschlagenes Ziel als Fehler und die E-Mail geht als **FEHLER** raus, mit + Nennung der betroffenen Ziele. Das OCR-PDF bleibt bewusst in `outgoing/` + liegen (das OCR selbst war ja erfolgreich) — das steht so auch im Log. +- Lief der Stabilitäts-Check einer Datei in den 60-Sekunden-Timeout, gab es nur + ein `log.warning`; `--once` meldete Exit-Code 0. Jetzt `log.error` + + `error_count`. Die Datei bleibt bewusst in `incoming/` liegen und wird beim + nächsten Lauf erneut versucht. Eine zwischenzeitlich *verschwundene* Datei + wird davon unterschieden und zählt weiterhin nicht als Fehler. + ## [0.3.1] - 2026-04-10 ### Fixed diff --git a/README.md b/README.md index b901708..9271917 100644 --- a/README.md +++ b/README.md @@ -83,10 +83,10 @@ Vollständiges Beispiel: [`config.example.toml`](config.example.toml). Wichtigst languages = "deu+eng" # Tesseract-Sprachen jobs = 4 # Threads pro PDF skip_text = true # bereits OCR-haltige Seiten überspringen -pdfa_level = "2" # "1", "2", "3" oder "" für reines PDF +pdfa_level = "" # "1", "2", "3" oder "" für reines PDF (Default "" wegen Ghostscript-Bug, s.u.) deskew = true max_workers = 2 # parallele PDFs -timeout = 1800 +timeout = 300 # max. Sekunden pro SEITE (Tesseract), 0 = ocrmypdf-Default ``` ### `[output]` @@ -239,5 +239,5 @@ MIT — © Sonith UG --- -**Version:** 0.3.1 +**Version:** 0.4.0 **Repo:** https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder diff --git a/VERSION b/VERSION index 9e11b32..1d0ba9e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.1 +0.4.0 diff --git a/config.example.toml b/config.example.toml index 878e2c5..fe85aa9 100644 --- a/config.example.toml +++ b/config.example.toml @@ -31,8 +31,13 @@ deskew = true clean = false # Maximale parallele PDFs (Hauptsystem hat selten mehr als 1-2 gleichzeitig) max_workers = 2 -# Timeout pro PDF in Sekunden -timeout = 1800 +# Max. Sekunden, die Tesseract pro SEITE laufen darf (0 = kein Limit). +# ocrmypdf kennt kein Gesamt-Timeout pro Dokument, nur dieses Seiten-Limit +# (ocrmypdf-Option --tesseract-timeout). Läuft eine Seite in den Timeout, +# wird sie ohne Textebene ins Ergebnis übernommen; die Verarbeitung der +# restlichen Seiten läuft weiter. +# 0 = wir geben kein Limit vor und überlassen es dem ocrmypdf-Default. +timeout = 300 [output] # Wie soll die Ziel-Datei im outgoing/-Ordner benannt werden? diff --git a/pdf_ocr_hotfolder/__init__.py b/pdf_ocr_hotfolder/__init__.py index 098b8ad..b727522 100644 --- a/pdf_ocr_hotfolder/__init__.py +++ b/pdf_ocr_hotfolder/__init__.py @@ -1,3 +1,3 @@ """PDF OCR Hotfolder — Scanner-PDFs automatisch durchsuchbar machen.""" -__version__ = "0.3.1" +__version__ = "0.4.0" diff --git a/pdf_ocr_hotfolder/__main__.py b/pdf_ocr_hotfolder/__main__.py index 2c23e2f..9f8dec9 100644 --- a/pdf_ocr_hotfolder/__main__.py +++ b/pdf_ocr_hotfolder/__main__.py @@ -7,7 +7,7 @@ import sys from pathlib import Path from . import __version__ -from .config import load_config +from .config import ConfigError, load_config from .service import HotfolderService, PreflightError @@ -36,7 +36,11 @@ def main() -> int: print(f"Config nicht gefunden: {cfg_path}", file=sys.stderr) return 2 - cfg = load_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) service = HotfolderService(cfg) diff --git a/pdf_ocr_hotfolder/config.py b/pdf_ocr_hotfolder/config.py index 324f3f0..860db16 100644 --- a/pdf_ocr_hotfolder/config.py +++ b/pdf_ocr_hotfolder/config.py @@ -7,6 +7,10 @@ from pathlib import Path from typing import Any +class ConfigError(RuntimeError): + """Konfigurationsdatei ist unvollständig oder fehlerhaft.""" + + @dataclass class Paths: incoming: Path @@ -21,11 +25,14 @@ class OcrConfig: jobs: int = 4 skip_text: bool = True oversample: int = 300 - pdfa_level: str = "2" + # Default bewusst leer: pdfa_level + skip_text zerschießt OCR mit + # Ghostscript 10.0.0-10.02.0 (Debian-12-Default), siehe Issue #3 + pdfa_level: str = "" deskew: bool = True clean: bool = False max_workers: int = 2 - timeout: int = 1800 + # Max. Sekunden, die Tesseract pro Seite laufen darf (0 = kein eigenes Limit) + timeout: int = 300 @dataclass @@ -107,17 +114,40 @@ def _section(data: dict[str, Any], *keys: str) -> dict[str, Any]: return cur if isinstance(cur, dict) else {} +def _require_path(p: dict[str, Any], key: str, cfg_path: Path) -> Path: + """Holt einen Pflicht-Pfad aus der [paths]-Sektion. + + Wirft ConfigError mit klarer Meldung statt eines nackten KeyError. + """ + value = p.get(key) + if value is None or (isinstance(value, str) and not value.strip()): + raise ConfigError( + f"{cfg_path}: In der Sektion [paths] fehlt der Eintrag '{key}' " + f"(oder er ist leer). Bitte ergänzen, z.B. " + f'{key} = "/var/lib/pdf-ocr-hotfolder/{key}" ' + f"— siehe config.example.toml." + ) + return Path(str(value)) + + def load_config(path: str | Path) -> Config: path = Path(path) with path.open("rb") as f: data = tomllib.load(f) + if not isinstance(data.get("paths"), dict): + raise ConfigError( + f"{path}: Die Sektion [paths] fehlt (oder ist keine Tabelle). " + "Sie muss die Einträge incoming, outgoing, working und error " + "enthalten — siehe config.example.toml." + ) + p = _section(data, "paths") paths = Paths( - incoming=Path(p["incoming"]), - outgoing=Path(p["outgoing"]), - working=Path(p["working"]), - error=Path(p["error"]), + incoming=_require_path(p, "incoming", path), + outgoing=_require_path(p, "outgoing", path), + working=_require_path(p, "working", path), + error=_require_path(p, "error", path), ) ocr = OcrConfig(**{k: v for k, v in _section(data, "ocr").items() diff --git a/pdf_ocr_hotfolder/processor.py b/pdf_ocr_hotfolder/processor.py index 191a6f0..7182b2d 100644 --- a/pdf_ocr_hotfolder/processor.py +++ b/pdf_ocr_hotfolder/processor.py @@ -11,6 +11,9 @@ from .config import OcrConfig, OutputConfig, VeraPdfConfig log = logging.getLogger(__name__) +# Erlaubte Werte für [output].name_mode — wird auch vom Preflight geprüft +VALID_NAME_MODES = ("prefix", "suffix", "none") + def build_output_name(src_name: str, mode: str, tag: str) -> str: """Erzeugt den Ziel-Dateinamen für ein OCR-PDF. @@ -65,6 +68,15 @@ def run_ocr(src: Path, dst: Path, cfg: OcrConfig) -> None: else: kwargs["output_type"] = "pdf" + # [ocr].timeout = max. Sekunden, die Tesseract pro Seite laufen darf. + # ocrmypdf kennt kein Gesamt-Timeout für ein Dokument, nur `tesseract_timeout` + # (pro Seite). ACHTUNG: ocrmypdf interpretiert tesseract_timeout=0 als + # "OCR komplett überspringen" — deshalb wird 0 bei uns als "kein eigenes + # Limit" behandelt und gar nicht erst durchgereicht (dann gilt der + # ocrmypdf-Default). + if cfg.timeout and cfg.timeout > 0: + kwargs["tesseract_timeout"] = float(cfg.timeout) + log.info("OCR start: %s", src.name) ocrmypdf.ocr(str(src), str(dst), **kwargs) log.info("OCR done: %s", dst.name) diff --git a/pdf_ocr_hotfolder/service.py b/pdf_ocr_hotfolder/service.py index cb5c35c..7bca349 100644 --- a/pdf_ocr_hotfolder/service.py +++ b/pdf_ocr_hotfolder/service.py @@ -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: diff --git a/pdf_ocr_hotfolder/uploaders.py b/pdf_ocr_hotfolder/uploaders.py index 2a88fa5..d033e29 100644 --- a/pdf_ocr_hotfolder/uploaders.py +++ b/pdf_ocr_hotfolder/uploaders.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +import shutil import smtplib import ssl from email.message import EmailMessage @@ -25,7 +26,9 @@ def upload_folder(pdf: Path, cfg: FolderUpload, default_target: Path) -> bool: try: if pdf.resolve() == dest.resolve(): return True - dest.write_bytes(pdf.read_bytes()) + # copyfile statt read_bytes/write_bytes: große PDFs nicht komplett + # in den Speicher laden + shutil.copyfile(pdf, dest) log.info("Folder upload OK: %s", dest) return True except OSError as e: diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..5ee6477 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +testpaths = tests diff --git a/tests/test_config_errors.py b/tests/test_config_errors.py new file mode 100644 index 0000000..8276518 --- /dev/null +++ b/tests/test_config_errors.py @@ -0,0 +1,79 @@ +"""Tests für verständliche Fehlermeldungen beim Laden der Config.""" +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +from pdf_ocr_hotfolder.config import ConfigError, load_config + +_FULL_PATHS = """ +[paths] +incoming = "/tmp/in" +outgoing = "/tmp/out" +working = "/tmp/work" +error = "/tmp/err" +""" + + +def _write(tmp_path: Path, content: str) -> Path: + cfg = tmp_path / "config.toml" + cfg.write_text(content) + return cfg + + +def test_missing_paths_section(tmp_path: Path) -> None: + """Fehlt [paths] komplett → ConfigError statt KeyError.""" + cfg = _write(tmp_path, '[ocr]\nlanguages = "deu"\n') + with pytest.raises(ConfigError) as exc: + load_config(cfg) + msg = str(exc.value) + assert "[paths]" in msg + assert str(cfg) in msg + + +def test_empty_config_file(tmp_path: Path) -> None: + cfg = _write(tmp_path, "") + with pytest.raises(ConfigError, match=r"\[paths\]"): + load_config(cfg) + + +@pytest.mark.parametrize("missing", ["incoming", "outgoing", "working", "error"]) +def test_missing_single_path_key(tmp_path: Path, missing: str) -> None: + """Fehlt ein einzelner Key, wird genau dieser genannt.""" + lines = [line for line in _FULL_PATHS.strip().splitlines() + if not line.startswith(missing)] + cfg = _write(tmp_path, "\n".join(lines) + "\n") + with pytest.raises(ConfigError) as exc: + load_config(cfg) + msg = str(exc.value) + assert missing in msg + assert str(cfg) in msg + + +def test_empty_path_value_is_rejected(tmp_path: Path) -> None: + """Ein leerer Pfad ist genauso falsch wie ein fehlender.""" + cfg = _write(tmp_path, _FULL_PATHS.replace('working = "/tmp/work"', + 'working = ""')) + with pytest.raises(ConfigError, match="working"): + load_config(cfg) + + +def test_complete_paths_section_loads(tmp_path: Path) -> None: + cfg = _write(tmp_path, _FULL_PATHS) + loaded = load_config(cfg) + assert loaded.paths.incoming == Path("/tmp/in") + assert loaded.paths.error == Path("/tmp/err") + + +def test_main_returns_2_on_broken_config(tmp_path: Path, monkeypatch, capsys) -> None: + """CLI bricht sauber mit Exit-Code 2 ab — ohne Traceback.""" + cfg = _write(tmp_path, '[ocr]\nlanguages = "deu"\n') + monkeypatch.setattr(sys, "argv", + ["pdf-ocr-hotfolder", "--config", str(cfg), "--once"]) + from pdf_ocr_hotfolder.__main__ import main + assert main() == 2 + err = capsys.readouterr().err + assert "FEHLER" in err + assert "[paths]" in err diff --git a/tests/test_error_counting.py b/tests/test_error_counting.py new file mode 100644 index 0000000..f87e202 --- /dev/null +++ b/tests/test_error_counting.py @@ -0,0 +1,257 @@ +"""Tests für die Fehlerzählung im Service. + +Deckt drei bisher stumme Fehlerpfade ab: +- Exception aus `process_pdf()` (z.B. fehlgeschlagener Move nach outgoing/) +- fehlgeschlagene Uploads +- Timeout im Stabilitäts-Check +""" +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +from pdf_ocr_hotfolder.processor import ProcessResult +from pdf_ocr_hotfolder.service import HotfolderService + + +def _run_once(tmp_config, **patches): + """Führt run_once() mit gemocktem Preflight aus und gibt den Service zurück.""" + stack = [ + patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None), + patch("pdf_ocr_hotfolder.service._wait_until_stable", + return_value=patches.pop("stable", True)), + ] + for target, kwargs in patches.items(): + stack.append(patch(f"pdf_ocr_hotfolder.service.{target}", **kwargs)) + + service = HotfolderService(tmp_config) + try: + for p in stack: + p.start() + service.run_once() + finally: + for p in reversed(stack): + p.stop() + service._executor.shutdown(wait=False) + return service + + +# ---------------- Exception aus process_pdf ---------------- + +def test_exception_from_process_pdf_counts_as_error(tmp_config) -> None: + """Eine Exception aus process_pdf() darf die Zählung nicht umgehen.""" + (tmp_config.paths.incoming / "boom.pdf").write_bytes(b"%PDF-1.4\n") + + def explode(src, **kwargs): + raise OSError("move to outgoing failed") + + service = _run_once(tmp_config, process_pdf={"side_effect": explode}) + + assert service.error_count == 1 + assert service.success_count == 0 + + +def test_exception_moves_file_to_error_dir(tmp_config) -> None: + """Die Datei landet nach einer Exception im error-Verzeichnis.""" + (tmp_config.paths.incoming / "boom.pdf").write_bytes(b"%PDF-1.4\n") + + def explode(src, **kwargs): + raise RuntimeError("kaputt") + + _run_once(tmp_config, process_pdf={"side_effect": explode}) + + assert (tmp_config.paths.error / "boom.pdf").exists() + assert not (tmp_config.paths.incoming / "boom.pdf").exists() + + +def test_exception_after_move_to_working_rescues_from_working(tmp_config) -> None: + """Realistischer Fall: process_pdf hat schon nach working/ verschoben.""" + src = tmp_config.paths.incoming / "boom.pdf" + src.write_bytes(b"%PDF-1.4\n") + + def explode(src: Path, working_dir: Path, **kwargs): + # process_pdf verschiebt zuerst nach working/, dann knallt der Move + # nach outgoing/ + src.rename(working_dir / src.name) + raise OSError("move to outgoing failed") + + service = _run_once(tmp_config, process_pdf={"side_effect": explode}) + + assert service.error_count == 1 + assert (tmp_config.paths.error / "boom.pdf").exists() + assert not (tmp_config.paths.working / "boom.pdf").exists() + + +def test_exception_with_vanished_file_does_not_raise(tmp_config) -> None: + """Ist die Datei nicht mehr auffindbar, wird nur geloggt — kein Crash.""" + (tmp_config.paths.incoming / "boom.pdf").write_bytes(b"%PDF-1.4\n") + + def explode(src: Path, **kwargs): + src.unlink(missing_ok=True) + raise RuntimeError("kaputt") + + service = _run_once(tmp_config, process_pdf={"side_effect": explode}) + + assert service.error_count == 1 + assert not (tmp_config.paths.error / "boom.pdf").exists() + + +def test_exception_triggers_error_notification(tmp_config) -> None: + """Auch bei einer Exception geht eine Fehler-Mail raus (success=False).""" + (tmp_config.paths.incoming / "boom.pdf").write_bytes(b"%PDF-1.4\n") + + def explode(src, **kwargs): + raise RuntimeError("kaputt") + + with patch("pdf_ocr_hotfolder.service.notify_email") as mail: + _run_once(tmp_config, process_pdf={"side_effect": explode}) + + assert mail.call_count == 1 + args = mail.call_args[0] + assert "FEHLER" in args[1] + assert args[3] is False # success-Flag + + +# ---------------- Upload-Fehler ---------------- + +def _fake_success(src: Path, working_dir, outgoing_dir, error_dir, **kwargs): + out = outgoing_dir / f"OCR_{src.name}" + out.parent.mkdir(parents=True, exist_ok=True) + out.write_bytes(b"%PDF-1.4 ocr\n") + src.unlink(missing_ok=True) + return ProcessResult(src, out, True) + + +def test_failed_upload_counts_as_error(tmp_config) -> None: + """Ein fehlgeschlagener Upload zählt als Fehler, nicht als Erfolg.""" + (tmp_config.paths.incoming / "a.pdf").write_bytes(b"%PDF-1.4\n") + + service = _run_once( + tmp_config, + process_pdf={"side_effect": _fake_success}, + upload_nextcloud={"return_value": False}, + ) + + assert service.error_count == 1 + assert service.success_count == 0 + + +def test_failed_upload_sends_error_mail_naming_targets(tmp_config) -> None: + """Die Fehler-Mail nennt die fehlgeschlagenen Ziele.""" + (tmp_config.paths.incoming / "a.pdf").write_bytes(b"%PDF-1.4\n") + + with patch("pdf_ocr_hotfolder.service.notify_email") as mail: + _run_once( + tmp_config, + process_pdf={"side_effect": _fake_success}, + upload_nextcloud={"return_value": False}, + upload_sftp={"return_value": False}, + ) + + assert mail.call_count == 1 + _cfg, subject, body, success = mail.call_args[0] + assert "FEHLER" in subject + assert success is False + assert "nextcloud" in body + assert "sftp" in body + + +def test_failed_upload_keeps_pdf_in_outgoing(tmp_config) -> None: + """Das OCR war erfolgreich — die Datei bleibt in outgoing/, nicht error/.""" + (tmp_config.paths.incoming / "a.pdf").write_bytes(b"%PDF-1.4\n") + + _run_once( + tmp_config, + process_pdf={"side_effect": _fake_success}, + upload_folder={"return_value": False}, + ) + + assert (tmp_config.paths.outgoing / "OCR_a.pdf").exists() + assert not (tmp_config.paths.error / "OCR_a.pdf").exists() + + +def test_successful_uploads_count_as_success(tmp_config) -> None: + """Gegenprobe: wenn alle Uploads durchgehen, zählt es als Erfolg.""" + (tmp_config.paths.incoming / "a.pdf").write_bytes(b"%PDF-1.4\n") + + service = _run_once(tmp_config, process_pdf={"side_effect": _fake_success}) + + assert service.success_count == 1 + assert service.error_count == 0 + + +def test_dispatch_uploads_reports_failed_targets(tmp_config) -> None: + """_dispatch_uploads() liefert die Namen der fehlgeschlagenen Ziele.""" + service = HotfolderService(tmp_config) + try: + pdf = tmp_config.paths.outgoing / "x.pdf" + pdf.write_bytes(b"%PDF-1.4\n") + with patch("pdf_ocr_hotfolder.service.upload_folder", return_value=True), \ + patch("pdf_ocr_hotfolder.service.upload_nextcloud", return_value=False), \ + patch("pdf_ocr_hotfolder.service.upload_sftp", return_value=True): + assert service._dispatch_uploads(pdf) == ["nextcloud"] + + with patch("pdf_ocr_hotfolder.service.upload_folder", return_value=True), \ + patch("pdf_ocr_hotfolder.service.upload_nextcloud", return_value=True), \ + patch("pdf_ocr_hotfolder.service.upload_sftp", return_value=True): + assert service._dispatch_uploads(pdf) == [] + finally: + service._executor.shutdown(wait=False) + + +# ---------------- Stabilitäts-Timeout ---------------- + +def test_unstable_file_counts_as_error(tmp_config) -> None: + """Stabilisiert sich eine Datei nicht, ist das ein Fehler (Exit 1).""" + (tmp_config.paths.incoming / "slow.pdf").write_bytes(b"%PDF-1.4\n") + + with patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None), \ + patch("pdf_ocr_hotfolder.service._wait_until_stable", return_value=False), \ + patch("pdf_ocr_hotfolder.service.process_pdf") as proc: + service = HotfolderService(tmp_config) + try: + errors = service.run_once() + finally: + service._executor.shutdown(wait=False) + + assert errors == 1 + assert service.error_count == 1 + proc.assert_not_called() + + +def test_unstable_file_stays_in_incoming(tmp_config) -> None: + """Die instabile Datei bleibt bewusst in incoming/ liegen.""" + (tmp_config.paths.incoming / "slow.pdf").write_bytes(b"%PDF-1.4\n") + + with patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None), \ + patch("pdf_ocr_hotfolder.service._wait_until_stable", return_value=False): + service = HotfolderService(tmp_config) + try: + service.run_once() + finally: + service._executor.shutdown(wait=False) + + assert (tmp_config.paths.incoming / "slow.pdf").exists() + assert not (tmp_config.paths.error / "slow.pdf").exists() + + +def test_vanished_file_is_not_an_error(tmp_config) -> None: + """Verschwundene Datei ist kein Fehler — _wait_until_stable liefert dafür + ebenfalls False.""" + pdf = tmp_config.paths.incoming / "weg.pdf" + pdf.write_bytes(b"%PDF-1.4\n") + + def vanish(path: Path, **kwargs) -> bool: + path.unlink(missing_ok=True) + return False + + with patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None), \ + patch("pdf_ocr_hotfolder.service._wait_until_stable", side_effect=vanish): + service = HotfolderService(tmp_config) + try: + errors = service.run_once() + finally: + service._executor.shutdown(wait=False) + + assert errors == 0 + assert service.error_count == 0 diff --git a/tests/test_ocr_timeout.py b/tests/test_ocr_timeout.py new file mode 100644 index 0000000..302fb45 --- /dev/null +++ b/tests/test_ocr_timeout.py @@ -0,0 +1,81 @@ +"""Tests für [ocr].timeout → ocrmypdf `tesseract_timeout`. + +ocrmypdf wird hier komplett gemockt (per sys.modules), es läuft also nie +wirklich — die Tests laufen auch ohne installiertes ocrmypdf. +""" +from __future__ import annotations + +import sys +import tomllib +from pathlib import Path +from types import ModuleType + +import pytest + +from pdf_ocr_hotfolder.config import OcrConfig +from pdf_ocr_hotfolder.processor import run_ocr + + +@pytest.fixture +def fake_ocrmypdf(monkeypatch) -> ModuleType: + """Schiebt ein Dummy-ocrmypdf in sys.modules und merkt sich die kwargs.""" + mod = ModuleType("ocrmypdf") + mod.calls = [] # type: ignore[attr-defined] + + def ocr(src, dst, **kwargs): + mod.calls.append({"src": src, "dst": dst, "kwargs": kwargs}) # type: ignore[attr-defined] + Path(dst).write_bytes(b"%PDF-1.4 ocr\n") + + mod.ocr = ocr # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "ocrmypdf", mod) + return mod + + +def _run(fake, tmp_path: Path, cfg: OcrConfig) -> dict: + src = tmp_path / "in.pdf" + src.write_bytes(b"%PDF-1.4\n") + run_ocr(src, tmp_path / "out.pdf", cfg) + assert len(fake.calls) == 1 + return fake.calls[0]["kwargs"] + + +def test_timeout_is_passed_as_tesseract_timeout(fake_ocrmypdf, tmp_path: Path) -> None: + kwargs = _run(fake_ocrmypdf, tmp_path, OcrConfig(timeout=120)) + assert kwargs["tesseract_timeout"] == 120.0 + + +def test_timeout_zero_means_no_limit(fake_ocrmypdf, tmp_path: Path) -> None: + """0 = kein Limit → der Key darf NICHT durchgereicht werden. + + ocrmypdf würde tesseract_timeout=0 als 'OCR überspringen' auslegen. + """ + kwargs = _run(fake_ocrmypdf, tmp_path, OcrConfig(timeout=0)) + assert "tesseract_timeout" not in kwargs + + +def test_negative_timeout_is_ignored(fake_ocrmypdf, tmp_path: Path) -> None: + kwargs = _run(fake_ocrmypdf, tmp_path, OcrConfig(timeout=-5)) + assert "tesseract_timeout" not in kwargs + + +def test_default_timeout_is_passed(fake_ocrmypdf, tmp_path: Path) -> None: + kwargs = _run(fake_ocrmypdf, tmp_path, OcrConfig()) + assert kwargs["tesseract_timeout"] == 300.0 + + +def test_other_kwargs_still_present(fake_ocrmypdf, tmp_path: Path) -> None: + """Der neue Key ersetzt nichts Bestehendes.""" + kwargs = _run(fake_ocrmypdf, tmp_path, + OcrConfig(languages="deu", jobs=2, pdfa_level="")) + assert kwargs["language"] == "deu" + assert kwargs["jobs"] == 2 + assert kwargs["output_type"] == "pdf" + assert kwargs["skip_text"] is True + + +def test_config_default_matches_example(tmp_path: Path) -> None: + """Dataclass-Default und config.example.toml dürfen nicht auseinanderlaufen.""" + cfg_path = Path(__file__).parent.parent / "config.example.toml" + with cfg_path.open("rb") as f: + data = tomllib.load(f) + assert data["ocr"]["timeout"] == OcrConfig().timeout == 300 diff --git a/tests/test_output_naming.py b/tests/test_output_naming.py index 96bf2d3..ffd6e9f 100644 --- a/tests/test_output_naming.py +++ b/tests/test_output_naming.py @@ -60,6 +60,62 @@ def test_check_output_config_invalid_mode() -> None: check_output_config("trash", "") +@pytest.mark.parametrize("name_mode", ["prefix", "suffix", "none"]) +def test_check_output_config_accepts_valid_name_modes(name_mode) -> None: + check_output_config("delete", "", name_mode) # ok + + +@pytest.mark.parametrize("name_mode", ["prefixx", "Prefix", "", "postfix"]) +def test_check_output_config_invalid_name_mode(name_mode) -> None: + """Tippfehler in name_mode muss schon im Preflight auffallen.""" + with pytest.raises(PreflightError, match="name_mode"): + check_output_config("delete", "", name_mode) + + +def test_run_once_aborts_on_invalid_name_mode(tmp_config) -> None: + """Der Dienst bricht beim Start ab, bevor eine Datei angefasst wird.""" + from unittest.mock import patch + + from pdf_ocr_hotfolder.service import HotfolderService + + tmp_config.output.name_mode = "bogus" + (tmp_config.paths.incoming / "a.pdf").write_bytes(b"%PDF-1.4\n") + + service = HotfolderService(tmp_config) + try: + with patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None): + with pytest.raises(PreflightError, match="name_mode"): + service.run_once() + finally: + service._executor.shutdown(wait=False) + + # Datei wurde nicht angefasst + assert (tmp_config.paths.incoming / "a.pdf").exists() + + +def test_main_returns_2_on_invalid_name_mode(tmp_path: Path, monkeypatch) -> None: + """CLI liefert Exit-Code 2 — gleicher Mechanismus wie die übrigen Preflights.""" + import sys + from unittest.mock import patch as _patch + + cfg_file = tmp_path / "cfg.toml" + cfg_file.write_text(f""" +[paths] +incoming = "{tmp_path / 'in'}" +outgoing = "{tmp_path / 'out'}" +working = "{tmp_path / 'work'}" +error = "{tmp_path / 'err'}" + +[output] +name_mode = "bogus" +""") + monkeypatch.setattr(sys, "argv", + ["pdf-ocr-hotfolder", "--config", str(cfg_file), "--once"]) + with _patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None): + from pdf_ocr_hotfolder.__main__ import main + assert main() == 2 + + # ---------------- process_pdf mit Original-Behandlung ---------------- def _fake_ocr(src: Path, dst: Path, cfg: OcrConfig) -> None: diff --git a/tests/test_upload_folder.py b/tests/test_upload_folder.py new file mode 100644 index 0000000..6bc8302 --- /dev/null +++ b/tests/test_upload_folder.py @@ -0,0 +1,66 @@ +"""Tests für upload_folder() — Kopie per shutil.copyfile statt read_bytes().""" +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +from pdf_ocr_hotfolder.config import FolderUpload +from pdf_ocr_hotfolder.uploaders import upload_folder + + +def test_upload_folder_copies_file(tmp_path: Path) -> None: + src = tmp_path / "out" / "OCR_scan.pdf" + src.parent.mkdir() + src.write_bytes(b"%PDF-1.4 inhalt\n") + target = tmp_path / "ziel" + + assert upload_folder(src, FolderUpload(enabled=True, target=str(target)), + tmp_path / "out") is True + assert (target / "OCR_scan.pdf").read_bytes() == b"%PDF-1.4 inhalt\n" + # Quelle bleibt liegen (Kopie, kein Move) + assert src.exists() + + +def test_upload_folder_uses_copyfile_not_read_bytes(tmp_path: Path) -> None: + """Große PDFs dürfen nicht komplett in den Speicher gelesen werden.""" + src = tmp_path / "out" / "OCR_scan.pdf" + src.parent.mkdir() + src.write_bytes(b"%PDF-1.4\n") + target = tmp_path / "ziel" + + with patch("pdf_ocr_hotfolder.uploaders.shutil.copyfile") as copyfile: + upload_folder(src, FolderUpload(enabled=True, target=str(target)), + tmp_path / "out") + copyfile.assert_called_once() + + +def test_upload_folder_skips_self_target(tmp_path: Path) -> None: + """Ist das Ziel = outgoing, wird nicht auf sich selbst kopiert.""" + out = tmp_path / "out" + out.mkdir() + src = out / "OCR_scan.pdf" + src.write_bytes(b"%PDF-1.4\n") + + with patch("pdf_ocr_hotfolder.uploaders.shutil.copyfile") as copyfile: + assert upload_folder(src, FolderUpload(enabled=True, target=""), out) is True + copyfile.assert_not_called() + assert src.read_bytes() == b"%PDF-1.4\n" + + +def test_upload_folder_disabled_returns_true(tmp_path: Path) -> None: + src = tmp_path / "OCR_scan.pdf" + src.write_bytes(b"%PDF-1.4\n") + assert upload_folder(src, FolderUpload(enabled=False), tmp_path) is True + + +def test_upload_folder_reports_failure(tmp_path: Path) -> None: + """OSError beim Kopieren → False (wird vom Service als Fehler gezählt).""" + src = tmp_path / "out" / "OCR_scan.pdf" + src.parent.mkdir() + src.write_bytes(b"%PDF-1.4\n") + + with patch("pdf_ocr_hotfolder.uploaders.shutil.copyfile", + side_effect=OSError("disk full")): + assert upload_folder(src, FolderUpload(enabled=True, + target=str(tmp_path / "ziel")), + tmp_path / "out") is False