3 Commits

Author SHA1 Message Date
techadmin 8da0b7da1c fix: veraPDF-FAIL respektiert original_on_success, Logverzeichnis raus (v0.4.1)
- Bei fehlgeschlagener veraPDF-Validierung wurde das Original bisher
  bedingungslos geloescht. Es folgt jetzt derselben
  [output].original_on_success-Regel wie im Erfolgsfall, damit "archive"
  das Original nicht ausgerechnet im Fehlerfall verliert.
- Das nie benutzte Logverzeichnis /var/log/pdf-ocr-hotfolder/ wird nicht
  mehr angelegt; der Dienst loggt ausschliesslich nach journald.
  README und Briefing nennen stattdessen die journalctl-Kommandos.
- 3 neue Tests (95 gesamt)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 21:10:27 +02:00
techadmin 578472872e 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>
2026-09-22 21:01:38 +02:00
techadmin cbdc9d6664 Fix Issues #4, #5, #6: LXC-Kompatibilität, WorkingDirectory, GS-Backports
- #4: LXC/Container Drop-in (lxc-compat.conf) deaktiviert systemd-Hardening;
  Installer erkennt Container automatisch und bietet Drop-in an
- #5: WorkingDirectory=/opt/pdf-ocr-hotfolder in Template-Unit ergänzt
- #6: Installer bietet auf Debian 12 bei betroffenen GS-Versionen
  automatisch bookworm-backports Upgrade an (statt nur Warnung)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 01:41:54 +02:00
20 changed files with 1100 additions and 87 deletions
+110 -33
View File
@@ -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.1
**Status:** Multi-Instanz-Betrieb, Preflight-Checks und Fehlerzählung vorhanden, Test-Suite grün (95 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 (95 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,26 +47,37 @@ 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/<instanz>.toml` | Config pro Instanz (mode 640, root:<service-group>) |
| `/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@<instanz>.service.d/user.conf` | Drop-in für abweichenden User (optional) |
| `/var/lib/pdf-ocr-hotfolder/<instanz>/{incoming,working,outgoing,error}/` | Daten pro Instanz |
| `/var/log/pdf-ocr-hotfolder/` | Logs |
| `/var/backups/pdf-ocr-hotfolder/` | Update-Backups |
Ein eigenes Logverzeichnis gibt es **nicht** (seit 0.4.1 auch nicht mehr vom
Installer angelegt): `_setup_logging()` nutzt `logging.basicConfig()` ohne
FileHandler, alles geht nach stdout → journald.
```bash
journalctl -u pdf-ocr-hotfolder@<instanz> -f # eine Instanz mitlesen
journalctl -u 'pdf-ocr-hotfolder@*' --since today # alle Instanzen, heute
```
## 👤 Service-User
- Basis-Install legt Default-User `pdfocr` an (als System-User, falls nicht schon vorhanden)
@@ -68,9 +91,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/<name>`), 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/<name>`), 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
- `<instanz>.toml` wird aus `config.example.toml` mit sed-substituierten Pfaden generiert
- Instanz wird sofort `enable --now` gestartet
Manuelles Löschen einer Instanz:
@@ -85,29 +109,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_<originalname>.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) — bei FAIL geht das OCR-Ergebnis nach `error/`, das Original folgt `original_on_success` (wird also bei `archive` **nicht** gelöscht)
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.1):**
| 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 laut `original_on_success` (`delete` → weg, `archive` → `archive_dir`; seit 0.4.1) |
| 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 +176,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/<instanz>.toml`. Deshalb `chmod 640` und `chown root:<service-gruppe>`, 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 +199,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 95 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` — 95 Tests
- [ ] Test-Lücken schließen: der watchdog-Eventpfad (`_Handler`/`Observer`) wird nirgends getestet, `run_verapdf()` ebenso wenig (der FAIL-*Pfad* in `process_pdf()` ist getestet, die veraPDF-CLI-Anbindung selbst nicht), 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 <error-file>`
- [ ] Optional: S3/MinIO Upload-Target
+96
View File
@@ -1,5 +1,101 @@
# Changelog
## [0.4.1] - 2026-09-22
### Fixed
- **veraPDF-FAIL hat das Original immer gelöscht.** Schlug die PDF/A-Validierung
fehl, wanderte das OCR-Ergebnis nach `error/` und das Original wurde per
`unlink()` entfernt — unabhängig von `[output].original_on_success`. Wer
`archive` konfiguriert hatte, verlor die Datei also ausgerechnet im
Fehlerfall. Der FAIL-Pfad nutzt jetzt dieselbe `_dispose_original()`-Logik
wie der Erfolgsfall: `archive` legt das Original samt
Timestamp-Kollisionsschutz im `archive_dir` ab, `delete` verhält sich wie
bisher. Die Log-Meldung nennt jetzt beides — wohin das OCR-Ergebnis ging und
was mit dem Original passiert ist.
### Removed
- Das nie benutzte Logverzeichnis `/var/log/pdf-ocr-hotfolder/` wird nicht mehr
vom Installer angelegt und ist aus README und Briefing entfernt. Es hat nie
ein Logfile enthalten: `_setup_logging()` nutzt `logging.basicConfig()` ohne
FileHandler, der Dienst loggt nach stdout → journald. **journald ist damit die
einzige Log-Quelle** (`journalctl -u pdf-ocr-hotfolder@<instanz> -f`).
Weder Installer noch Updater fassen das Verzeichnis an: ein vorhandenes,
leeres `/var/log/pdf-ocr-hotfolder/` kann auf bestehenden Installationen
gefahrlos von Hand entfernt werden (`sudo rmdir /var/log/pdf-ocr-hotfolder`).
### Added
- 3 neue Tests für den veraPDF-FAIL-Pfad (`delete`, `archive`,
Archiv-Namenskollision); veraPDF wird dabei gemockt. Suite jetzt 95 Tests.
## [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
- **Issue #4**: LXC/Container-Kompatibilität — systemd-Hardening (`PrivateTmp`, `ProtectSystem`, etc.)
verursacht Error 226/NAMESPACE in LXC-Containern. Installer erkennt Container-Umgebung automatisch
und bietet ein Drop-in an. Zusätzlich liegt `systemd/lxc-compat.conf` als Vorlage im Repo.
- **Issue #5**: `WorkingDirectory=/opt/pdf-ocr-hotfolder` in der systemd Template-Unit ergänzt —
ohne diesen Eintrag konnte das Python-Modul nicht gefunden werden.
- **Issue #6**: Auf Debian 12 bietet der Installer bei betroffenen Ghostscript-Versionen (10.0.0–10.02.0)
jetzt automatisch an, bookworm-backports zu aktivieren und GS zu upgraden (statt nur zu warnen).
## [0.3.0] - 2026-04-09
### Added
+31 -4
View File
@@ -71,7 +71,6 @@ Manuell eine weitere Instanz anlegen geht auch — einfach `install.sh` erneut s
| `/var/lib/pdf-ocr-hotfolder/<instanz>/working` | Arbeitsverzeichnis während OCR |
| `/var/lib/pdf-ocr-hotfolder/<instanz>/outgoing` | Ausgang (fertige PDFs) |
| `/var/lib/pdf-ocr-hotfolder/<instanz>/error` | Fehlgeschlagene PDFs |
| `/var/log/pdf-ocr-hotfolder/` | Logs (zusätzlich zu journald) |
| `/var/backups/pdf-ocr-hotfolder/` | Update-Backups |
## Konfiguration
@@ -83,10 +82,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]`
@@ -148,6 +147,16 @@ sudo systemctl status 'pdf-ocr-hotfolder@*'
sudo systemctl restart 'pdf-ocr-hotfolder@*'
```
### Logs
Der Dienst schreibt **kein eigenes Logfile** — alles geht nach stdout und damit
ins journal:
```bash
journalctl -u pdf-ocr-hotfolder@<instanz> -f # eine Instanz mitlesen
journalctl -u 'pdf-ocr-hotfolder@*' --since today # alle Instanzen, heute
```
## Update
```bash
@@ -190,6 +199,24 @@ Service-User braucht **rw** auf alle vier Verzeichnisse unter `/var/lib/pdf-ocr-
sudo chown -R DOMAIN\\scanuser:DOMAIN\\scangroup /var/lib/pdf-ocr-hotfolder
```
### LXC/Container: Error 226/NAMESPACE
In LXC-Containern schlagen systemd-Hardening-Optionen fehl. Der Installer erkennt Container automatisch und bietet ein Drop-in an. Manuell:
```bash
sudo mkdir -p /etc/systemd/system/pdf-ocr-hotfolder@.service.d/
sudo cp /opt/pdf-ocr-hotfolder/systemd/lxc-compat.conf \
/etc/systemd/system/pdf-ocr-hotfolder@.service.d/
sudo systemctl daemon-reload
sudo systemctl restart 'pdf-ocr-hotfolder@*'
```
### Ghostscript PDF/A-Bug auf Debian 12
GS 10.00.0–10.02.0 (Debian 12 Default) zerstört OCR bei `pdfa_level` + `skip_text=true`. Der Installer bietet automatisch bookworm-backports an. Manuell:
```bash
echo 'deb http://deb.debian.org/debian bookworm-backports main' | \
sudo tee /etc/apt/sources.list.d/bookworm-backports.list
sudo apt update && sudo apt install -t bookworm-backports ghostscript
```
### veraPDF-Validierung schlägt immer fehl
veraPDF binary prüfen (`[verapdf].binary`). Wenn nicht zwingend gebraucht: `enabled = false`.
@@ -221,5 +248,5 @@ MIT — © Sonith UG
---
**Version:** 0.2.0
**Version:** 0.4.1
**Repo:** https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder
+1 -1
View File
@@ -1 +1 @@
0.3.0
0.4.1
+7 -2
View File
@@ -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?
+41 -8
View File
@@ -26,7 +26,6 @@ fi
INSTALL_DIR="/opt/pdf-ocr-hotfolder"
CONFIG_DIR="/etc/pdf-ocr-hotfolder"
DATA_ROOT="/var/lib/pdf-ocr-hotfolder"
LOG_DIR="/var/log/pdf-ocr-hotfolder"
SERVICE_TEMPLATE="pdf-ocr-hotfolder@.service"
DEFAULT_USER="pdfocr"
@@ -52,7 +51,7 @@ install_base() {
icc-profiles-free ca-certificates curl
log_info "System-Pakete ok ✓"
# Ghostscript-Versions-Check (Issue #3)
# Ghostscript-Versions-Check (Issue #3 + Issue #6)
if command -v gs >/dev/null 2>&1; then
GS_VER="$(gs --version 2>/dev/null || echo 0.0)"
log_info "Ghostscript: $GS_VER"
@@ -62,16 +61,50 @@ install_base() {
log_warn "═══════════════════════════════════════════════════════════════"
log_warn "Ghostscript $GS_VER ist vom PDF/A-Bug betroffen (10.0.0–10.02.0)."
log_warn "Mit pdfa_level + skip_text=true kann ocrmypdf KEINE PDFs verarbeiten."
log_warn ""
log_warn "Workarounds:"
log_warn " 1. ghostscript aus bookworm-backports installieren (>=10.02.1)"
log_warn " 2. In der Config [ocr].pdfa_level = \"\" setzen (Default ab v0.2.2)"
log_warn "═══════════════════════════════════════════════════════════════"
echo
# Prüfe ob Debian bookworm (12) — Backports anbieten
if grep -q 'bookworm' /etc/os-release 2>/dev/null; then
read -r -p "Ghostscript via bookworm-backports upgraden? [J/n]: " UPGRADE_GS
UPGRADE_GS="${UPGRADE_GS:-J}"
if [[ "$UPGRADE_GS" =~ ^[JjYy]$ ]]; then
log_info "Aktiviere bookworm-backports..."
if ! grep -q 'bookworm-backports' /etc/apt/sources.list /etc/apt/sources.list.d/*.list 2>/dev/null; then
echo 'deb http://deb.debian.org/debian bookworm-backports main' \
> /etc/apt/sources.list.d/bookworm-backports.list
apt-get update -qq
fi
apt-get install -y -t bookworm-backports ghostscript
GS_VER_NEW="$(gs --version 2>/dev/null || echo '?')"
log_info "Ghostscript aktualisiert: $GS_VER → $GS_VER_NEW ✓"
else
log_warn "Workaround: In der Config [ocr].pdfa_level = \"\" setzen (Default ab v0.2.2)"
fi
else
log_warn "Kein Debian bookworm erkannt — manuelles Upgrade nötig."
log_warn "Workaround: In der Config [ocr].pdfa_level = \"\" setzen (Default ab v0.2.2)"
fi
echo
;;
esac
fi
# LXC/Container-Erkennung (Issue #4)
if systemd-detect-virt --container -q 2>/dev/null; then
VIRT_TYPE="$(systemd-detect-virt --container 2>/dev/null || echo 'container')"
log_warn "Container-Umgebung erkannt ($VIRT_TYPE)."
log_warn "systemd-Hardening kann in Containern fehlschlagen (Error 226/NAMESPACE)."
read -r -p "LXC-Kompatibilitäts-Drop-in installieren? [J/n]: " LXC_FIX
LXC_FIX="${LXC_FIX:-J}"
if [[ "$LXC_FIX" =~ ^[JjYy]$ ]]; then
local LXC_DROPIN_DIR="/etc/systemd/system/pdf-ocr-hotfolder@.service.d"
mkdir -p "$LXC_DROPIN_DIR"
cp "$REPO_DIR/systemd/lxc-compat.conf" "$LXC_DROPIN_DIR/lxc-compat.conf"
systemctl daemon-reload
log_info "LXC-Kompatibilitäts-Drop-in installiert ✓"
fi
fi
log_step "Default-User '$DEFAULT_USER' prüfen"
if id "$DEFAULT_USER" &>/dev/null; then
log_info "'$DEFAULT_USER' existiert bereits"
@@ -81,7 +114,7 @@ install_base() {
fi
log_step "Verzeichnisse anlegen"
mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" "$DATA_ROOT" "$LOG_DIR"
mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" "$DATA_ROOT"
chown root:"$DEFAULT_USER" "$CONFIG_DIR"
chmod 750 "$CONFIG_DIR"
@@ -106,7 +139,7 @@ install_base() {
systemctl daemon-reload
log_info "Template-Unit installiert ✓"
chown -R "$DEFAULT_USER":"$DEFAULT_USER" "$INSTALL_DIR" "$LOG_DIR"
chown -R "$DEFAULT_USER":"$DEFAULT_USER" "$INSTALL_DIR"
}
# ============================================================
+1 -1
View File
@@ -1,3 +1,3 @@
"""PDF OCR Hotfolder — Scanner-PDFs automatisch durchsuchbar machen."""
__version__ = "0.1.0"
__version__ = "0.4.1"
+6 -2
View File
@@ -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)
+36 -6
View File
@@ -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()
+27 -2
View File
@@ -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)
@@ -121,8 +133,17 @@ def process_pdf(
if vera_cfg.enabled:
vera_ok = run_verapdf(work_out, vera_cfg)
if not vera_ok:
# Das OCR-Ergebnis ist unbrauchbar und wandert nach error/. Das
# Original wird aber NICHT bedingungslos gelöscht: es folgt derselben
# [output].original_on_success-Regel wie im Erfolgsfall, sonst
# verliert man es ausgerechnet im Fehlerfall (archive!).
_move_to_error(work_out, error_dir)
work_src.unlink(missing_ok=True)
_dispose_original(work_src, src.name, output_cfg)
log.error(
"veraPDF FAIL: %s — OCR-Ergebnis nach %s verschoben, Original %s",
src.name, error_dir,
"archiviert" if output_cfg.original_on_success == "archive" else "gelöscht",
)
return ProcessResult(src, final_out, False,
"verapdf validation failed", verapdf_passed=False)
@@ -133,7 +154,11 @@ def process_pdf(
def _dispose_original(work_src: Path, original_name: str, cfg: OutputConfig) -> None:
"""Entsorgt das Original nach erfolgreichem OCR — löschen oder archivieren."""
"""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.
"""
if not work_src.exists():
return
mode = cfg.original_on_success
+119 -27
View File
@@ -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:
+4 -1
View File
@@ -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:
+2
View File
@@ -0,0 +1,2 @@
[pytest]
testpaths = tests
+10
View File
@@ -0,0 +1,10 @@
# Drop-in für LXC/Container-Betrieb
# Kopieren nach: /etc/systemd/system/pdf-ocr-hotfolder@.service.d/lxc-compat.conf
# Danach: systemctl daemon-reload && systemctl restart 'pdf-ocr-hotfolder@*'
[Service]
PrivateTmp=false
ProtectSystem=false
ProtectKernelTunables=false
ProtectKernelModules=false
ProtectControlGroups=false
+1
View File
@@ -7,6 +7,7 @@ Wants=network-online.target
Type=simple
User=pdfocr
Group=pdfocr
WorkingDirectory=/opt/pdf-ocr-hotfolder
ExecStart=/opt/pdf-ocr-hotfolder/venv/bin/python -m pdf_ocr_hotfolder --config /etc/pdf-ocr-hotfolder/%i.toml
Restart=on-failure
RestartSec=5
+79
View File
@@ -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
+257
View File
@@ -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
+81
View File
@@ -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
+125
View File
@@ -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:
@@ -188,3 +244,72 @@ def test_process_pdf_archive_name_collision(tmp_path: Path) -> None:
archived = list(env["archive"].glob("scan_*.pdf"))
assert len(archived) == 1
assert archived[0].read_bytes() == b"%PDF-1.4 original\n"
# ---------------- veraPDF FAIL: Original folgt original_on_success ----------------
def _run_with_vera_fail(env: dict, out_cfg: OutputConfig):
"""process_pdf mit gemocktem OCR und einem veraPDF, das FAIL meldet."""
with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_fake_ocr), \
patch("pdf_ocr_hotfolder.processor.run_verapdf", return_value=False):
return process_pdf(
src=env["src"],
working_dir=env["working"],
outgoing_dir=env["outgoing"],
error_dir=env["error"],
ocr_cfg=OcrConfig(),
vera_cfg=VeraPdfConfig(enabled=True),
output_cfg=out_cfg,
)
def test_process_pdf_verapdf_fail_delete_removes_original(tmp_path: Path) -> None:
"""delete: Verhalten wie bisher — OCR-Ergebnis nach error/, Original weg."""
env = _prepare(tmp_path)
out_cfg = OutputConfig(name_mode="prefix", name_tag="OCR_",
original_on_success="delete")
result = _run_with_vera_fail(env, out_cfg)
assert not result.success
assert result.verapdf_passed is False
# OCR-Ergebnis liegt in error/
assert (env["error"] / "__ocr_OCR_scan.pdf").exists()
# Original ist weg
assert not env["src"].exists()
assert not (env["working"] / "scan.pdf").exists()
assert not (env["outgoing"] / "OCR_scan.pdf").exists()
def test_process_pdf_verapdf_fail_archive_keeps_original(tmp_path: Path) -> None:
"""archive: das Original darf im Fehlerfall NICHT verloren gehen."""
env = _prepare(tmp_path)
out_cfg = OutputConfig(name_mode="prefix", name_tag="OCR_",
original_on_success="archive",
archive_dir=str(env["archive"]))
result = _run_with_vera_fail(env, out_cfg)
assert not result.success
assert result.verapdf_passed is False
# OCR-Ergebnis liegt in error/
assert (env["error"] / "__ocr_OCR_scan.pdf").exists()
# Original liegt unversehrt im Archiv
archived = env["archive"] / "scan.pdf"
assert archived.exists()
assert archived.read_bytes() == b"%PDF-1.4 original\n"
assert not (env["working"] / "scan.pdf").exists()
assert not (env["outgoing"] / "OCR_scan.pdf").exists()
def test_process_pdf_verapdf_fail_archive_name_collision(tmp_path: Path) -> None:
"""Auch im veraPDF-FAIL-Pfad greift der Timestamp-Kollisionsschutz."""
env = _prepare(tmp_path)
(env["archive"] / "scan.pdf").write_bytes(b"old")
out_cfg = OutputConfig(name_mode="prefix", name_tag="OCR_",
original_on_success="archive",
archive_dir=str(env["archive"]))
_run_with_vera_fail(env, out_cfg)
assert (env["archive"] / "scan.pdf").read_bytes() == b"old"
archived = list(env["archive"].glob("scan_*.pdf"))
assert len(archived) == 1
assert archived[0].read_bytes() == b"%PDF-1.4 original\n"
+66
View File
@@ -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