10 Commits

Author SHA1 Message Date
techadmin cd803a3dfe feat: stille Datenverlust-Pfade geschlossen, gemeinsame Shell-Lib (v0.7.0)
Vor dem Rollout durchgesehen und die verbliebenen Stellen geschlossen, an
denen etwas schiefgehen konnte, ohne dass es irgendwo sichtbar wurde.

Datenverlust:
- veraPDF: das in [verapdf].binary konfigurierte Programm wird im Preflight
  geprueft. Bisher galt bei falschem Pfad JEDE Datei als "nicht konform" —
  Ergebnis nach error/, Original geloescht (Default delete). run_verapdf()
  trennt jetzt ausserdem ein echtes FAIL-Urteil von einer Stoerung
  (VeraPdfUnavailable: nicht startbar, abgestuerzt, kein PASS/FAIL in der
  Ausgabe). Bei Stoerung wandern Original UND Ergebnis nach error/, das
  Original wird nicht entsorgt.
- Gleichnamige Dateien wurden in outgoing/, error/ und beim Ordner-Upload
  mit abweichendem target kommentarlos ueberschrieben. Jetzt Zeitstempel
  daneben, mit Warnung; ProcessResult.output traegt den echten Pfad.

Robustheit:
- Kaputtes oder nicht lesbares TOML beim Start: Exit 2 statt Traceback.
- RestartPreventExitStatus=2 in der Unit — Exit 2 (Config/Preflight) laeuft
  nicht mehr endlos neu, die Instanz bleibt sichtbar failed stehen.
- Toter watchdog-Observer wird erkannt: Exit 3, systemd setzt den Watch neu
  auf. Vorher blieb die Unit "active" und verarbeitete nichts mehr.
- Relative Pfade in [paths]/archive_dir/target sind ein Config-Fehler statt
  still unter /opt zu landen.
- Fehler beim Archivieren entwertet den Durchlauf nicht mehr: Upload und
  Mail laufen, Sichtbarkeit ueber log.error + "OK mit Warnung"-Mail.
- Nicht-PDFs in incoming/ werden beim Start-Scan gesammelt gemeldet.
- Logging explizit nach stdout (die Doku versprach das schon).

Struktur:
- Neue lib/common.sh, von install.sh und update.sh gesourct. Die doppelte
  venv_is_healthy() gibt es nur noch einmal, in der gruendlichen Fassung —
  die schlanke in install.sh haette eine nach einem Distro-Sprung kaputte
  venv als gesund durchgewunken (nachgewiesen).
- install.sh warnt in Containern, wenn systemd-journald nicht laeuft.

Doku: Dateisystem-Festlegung (ext4/xfs/zfs, kein CIFS/NFS wegen inotify),
Debian 13 in LXC auf Proxmox scheitert an journald (243/CREDENTIALS,
AppArmor blockiert sd-mkdcreds) inkl. Abhilfe, echte Speicher-Messwerte,
Exit-Code-Tabelle.

254 Tests gruen (vorher 152).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 00:59:17 +02:00
techadmin 305454eeb5 docs: Systemanforderungen und journald-Abhaengigkeit dokumentieren (v0.6.3)
Aus den Testlaeufen auf Debian 12 (CT 200) und Debian 13 (CT 201):

- Systemanforderungen: mindestens 2 GB RAM. Eine einzelne A4-Seite in
  300 dpi mit deskew=true hat auf einem 512-MB-Container den OOM-Killer
  ausgeloest (anon-rss:489676kB). Dazu der Zusammenhang RAM <->
  max_workers x jobs x Aufloesung, wie sich ein OOM-Kill aeussert und wie
  man ihn nachweist (/sys/fs/cgroup/memory.events, dmesg auf dem Host).
- Troubleshooting: kaputtes systemd-journald ist ein blinder Fleck, weil
  der Dienst seit v0.4.1 nur dorthin loggt. Symptom, erste Pruefung und
  ein Vordergrund-Notbehelf. Auf einem von zwei Testcontainern aufgetreten
  — kein generelles LXC-Muster.

Nebenbei korrigiert: toter Anker in docs/INSTALLATION.md (#3-ocr-sprachen
-> #4-ocr-sprachen) und eine veraltete Stelle im Briefing, die
requirements.txt noch als "ocrmypdf 16.x" beschrieb.

Reine Doku-Version, 152 Tests unveraendert gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 00:18:45 +02:00
techadmin 04dc3c7b72 fix: gedrucktem --check-config-Befehl fehlte das cd ins Installationsverzeichnis (v0.6.2)
Das Paket ist nicht pip-installiert, sondern liegt unter /opt/pdf-ocr-hotfolder
und wird nur ueber das Arbeitsverzeichnis gefunden. Der Hinweis, den update.sh
in der Zusammenfassung ausgibt, lief deshalb so wie gedruckt nicht
("No module named pdf_ocr_hotfolder"). Gleiches galt fuer die Beispiele in
README.md, docs/INSTALLATION.md, docs/UPDATE.md und docs/OS-UPGRADE.md.

Gefunden beim Update-Test v0.3.1 -> v0.6.1 auf CT 200 (Debian 12).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 23:05:29 +02:00
techadmin aa9918adba fix: ocrmypdf-Pin auf 17.4.1, Preflight erkennt den GS-Fall, Rauchtest (v0.6.1)
v0.6.0 hat ocrmypdf auf 16.13.0 gepinnt, um einen ungewollten Major-Sprung
zu verhindern. Auf Bestandsinstallationen war das ein DOWNGRADE (dort lief
via ">=16.0" bereits 17.x) — und 16.13.0 bricht auf Debian 12 mit dem
Bord-Ghostscript 10.0.0 bei JEDER PDF ab, sobald skip_text gesetzt ist.
Im Test auf CT 200 lief das Update mit Exit 0 durch, der Dienst blieb
"active", --check-config meldete "Preflight ok" — und jede Datei landete
in error/. Stiller Totalausfall.

- requirements.txt: ocrmypdf==17.4.1 (real auf Debian 12 + gs 10.0.0
  verifiziert). Ab 17.0.0 steht die GS-Pruefung in ocrmypdf unter einem
  `if options.output_type.startswith('pdfa')`; bis 16.x lief sie ohne
  diesen Guard und schlug auch bei output_type="pdf" zu.
- check_preflight() prueft Ghostscript nicht mehr nur bei gesetztem
  pdfa_level, sondern bildet die reale Bedingung ab:
  betroffene GS-Version UND skip_text UND (PDF/A ODER ocrmypdf < 17).
  Der Dienst bricht damit beim Start ab statt bei der ersten Datei.
- update.sh zeigt Versionsspruenge der gepinnten Pakete; Downgrades als
  WARN, auch in der Abschluss-Zusammenfassung.
- update.sh faehrt nach dem Start einen Rauchtest (eingebettete Mini-PDF
  durch die echte Pipeline) und raeumt restlos auf. Uebersprungen, wenn
  Upload-Ziele oder E-Mail-Notify aktiv sind, damit kein Testmuell zum
  Kunden geht. Abschaltbar mit --no-smoke-test.
- Doku korrigiert: pdfa_level = "" allein ist keine Entwarnung, die haengt
  an der ocrmypdf-Version.

152 Tests gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 22:55:47 +02:00
techadmin 3e24aa2ecd feat: Wiederaufnahme aus working/, --check-config, feldtauglicher Updater (v0.6.0)
Datenverlust behoben:
- Nach hartem Stopp blieb das Original in working/ liegen und wurde nie
  wieder angefasst (_scan_existing sah nur incoming/). Es wird jetzt beim
  Start an Ort und Stelle wieder aufgegriffen, mit Kollisionsschutz gegen
  gleichnamige neue Scans; angefangene __ocr_-Fragmente werden geloescht.
- TimeoutStopSec 30 -> 300, damit laufendes OCR zu Ende laufen darf.

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 22:04:04 +02:00
techadmin 2062476252 feat: Installer fragt OCR-Sprachen und Archiv pro Instanz ab (v0.5.0)
- Sprach-Abfrage pro Instanz (Default-Vorschlag deu+eng), bewusst
  instanz-lokal: ein Hotfolder kann mit "deu" laufen, ein anderer mit
  "deu+eng+fra". Hinweis im Prompt, dass jede zusaetzliche Sprache
  Laufzeit und Erkennungsqualitaet kostet.
- Jeder Sprachcode wird gegen "tesseract --list-langs" geprueft, fehlende
  Pakete (tesseract-ocr-<code>) werden zur Installation angeboten; lehnt
  der User ab oder scheitert apt, wird gewarnt und erneut gefragt.
- Abfrage "Original archivieren?" mit $BASE/archive als Default; Archiv
  ausserhalb von $BASE wird eigens angelegt und gechownt. Ein Pfad auf
  incoming/outgoing/working/error wird abgewiesen.
- sed-Kette der Config-Erzeugung jetzt verankert (^key =) und escaped,
  setzt zusaetzlich languages, original_on_success und archive_dir;
  die erzeugte Config wird gegen die Eingabe nachgeprueft.
- README und Briefing um "Sprachen pro Instanz" ergaenzt

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 21:24:56 +02:00
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
techadmin a23a3968ef feat: konfigurierbarer Dateiname + Archiv-Modus für Original (v0.3.0)
Neue [output]-Section:
- name_mode: prefix | suffix | none (suffix wird vor Extension eingefügt)
- name_tag: verbatim einfügbarer String
- original_on_success: delete | archive
- archive_dir mit Kollisions-Schutz (Timestamp-Suffix)

20 neue Tests (50 insgesamt, alle grün).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 22:32:41 +02:00
41 changed files with 8515 additions and 379 deletions
+361 -54
View File
@@ -1,8 +1,15 @@
# 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-23
**Version:** 0.7.0
**Status:** Multi-Instanz-Betrieb, Preflight-Checks (inkl. veraPDF-Binary), Fehlerzählung, Wiederaufnahme aus `working/`, Kollisionsschutz auf allen Schreibpfaden, Bewachung des Verzeichnis-Watches und ein abgesicherter Updater (venv-Rebuild, Backup, Verifikation, Versionssprung-Meldung, Rauchtest). `install.sh` und `update.sh` teilen sich `lib/common.sh`. Test-Suite grün (**254 pytest-Tests**). Ein Produktiv-Einsatz ist im Repo (README/CHANGELOG) nicht dokumentiert — die bisherigen Fixes stammen aus Issues #1–#6, aus Vorbereitungen auf Debian 13 und aus einer Durchsicht auf stille Datenverlust-Pfade (0.7.0), nicht aus einem belegten Dauerbetrieb.
> **Betriebsabläufe stehen nicht hier**, sondern in:
> [README.md](README.md) (Einstieg, Layout, Config-Überblick) ·
> [docs/INSTALLATION.md](docs/INSTALLATION.md) (Erstinstallation, Instanzen, Konfigurationsreferenz) ·
> [docs/UPDATE.md](docs/UPDATE.md) (Update, Backup, Rollback, `--check-config`) ·
> [docs/OS-UPGRADE.md](docs/OS-UPGRADE.md) (Debian-Major-Upgrade, venv-Rebuild, Pins).
> Dieses Briefing beschreibt **wie der Code aufgebaut ist und warum** — Schritt-für-Schritt-Anleitungen gehören in die drei Dokumente, nicht hierher.
## 🎯 Projektziel
@@ -13,21 +20,54 @@ 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, --check-config, --version)
│ ├── config.py # TOML-Loader, Dataclasses, ConfigError, Warnungen
│ ├── service.py # HotfolderService (watchdog + ThreadPool), Preflight, Resume, Zähler, Observer-Bewachung
│ ├── processor.py # ocrmypdf-Call, veraPDF, Ausgabename, Original-Entsorgung, Kollisionsschutz
│ └── uploaders.py # folder, nextcloud (WebDAV), sftp, E-Mail-Notify
├── lib/
│ └── common.sh # gemeinsam fuer install.sh + update.sh: Logging, require_root,
│ # Layout-Konstanten, apt-Paketliste, venv_is_healthy()
├── tests/ # pytest-Suite (254 Tests, ocrmypdf wird gemockt)
│ ├── conftest.py # Fixtures tmp_config / dummy_pdf
│ ├── test_check_config.py # --check-config, Exit 0/1/2
│ ├── test_config_errors.py
│ ├── test_config_warnings.py # Legacy- und Unbekannt-Warnungen
│ ├── test_dispose_failure.py # Original nicht entsorgbar -> Erfolg + warning
│ ├── test_error_collision.py # error/ ueberschreibt nichts
│ ├── test_error_counting.py
│ ├── test_ghostscript_version.py
│ ├── test_incoming_non_pdf.py # Sammelmeldung fuer Fremddateien
│ ├── test_log_stream.py # Logging geht nach stdout
│ ├── test_observer_watchdog.py # toter Observer -> EXIT_OBSERVER_DEAD
│ ├── test_ocr_timeout.py
│ ├── test_once_exit_code.py
│ ├── test_outgoing_collision.py # outgoing/ + Archiv ueberschreiben nichts
│ ├── test_output_naming.py
│ ├── test_preflight.py
│ ├── test_relative_paths.py # relative Pfade -> ConfigError
│ ├── test_resume_working.py # Wiederaufnahme + __ocr_-Fragmente
│ ├── test_startup_toml_error.py # kaputtes TOML beim Dienststart -> Exit 2
│ ├── test_upload_folder.py
│ └── test_verapdf_preflight.py # Binary-Pruefung + VeraPdfUnavailable
├── systemd/
│ └── pdf-ocr-hotfolder@.service # systemd Template-Unit (Instanz = %i)
│ ├── pdf-ocr-hotfolder@.service # Template-Unit (Instanz = %i), TimeoutStopSec=300,
│ │ # RestartPreventExitStatus=2
│ └── lxc-compat.conf # Drop-in-Vorlage: Hardening für LXC abschalten
├── docs/
│ ├── INSTALLATION.md # Erstinstallation, Konfigurationsreferenz, Exit-Codes, Troubleshooting
│ ├── UPDATE.md # update.sh, Backup/Rollback, --check-config, Config-Drift
│ └── OS-UPGRADE.md # Debian 12 -> 13 -> 14, venv-Rebuild, Pins
├── pytest.ini # testpaths = tests
├── config.example.toml
├── install.sh # Interaktiver Installer
├── update.sh # Update aus Repo
├── requirements.txt
├── install.sh # Interaktiver Installer + Instanz-Manager, ~500 Zeilen
├── update.sh # Updater (--help, --rebuild-venv, --no-smoke-test), ~1270 Zeilen
├── requirements.txt # feste Pins (ocrmypdf 17.x — 16.x ist unbrauchbar, s. 0.6.1)
├── VERSION
├── CHANGELOG.md
└── README.md
├── README.md
└── AI_AGENT_BRIEFING.md
```
## 🔧 Stack
@@ -35,25 +75,45 @@ 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) |
Die vier Python-Deps sind in `requirements.txt` **fest gepinnt** (`==`), damit ein
Update nicht ungefragt einen Major-Sprung einzieht (ocrmypdf 16 → 17 würde alle
Instanzen auf einmal reißen). Geprüft gegen Python 3.11 (Debian 12) und 3.13
(Debian 13), Wheels für beide vorhanden. Anheben nur mit Testmaschine —
[docs/OS-UPGRADE.md](docs/OS-UPGRADE.md#pins-in-requirementstxt).
## 🖥️ Installations-Layout (Multi-Instanz)
| Pfad | Inhalt |
|------|--------|
| `/opt/pdf-ocr-hotfolder/` | Code + venv (für alle Instanzen gemeinsam) |
| `/opt/pdf-ocr-hotfolder/lib/common.sh` | Kopie der gemeinsamen Shell-Bibliothek; `update.sh` sourct sie, wenn es nicht aus dem Repo läuft |
| `/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 |
| `/var/backups/pdf-ocr-hotfolder/` | Update-Backups (0600, letzte 5) |
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. Der Stream wird seit 0.7.0
**explizit** auf `sys.stdout` gesetzt — der `basicConfig()`-Default ist
**stderr**, und README wie `docs/INSTALLATION.md` versprachen stdout.
```bash
journalctl -u pdf-ocr-hotfolder@<instanz> -f # eine Instanz mitlesen
journalctl -u 'pdf-ocr-hotfolder@*' --since today # alle Instanzen, heute
```
## 👤 Service-User
@@ -63,51 +123,263 @@ pdf-ocr-hotfolder/
- Existierende User (lokal oder AD via SSSD/Winbind) werden übernommen, primäre Gruppe via `id -gn` ermittelt
- Bei AD-Usern mit lokaler UID werden Datei-Berechtigungen über die UID gesetzt — transparent
## 🗂️ Instanz-Management
## 🗂️ Instanz-Management (Kurzfassung)
`install.sh` ist gleichzeitig **Installer und Instanz-Manager**:
`install.sh` ist gleichzeitig **Installer und Instanz-Manager**. Der komplette
Ablauf inklusive aller fünf Abfragen steht in
[docs/INSTALLATION.md](docs/INSTALLATION.md#die-abfragen-pro-instanz). Für die
Arbeit am Code zählt:
- 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
- Instanz wird sofort `enable --now` gestartet
- Basis-Install wird an `venv` + Template-Unit erkannt und übersprungen — **außer**
die venv passt nicht mehr zum System-Python, dann läuft er zur Reparatur erneut
(`venv_is_healthy()` aus `lib/common.sh`, Befunde über `report_venv_issues`).
- In Containern (`systemd-detect-virt --container`) bietet der Installer das
LXC-Drop-in an **und** prüft, ob `systemd-journald` läuft. Tut es das nicht,
warnt er (der Dienst loggt ausschließlich nach journald), nennt den
`ImportCredential=`-Drop-in für den Debian-13-Fall und fragt, ob fortgefahren
werden soll.
- Abfragen pro Instanz: Name, Basis-Pfad, Service-User, **OCR-Sprachen**,
**Original archivieren?** — `LANGS`/`ORIG_MODE`/`ARCHIVE_DIR` sind `local` in
`create_instance()`, gelten also **instanz-lokal** und nicht global.
- Sprachprüfung gegen `tesseract --list-langs`, fehlende Pakete werden als
`tesseract-ocr-<code>` angeboten (Unterstrich → Bindestrich). Ablehnung führt
nicht zum Abbruch, sondern zurück zur Sprach-Abfrage.
- Das Archiv-Verzeichnis darf **nicht** `incoming/`/`outgoing/`/`working/`/`error/`
sein — im Eingang würde das Original endlos neu aufgegriffen.
- `<instanz>.toml` wird aus `config.example.toml` per `sed` erzeugt. Substituiert
werden die vier `[paths]`-Zeilen **sowie** `[ocr].languages`,
`[output].original_on_success` und `[output].archive_dir`. Die Ausdrücke sind
am Zeilenanfang verankert (`^key[[:space:]]*=`), damit die deutschen
Kommentarzeilen über den Keys nicht getroffen werden; Pfad-Variablen laufen
vorher durch `sed_escape_repl()` (maskiert `\`, `&`, `|`). Nach dem sed-Lauf
liest `config_value()` die drei Keys zurück und vergleicht sie mit der Eingabe.
- Die apt-Paketliste steht als **einzige Quelle** in `lib/common.sh` zwischen den
Marken `# --- BEGIN apt-packages` / `# --- END apt-packages` in der Funktion
`pdf_ocr_apt_packages()`. `install.sh` bekommt sie durchs Sourcen;
**`update.sh` schneidet den Block zusätzlich per `sed` aus der Repo-Fassung
heraus und evaluiert ihn**, weil gesourct evtl. die ältere installierte Kopie
wurde. Marken und Funktionsname dürfen sich nicht ändern, ohne `update.sh`
anzupassen.
- Instanz wird sofort `enable --now` gestartet. Löschen macht der Installer
nicht, das steht als Handgriff in
[docs/INSTALLATION.md](docs/INSTALLATION.md#instanz-manuell-löschen).
Manuelles Löschen einer Instanz:
```bash
systemctl disable --now pdf-ocr-hotfolder@<name>
rm /etc/pdf-ocr-hotfolder/<name>.toml
rm -rf /etc/systemd/system/pdf-ocr-hotfolder@<name>.service.d
systemctl daemon-reload
# Datenverzeichnis /var/lib/pdf-ocr-hotfolder/<name> manuell aufräumen
```
## 🧰 `lib/common.sh` — gemeinsame Shell-Bibliothek
## 🔄 Update-Verhalten
Seit 0.7.0 sourcen `install.sh` und `update.sh` dieselbe Datei. Sie führt beim
Sourcen **nichts** aus, was das System anfasst, und enthält nur Definitionen:
`update.sh`:
1. Ermittelt alle aktiven `pdf-ocr-hotfolder@*.service` Units
2. Stoppt diese
3. Backup nach `/var/backups/pdf-ocr-hotfolder/`
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
| Inhalt | Details |
|--------|---------|
| Ausgabe | `log_info`/`log_warn`/`log_error`/`log_step`, Farbkonstanten |
| Rechte | `require_root "<gemeinter Aufruf>"` |
| Layout | `INSTALL_DIR`, `CONFIG_DIR`, `DATA_ROOT`, `SYSTEMD_DIR`, `DEFAULT_USER`, `SERVICE_TEMPLATE`, `LXC_DROPIN_DIR`, `LXC_DROPIN`, `COMMON_LIB_REL` — alle per `: "${X:=…}"`, also aus der Umgebung überschreibbar (Tests) |
| Pakete | `pdf_ocr_apt_packages()` zwischen den BEGIN/END-Marken |
| venv | `py_mm()`, `pyvenv_cfg_mm()`, `venv_is_healthy()`, `report_venv_issues()` |
Config-Dateien werden **nie** überschrieben.
Drei Dinge, die man dabei wissen muss:
- **`venv_is_healthy()` gibt es nur noch einmal.** Vorher hatte jedes der beiden
Skripte eine eigene Fassung, und die in `install.sh` war die schlankere: sie
verglich nur `major.minor` des venv-Interpreters mit dem System-Python und
hätte den Distro-Upgrade-Fall über `pyvenv.cfg` nicht bemerkt. Erhalten
geblieben ist die gründliche Fassung (Verzeichnis, ausführbarer Interpreter,
Interpreter **läuft**, Version == System-Python, `pyvenv.cfg` == Interpreter).
Sie setzt `VENV_ISSUES` und gibt nichts selbst aus — dafür ist
`report_venv_issues()` da.
- **`lib/` wird mitinstalliert.** `install.sh` **und** `update.sh` kopieren es
nach `/opt/pdf-ocr-hotfolder/lib/`, jeweils mit vorherigem
`rm -rf "${INSTALL_DIR:?}/lib"`. Es liegt damit auch im Update-Backup (das
sichert `$INSTALL_DIR` ohne venv).
- **Fundreihenfolge in `update.sh`:** erst `$SCRIPT_DIR/lib/common.sh` (Repo),
dann `${INSTALL_DIR}/lib/common.sh`. Fehlt sie überall, bricht das Skript
**sofort** ab — ein `command not found` mitten im Lauf wäre die schlechtere
Nachricht. `install.sh` sucht nur neben sich und verlangt das vollständige
Repo.
## 🔄 Update-Verhalten (Kurzfassung)
Vollständig: [docs/UPDATE.md](docs/UPDATE.md). Für die Arbeit am Skript wichtig:
- `update.sh` hat `--help` und `--rebuild-venv`, läuft mit `set -Eeuo pipefail`
und hat ab dem Stoppen der Instanzen einen **ERR/INT/TERM-Trap**: er sagt, ob
auf der Platte schon getauscht wurde (`TOUCHED`), startet die vorher laufenden
Instanzen wieder und nennt Backup + Rollback-Befehl.
- Reihenfolge: Instanzen erfassen → apt-Sync → venv-Health → stoppen → Backup →
Code → Deps/venv → Units → chown → `--check-config` → starten + verifizieren →
Zusammenfassung (Soll gegen Ist, Exit 1 bei Regression/Config-Fehler).
- **Instanz-Erfassung** deckt `list-units --all` (inkl. `activating`/`failed`),
`list-unit-files` und die Configs unter `/etc/pdf-ocr-hotfolder/` ab. Drei
Gruppen: `PREV_OK`, `PREV_BROKEN`, `PREV_STOPPED` — bewusst gestoppte bleiben
gestoppt.
- **Verifikation**: `verify_unit()` wartet `VERIFY_WAIT` (6 s) und prüft
`is-active`, `is-failed` **und** `NRestarts` — sonst würde ein Crash-Loop bei
`Type=simple` als Erfolg durchgehen. Vorher `reset-failed`.
- **venv-Health** (`venv_is_healthy()` aus `lib/common.sh`): Verzeichnis,
ausführbarer Interpreter, Interpreter **läuft** überhaupt, `major.minor` ==
System-Python, `pyvenv.cfg` stimmt mit dem Interpreter überein. Bei Drift wird
auch ohne `--rebuild-venv` neu gebaut.
- **`rebuild_venv()` ist ganz oder gar nicht**: alte venv nach
`venv.old-<ts>`, neu bauen, Requirements installieren, **erst bei Erfolg** die
alte löschen; scheitert etwas, wird zurückgerollt und hart abgebrochen.
`pip_install_requirements()` übersetzt pip-Fehler in eine Ansage mit dem
gescheiterten Paketnamen und dem Hinweis "requirements.txt anheben".
- **apt-Sync** läuft auch beim Update (`sync_system_packages()`), ist idempotent
und fasst nachinstallierte Sprachpakete nicht an (kein purge/autoremove).
Fehlschläge setzen nur `APT_WARN`, sie brechen nicht ab.
- **Backup** (`create_backup()`): Code, `/etc/pdf-ocr-hotfolder/`, Template-Unit,
alle Drop-ins und ein `pip-freeze.txt` der alten venv. **Ohne** venv und
**ohne** Datenverzeichnisse. `umask 077` + `chmod 600 root:root`, weil die
Configs Klartext-Passwörter enthalten. Rotation: letzte `BACKUP_KEEP` = 5.
- **LXC-Drop-in** wird beim Update aus dem Repo nachgezogen, **falls es
installiert ist** — sonst würde ein neu ergänzter Hardening-Schalter in der
Template-Unit alle Container-Instanzen reißen (Issue #4 redux).
- Configs unter `/etc/pdf-ocr-hotfolder/` werden **nie** überschrieben. Das Repo
muss erhalten bleiben — `update.sh` kopiert daraus (`.repo_path`).
- `PDF_OCR_UPDATE_LIB_ONLY=1 source ./update.sh` lädt nur die Funktionen, ohne
irgendetwas zu tun — dafür sind `INSTALL_DIR`, `CONFIG_DIR`, `SYSTEMD_DIR`,
`BACKUP_DIR`, `TAR_ROOT`, `VERIFY_WAIT`, `BACKUP_KEEP` überschreibbar. Die
Vorgaben stehen in `lib/common.sh` und sind dort ebenfalls überschreibbar
gehalten (`: "${X:=…}"`).
## ⚙️ Konfiguration (Überblick)
Key-für-Key-Referenz: [docs/INSTALLATION.md](docs/INSTALLATION.md#konfigurationsreferenz).
Vollständiges Beispiel mit Kommentaren: `config.example.toml`.
| Sektion | Zweck |
|---------|-------|
| `[paths]` | `incoming`, `outgoing`, `working`, `error` — **Pflicht** und **absolut**, fehlt einer oder ist relativ → `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` (absolut) |
| `[verapdf]` | `enabled`, `binary`, `flavour` — optionale PDF/A-Validierung per CLI |
| `[upload.folder]` | `enabled`, `target` (leer = `[paths].outgoing`, dann No-op; sonst absolut) |
| `[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 |
**Absolute Pfade sind Pflicht** (`_require_absolute()` in `config.py`, seit
0.7.0): `[paths]`-Einträge, `[output].archive_dir` und
`[upload.folder].target`. Ein relativer Pfad wurde gegen das
`WorkingDirectory` der Unit aufgelöst, landete also still unter
`/opt/pdf-ocr-hotfolder/` — der Scanner schrieb dann woanders hin als der
Dienst schaute, ohne dass irgendwo ein Fehler auftauchte. Leere Werte bleiben
erlaubt (beide Keys sind optional).
Unbekannte Keys werden beim Laden zwar ignoriert, aber **nicht mehr still**:
`_collect_unknown_keys()` sammelt sie in `Config.unknown_keys` (Format
`[ocr].langauges`, auch ganze unbekannte Sektionen und Upload-/Notify-Targets),
`unknown_key_warnings()` macht Meldungen daraus. Ein Tippfehler fällt damit auf.
### Warnungen statt Überraschungen
`config.py` kennt zwei Warnungsquellen, beide über `config_warnings()` gebündelt
— der Text steht **nur dort**, weil ihn sowohl der Dienststart
(`_log_config_warnings()` → `log.warning`) als auch `--check-config` ausgibt:
- `legacy_warnings()`: `[ocr].timeout >= LEGACY_TIMEOUT_THRESHOLD` (900) deutet
auf den alten Gesamt-Timeout-Wert 1800 hin (Richtwert
`RECOMMENDED_PAGE_TIMEOUT` = 300); gesetztes `pdfa_level` weist auf den
Ghostscript-Bug hin.
- `unknown_key_warnings()`: siehe oben.
### `--check-config`
`python -m pdf_ocr_hotfolder --check-config --config <datei>` lädt die Config,
zeigt Pfade/Sprachen/Timeout/PDF/A sowie veraPDF-Binary und -Flavour (bzw.
`(aus)`), fährt `check_preflight()` und `check_output_config()` und gibt die
Warnungen aus. Exit-Codes: `CHECK_OK=0`, `CHECK_WARN=1`, `CHECK_ERROR=2`. Hat
Vorrang vor `--once`. `update.sh` wertet genau diese Codes aus und erkennt an
der argparse-Meldung, wenn der installierte Code das Flag noch nicht kennt.
### Exit-Codes des Prozesses
| Code | Woher | Bedeutung |
|------|-------|-----------|
| `0` | `main()` | regulärer Stopp, `--once` ohne Fehler, Config sauber |
| `1` | `main()` | `--once` mit `error_count > 0`; `--check-config` mit Warnungen |
| `2` | `main()` | `ConfigError`, `TOMLDecodeError`, `OSError` beim Laden, `PreflightError` |
| `3` | `service.EXIT_OBSERVER_DEAD` | watchdog-Observer gestorben — Neustart **erwünscht** |
`run()` gibt seit 0.7.0 einen `int` zurück (vorher `None`), `main()` reicht ihn
durch. Die Unit setzt **`RestartPreventExitStatus=2`**: Config-/Preflight-Fehler
heilt kein Neustart, die Instanz bleibt sichtbar `failed` stehen statt im
5-Sekunden-Takt zu kreisen (das Start-Rate-Limit greift bei `RestartSec=5` nie).
Exit 3 ist **bewusst nicht** 2, damit `Restart=on-failure` dort greift.
Anwender-Sicht: [docs/INSTALLATION.md](docs/INSTALLATION.md#exit-codes).
## 🔄 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** — beide rufen
dasselbe `_preflight()`:
1. `check_preflight(pdfa_level, skip_text, verapdf_enabled, verapdf_binary)` — `tesseract` und `gs` müssen im PATH sein; zusätzlich wird die Ghostscript-Version gegen den 10.0.0–10.02.0-Bug geprüft, und zwar unter genau der Bedingung, unter der ocrmypdf selbst abbricht (`_gs_block_reason()`: betroffene GS-Version **und** `skip_text` **und** (`pdfa_level` gesetzt **oder** ocrmypdf < 17))
2. `check_verapdf_binary()` — nur bei `[verapdf].enabled`: `binary` darf nicht leer sein und muss über `resolve_verapdf_binary()` auffindbar **und ausführbar** sein (Pfad mit `/` direkt geprüft, nackter Name über `shutil.which`)
3. `check_output_config()` — validiert `original_on_success`, `archive_dir` (Pflicht bei `archive`) und `name_mode`
4. Scheitert eines davon → `PreflightError`, CLI beendet sich mit **Exit-Code 2** (ebenso bei kaputter/unlesbarer/fehlender Config)
5. `ensure_dirs()`, dann `_scan_existing()`: **zuerst `working/`**, danach `incoming/`; Dateien ohne `.pdf`-Endung meldet `_report_non_pdf()` als **eine** Sammelzeile (Anzahl + bis zu 3 Beispiele), nur beim Start-Scan
Fehler → Move nach `error/`, Service läuft weiter (kein `exit 1` wie im alten Bash-Tool).
**Wiederaufnahme aus `working/` (`_scan_working()`):**
`process_pdf()` verschiebt das Original vor dem OCR nach `working/`. Wird der
Dienst dort abgeschossen (SIGKILL nach `TimeoutStopSec`), blieb es früher
liegen und wurde nie wieder angefasst — stiller Datenverlust. Jetzt:
- Dateien mit dem Präfix `OCR_TEMP_PREFIX` (`__ocr_`) sind **unvollständige
Fragmente** des abgebrochenen ocrmypdf-Laufs: als Eingabe unbrauchbar, als
Ergebnis wertlos → werden **gelöscht** (mit `log.warning`).
- Echte PDFs werden **an Ort und Stelle** wiederaufgenommen; `process_pdf()`
erkennt das über `_is_same_file()` und verschiebt nicht erneut.
- Liegt in `incoming/` eine gleichnamige, andere Datei, bekommt die
wiederaufgenommene per `_free_resume_name()` einen Zeitstempel angehängt —
sonst würden beide dieselbe working- und outgoing-Datei beanspruchen.
- Liegt in `working/` bereits eine **andere** Datei desselben Namens, bricht
`process_pdf()` für die neue ab und lässt sie in `incoming/` liegen, statt den
laufenden Vorgang stillschweigend zu überschreiben.
**Im Betrieb (`_wait_loop()`):** die Schleife wartet nicht nur auf den Stopp,
sie prüft **sekündlich `self._observer.is_alive()`**. Stirbt der Observer
(erschöpftes `fs.inotify.max_user_watches`, ersetztes oder neu gemountetes
Verzeichnis), blieb die Unit früher `active (running)` und verarbeitete stumm
nichts mehr. Jetzt: `log.error` mit den möglichen Ursachen und `return
EXIT_OBSERVER_DEAD` (3), damit `Restart=on-failure` den Watch neu aufsetzt. Bei
regulärem Stopp wird die Prüfung übersprungen, sonst gäbe es dort einen
Fehlalarm.
**Pro Datei:**
1. `watchdog` triggert auf `created`/`moved`/`closed` in `incoming/`
2. `_wait_until_stable()` wartet, bis die Datei nicht mehr wächst (max. ~60s)
3. Move nach `working/` (entfällt bei Wiederaufnahme)
4. `ocrmypdf.ocr()` als **Library-Call** (kein Subprozess-Start pro PDF), Ziel ist `working/__ocr_<zielname>`
5. Optional: veraPDF-Validierung (CLI-Subprozess). **FAIL** → OCR-Ergebnis nach `error/`, Original folgt `original_on_success` (bei `archive` also erhalten). **`VeraPdfUnavailable`** → Original **und** Ergebnis nach `error/`, das Original wird weder gelöscht noch archiviert
6. Move nach `outgoing/` unter dem laut `[output]` gebauten Namen (`build_output_name()`), vorher durch `_collision_free_path()` — `ProcessResult.output` trägt den **tatsächlich** geschriebenen Pfad
7. Original in `working/` wird laut `original_on_success` **gelöscht** oder nach `archive_dir` **archiviert** (Kollision → Timestamp-Suffix). `_dispose_original()` wirft nicht, sondern liefert bei Misserfolg einen Meldungstext → `ProcessResult.warning`
8. Aktive Upload-Targets ausführen (folder/nextcloud/sftp)
9. E-Mail-Notify je nach `[notify.email].on` — bei gesetztem `warning` als **„OK mit Warnung"** und mit `success=False` an `notify_email()`, damit sie auch bei `on = "errors"` zugestellt wird
**Kollisionsschutz (`_collision_free_path()` in `processor.py`):** existiert das
Ziel, wird `scan.pdf` zu `scan_<YYYYmmdd-HHMMSS>.pdf`; ist auch das belegt (zwei
Dateien in derselben Sekunde, mehrere Worker), wird zusätzlich hochgezählt.
Benutzt von `outgoing/`, `_dispose_original()` (Archiv), `_move_to_error()` —
und damit auch `_rescue_to_error()` — sowie `upload_folder()` in
`uploaders.py`. Jeder dieser Pfade überschrieb vorher still. **`uploaders.py`
importiert dafür `_collision_free_path` aus `processor.py`** — die einzige
Abhängigkeit in diese Richtung.
**Fehlerbehandlung:**
| 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 | — |
| In `working/` liegt schon eine andere Datei gleichen Namens | ja | bleibt in `incoming/` |
| 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) |
| veraPDF nicht befragbar (`VeraPdfUnavailable`) | ja | **Original UND Ergebnis** nach `error/`; das Original wird weder gelöscht noch archiviert (seit 0.7.0) |
| Original lässt sich nicht entsorgen (`_dispose_original`) | **nein** — gilt als Erfolg | Ergebnis in `outgoing/`, Upload läuft; Original bleibt in `working/` und wird beim nächsten Start erneut verarbeitet. `log.error` + Mail „OK mit Warnung" |
| 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 +388,28 @@ 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. ocrmypdf verweigert damit die Arbeit — aber die Bedingung dafür hängt an der **ocrmypdf-Version**, und genau daran ist 0.6.0 gescheitert:
- **ocrmypdf ≤ 16.x**: die Prüfung in `builtin_plugins/ghostscript.py::check_options()` läuft **bedingungslos**. `skip_text = true` allein reicht — `output_type` wird nicht geprüft. Auf Debian 12 scheitert damit **jede** Datei.
- **ocrmypdf ≥ 17.0**: derselbe Block steckt in einem `if options.output_type.startswith('pdfa'):`. Ohne PDF/A wird Ghostscript nicht angefasst.
`pdfa_level = ""` ist deshalb **kein** Schutz für sich genommen — es wirkt nur mit ocrmypdf ≥ 17. `requirements.txt` pinnt daher 17.x; ein Downgrade auf 16.x macht jede Debian-12-Instanz unbrauchbar, bei grünem `systemctl status`. Der Preflight bildet die reale Bedingung ab (`_gs_block_reason()`) und bricht mit Exit 2 ab, `--check-config` meldet denselben Zustand als Fehler. `redo_ocr` ist bewusst **nicht** in der Bedingung: die Config kennt keinen solchen Key. Abhilfe: Ghostscript ≥ 10.02.1 aus bookworm-backports (der Installer bietet das an) oder `skip_text = false`.
- **`[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, ab 900 warnt `--check-config`. 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.
- **`TimeoutStopSec=300` in der Unit ist Absicht.** Ein laufendes OCR soll beim Stoppen zu Ende laufen dürfen — ein `systemctl stop` kann deshalb pro Instanz bis zu 5 Minuten dauern, und `update.sh` (das nacheinander stoppt) entsprechend länger. Bei SIGKILL bliebe das Original in `working/` liegen; das wird zwar wiederaufgenommen, kostet aber den kompletten Durchlauf.
- **Die venv hängt an der Python-Version der Distribution.** Nach einem Debian-Major-Upgrade ist `venv/bin/python` tot (systemd: `203/EXEC`) oder eine andere Version als das System-Python. Der Weg dahin und zurück steht in [docs/OS-UPGRADE.md](docs/OS-UPGRADE.md); im Code prüfen beide Skripte das mit **demselben** `venv_is_healthy()` aus `lib/common.sh` (seit 0.7.0 — vorher hatte jedes eine eigene Fassung, und die in `install.sh` war die schwächere).
- **`incoming/` darf nicht auf CIFS/NFS liegen.** Der Hotfolder hängt vollständig an inotify, und inotify sieht nur Änderungen des lokalen Kernels. Schreibt ein anderer Rechner über SMB/NFS in ein gemountetes Verzeichnis, entsteht **gar kein Event** — der Dienst meldet `active (running)`, arbeitet beim Start-Scan den Bestand ab und bemerkt danach nichts mehr. Betriebsvorgabe: ext4, xfs oder zfs, `incoming/` lokal ([docs/INSTALLATION.md](docs/INSTALLATION.md#dateisystem-ext4-xfs-oder-zfs)).
- **Debian 13 in LXC auf Proxmox: journald scheitert mit `243/CREDENTIALS`.** systemd ≥ 255 (Debian 13 hat 257) setzt `ImportCredential=journal.*`; der Hilfsprozess `(sd-mkdcreds)` mountet dafür, und das AppArmor-Profil des Proxmox-Hosts blockiert das. Da der Dienst ausschließlich nach journald loggt, gibt es dann **keine** Logs. Betrifft jede Debian-13-LXC auf Proxmox 8.4 (Debian 12 mit systemd 252 nicht) und legt auch logind, networkd, console-getty und tmpfiles-setup lahm. Abhilfe und Hintergrund: [docs/INSTALLATION.md](docs/INSTALLATION.md#debian-13-in-lxc-auf-proxmox-journald-scheitert-243credentials). In 0.6.3 stand hier noch, das sei ein Schaden auf genau einer Maschine — das war falsch.
- **Ein nicht aufrufbares veraPDF war bis 0.6.3 der gefährlichste Fehler des Dienstes.** `run_verapdf()` lieferte für ein fehlendes Binary, einen Timeout oder eine leere Ausgabe schlicht `False` — also ein inhaltliches FAIL-Urteil. Damit wanderte jedes OCR-Ergebnis nach `error/` und das Original wurde laut `original_on_success` entsorgt, beim Default `delete` also gelöscht. Scan für Scan, bei grünem `systemctl status`. Seit 0.7.0: Preflight-Prüfung (Exit 2) **und** `VeraPdfUnavailable` als eigene Ausnahme, die ausdrücklich kein Urteil über die Datei ist.
- **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, `update.sh` zieht ein vorhandenes Drop-in nach.
- **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). Auch `update.sh` ruft `--check-config` deshalb mit `cd "$INSTALL_DIR"` auf.
- **Relative Pfade in der Config waren still falsch.** Sie wurden gegen `WorkingDirectory=/opt/pdf-ocr-hotfolder` aufgelöst, nicht gegen das Verzeichnis der Config. Seit 0.7.0 `ConfigError` + Exit 2 für `[paths]`, `[output].archive_dir`, `[upload.folder].target`. **Das ist der einzige Fall, in dem ein Update von 0.6.x eine bisher „laufende" Instanz stoppt** — gewollt, siehe [docs/UPDATE.md](docs/UPDATE.md#relative-pfade--fehler-seit-070).
- **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`. **Das Update-Backup enthält diese Configs** und ist deshalb `0600 root:root` in einem `700`-Verzeichnis. Beim Debuggen weder Config noch Backup in ein Ticket kopieren.
- **Das Update-Backup enthält die venv NICHT.** Ein Rollback per `tar -xzf … -C /` holt den Paketstand also nicht zurück, und `tar` löscht nichts, was neu hinzugekommen ist. Grenzen des Rollbacks: [docs/UPDATE.md](docs/UPDATE.md#grenzen-des-rollbacks).
## 🛠️ Entwicklung
Lokaler Test ohne Installation:
@@ -130,11 +422,25 @@ 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 254 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. veraPDF wird über `subprocess.run` gemockt, der watchdog-Observer über ein Fake-Objekt mit `is_alive()`.
## 📋 Roadmap / TODO
- [ ] Tests (`pytest`) für `processor` und `uploaders`
- [x] Tests (`pytest`) für `processor` und `uploaders` — 254 Tests
- [x] Wiederaufnahme abgebrochener Läufe aus `working/`
- [x] Config-Prüfung ohne Verarbeitung (`--check-config`) + Auswertung im Updater
- [x] Updater übersteht Debian-Major-Upgrades (venv-Rebuild, Pins, Rollback)
- [x] Stille Datenverlust-Pfade geschlossen: Kollisionsschutz in `outgoing/`, Archiv, `error/` und Ordner-Upload; veraPDF-Preflight + `VeraPdfUnavailable`; relative Pfade als Config-Fehler
- [x] Toter watchdog-Observer wird erkannt (Exit 3) und getestet
- [ ] Test-Lücken schließen: `run_ocr()` läuft nur gegen ein gemocktes ocrmypdf — es gibt keinen Test mit einer echten PDF-Datei. Der `_Handler`-Eventpfad ist weiterhin ungetestet (getestet ist nur die Observer-**Bewachung** in `_wait_loop()`). `upload_nextcloud()` und `upload_sftp()` sind ungetestet (nur `upload_folder()`). **`install.sh`/`update.sh`/`lib/common.sh` haben keine automatisierten Tests** — die `LIB_ONLY`-Schnittstelle in `update.sh` ist dafür vorbereitet, aber ungenutzt; `lib/common.sh` wäre jetzt die einfachste Stelle zum Anfangen, weil sie beim Sourcen nichts tut.
- [ ] Prometheus-Metriken (verarbeitete PDFs, Fehlerquote, Laufzeit)
- [ ] CLI-Subkommandos: `pdf-ocr-hotfolder reprocess <error-file>`
- [ ] Instanz-Löschung in `install.sh` statt als Handarbeit
- [ ] Optional: S3/MinIO Upload-Target
- [ ] Docker-Image für Setups ohne systemd
@@ -142,6 +448,7 @@ python -m pdf_ocr_hotfolder --config /tmp/config.toml
- **Repo:** https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder
- **Owner:** sonith_ug
- **SSH-User ist `gitea`, nicht `git`:** `gitea@gitea.sonith.de:sonith_ug/pdf-ocr-hotfolder.git`
- **Versionierung:** Semver (PATCH bei jedem Build, MINOR bei Features, MAJOR manuell)
- **Tags:** `v{VERSION}`, automatischer Push nach Commit
+625
View File
@@ -1,5 +1,630 @@
# Changelog
## [0.7.0] - 2026-09-23
Schliesst die stillen Datenverlust-Pfade: gleichnamige Dateien werden nirgends
mehr ueberschrieben, und ein nicht aufrufbares veraPDF wird nicht mehr als
"PDF ist ungueltig" missverstanden. Dazu Robustheit (Exit 2 statt Traceback,
toter Verzeichnis-Watch faellt auf, relative Pfade sind ein Config-Fehler) und
eine gemeinsame Shell-Bibliothek fuer install.sh und update.sh.
Test-Suite: 254 pytest-Tests gruen.
### Added
- **`lib/common.sh` — gemeinsame Shell-Bibliothek.** `install.sh` und
`update.sh` sourcen sie und teilen sich darueber Log-Funktionen
(`log_info`/`log_warn`/`log_error`/`log_step`), `require_root`, die
Layout-Konstanten (`INSTALL_DIR`, `CONFIG_DIR`, `DATA_ROOT`, `SYSTEMD_DIR`,
`DEFAULT_USER`, `SERVICE_TEMPLATE`, `LXC_DROPIN*`), die apt-Paketliste
(`pdf_ocr_apt_packages()` zwischen den BEGIN/END-Marken) und die
venv-Pruefung (`py_mm`, `pyvenv_cfg_mm`, `venv_is_healthy`,
`report_venv_issues`).
- Die **Paketliste** steht damit nicht mehr in `install.sh`, sondern in
`lib/common.sh`; `update.sh` schneidet sie weiterhin per `sed` aus der
**Repo**-Fassung heraus, damit beim Update die neue Liste gilt und nicht
die vielleicht aeltere, bereits gesourcte aus der Installation.
- `venv_is_healthy()` gab es bisher **zweimal** — die schlanke Variante in
`install.sh` haette den Distro-Upgrade-Fall (`pyvenv.cfg` gegen
System-Python) nicht erkannt. Jetzt existiert nur noch die gruendliche
Fassung, und `install.sh` nennt die Befunde ueber `report_venv_issues`.
- `lib/` wird von `install.sh` **und** `update.sh` nach
`/opt/pdf-ocr-hotfolder/lib/` mitkopiert und liegt damit im
Update-Backup. `update.sh` sourct bevorzugt die Repo-Fassung neben sich
und faellt auf die installierte zurueck; fehlt sie ueberall, bricht es
sofort ab statt mitten im Lauf mit "command not found".
- **veraPDF wird im Preflight geprueft** (`check_verapdf_binary()`,
`resolve_verapdf_binary()`). Mit `[verapdf].enabled = true` muss
`[verapdf].binary` auf ein vorhandenes, ausfuehrbares Programm zeigen —
sonst startet der Dienst gar nicht erst (Exit 2), und `--check-config`
meldet den Fehler. Ein Pfad mit `/` wird direkt geprueft, ein nackter Name
im `PATH` gesucht. `--check-config` zeigt jetzt ausserdem Binary und Flavour
bzw. `(aus)` an.
Hintergrund: ein Tippfehler im Pfad war der **gefaehrlichste Fehler des
ganzen Dienstes**. `run_verapdf()` fand das Programm fuer JEDE Datei nicht,
wertete das als FAIL, schob das OCR-Ergebnis nach `error/` — und
`_dispose_original()` entsorgte das Original laut
`[output].original_on_success`, bei dessen Default `delete` also Scan fuer
Scan die Vorlage. Die Unit stand dabei als `active (running)` da.
- **Exit 3: toter Verzeichnis-Watch** (`EXIT_OBSERVER_DEAD` in `service.py`).
Stirbt der watchdog-Observer im Betrieb (erschoepftes
`fs.inotify.max_user_watches`, ersetztes oder neu gemountetes Verzeichnis),
blieb die Unit bisher `active (running)` und verarbeitete nichts mehr — kein
Log, keine Mail, niemand merkt es. Die Hauptschleife prueft den Observer
jetzt sekuendlich mit, loggt im Ernstfall die moeglichen Ursachen und
beendet sich mit Exit 3; `Restart=on-failure` startet den Dienst neu und der
Watch wird neu aufgesetzt. Bewusst **nicht** Exit 2 — den unterdrueckt die
Unit jetzt beim Neustart (s.u.).
- **`ProcessResult.warning`** — erfolgreicher Durchlauf mit Nebenbefund. Bisher
gab es nur Erfolg oder Fehler; ein liegengebliebenes Original passte in
keine der beiden Schubladen.
- **Sammelmeldung fuer Nicht-PDF-Dateien in `incoming/`**
(`_report_non_pdf()`). Alles ohne `.pdf`-Endung wurde ignoriert und
sammelte sich stumm an (Scanner-Fehlablagen, abgebrochene Uploads,
Thumbnails). Beim Start-Scan gibt es jetzt **eine** Warnung mit Anzahl und
bis zu drei Beispielnamen — keine Zeile pro Datei und nichts im laufenden
Betrieb.
- **`install.sh` warnt beim Erstinstall in Containern, wenn
`systemd-journald` nicht laeuft.** Der Dienst loggt ausschliesslich nach
journald; ist journald kaputt, gibt es gar keine Logs. Die Warnung nennt den
Drop-in-Befehl fuer den Debian-13-Fall und fragt, ob fortgefahren werden
soll.
- **Dokumentation** (siehe unten unter *Docs*): Dateisystem-Festlegung,
Debian-13-journald-Befund, konkrete Speicher-Messwerte, Exit-Code-Tabelle.
### Fixed
- **`outgoing/`: gleichnamige Datei wird nicht mehr ueberschrieben.** Liefert
der Scanner denselben Dateinamen ein zweites Mal (oder wurde das
Vorgaengerergebnis noch nicht abgeholt), legte der `shutil.move` die
aeltere Datei kommentarlos um. Neu: `_collision_free_path()` haengt einen
Zeitstempel an (`scan.pdf` -> `scan_20260923-081500.pdf`), bei Kollision
innerhalb derselben Sekunde zusaetzlich einen Zaehler. Es gibt eine
`log.warning`, und `ProcessResult.output` traegt den **tatsaechlich**
geschriebenen Pfad — die Upload-Ziele und die Mail nennen damit die richtige
Datei.
- **Dieselbe Klasse in `error/` und beim Ordner-Upload.** `_move_to_error()`
(und damit auch `_rescue_to_error()`) sowie `upload_folder()` mit
abweichendem `[upload.folder].target` ersetzten bisher still eine
gleichnamige Datei im Ziel. Beide nutzen jetzt denselben
Zeitstempel-Ausweg. Scheitert dieselbe `scan.pdf` zweimal, liegen jetzt
beide Fassungen in `error/`.
- **veraPDF: Stoerung wird nicht mehr als FAIL gewertet.** `run_verapdf()`
unterscheidet jetzt zwischen einem echten Urteil (PASS/FAIL) und
"Programm nicht aufrufbar, nicht startbar, Timeout oder kein PASS/FAIL in
der Ausgabe" — Letzteres wirft `VeraPdfUnavailable`. Im Stoerungsfall
wandern **Original UND OCR-Ergebnis** nach `error/`, und das Original wird
weder geloescht noch archiviert, unabhaengig von
`[output].original_on_success`. Vorher lieferte jeder dieser Faelle
schlicht `False` und damit ein Fehlurteil ueber die Datei.
- **Kaputtes TOML und nicht lesbare Config beenden den Dienststart mit
Exit 2** statt mit einem nackten Traceback. `main()` faengt jetzt
`tomllib.TOMLDecodeError` und `OSError` genauso ab wie `--check-config`; die
Meldung nennt Zeile und Spalte, sofern der Interpreter sie liefert
(`TOMLDecodeError.lineno`/`.colno` gibt es erst ab Python 3.14 — auf
Debian 12 steht die Position nur im Meldungstext, deshalb `getattr`).
- **Relative Pfade in der Config sind jetzt ein Fehler.** `[paths].incoming`,
`outgoing`, `working`, `error` sowie `[output].archive_dir` und
`[upload.folder].target` muessen absolut sein (`_require_absolute()`,
`ConfigError` + Exit 2). Ein relativer Pfad wurde gegen das
`WorkingDirectory` der Unit aufgeloest und landete still unter
`/opt/pdf-ocr-hotfolder/` — der Scanner schrieb dann woanders hin als der
Dienst schaute, ohne dass irgendwo ein Fehler auftauchte. Leere Werte
bleiben erlaubt (`archive_dir`/`target` sind optional).
- **Ein Fehler beim Entsorgen des Originals entwertet den Durchlauf nicht
mehr.** `_dispose_original()` wirft nicht mehr, sondern liefert eine
Meldung zurueck: zum Aufrufzeitpunkt liegt das fertige PDF schon in
`outgoing/`, eine Exception von dort haette den gelungenen Durchlauf im
Catch-all des Service in einen Fehler verwandelt — mitsamt ausgefallenem
Upload. Jetzt gilt der Lauf als Erfolg, Upload und Benachrichtigung laufen,
es gibt aber eine `log.error` (Original liegt noch in `working/` und wird
beim naechsten Start erneut durch das OCR geschickt), und die Mail geht als
**"OK mit Warnung"** auch bei `[notify.email].on = "errors"` raus — sonst
waere genau das wieder ein stiller Fehlerpfad.
- **Log geht explizit nach stdout.** `logging.basicConfig()` schreibt per
Default nach **stderr**; README und `docs/INSTALLATION.md` versprachen aber
stdout. Fuer journald egal, fuer den dort beschriebenen
Vordergrund-Notbehelf und fuer jede Weiterleitung nicht.
### Changed
- **`systemd/pdf-ocr-hotfolder@.service`: `RestartPreventExitStatus=2`.**
Exit 2 = Config- oder Preflight-Fehler, den behebt kein Neustart. Bisher
startete `Restart=on-failure` die Instanz endlos im 5-Sekunden-Takt neu
(das Start-Rate-Limit greift bei `RestartSec=5` nie). Jetzt bleibt die
Instanz sichtbar `failed` stehen.
- `HotfolderService.run()` gibt einen **Exit-Code** zurueck (0 oder
`EXIT_OBSERVER_DEAD`) statt `None`; `main()` reicht ihn durch. Preflight und
`check_output_config()` stehen jetzt gebuendelt in `_preflight()`, das
`run()` und `run_once()` gemeinsam nutzen.
- `check_preflight()` nimmt zwei weitere Parameter (`verapdf_enabled`,
`verapdf_binary`) — beide mit Default, bestehende Aufrufe bleiben gueltig.
- `config.example.toml`: Warnhinweise zu absoluten Pfaden (`[paths]`,
`[output].archive_dir`, `[upload.folder].target`), zur veraPDF-Preflight-
Pruefung samt Begruendung und zum Kollisionsverhalten im Upload-Ziel.
- `install.sh` ist von ~73 Zeilen Kopf auf das Sourcen von `lib/common.sh`
geschrumpft und nutzt durchgehend `SYSTEMD_DIR`/`LXC_DROPIN` statt
hartkodierter Pfade.
### Docs
- **`docs/INSTALLATION.md`, Systemanforderungen: Dateisystem festgelegt.** Der
Dienst laeuft ausschliesslich auf **ext4, xfs oder zfs**. `incoming/` gehoert
auf ein lokales, inotify-faehiges Dateisystem — **kein CIFS/NFS-Mount**: dort
liefert inotify grundsaetzlich keine Events, weil Schreibzugriffe anderer
Rechner am lokalen Kernel vorbeigehen. Der Dienst wuerde dann nur noch beim
Start etwas verarbeiten und im laufenden Betrieb nichts mehr bemerken.
- **Speicher-Messwerte konkretisiert.** Zur bestehenden 512-MB-Messung kommen
Zahlen von einem **2-GB-Container** (Debian 13, 300-dpi-A4-Seite mit
`deskew`, `oversample = 300`, `jobs = 4`): Laufzeit **19 s**, `MemoryPeak`
des Dienstes **380 MB**, `memory.peak` des ganzen Containers **503 MB**,
`oom_kill 0`. Auf derselben Maschine mit 512 MB war genau das der OOM-Kill.
Die Empfehlung "mindestens 2 GB" ist damit belegt statt geschaetzt.
- **Debian 13 in LXC auf Proxmox: journald scheitert — verifizierter Befund,
betrifft jede Debian-13-LXC auf Proxmox 8.4.** In 0.6.3 stand noch, das sei
ein Schaden auf genau einer Maschine; das war falsch. Ursache: systemd >= 255
(Debian 13 hat 257) setzt `ImportCredential=journal.*` in der
journald-Unit, der Hilfsprozess `(sd-mkdcreds)` mountet dafuer, und das
AppArmor-Profil des Proxmox-Hosts blockiert das
(`apparmor="DENIED" operation="mount" profile="lxc-<id>_</var/lib/lxc>"
name="/dev/" comm="(sd-mkdcreds)"` im Host-Log). Debian 12 (systemd 252)
kennt `ImportCredential` nicht und ist nicht betroffen. Dokumentiert sind
die reboot-feste Abhilfe im Container (Drop-in
`systemd-journald.service.d/no-credentials.conf` mit leerem
`ImportCredential=`), der Hinweis auf die weiteren betroffenen Units
(logind, networkd, console-getty, tmpfiles-setup) und der saubere Weg
host-seitig.
- **Exit-Code-Tabelle 0/1/2/3** in `docs/INSTALLATION.md`, verlinkt aus
README, `docs/UPDATE.md` und dem Troubleshooting — mit dem Hinweis, dass die
Unit bei 2 **nicht** neu startet und bei 3 gerade doch.
- Die fuenf Ueberschriften der Instanz-Abfragen in `docs/INSTALLATION.md` sind
**entnummeriert** (`### 4. OCR-Sprachen` -> `### OCR-Sprachen`). Die Anker
hiessen vorher `#4-ocr-sprachen` und waeren bei jeder Umsortierung
gebrochen; die Reihenfolge steht weiterhin im Text.
- `AI_AGENT_BRIEFING.md` auf den heutigen Stand gezogen: Dateibaum mit `lib/`
und allen 20 Test-Dateien, nur noch **ein** `venv_is_healthy()`,
Paketliste in `lib/common.sh`, veraPDF-Preflight, Kollisionsschutz,
Exit-Codes, Observer-Bewachung, Testzahl 254.
- Testzahl ueberall von 152 auf **254** korrigiert (README,
`AI_AGENT_BRIEFING.md`, `docs/OS-UPGRADE.md`).
## [0.6.3] - 2026-09-23
Reine Doku-Version — kein Code, kein Installer, kein Updater, keine Unit, keine
Tests angefasst (ausser dem Versionsstring). Beide Erkenntnisse stammen aus den
Testlaeufen auf Debian 12 und Debian 13.
### Added
- **Abschnitt "Systemanforderungen" in `docs/INSTALLATION.md`.** Die
Dimensionierung fehlte bisher komplett. Empfohlen werden **mindestens 2 GB
RAM**; 512 MB reichen fuer 300-dpi-Scans nachweislich nicht. Gemessen auf
einem LXC-Container mit 512 MB RAM + 512 MB Swap, Debian 13,
Ghostscript 10.05.1: eine **einzelne A4-Seite in 300 dpi** mit
`deskew = true` riss das cgroup-Limit und der Dienst wurde vom OOM-Killer
beendet (`oom-kill:constraint=CONSTRAINT_MEMCG, oom_memcg=/lxc/201,
task=python`, `total-vm:1168764kB, anon-rss:489676kB`). Dieselbe
Verarbeitung mit einer kleineren Seite (850x1100 px) lief in ca. 19 s sauber
durch.
- Dokumentiert ist der Zusammenhang **RAM <-> `max_workers` x `jobs` x
Aufloesung**: `max_workers` (Default 2) laesst zwei solcher Seiten
gleichzeitig laufen, der Spitzenbedarf multipliziert sich entsprechend.
Wer knapp dimensioniert, zieht zuerst `max_workers` herunter.
- Dazu, **wie sich ein OOM-Kill aeussert** (Dienst weg bzw. von systemd neu
gestartet, `NRestarts` steigt, Abbruch mitten in der Datei ohne Traceback,
PDF bleibt in `working/` liegen) und **wie man ihn nachweist**
(`/sys/fs/cgroup/memory.events` im Container, `dmesg` auf dem LXC-Host).
Ohne diese Pruefung sieht der Fall wie ein Anwendungsfehler aus und man
sucht in ocrmypdf, Tesseract oder der Config.
- Mit dem Hinweis, dass die Wiederaufnahme aus `working/` seit v0.6.0 den
Datenverlust abfaengt — der OOM selbst bleibt aber ein Problem: bei
unveraenderter Dimensionierung laeuft dieselbe Datei nach dem Neustart
erneut hinein.
- `README.md` bekommt im Schnellstart nur einen Einzeiler mit Verweis, keine
Dublette.
- **Troubleshooting-Eintrag "Keine Logs: No journal files were found" in
`docs/INSTALLATION.md`.** Auf einem der Testcontainer war
`systemd-journald.service` kaputt (`failed`, `status=243/CREDENTIALS`),
`journalctl` lieferte `No journal files were found.` Da der Dienst seit
v0.4.1 **ausschliesslich** nach journald loggt (kein FileHandler, kein
Logverzeichnis — bewusste Entscheidung), gibt es dann gar keine Dienstlogs:
ein blinder Fleck, der vor jeder Fehlersuche per
`systemctl status systemd-journald` auszuschliessen ist. Als Notbehelf ist
der Vordergrund-Aufruf dokumentiert
(`cd /opt/pdf-ocr-hotfolder && sudo -u pdfocr ./venv/bin/python -m
pdf_ocr_hotfolder --config ...`, das `cd` ist zwingend, s. 0.6.2).
Ausdruecklich festgehalten: das ist **kein** generelles LXC-Muster — der
zweite Testcontainer war in Ordnung, es war Schaden auf genau dieser
Maschine.
### Changed
- `AI_AGENT_BRIEFING.md`: der Kommentar zu `requirements.txt` in der
Projektstruktur nannte noch "ocrmypdf 16.x!" — genau die Version, die seit
0.6.1 als unbrauchbar gilt und gegen die der Pin schuetzt. Korrigiert auf
17.x.
## [0.6.2] - 2026-09-22
### Fixed
- Der `--check-config`-Befehl, den `update.sh` in der Zusammenfassung ausgibt,
lief so wie gedruckt nicht (`No module named pdf_ocr_hotfolder`). Das Paket
wird nicht pip-installiert, sondern nach `/opt/pdf-ocr-hotfolder` kopiert und
nur ueber das Arbeitsverzeichnis gefunden — dem Hinweis fehlte das
vorangestellte `cd`. Betraf auch die Beispiele in README.md,
docs/INSTALLATION.md, docs/UPDATE.md und docs/OS-UPGRADE.md.
Gefunden beim Update-Test v0.3.1 -> v0.6.1 auf Debian 12.
## [0.6.1] - 2026-09-22
> **Fuer Bestandsinstallationen wichtig.** Wer 0.6.0 bereits eingespielt hat,
> laeuft auf Debian 12 mit hoher Wahrscheinlichkeit im Totalausfall: der Dienst
> meldet `active`, `--check-config` meldet "Preflight ok" — und **jede** PDF
> landet in `error/`. Nach dem Update auf 0.6.1 nachsehen, ob in `error/`
> unverarbeitete Dateien liegen, und diese zurueck nach `incoming/` schieben.
> Der Updater faehrt jetzt selbst einen Rauchtest, der so einen Zustand sofort
> aufdeckt.
### Fixed
- **ocrmypdf-Pin von 16.13.0 auf 17.4.1 korrigiert — das war ein stiller
Totalausfall.** 0.6.0 pinnte `ocrmypdf==16.13.0`. Auf Bestandssystemen mit
vorher `ocrmypdf>=16.0` war das ein **Downgrade** von 17.4.1, und auf
Debian 12 (Ghostscript 10.0.0) bricht ocrmypdf 16.13.0 bei jeder PDF ab:
```
MissingDependencyError: Ghostscript 10.0.0 through 10.02.0 (your version:
10.0.0) contain serious regressions that corrupt PDFs with existing text
```
Ursache, verifiziert im Quelltext von
`ocrmypdf/builtin_plugins/ghostscript.py::check_options()`: bis
einschliesslich 16.x laeuft die Ghostscript-Pruefung **bedingungslos** —
`skip_text=true` allein genuegt, `output_type` wird gar nicht geprueft,
obwohl die Fehlermeldung selbst `--output-type pdf` empfiehlt. Ab **17.0.0**
umschliesst denselben Block ein
`if options.output_type.startswith('pdfa'):`; ohne PDF/A wird Ghostscript
nicht angefasst. `run_ocr()` setzt bei leerem `pdfa_level` genau
`output_type="pdf"` und hielt sich damit faelschlich fuer sicher.
Betroffen war nicht nur das Update, sondern ebenso jede **Neuinstallation**:
`skip_text = true` ist der Default. — 17.4.1 ist die auf Debian 12 + gs 10.0.0
real verifizierte Version; `skip_text` bleibt in 17.x als Alias fuer
`mode='skip'` unterstuetzt, `run_ocr()` musste nicht angepasst werden.
- **Preflight prueft jetzt die reale Bedingung.** `check_preflight()` sah die
Ghostscript-Version bisher nur bei gesetztem `pdfa_level` an (`if pdfa_level:`)
— genau deshalb ging der kaputte Zustand als "Preflight ok" durch. Die neue
Bedingung (`_gs_block_reason()`) bildet ocrmypdf nach:
```
betroffene GS-Version UND skip_text UND (pdfa_level ODER ocrmypdf < 17)
```
Die Signatur ist jetzt `check_preflight(pdfa_level, skip_text)`; alle
Aufrufstellen (`run()`, `run_once()`, `--check-config`) reichen beides durch.
`redo_ocr` steht bewusst **nicht** in der Bedingung: die Config kennt keinen
solchen Key, und ein erfundener waere schlimmer als ein fehlender.
Ergebnis: der Dienst bricht beim **Start** mit Exit 2 ab statt bei der ersten
Datei, und `--check-config` meldet den Zustand als **Fehler** (Exit 2) — also
auch mitten im Update. Die Meldung nennt beide Auswege: Ghostscript >= 10.02.1
aus bookworm-backports (der Installer bietet das an) oder
`[ocr].skip_text = false`.
### Added
- **`update.sh` macht Versionsspruenge der Kernabhaengigkeiten sichtbar.** Die
Versionen der in `requirements.txt` gepinnten Pakete werden vor und nach
`pip install` gemessen; Downgrades erscheinen als `[WARN]`, Upgrades und neue
Pakete als `[INFO]`, beides zusaetzlich in der Abschluss-Zusammenfassung. Das
Downgrade 17.4.1 -> 16.13.0 verschwand bisher wortlos hinter
`[INFO] Dependencies ok ✓`.
- **Rauchtest in `update.sh`.** Nach dem Start jeder Instanz geht eine winzige
Test-PDF durch die **echte** Pipeline; der Test gilt als bestanden, wenn sie
in `outgoing/` ankommt. Erst das deckt einen Totalausfall auf, den systemd
nicht sieht.
- Die Test-PDF (694 Bytes, eine Seite) steckt als base64 im Skript — kein
Pillow, kein `gs`, kein `convert` noetig.
- Eindeutiger Dateiname (`__smoketest_update_<zeitstempel>_<pid>.pdf`), der
mit keiner Kundendatei kollidieren kann.
- **Raeumt restlos auf** — Testdatei und Ergebnis, in `incoming/`, `working/`
(inkl. `__ocr_`-Zwischendatei), `outgoing/`, `error/` und Archiv, auch bei
Fehlschlag und Timeout.
- **Uebersprungen** bei Instanzen mit aktivem `[upload.nextcloud]`,
`[upload.sftp]`, `[notify.email]` oder `[upload.folder]` mit gesetztem
`target`: dort wuerde die Testdatei nach aussen gehen, im Zweifel zum
Kunden. `[upload.folder]` ohne `target` schreibt nach `outgoing/` und ist
harmlos.
- Wartezeit `SMOKE_TIMEOUT` (Standard 90 s), danach durchgefallen — das
Skript haengt nicht.
- Ein Fehlschlag setzt den Exit-Code auf 1 und nennt den `journalctl`-Befehl,
rollt aber **nichts** zurueck.
- Abschaltbar mit `--no-smoke-test`, dokumentiert in `--help`.
- `--check-config` zeigt zusaetzlich `skip_text` sowie die installierte
ocrmypdf- und Ghostscript-Version an.
### Changed
- **Doku praezisiert.** `config.example.toml`, `config.py`,
`docs/INSTALLATION.md` und `docs/UPDATE.md` behaupteten sinngemaess,
`pdfa_level = ""` sei der sichere Default gegen den Ghostscript-Bug. Das
stimmt so nicht: die Entwarnung haengt an der ocrmypdf-Version und gilt erst
ab 17. Der Ghostscript-Abschnitt in `INSTALLATION.md` stellt die Bedingung
jetzt je ocrmypdf-Major gegenueber und nennt `skip_text = false` als zweiten
Weg; `UPDATE.md` beschreibt Rauchtest und Versionssprung-Meldung.
- Kommentarblock in `requirements.txt` korrigiert: der Schutz gilt gegen den
naechsten ungewollten Major-Sprung (18), **nicht** gegen 17 — samt
Begruendung, warum 16.x fuer uns unbrauchbar ist.
### Tests
- 152 statt 135 Tests. Neu: die Preflight-Matrix aus GS-Version x `skip_text` x
`pdfa_level` x ocrmypdf-Major — darunter der Fall, der durchrutschte
(betroffene GS-Version + `skip_text=true` + leeres `pdfa_level` +
ocrmypdf 16.x muss `PreflightError` ausloesen) und die Gegenprobe, dass
dieselbe Config mit ocrmypdf 17.x **nicht** ausloest (sonst startet keine
Debian-12-Bestandsinstanz mehr). Dazu `ocrmypdf_checks_gs_always()`, der
Abbruch in `run_once()` und Exit 2 bei `--check-config`.
## [0.6.0] - 2026-09-22
### Added
- **Wiederaufnahme aus `working/` beim Start.** `process_pdf()` verschiebt das
Original vor dem OCR nach `working/`. Wurde der Dienst dort hart abgeschossen
(SIGKILL nach `TimeoutStopSec`), blieb die Datei liegen und wurde **nie wieder
angefasst** — stiller Datenverlust. `_scan_working()` greift sie jetzt beim
Start auf (vor `incoming/`), das OCR laeuft fuer sie neu. Liegt in `incoming/`
eine gleichnamige, andere Datei, bekommt die wiederaufgenommene einen
Zeitstempel angehaengt, damit sich beide nicht ueberschreiben. Liegt in
`working/` bereits eine andere Datei desselben Namens, bricht `process_pdf()`
fuer die neue ab und laesst sie in `incoming/` liegen, statt den laufenden
Vorgang stillschweigend zu ueberschreiben.
- **Unvollstaendige OCR-Fragmente werden geloescht.** Die Zwischendatei, in die
ocrmypdf schreibt, traegt jetzt das Praefix `__ocr_` (`OCR_TEMP_PREFIX`).
Bleibt so eine Datei nach einem harten Stopp in `working/` liegen, ist sie als
Eingabe unbrauchbar und als Ergebnis wertlos — sie wird beim Start mit einer
Warnung entfernt, damit sie niemand fuer ein fertiges PDF haelt.
- **`--check-config`**: prueft eine Instanz-Config, ohne irgendetwas zu
verarbeiten (hat Vorrang vor `--once`). Zeigt die vier Pfade inkl. Hinweis auf
noch fehlende Verzeichnisse, Sprachen, Seiten-Timeout und PDF/A-Level, faehrt
Preflight und `[output]`-Validierung und gibt alle Warnungen aus.
Exit **0** = sauber, **1** = nur Warnungen, **2** = Fehler (Dienst wuerde nicht
starten).
- **Legacy-Warnungen fuer `[ocr].timeout` und `[ocr].pdfa_level`.** Ein
`timeout >= 900` stammt fast sicher aus einer Config vor 0.4.0, wo der Wert ein
wirkungsloses Gesamt-Timeout mit Default 1800 war — seither sind es Sekunden
**pro Seite** (Richtwert 300). Ein gesetztes `pdfa_level` weist auf den
Ghostscript-Bug hin. Die Texte stehen nur in `config.py`
(`legacy_warnings()`), weil sie sowohl beim Dienststart ins Log gehen als auch
von `--check-config` ausgegeben werden.
- **Unbekannte Config-Keys werden gemeldet** statt still verworfen.
`_collect_unknown_keys()` sammelt Tippfehler (`[ocr].langauges`), Optionen aus
aelteren Versionen, unbekannte Sektionen und unbekannte Upload-/Notify-Targets
in `Config.unknown_keys`; die Meldung nennt den vollen Pfad. Warnung, kein
Fehler — der Dienst startet, der Eintrag tut nur nichts.
- **`TimeoutStopSec=300` in der Template-Unit**: ein laufendes OCR darf beim
Stoppen zu Ende laufen. Ein `systemctl stop` kann dadurch pro Instanz bis zu
5 Minuten dauern — das ist gewollt, ein SIGKILL wuerde den Durchlauf kosten.
- **Feste Pins in `requirements.txt`** (`ocrmypdf==16.13.0`, `watchdog==6.0.0`,
`requests==2.33.1`, `paramiko==4.0.0`). Ohne Pins zieht ein
`pip install --upgrade` beim Update ungefragt einen Major-Sprung ein; ocrmypdf
16 -> 17 wuerde alle Instanzen auf einmal reissen. Geprueft gegen Python 3.11
(Debian 12) und 3.13 (Debian 13), Wheels fuer beide vorhanden.
- 40 neue Tests (Wiederaufnahme aus `working/`, `--check-config`,
Config-Warnungen). Suite jetzt **135 Tests**.
### Changed
- **Die Betriebsdoku ist in drei Dokumente aufgeteilt.** Der README ist wieder
der Einstieg (Kurzbeschreibung, Features, Schnellstart, Verzeichnis-Layout,
Config-Ueberblick) und verlinkt:
- `docs/INSTALLATION.md` — Erstinstallation, Basis-Install vs. Instanz-Anlage,
die Abfragen pro Instanz, Multi-Instanz-Betrieb, LXC (Error 226/NAMESPACE),
Ghostscript auf Debian 12, Instanz manuell loeschen und die vollstaendige
**Konfigurationsreferenz**.
- `docs/UPDATE.md` — Ablauf von `update.sh`, was es nicht anfasst,
Backup-Inhalt/-Rechte/-Rotation, Rollback und dessen Grenzen,
`--check-config` mit den Exit-Codes und Config-Drift.
- `docs/OS-UPGRADE.md` — Debian-Major-Upgrade als eigener Ablauf.
`AI_AGENT_BRIEFING.md` bleibt der Agent-Kontext (Aufbau und Begruendungen) und
verweist fuer Ablaeufe auf die drei Dokumente, statt sie zu wiederholen.
Nichts wird doppelt gepflegt.
- **`update.sh` komplett ueberarbeitet** (`--help`, `--rebuild-venv`,
`set -Eeuo pipefail`):
- **venv-Health-Check und Neubau.** Geprueft werden Existenz, Lauffaehigkeit
des Interpreters, `major.minor` gegen das System-Python und `pyvenv.cfg`.
Passt etwas nicht — typisch nach einem Debian-Major-Upgrade, systemd meldet
dann `203/EXEC` —, wird die venv neu gebaut, auch ohne `--rebuild-venv`. Der
Neubau ist ganz oder gar nicht: alte venv weg sichern, neu bauen,
Requirements installieren, **erst bei Erfolg** die alte loeschen; scheitert
etwas, wird zurueckgerollt und hart abgebrochen. Scheitert pip an einem Pin,
nennt das Skript das gescheiterte Paket und den naechsten Schritt
("requirements.txt anheben").
- **apt-Sync auch beim Update.** Die Paketliste wird aus `install.sh`
extrahiert (einzige Quelle, Marken `BEGIN/END apt-packages`) und
installiert; nachinstallierte Tesseract-Sprachpakete bleiben unangetastet
(kein purge, kein autoremove). Fehlschlaege warnen nur.
- **Haertere Verifikation.** Nach dem Start prueft `verify_unit()` nicht nur
`is-active`, sondern auch `is-failed` und den Restart-Zaehler — ein
Crash-Loop galt bei `Type=simple` bisher als Erfolg. Die Zusammenfassung
stellt Soll gegen Ist und meldet eine **Regression** namentlich.
- **Vollstaendiges Backup.** Gesichert werden Code, alle Instanz-Configs, die
Template-Unit, alle Drop-ins und ein `pip-freeze.txt` der alten venv — ohne
venv und ohne Datenverzeichnisse. Weil die Configs Klartext-Passwoerter
enthalten, wird das Archiv mit `umask 077` erzeugt und auf `0600 root:root`
gesetzt, das Verzeichnis auf `700`. Rotation: die letzten 5 Archive bleiben.
- **ERR-Trap.** Bricht das Update ab (Fehler, Strg-C, `kill`), sagt das Skript,
ob auf der Platte schon getauscht wurde, startet die vorher laufenden
Instanzen wieder und nennt Backup-Datei und Rollback-Befehl.
- **Instanz-Erfassung** deckt jetzt auch `activating` und `failed` ab (ueber
`list-units --all`, `list-unit-files` und die vorhandenen Configs). Vorher
kaputte Instanzen werden mitgestartet, gelten aber erst als Erfolg, wenn sie
danach wirklich laufen; bewusst gestoppte bleiben gestoppt.
- **Config-Pruefung vor dem Start**: `--check-config` je Instanz, Exit 2 zaehlt
als Fehler (Update-Exit 1), Exit 1 wird als Warnung samt Nachstell-Befehl
ausgegeben. Kennt der installierte Code das Flag noch nicht, wird die
Pruefung uebersprungen und das Update laeuft weiter.
- **`install.sh` repariert eine kaputte venv.** Bisher reichte das blosse
Vorhandensein von `venv/`, um den Basis-Install zu ueberspringen — nach einem
Distributions-Upgrade hat der Installer damit gar nichts repariert. Jetzt wird
die venv gegen das System-Python geprueft und bei Drift nach
`venv.old-<timestamp>` gesichert und neu gebaut.
- Die apt-Paketliste steht als **einzige Quelle** in `install.sh` in der Funktion
`pdf_ocr_apt_packages()` zwischen den Marken `# --- BEGIN apt-packages` /
`# --- END apt-packages`. `update.sh` schneidet den Block heraus und wertet ihn
aus — Marken und Funktionsname duerfen sich nicht ohne Anpassung aendern.
### Fixed
- **Dateien in `working/` gingen nach einem harten Stopp still verloren.** Siehe
Wiederaufnahme oben — der Fall trat bei jedem SIGKILL waehrend eines OCR-Laufs
auf, also auch bei einem Update ohne `TimeoutStopSec`.
- **Tippfehler in Config-Keys fielen nicht auf.** `load_config()` filterte
stumm gegen die Dataclass-Annotationen; `[ocr].langauges` lief damit
wirkungslos mit. Jetzt gibt es eine Warnung mit vollem Key-Pfad.
## [0.5.0] - 2026-09-22
### Added
- Der Installer weist einen Archiv-Pfad ab, der auf `incoming/`, `outgoing/`,
`working/` oder `error/` der Instanz zeigt — im Eingang wuerde das Original
sonst endlos neu aufgegriffen.
- **`install.sh` fragt beim Anlegen einer Instanz die OCR-Sprachen ab**
(`Tesseract-Sprachen [deu+eng]:`). Die Wahl gilt bewusst **pro Instanz** —
ein Hotfolder `buchhaltung` kann mit `deu` laufen, ein Hotfolder `export` mit
`deu+eng+fra`. Der Installer weist vorher darauf hin, dass jede zusaetzliche
Sprache Laufzeit **und** Erkennungsqualitaet kostet, die Liste also eng
gehalten werden sollte. Das Eingabeformat wird geprueft (Sprachcodes mit `+`
verbunden, `chi_sim` & Co. erlaubt); bei Unsinn wird erneut gefragt statt
abzubrechen.
- **Sprachpakete werden nachinstalliert.** Jeder eingegebene Code wird gegen
`tesseract --list-langs` geprueft. Fehlt eine Sprachdatei, bietet der
Installer das passende apt-Paket an (`tesseract-ocr-<code>`, Unterstrich wird
zum Bindestrich: `chi_sim` → `tesseract-ocr-chi-sim`). Lehnt der User ab oder
laesst sich das Paket nicht installieren, warnt der Installer, dass OCR mit
dieser Sprache **bei jeder Datei** scheitern wuerde, und fragt die Sprachen
erneut ab — so kann die Sprache einfach wieder rausgeworfen werden. Ist
`tesseract` nicht aufrufbar, wird die Pruefung uebersprungen und die Eingabe
unveraendert uebernommen.
- **Abfrage `Original nach erfolgreichem OCR archivieren? [j/N]:`** — Default
nein, also weiterhin `original_on_success = "delete"`. Bei ja wird der
Archiv-Pfad abgefragt (Vorschlag `<basis>/archive`), angelegt und auf den
Service-User gechownt; ein Archiv ausserhalb des Instanz-Basis-Pfads bekommt
ein eigenes `chown -R`.
### Changed
- Die Instanz-Config wird weiterhin per `sed` aus `config.example.toml`
erzeugt, substituiert jetzt aber zusaetzlich `[ocr].languages`,
`[output].original_on_success` und `[output].archive_dir` — bisher waren das
die Beispiel-Defaults, `archive_dir` musste von Hand nachgetragen werden.
Die Ausdruecke sind am Zeilenanfang verankert (`^key[[:space:]]*=`), damit die
deutschen Kommentarzeilen ueber den Keys unangetastet bleiben, und
Pfad-Variablen laufen durch `sed_escape_repl()` (maskiert `\`, `&`, `|`) —
Pfade mit Sonderzeichen landen damit korrekt in der Config.
- Nach dem sed-Lauf liest der Installer die drei Keys aus der erzeugten Config
zurueck und vergleicht sie mit der Eingabe. Erst wenn das passt, nennt die
Abschluss-Zusammenfassung zusaetzlich die gewaehlten **Sprachen** und (bei
Archivierung) das **Archiv-Verzeichnis**; sonst gibt es eine Warnung.
## [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
- Neue Config-Sektion `[output]` mit:
- `name_mode` — Platzierung des Tags im Dateinamen: `"prefix"`, `"suffix"` (vor Extension), `"none"`
- `name_tag` — verbatim einzufügender String, z.B. `"OCR_"` oder `"_OCR"`
- `original_on_success` — `"delete"` (alter Default) oder `"archive"`
- `archive_dir` — Zielverzeichnis für `"archive"`, mit Kollisions-Schutz (Timestamp-Suffix)
- Runtime-Validierung der Output-Config in `check_output_config()`
- 20 neue Tests für `build_output_name()`, `check_output_config()` und `process_pdf()`
mit allen Kombinationen aus Modus + Original-Behandlung
### Changed
- `process_pdf()` nimmt jetzt `output_cfg: OutputConfig` als Pflicht-Argument
## [0.2.2] - 2026-04-09
### Fixed
+89 -114
View File
@@ -2,33 +2,53 @@
Verwandelt eingehende gescannte PDFs automatisch in **durchsuchbare PDFs** (PDF/A optional) per OCR. Hauptanwendung: Kunden-Scanner schiebt PDF in einen Ordner — Sekunden später liegt die OCR-Version im Ausgang oder wird in Nextcloud / per SFTP weitergeleitet.
## Dokumentation
| Dokument | Inhalt |
|----------|--------|
| **[docs/INSTALLATION.md](docs/INSTALLATION.md)** | Erstinstallation, Instanzen anlegen, LXC, Ghostscript, **Konfigurationsreferenz**, Troubleshooting |
| **[docs/UPDATE.md](docs/UPDATE.md)** | Update mit `update.sh`: Ablauf, Backup, Rollback, `--check-config`, Config-Drift |
| **[docs/OS-UPGRADE.md](docs/OS-UPGRADE.md)** | Debian-Major-Upgrade (12 → 13): venv neu bauen, Pins anheben |
Weiter: [CHANGELOG.md](CHANGELOG.md) · [AI_AGENT_BRIEFING.md](AI_AGENT_BRIEFING.md) · [config.example.toml](config.example.toml)
## Features
- 🔍 **OCR via ocrmypdf + Tesseract** (Library-Call, kein Subprozess-Overhead)
- 📂 **Hotfolder via watchdog** — reagiert auf `created`, `moved`, `closed` Events
- 🧠 **Stabilitäts-Erkennung**: wartet bis Scanner fertig geschrieben hat
- 🔁 **Parallelverarbeitung** mehrerer PDFs (ThreadPool, konfigurierbar)
- ♻️ **Wiederaufnahme aus `working/`** nach einem harten Stopp — keine Datei bleibt liegen
- ✅ **PDF/A-Output** (1, 2 oder 3) optional
- 🛡️ **veraPDF-Validierung** optional
- 🛡️ **veraPDF-Validierung** optional — Binary wird im Preflight geprüft, eine Störung gilt nicht als FAIL
- 🚫 **Überschreibt nie eine gleichnamige Datei** — in `outgoing/`, `error/`, Archiv und Ordner-Upload weicht sie mit Zeitstempel aus
- ☁️ **Upload-Ziele**: lokaler Ordner, Nextcloud (WebDAV via Python), SFTP
- 📧 **E-Mail-Notify** (immer / nur Fehler / nie)
- 🔐 **Service-User-Support** für lokale **und AD-User mit lokaler UID** (SSSD/Winbind)
- ⚙️ Saubere systemd-Integration mit auto-Restart
- ⚙️ Saubere systemd-Integration mit auto-Restart, **Multi-Instanz** über eine Template-Unit
- 👁️ **Toter Verzeichnis-Watch wird erkannt** — der Dienst beendet sich (Exit 3), systemd setzt den Watch neu auf
- 🩺 **`--check-config`** prüft eine Instanz-Config ohne etwas zu verarbeiten
## Schnellstart
**Voraussetzungen:** Debian 12 oder 13, Python 3.11+, root — und **mindestens
2 GB RAM** (512 MB reichen für 300-dpi-Scans nachweislich nicht, siehe
[Systemanforderungen](docs/INSTALLATION.md#systemanforderungen)).
Dateisystem **ext4, xfs oder zfs**; `incoming/` muss **lokal** liegen — auf
einem CIFS/NFS-Mount liefert inotify keine Events und der Hotfolder bemerkt
neue Dateien nur noch beim Start
([warum](docs/INSTALLATION.md#dateisystem-ext4-xfs-oder-zfs)).
```bash
git clone https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder.git
git clone gitea@gitea.sonith.de:sonith_ug/pdf-ocr-hotfolder.git
cd pdf-ocr-hotfolder
sudo ./install.sh
```
Der Installer:
1. Installiert einmalig Code + venv + systemd-Template-Unit
2. Fragt nach Instanz-Name, Basis-Pfad, Service-User
3. Legt so viele Hotfolder-Instanzen an, wie du willst (`Weitere Instanz anlegen? [j/N]`)
Bei jedem erneuten Aufruf erkennt der Installer bestehende Instanzen und fragt nur nach neuen.
Der Installer legt einmalig Code, venv und die systemd-Template-Unit an und
fragt danach **pro Instanz** Name, Basis-Pfad, Service-User, OCR-Sprachen und
die Original-Behandlung ab. Bei jedem erneuten Aufruf erkennt er bestehende
Instanzen und fragt nur nach neuen.
Test:
@@ -39,85 +59,68 @@ journalctl -u pdf-ocr-hotfolder@<instanz> -f
Nach wenigen Sekunden liegt das OCR-PDF im `outgoing/`-Ordner der Instanz.
## Multi-Instanz-Betrieb
Alle Details zu den Abfragen, zum Multi-Instanz-Betrieb und zu den Fallstricken
(LXC, Ghostscript): **[docs/INSTALLATION.md](docs/INSTALLATION.md)**.
Das Tool arbeitet komplett **instanzbasiert** über eine systemd Template-Unit `pdf-ocr-hotfolder@<name>.service`. Jede Instanz hat:
- eigene Config-Datei: `/etc/pdf-ocr-hotfolder/<name>.toml`
- eigene Datenverzeichnisse: `/var/lib/pdf-ocr-hotfolder/<name>/{incoming,working,outgoing,error}/`
- eigene systemd-Unit: `pdf-ocr-hotfolder@<name>.service`
- optional eigenen Service-User (via Drop-in `/etc/systemd/system/pdf-ocr-hotfolder@<name>.service.d/user.conf`)
Beispiel für 3 Hotfolder:
```bash
sudo ./install.sh
# → legt z.B. kunde-a, kunde-b, buchhaltung an
systemctl status 'pdf-ocr-hotfolder@*'
journalctl -u pdf-ocr-hotfolder@kunde-a -f
```
Manuell eine weitere Instanz anlegen geht auch — einfach `install.sh` erneut starten, er fragt wieder nach.
Update: `git pull && sudo ./update.sh` — siehe **[docs/UPDATE.md](docs/UPDATE.md)**.
Nach einem Debian-Major-Upgrade: **[docs/OS-UPGRADE.md](docs/OS-UPGRADE.md)**.
## Verzeichnisse
| Pfad | Zweck |
|------|-------|
| `/opt/pdf-ocr-hotfolder/` | Code + venv (für alle Instanzen gemeinsam) |
| `/etc/pdf-ocr-hotfolder/<instanz>.toml` | Config pro Instanz |
| `/opt/pdf-ocr-hotfolder/lib/common.sh` | gemeinsame Shell-Funktionen für `install.sh`/`update.sh` (mitkopiert) |
| `/etc/pdf-ocr-hotfolder/<instanz>.toml` | Config pro Instanz (640, root:\<service-gruppe\>) |
| `/etc/systemd/system/pdf-ocr-hotfolder@.service` | systemd Template-Unit |
| `/var/lib/pdf-ocr-hotfolder/<instanz>/incoming` | Eingang (Scanner schreibt hier rein) |
| `/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 |
| `/var/backups/pdf-ocr-hotfolder/` | Update-Backups (0600, letzte 5) |
## Konfiguration
Ein eigenes Logverzeichnis gibt es nicht — der Dienst loggt nach stdout und
damit ins journal.
Vollständiges Beispiel: [`config.example.toml`](config.example.toml). Wichtigste Sektionen:
## Konfiguration im Überblick
### `[ocr]`
```toml
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
deskew = true
max_workers = 2 # parallele PDFs
timeout = 1800
Jede Instanz hat ihre eigene TOML unter `/etc/pdf-ocr-hotfolder/<instanz>.toml`.
Vollständiges, kommentiertes Beispiel: [`config.example.toml`](config.example.toml).
Key-für-Key-Referenz: [docs/INSTALLATION.md](docs/INSTALLATION.md#konfigurationsreferenz).
| Sektion | Zweck |
|---------|-------|
| `[paths]` | `incoming`, `outgoing`, `working`, `error` — **Pflicht**, alle **absolut** |
| `[ocr]` | Sprachen, `jobs`, `skip_text`, `pdfa_level`, `deskew`, `max_workers`, `timeout` (Sekunden **pro Seite**) |
| `[output]` | Dateibenennung (`name_mode`/`name_tag`) und Original-Behandlung (`delete`/`archive`) |
| `[verapdf]` | optionale PDF/A-Validierung per CLI |
| `[upload.folder]` / `[upload.nextcloud]` / `[upload.sftp]` | Upload-Ziele, beliebig viele gleichzeitig |
| `[notify.email]` | SMTP-Benachrichtigung: `always` \| `errors` \| `never` |
| `[logging]` | `level` = DEBUG/INFO/WARNING/ERROR |
Die Instanz-Configs enthalten **Klartext-Passwörter** (SMTP, Nextcloud, SFTP) —
deshalb `640 root:<service-gruppe>` und beim Debuggen nicht in Tickets kopieren.
Config prüfen, ohne etwas zu verarbeiten:
```bash
cd /opt/pdf-ocr-hotfolder && sudo ./venv/bin/python -m pdf_ocr_hotfolder \
--check-config --config /etc/pdf-ocr-hotfolder/<instanz>.toml
```
### `[upload.nextcloud]`
```toml
enabled = true
url = "https://cloud.example.com"
username = "scanuser"
password = "app-password"
remote_path = "Scans/Inbox"
```
Exit 0 = sauber, 1 = Warnungen, 2 = Fehler. Details:
[docs/UPDATE.md](docs/UPDATE.md#config-prüfung-per---check-config).
### `[upload.sftp]`
```toml
enabled = true
host = "sftp.example.com"
username = "scanuser"
key_file = "/etc/pdf-ocr-hotfolder/sftp_key"
remote_path = "/uploads"
```
### Exit-Codes des Dienstes
### `[notify.email]`
```toml
enabled = true
smtp_host = "smtp.example.com"
smtp_port = 587
smtp_user = "alerts@example.com"
smtp_password = "secret"
from_addr = "PDF OCR <alerts@example.com>"
to_addrs = ["admin@example.com"]
on = "errors" # always | errors | never
```
| Exit | Bedeutung | Neustart durch systemd |
|------|-----------|------------------------|
| `0` | regulärer Stopp | — |
| `1` | nur bei `--once`: mindestens eine PDF fehlgeschlagen | — |
| `2` | Config- oder Preflight-Fehler | **nein** (`RestartPreventExitStatus=2`) — die Instanz bleibt sichtbar `failed` |
| `3` | Verzeichnis-Watch gestorben, es würden keine Dateien mehr erkannt | **ja**, genau dafür |
Vollständig: [docs/INSTALLATION.md](docs/INSTALLATION.md#exit-codes).
## Service-Verwaltung
@@ -130,52 +133,11 @@ journalctl -u pdf-ocr-hotfolder@kunde-a -f
# Alle Instanzen
sudo systemctl status 'pdf-ocr-hotfolder@*'
sudo systemctl restart 'pdf-ocr-hotfolder@*'
journalctl -u 'pdf-ocr-hotfolder@*' --since today
```
## Update
```bash
cd /pfad/zum/repo
git pull
sudo ./update.sh
```
`update.sh`:
1. Stoppt alle laufenden Instanzen
2. Sichert den alten Code nach `/var/backups/pdf-ocr-hotfolder/`
3. Aktualisiert Code + venv + systemd-Template-Unit in `/opt/pdf-ocr-hotfolder/`
4. Startet alle zuvor laufenden Instanzen neu
Config-Dateien unter `/etc/pdf-ocr-hotfolder/` werden **nie** überschrieben.
Das Repo muss bestehen bleiben — `update.sh` kopiert daraus.
## Manueller Lauf (One-Shot)
Bestehende PDFs einer Instanz einmalig verarbeiten und beenden:
```bash
sudo -u pdfocr /opt/pdf-ocr-hotfolder/venv/bin/python -m pdf_ocr_hotfolder \
--config /etc/pdf-ocr-hotfolder/kunde-a.toml --once
```
## Troubleshooting
### Tesseract findet die Sprache nicht
```bash
sudo apt install tesseract-ocr-deu tesseract-ocr-eng
```
### "PriorOcrFoundError"
ocrmypdf erkennt bereits vorhandenen OCR-Text. `skip_text = true` in der Config setzen.
### Berechtigungsprobleme bei AD-User
Service-User braucht **rw** auf alle vier Verzeichnisse unter `/var/lib/pdf-ocr-hotfolder/`. Bei AD-User mit lokaler UID:
```bash
sudo chown -R DOMAIN\\scanuser:DOMAIN\\scangroup /var/lib/pdf-ocr-hotfolder
```
### veraPDF-Validierung schlägt immer fehl
veraPDF binary prüfen (`[verapdf].binary`). Wenn nicht zwingend gebraucht: `enabled = false`.
Ein laufendes OCR darf beim Stoppen zu Ende laufen (`TimeoutStopSec=300`) — ein
`stop` kann deshalb pro Instanz bis zu 5 Minuten dauern.
## Architektur
@@ -199,11 +161,24 @@ veraPDF binary prüfen (`[verapdf].binary`). Wenn nicht zwingend gebraucht: `ena
└────────────┘ └────────────┘ └────────────┘
```
Beim Start wird `working/` zuerst durchsucht: was ein harter Stopp dort liegen
ließ, wird wiederaufgenommen; unvollständige OCR-Fragmente (`__ocr_*`) werden
gelöscht.
## Tests
```bash
pytest # 254 Tests
```
`ocrmypdf` muss dafür nicht installiert sein — der Import ist lazy und wird in
den Tests gemockt.
## Lizenz
MIT — © Sonith UG
---
**Version:** 0.2.0
**Version:** 0.7.0
**Repo:** https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder
+1 -1
View File
@@ -1 +1 @@
0.2.2
0.7.0
+51 -7
View File
@@ -1,7 +1,12 @@
# PDF OCR Hotfolder — Konfiguration
# Speichern als /etc/pdf-ocr-hotfolder/config.toml
# Vorlage — install.sh erzeugt daraus pro Instanz /etc/pdf-ocr-hotfolder/<instanz>.toml
[paths]
# ACHTUNG: Alle Pfade MUESSEN absolut sein. Ein relativer Pfad wuerde gegen
# das Arbeitsverzeichnis des Dienstes aufgeloest (WorkingDirectory der
# systemd-Unit), nicht gegen das Verzeichnis dieser Datei — der Scanner
# schriebe dann woanders hin als der Dienst schaut. Der Dienst startet
# deshalb mit einem relativen Pfad gar nicht erst.
# Eingangsverzeichnis: hier landen gescannte PDFs
incoming = "/var/lib/pdf-ocr-hotfolder/incoming"
# Ausgangsverzeichnis: fertige durchsuchbare PDFs
@@ -21,9 +26,17 @@ skip_text = true
# Auflösung für gerasterte Seiten
oversample = 300
# PDF/A-Konformitätsstufe ("1", "2", "3" oder leer für keinen PDF/A-Output)
# ACHTUNG: Ghostscript 10.0.0 bis 10.02.0 (Debian 12 default!) haben einen Bug,
# der mit pdfa_level + skip_text=true ocrmypdf komplett blockiert.
# Sicherer Default ist "" — nur auf "1"/"2"/"3" setzen, wenn gs >= 10.02.1 installiert ist.
# ACHTUNG: Ghostscript 10.0.0 bis 10.02.0 (Debian 12 default!) haben einen Bug;
# ocrmypdf lehnt damit die Kombination pdfa_level + skip_text=true komplett ab.
# Nur auf "1"/"2"/"3" setzen, wenn gs >= 10.02.1 installiert ist.
#
# pdfa_level = "" ist deshalb der Default — aber KEIN genereller Schutz gegen
# den Ghostscript-Bug: das gilt erst zusammen mit ocrmypdf >= 17. Bis
# ocrmypdf 16.x läuft dieselbe Prüfung auch ohne PDF/A, und dann scheitert mit
# skip_text = true jede einzelne Datei. Die Entwarnung hängt also an der
# ocrmypdf-Version, nicht an dieser Zeile; requirements.txt pinnt darum 17.x.
# Der Preflight prüft beides zusammen und lässt den Dienst gar nicht erst
# starten, wenn die Kombination nicht trägt.
pdfa_level = ""
# Schiefe Scans automatisch begradigen
deskew = true
@@ -31,11 +44,40 @@ 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?
# "prefix" : name_tag wird vor den Dateinamen gestellt (OCR_scan.pdf)
# "suffix" : name_tag wird vor die Extension gestellt (scan_OCR.pdf)
# "none" : Dateiname bleibt wie das Original
name_mode = "prefix"
# Verbatim einzufügender String. Leerer String = kein Tag (wie mode="none").
# Beispiele: "OCR_", "[OCR]_", "_OCR", "_searchable"
name_tag = "OCR_"
# Was passiert mit dem Original, wenn OCR erfolgreich war?
# "delete" : Original wird gelöscht (alter Standard)
# "archive" : Original wird in archive_dir verschoben
original_on_success = "delete"
# Absoluter Pfad (Pflicht, relativ wird abgelehnt); nur relevant wenn
# original_on_success = "archive"
archive_dir = ""
[verapdf]
# PDF/A-Validierung (optional)
# ACHTUNG: Mit enabled = true muss "binary" auf ein vorhandenes, ausfuehrbares
# Programm zeigen. Der Preflight prueft das und laesst den Dienst sonst gar
# nicht erst starten (--check-config meldet Exit 2). Grund: ein nicht
# aufrufbares veraPDF wuerde jede einzelne PDF als ungueltig werten, das
# OCR-Ergebnis nach error/ schieben und das Original laut
# original_on_success entsorgen — bei "delete" also Scan fuer Scan die
# Vorlage vernichten, waehrend der Dienst als "laeuft" dasteht.
enabled = false
binary = "/opt/verapdf/verapdf"
flavour = "1b"
@@ -45,7 +87,9 @@ flavour = "1b"
[upload.folder]
enabled = true
# Wenn leer, wird [paths].outgoing verwendet
# Wenn leer, wird [paths].outgoing verwendet. Sonst: absoluter Pfad (Pflicht,
# relativ wird abgelehnt). Liegt im Ziel schon eine gleichnamige Datei, wird
# die neue mit Zeitstempel abgelegt statt die alte zu ueberschreiben.
target = ""
[upload.nextcloud]
+877
View File
@@ -0,0 +1,877 @@
# Installation
Erstinstallation und Anlage von Hotfolder-Instanzen mit `install.sh`.
Verwandte Dokumente: [README](../README.md) · [Update](UPDATE.md) · [Debian-Major-Upgrade](OS-UPGRADE.md)
---
## Voraussetzungen
| Punkt | Anforderung |
|-------|-------------|
| Betriebssystem | Debian 12 (bookworm) oder Debian 13 — systemd wird vorausgesetzt |
| Python | 3.11+ (wegen `tomllib` aus der stdlib); kommt aus der Distribution |
| Arbeitsspeicher | **mindestens 2 GB** für den produktiven Betrieb — siehe [Systemanforderungen](#systemanforderungen) |
| Dateisystem | **ext4, xfs oder zfs**; `incoming/` **lokal**, kein CIFS/NFS — siehe [Dateisystem](#dateisystem-ext4-xfs-oder-zfs) |
| Rechte | `root` (`sudo ./install.sh`) |
| Netz | apt-Zugriff für die System-Pakete, PyPI-Zugriff für die venv |
| Repo | muss dauerhaft liegen bleiben — `update.sh` kopiert daraus (s. [UPDATE.md](UPDATE.md)) |
Die System-Pakete installiert der Installer selbst. Die Liste steht als
einzige Quelle in `lib/common.sh` (Funktion `pdf_ocr_apt_packages()`, zwischen
den Marken `# --- BEGIN apt-packages` / `# --- END apt-packages`); `install.sh`
sourct die Datei, und `update.sh` schneidet den Block zusätzlich noch einmal
aus der **Repo**-Fassung heraus, damit beim Update die neue Liste gilt und
nicht die eventuell ältere Kopie unter `/opt/pdf-ocr-hotfolder/lib/`:
```
python3 python3-venv python3-pip
tesseract-ocr tesseract-ocr-deu tesseract-ocr-eng
ghostscript qpdf unpaper pngquant icc-profiles-free
ca-certificates curl
```
Weitere Tesseract-Sprachpakete installiert der Installer bei Bedarf pro Instanz
nach (siehe [OCR-Sprachen](#ocr-sprachen)).
Die Python-Abhängigkeiten stehen **fest gepinnt** in `requirements.txt`
(ocrmypdf, watchdog, requests, paramiko). Warum das so ist und wie man die Pins
anhebt: [OS-UPGRADE.md](OS-UPGRADE.md#pins-in-requirementstxt).
---
## Systemanforderungen
CPU und Platte sind unkritisch — **der Arbeitsspeicher ist es nicht.** OCR
rastert jede Seite in voller Auflösung ins RAM; der Spitzenbedarf hängt an der
Seitengröße, nicht an der Dateigröße der PDF.
**Empfehlung: mindestens 2 GB RAM.** Für Mehr-Instanz-Betrieb oder
`max_workers > 2` entsprechend mehr.
### Dateisystem: ext4, xfs oder zfs
Der Dienst wird **ausschließlich auf ext4, xfs oder zfs** betrieben. Andere
Dateisysteme sind nicht vorgesehen und werden nicht getestet.
**`incoming/` gehört auf ein lokales, inotify-fähiges Dateisystem — kein
CIFS/NFS-Mount.** Der Hotfolder hängt vollständig an inotify (`watchdog`
meldet `created`, `moved`, `closed`). inotify ist ein Mechanismus des *lokalen*
Kernels: er sieht nur Änderungen, die dieser Kernel selbst ausführt. Schreibt
ein anderer Rechner über SMB oder NFS in ein gemountetes Verzeichnis, geht das
am lokalen VFS vorbei und **es entsteht gar kein Event** — nicht verzögert,
nicht unzuverlässig, sondern grundsätzlich keines.
Die Folge ist heimtückisch, weil nichts kaputt aussieht: Der Dienst startet,
meldet `active (running)`, arbeitet den Bestand beim Start-Scan sauber ab — und
bemerkt danach **keine einzige neue Datei mehr**. Es gibt keinen Fehler, keine
Meldung, keine Mail. Erst wenn jemand die Instanz neu startet, wird der
inzwischen angesammelte Stapel auf einmal verarbeitet.
Richtiger Aufbau: der Scanner schreibt über SMB/NFS auf **den Rechner, auf dem
der Dienst läuft**, und `incoming/` liegt dort auf der lokalen Platte. Der
Netz-Export zeigt auf dieses lokale Verzeichnis, nicht umgekehrt. Für die
**Ausgabe** gilt die Einschränkung nicht — `outgoing/` und
`[upload.folder].target` dürfen auf einem Netz-Share liegen, dorthin wird nur
geschrieben.
### Was 2 GB tatsächlich tragen
Gemessen auf einem LXC-Container mit **2 GB RAM**, Debian 13 — eine
**A4-Seite in 300 dpi** mit `deskew = true`, `oversample = 300`, `jobs = 4`:
| Messwert | Ergebnis |
|----------|----------|
| Laufzeit | **19 s** |
| `MemoryPeak` des Dienstes (`systemctl show -p MemoryPeak`) | **380 MB** |
| `memory.peak` des ganzen Containers | **503 MB** |
| `oom_kill` in `/sys/fs/cgroup/memory.events` | **0** |
**Dieselbe Seite auf derselben Maschine mit 512 MB war genau der OOM-Kill
unten.** Der Spitzenbedarf liegt also bei rund einem halben Gigabyte für
*eine* Seite bei *einem* Worker — 2 GB lassen damit Luft für den
Default `max_workers = 2`, für das Betriebssystem und für einen zweiten
Hotfolder, sind aber keine üppige Reserve.
### Warum 512 MB nachweislich nicht reichen
Gemessen auf einem LXC-Container mit **512 MB RAM + 512 MB Swap**, Debian 13,
Ghostscript 10.05.1:
| Vorgang | Ergebnis |
|---------|----------|
| eine einzelne **A4-Seite in 300 dpi**, `deskew = true` | cgroup-Limit gerissen, Dienst vom **OOM-Killer** beendet |
| dieselbe Verarbeitung mit einer kleineren Seite (850 × 1100 px) | läuft sauber durch, ca. 19 s |
Beleg aus dem `dmesg` des LXC-Hosts:
```
oom-kill:constraint=CONSTRAINT_MEMCG, oom_memcg=/lxc/201, task=python
total-vm:1168764kB, anon-rss:489676kB
```
Eine Seite, ein Worker — und schon knapp 500 MB anonymer Speicher. 512 MB sind
damit für 300-dpi-Scans keine knappe, sondern eine unzureichende Dimensionierung.
### RAM ↔ `max_workers` × `jobs` × Auflösung
Der Spitzenbedarf multipliziert sich über drei Config-Werte aus
[`[ocr]`](#ocr):
| Key | Default | Wirkung auf den Speicher |
|-----|---------|--------------------------|
| `max_workers` | `2` | **so viele PDFs gleichzeitig** — jede mit eigenem Seitenpuffer. Der direkte Multiplikator |
| `jobs` | `4` | Threads **innerhalb** einer PDF; mehrere Seiten gleichzeitig im Speicher |
| `oversample` | `300` | Auflösung gerasterter Seiten — der Bedarf wächst quadratisch mit der dpi |
Der Default `max_workers = 2` erlaubt also, dass **zwei** solcher Seiten
parallel verarbeitet werden. Wer knapp dimensioniert, zieht zuerst
`max_workers` auf `1` herunter, danach `jobs`. `oversample` unter 300 zu
drücken, spart zwar Speicher, kostet aber Erkennungsqualität — das ist der
letzte Hebel, nicht der erste.
### Wie sich ein OOM-Kill äußert
Von außen sieht ein OOM-Kill wie ein Anwendungsfehler aus — er ist keiner:
- Der Dienst ist **weg** bzw. wurde von systemd neu gestartet
(`Restart=on-failure`); `systemctl show -p NRestarts` steigt.
- Im journal bricht die Verarbeitung **mitten in der Datei** ab, ohne
Python-Traceback und ohne `ERROR`-Zeile aus dem Tool.
- Die betroffene PDF bleibt in `working/` liegen.
### Wie man ihn nachweist
**Im Container:**
```bash
cat /sys/fs/cgroup/memory.events
# oom_kill 1 <- alles über 0 ist ein Treffer
```
**Auf dem LXC-Host** (im Container zeigt `dmesg` diese Zeilen nicht):
```bash
dmesg -T | grep -i oom-kill
```
Diese Prüfung gehört an den **Anfang** der Fehlersuche, wenn Dateien
unerklärlich in `working/` liegen bleiben: ohne sie sucht man den Fehler in
ocrmypdf, Tesseract oder der Config, wo keiner ist.
### Datenverlust ist abgefangen, der OOM bleibt
Seit **v0.6.0** greift der Dienst beim nächsten Start auf, was in `working/`
liegen geblieben ist (siehe [UPDATE.md](UPDATE.md#wiederaufnahme-aus-working)).
Eine vom OOM-Killer unterbrochene Datei geht also nicht verloren. Behoben ist
damit aber nur die Folge: bei unveränderter Dimensionierung läuft dieselbe Datei
nach dem Neustart erneut in denselben OOM — bis `max_workers` sinkt oder das
System mehr RAM bekommt.
---
## Installation
```bash
git clone gitea@gitea.sonith.de:sonith_ug/pdf-ocr-hotfolder.git
cd pdf-ocr-hotfolder
sudo ./install.sh
```
`install.sh` ist **Installer und Instanz-Manager in einem** und idempotent —
jeder weitere Aufruf überspringt, was schon steht.
### Basis-Install vs. Instanz-Anlage
Der Installer unterscheidet zwei Ebenen:
| Ebene | Wann | Was passiert |
|-------|------|--------------|
| **Basis-Install** | einmalig; erkannt an `venv` + Template-Unit | System-Pakete, Ghostscript-Check, Container-Erkennung (inkl. [journald-Prüfung](#debian-13-in-lxc-auf-proxmox-journald-scheitert-243credentials)), Default-User `pdfocr`, Code **und `lib/`** nach `/opt/pdf-ocr-hotfolder/`, venv, systemd-Template-Unit |
| **Instanz-Anlage** | bei jedem Lauf, beliebig oft | Abfragen pro Instanz, Datenverzeichnisse, `<instanz>.toml`, optionales User-Drop-in, `enable --now` |
Ist die Basis-Installation vorhanden, aber die venv passt nicht mehr zum
System-Python (typisch nach einem Distributions-Upgrade), läuft der Basis-Install
**zur Reparatur erneut**: die alte venv wird nach `venv.old-<timestamp>`
weggesichert und neu gebaut. Für den geplanten Weg über ein Debian-Major-Upgrade
ist aber `update.sh --rebuild-venv` gedacht, siehe [OS-UPGRADE.md](OS-UPGRADE.md).
Beim Erstlauf ist mindestens **eine** Instanz Pflicht. Danach fragt der
Installer in der Schleife `Weitere Instanz anlegen? [j/N]:`.
---
## Die Abfragen pro Instanz
`create_instance()` stellt fünf Fragen, in der Reihenfolge der folgenden
Abschnitte: Instanz-Name, Basis-Pfad, Service-User, OCR-Sprachen,
Original-Behandlung. Alle Antworten gelten **nur für diese Instanz** — nichts
davon ist global.
### Instanz-Name
```
Instanz-Name (nur a-z, 0-9, -):
```
Muster `^[a-z0-9][a-z0-9-]*$`. Der Name wird zum Unit-Suffix
(`pdf-ocr-hotfolder@<name>.service`) und zum Config-Dateinamen
(`/etc/pdf-ocr-hotfolder/<name>.toml`). Existiert die Config schon, bricht die
Anlage ab — ein versehentliches Überschreiben gibt es nicht.
### Basis-Pfad für die Daten
```
Basis-Pfad für Daten [/var/lib/pdf-ocr-hotfolder/<name>]:
```
Darunter entstehen `incoming/`, `outgoing/`, `working/`, `error/` und werden auf
den Service-User gechownt.
### Service-User
```
Service-User [pdfocr]:
```
- **Existiert der User** (lokal oder als **AD-User via SSSD/Winbind**), wird er
übernommen; die primäre Gruppe ermittelt der Installer per `id -gn`.
- **Existiert er nicht**, bietet der Installer an, ihn lokal als System-User
anzulegen. Wird das abgelehnt, bricht die Instanz-Anlage ab — der User muss
dann erst über AD/SSSD bereitstehen.
- Ist der gewählte User **nicht** `pdfocr`, legt der Installer das Drop-in
`/etc/systemd/system/pdf-ocr-hotfolder@<instanz>.service.d/user.conf` mit
`User=`/`Group=` an.
Bei AD-Usern mit lokaler UID werden die Datei-Berechtigungen über die UID
gesetzt — das läuft transparent.
### OCR-Sprachen
```
Tesseract-Sprachen [deu+eng]:
```
Die Sprachen werden bewusst **je Instanz** abgefragt: ein Hotfolder
`buchhaltung` sieht nur deutsche Belege, ein Hotfolder `export`
internationale Korrespondenz.
```toml
# /etc/pdf-ocr-hotfolder/buchhaltung.toml
languages = "deu"
# /etc/pdf-ocr-hotfolder/export.toml
languages = "deu+eng+fra"
```
**Die Liste so eng wie möglich halten.** Jede zusätzliche Sprache kostet
Laufzeit *und* Erkennungsqualität: Tesseract wägt mehr Modelle gegeneinander ab
und verwechselt dabei Wörter, die in einer Sprache eindeutig wären.
`deu+eng+fra` auf reinen Deutsch-Scans ist kein Sicherheitsnetz, sondern ein
Rückschritt.
Was der Installer damit macht:
1. **Format prüfen** — Sprachcodes mit `+` verbunden
(`^[a-z]{3}(_[A-Za-z]+)?(\+…)*$`), also `deu`, `deu+eng`, `chi_sim+eng`.
Bei Unsinn wird erneut gefragt, nicht abgebrochen.
2. **Jeden Code gegen `tesseract --list-langs` prüfen.** Fehlt eine
Sprachdatei, bietet er das passende apt-Paket an: `tesseract-ocr-<code>`,
Unterstrich wird zum Bindestrich (`chi_sim` → `tesseract-ocr-chi-sim`).
3. **Lehnt man ab oder scheitert die Installation**, warnt er, dass OCR mit
dieser Sprache **bei jeder Datei** scheitern würde, und fragt die Sprachen
erneut ab — die fehlende Sprache kann man dann einfach weglassen.
4. Ist `tesseract` gar nicht aufrufbar, wird die Prüfung übersprungen und die
Eingabe unverändert übernommen.
### Original archivieren?
```
Original nach erfolgreichem OCR archivieren? [j/N]:
```
- **Nein** (Default) → `original_on_success = "delete"`, das Original wird nach
erfolgreichem OCR gelöscht.
- **Ja** → `original_on_success = "archive"`, danach:
```
Archiv-Verzeichnis [<basis>/archive]:
```
Der Pfad muss **absolut** sein und darf **nicht** `incoming/`, `outgoing/`,
`working/` oder `error/` der Instanz sein — im Eingang würde das Original sonst
endlos neu aufgegriffen, in den übrigen kollidiert es mit der Verarbeitung. Das
Verzeichnis wird angelegt und auf den Service-User gechownt; innerhalb des
Basis-Pfads erledigt das bestehende `chown -R` das mit, ein Archiv **außerhalb**
bekommt ein eigenes.
### Was danach passiert
Die Instanz-Config entsteht per `sed` aus `config.example.toml`. Substituiert
werden die vier `[paths]`-Zeilen sowie `[ocr].languages`,
`[output].original_on_success` und `[output].archive_dir`. Anschließend liest
der Installer diese drei Keys aus der erzeugten Datei zurück und vergleicht sie
mit der Eingabe; erst wenn das passt, nennt die Zusammenfassung Sprachen und
Archiv-Verzeichnis. Sonst gibt es eine Warnung mit der Bitte, die Config von
Hand nachzuziehen.
Die Config bekommt `chmod 640` und `chown root:<service-gruppe>`, das
Verzeichnis `/etc/pdf-ocr-hotfolder` selbst `750 root:pdfocr` — in den
Instanz-Configs stehen **Klartext-Passwörter** für SMTP, Nextcloud und SFTP.
Zum Schluss: `daemon-reload` und `systemctl enable --now
pdf-ocr-hotfolder@<instanz>.service`.
### Test
```bash
cp irgendein-scan.pdf /var/lib/pdf-ocr-hotfolder/<instanz>/incoming/
journalctl -u pdf-ocr-hotfolder@<instanz> -f
```
Nach wenigen Sekunden liegt das OCR-PDF im `outgoing/` der Instanz.
---
## Multi-Instanz-Betrieb
Das Tool arbeitet komplett instanzbasiert über die systemd-Template-Unit
`pdf-ocr-hotfolder@<name>.service`. Jede Instanz hat eigene Config, eigene
Datenverzeichnisse, eigene Unit, optional eigenen Service-User — und eigene
OCR-Sprachen und Original-Behandlung.
```bash
sudo ./install.sh # legt z.B. kunde-a, kunde-b, buchhaltung an
systemctl status 'pdf-ocr-hotfolder@*'
journalctl -u pdf-ocr-hotfolder@kunde-a -f
```
Der Code unter `/opt/pdf-ocr-hotfolder/` (inkl. venv) ist für **alle** Instanzen
gemeinsam. Ein Update trifft damit immer alle Instanzen auf einmal — siehe
[UPDATE.md](UPDATE.md).
Das vollständige Verzeichnis-Layout steht im
[README](../README.md#verzeichnisse).
---
## LXC/Container: Error 226/NAMESPACE
In LXC-Containern schlagen die systemd-Hardening-Optionen der Unit
(`PrivateTmp`, `ProtectSystem`, `ProtectKernelTunables`, …) fehl; systemd
quittiert das mit `Error 226/NAMESPACE`.
Der Installer erkennt Container über `systemd-detect-virt --container` und
bietet das Drop-in automatisch 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@*'
```
Das Drop-in setzt alle betroffenen Hardening-Schalter auf `false`. Es liegt auf
Template-Ebene (`pdf-ocr-hotfolder@.service.d/`) und gilt damit für alle
Instanzen. Ist es installiert, zieht `update.sh` es bei jedem Update aus dem
Repo nach, damit ein neu ergänzter Hardening-Schalter nicht alle
Container-Instanzen reißt.
---
## Ghostscript-Bug auf Debian 12
Ghostscript 10.0.0 bis einschließlich 10.02.0 — der **Debian-12-Default** —
enthält Regressionen, die PDFs mit vorhandenem Text beschädigen. ocrmypdf
verweigert deshalb den Dienst, statt ein kaputtes Ergebnis zu liefern.
### Wann genau ocrmypdf abbricht
Die Bedingung steht in `ocrmypdf/builtin_plugins/ghostscript.py`
(`check_options()`) und hängt an **zwei** Dingen — an der Config *und* an der
ocrmypdf-Version:
| ocrmypdf | Die Prüfung greift bei | Heißt für uns |
|----------|------------------------|---------------|
| **≤ 16.x** | `skip_text` oder `redo_ocr` — **unabhängig vom `output_type`** | Auch ohne PDF/A scheitert **jede** Datei, denn `skip_text = true` ist unser Default |
| **≥ 17.0** | dasselbe, aber nur innerhalb von `if options.output_type.startswith('pdfa')` | Ohne PDF/A wird Ghostscript gar nicht angefasst — unkritisch |
> ⚠️ **`pdfa_level = ""` allein ist damit kein Schutz.** Die Entwarnung gilt nur
> zusammen mit **ocrmypdf ≥ 17**. Das war der Fehler in 0.6.0: der Pin stand auf
> `ocrmypdf==16.13.0`, und auf Debian 12 landete daraufhin jede PDF in `error/` —
> bei grünem `systemctl status` und „Preflight ok".
> `requirements.txt` pinnt deshalb 17.x.
### Was das Tool dagegen tut
- `pdfa_level = ""` ist der Default (kein PDF/A-Output).
- `requirements.txt` pinnt **ocrmypdf 17.x**. Ein Downgrade auf 16.x macht jede
Debian-12-Instanz unbrauchbar; `update.sh` weist Versionssprünge der
gepinnten Pakete deshalb ausdrücklich aus.
- Der Preflight bricht beim **Dienststart** mit **Exit 2** ab, wenn die
Ghostscript-Version betroffen ist **und** die Kombination aus `skip_text`,
`pdfa_level` und installierter ocrmypdf-Version tatsächlich zum Abbruch
führen würde. Der Dienst startet dann gar nicht erst, statt jede Datei
einzeln scheitern zu lassen.
- `--check-config` meldet denselben Zustand als **Fehler (Exit 2)** und zeigt
ocrmypdf- und Ghostscript-Version an (siehe
[UPDATE.md](UPDATE.md#config-prüfung-per---check-config)).
- Der [Rauchtest](UPDATE.md#rauchtest) in `update.sh` schiebt nach dem Update
eine Test-PDF durch die echte Pipeline — er hätte den Ausfall sofort gezeigt.
### Abhilfe
**Weg 1 — Ghostscript anheben** (empfohlen). Der Installer erkennt betroffene
Versionen und bietet auf Debian 12 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
```
Ab Ghostscript 10.02.1 ist alles in Ordnung; PDF/A kann dann eingeschaltet
werden.
**Weg 2 — `skip_text = false` setzen.** Dann wird vorhandener Text neu erkannt
statt übersprungen, und die Bedingung greift nicht mehr. Das kostet Laufzeit bei
PDFs, die bereits eine Textebene haben.
---
## Instanz manuell löschen
Der Installer legt Instanzen an, löscht aber keine. Von Hand:
```bash
sudo systemctl disable --now pdf-ocr-hotfolder@<name>
sudo rm /etc/pdf-ocr-hotfolder/<name>.toml
sudo rm -rf /etc/systemd/system/pdf-ocr-hotfolder@<name>.service.d
sudo systemctl daemon-reload
# Datenverzeichnis /var/lib/pdf-ocr-hotfolder/<name> manuell aufräumen
```
Das Datenverzeichnis bleibt **bewusst** liegen: dort können noch unverarbeitete
PDFs in `incoming/`, Fehlerfälle in `error/` oder Originale im Archiv liegen.
Erst hineinsehen, dann löschen.
Solange die Config unter `/etc/pdf-ocr-hotfolder/` liegt, zählt `update.sh` die
Instanz weiter mit — auch wenn sie gestoppt ist.
---
## Konfigurationsreferenz
Vollständiges, kommentiertes Beispiel: [`config.example.toml`](../config.example.toml).
Jede Instanz hat ihre eigene Kopie unter `/etc/pdf-ocr-hotfolder/<instanz>.toml`.
Unbekannte Keys werden beim Laden ignoriert, aber **gemeldet** — beim
Dienststart im Log und von `--check-config`. Ein Tippfehler wie
`[ocr].langauges` fällt damit auf.
### `[paths]` — Pflicht
| Key | Bedeutung |
|-----|-----------|
| `incoming` | Eingang, hier schreibt der Scanner hinein |
| `outgoing` | Ausgang, fertige OCR-PDFs |
| `working` | Arbeitsverzeichnis während der Verarbeitung |
| `error` | fehlgeschlagene PDFs |
Fehlt die Sektion oder einer der vier Einträge, gibt es eine deutsche
`ConfigError`-Meldung mit Datei- und Key-Nennung und **Exit 2** — der Dienst
startet nicht.
**Alle vier Pfade müssen absolut sein.** Ein relativer Pfad wird gegen das
Arbeitsverzeichnis des *Prozesses* aufgelöst, bei der Unit also gegen
`WorkingDirectory=/opt/pdf-ocr-hotfolder` — **nicht** gegen das Verzeichnis, in
dem die Config liegt. `incoming = "in"` legte damit still
`/opt/pdf-ocr-hotfolder/in` an: der Scanner schreibt woanders hin als der
Dienst schaut, und niemand sieht einen Fehler. Seit 0.7.0 ist das ein
Config-Fehler mit **Exit 2**. Dieselbe Regel gilt für
[`[output].archive_dir`](#output) und
[`[upload.folder].target`](#uploadfolder--uploadnextcloud--uploadsftp);
`install.sh` erzeugt ohnehin nur absolute Pfade.
`incoming` muss außerdem auf einem **lokalen** Dateisystem liegen — siehe
[Dateisystem](#dateisystem-ext4-xfs-oder-zfs).
### `[ocr]`
| Key | Default | Bedeutung |
|-----|---------|-----------|
| `languages` | `"deu+eng"` | Tesseract-Sprachen; der Installer fragt sie pro Instanz ab |
| `jobs` | `4` | Threads, die ocrmypdf innerhalb **einer** PDF nutzt |
| `skip_text` | `true` | Seiten, die schon Text haben, nicht neu OCRen |
| `oversample` | `300` | Auflösung für gerasterte Seiten |
| `pdfa_level` | `""` | `"1"`, `"2"`, `"3"` oder leer für reines PDF — leer wegen des [Ghostscript-Bugs](#ghostscript-bug-auf-debian-12). Achtung: leer allein schützt nur zusammen mit ocrmypdf ≥ 17 |
| `deskew` | `true` | schiefe Scans begradigen |
| `clean` | `false` | Hintergrund säubern (unpaper) |
| `max_workers` | `2` | wie viele PDFs **parallel** verarbeitet werden |
| `timeout` | `300` | max. Sekunden, die Tesseract **pro Seite** laufen darf; `0` = kein eigenes Limit |
**`timeout` ist ein Seiten-Timeout, kein Gesamt-Timeout.** Der Wert geht als
`tesseract_timeout` an ocrmypdf; ein Dokument-Timeout kennt ocrmypdf nicht. Wer
noch den alten Default `1800` aus einer Config vor 0.4.0 stehen hat, gibt
Tesseract 30 Minuten **je Seite** — Richtwert ist 300. Läuft eine Seite in den
Timeout, landet sie ohne Textebene im Ergebnis, die übrigen Seiten laufen
weiter. Ein durchgereichtes `0` würde ocrmypdf dazu bringen, OCR **still zu
überspringen**, deshalb wird `0` (oder negativ) gar nicht erst übergeben und der
ocrmypdf-Default greift. Siehe auch
[Config-Drift](UPDATE.md#config-drift-nach-einem-update).
### `[output]`
| Key | Default | Bedeutung |
|-----|---------|-----------|
| `name_mode` | `"prefix"` | `prefix` → `OCR_scan.pdf`, `suffix` → `scan_OCR.pdf` (vor der Extension), `none` → unverändert |
| `name_tag` | `"OCR_"` | verbatim eingefügter String; leer wirkt wie `none` |
| `original_on_success` | `"delete"` | `delete` oder `archive` — Installer fragt das ab |
| `archive_dir` | `""` | absoluter Pfad (relativ wird abgelehnt), **Pflicht** bei `archive`; Namenskollision → Zeitstempel-Suffix |
Ein Tippfehler in `name_mode` oder `original_on_success` führt beim Start zum
Abbruch mit **Exit 2**, nicht erst bei der ersten Datei.
**Kollisionen überschreiben nichts.** Liegt im Ziel bereits eine Datei
desselben Namens, wird die neue mit Zeitstempel danebengelegt
(`scan.pdf` → `scan_20260923-081500.pdf`; bei zwei Dateien innerhalb derselben
Sekunde zusätzlich mit Zähler), und es gibt eine Warnung im Journal. Das gilt
seit 0.7.0 einheitlich für **`outgoing/`, das Archiv, `error/` und den
Ordner-Upload** — vorher ersetzte der zweite Durchlauf das Ergebnis des ersten
kommentarlos. Die E-Mail-Benachrichtigung und die Upload-Ziele nennen den
tatsächlich geschriebenen Namen.
**Scheitert das Entsorgen des Originals** (Platte voll, Verzeichnis
read-only), gilt der Durchlauf trotzdem als Erfolg: das fertige PDF liegt
bereits in `outgoing/` und wird normal ausgeliefert. Es gibt aber eine
`ERROR`-Zeile im Journal, und die Mail geht als **„OK mit Warnung"** raus —
auch bei `[notify.email].on = "errors"`. Das Original bleibt dann in
`working/` liegen und wird beim nächsten Start **erneut** durch das OCR
geschickt; es gehört von Hand aufgeräumt und die Ursache behoben.
### `[verapdf]`
| Key | Default | Bedeutung |
|-----|---------|-----------|
| `enabled` | `false` | PDF/A-Validierung per veraPDF-CLI |
| `binary` | `/opt/verapdf/verapdf` | Pfad zum veraPDF-Binary, oder ein nackter Name, der im `PATH` gesucht wird |
| `flavour` | `"1b"` | PDF/A-Flavour |
veraPDF startet eine JVM und ist entsprechend teuer — nur einschalten, wenn die
Validierung wirklich gebraucht wird. Bei FAIL wandert das OCR-Ergebnis nach
`error/`; das Original folgt `original_on_success` (bei `archive` bleibt es also
erhalten).
**Mit `enabled = true` wird `binary` im Preflight geprüft** (seit 0.7.0). Zeigt
der Pfad nicht auf ein vorhandenes, ausführbares Programm, startet der Dienst
gar nicht erst (**Exit 2**), und `--check-config` meldet denselben Fehler.
`--check-config` zeigt Binary und Flavour außerdem in der Übersicht an.
> ⚠️ **Warum das eine harte Sperre ist.** Bis 0.6.3 war ein Tippfehler in
> `binary` der gefährlichste Fehler des ganzen Dienstes: `run_verapdf()` fand
> das Programm für **jede** Datei nicht, wertete das als „nicht konform",
> schob das OCR-Ergebnis nach `error/` — und entsorgte das Original laut
> `original_on_success`, beim Default `delete` also die Vorlage. Scan für Scan
> verschwanden so die Originale, während die Unit als `active (running)`
> dastand.
**Störung ist kein FAIL.** Lässt sich veraPDF im laufenden Betrieb nicht mehr
befragen — Programm verschwunden, JVM startet nicht, Timeout (300 s pro Datei),
oder die Ausgabe enthält weder `PASS` noch `FAIL` —, ist das **kein Urteil über
die PDF**. In diesem Fall wandern **Original und OCR-Ergebnis** nach `error/`,
und das Original wird **weder gelöscht noch archiviert**, unabhängig von
`original_on_success`. Im Journal steht die Ursache samt Hinweis auf
`--check-config`.
### `[upload.folder]` / `[upload.nextcloud]` / `[upload.sftp]`
Beliebig viele Ziele gleichzeitig aktivierbar. Sind alle aus, bleibt das fertige
PDF einfach in `outgoing/` liegen.
| Sektion | Keys |
|---------|------|
| `[upload.folder]` | `enabled`, `target` — leer heißt `[paths].outgoing`, dann No-op; sonst **absoluter** Pfad (relativ wird abgelehnt, Exit 2) |
| `[upload.nextcloud]` | `enabled`, `url`, `username`, `password`, `remote_path`, `verify_ssl` |
| `[upload.sftp]` | `enabled`, `host`, `port`, `username`, `key_file`, `password`, `remote_path` |
Schlägt mindestens ein Ziel fehl, zählt das als Fehler und die Mail geht als
FEHLER raus — das PDF bleibt aber **bewusst in `outgoing/`** liegen, das OCR
selbst war ja erfolgreich.
Liegt im `target` von `[upload.folder]` schon eine gleichnamige Datei, wird sie
seit 0.7.0 **nicht mehr ersetzt**, sondern die Kopie mit Zeitstempel
danebengelegt (samt Warnung) — dieselbe Regel wie in [`[output]`](#output).
### `[notify.email]`
| Key | Default | Bedeutung |
|-----|---------|-----------|
| `enabled` | `false` | E-Mail-Benachrichtigung an/aus |
| `smtp_host`, `smtp_port`, `smtp_user`, `smtp_password`, `use_starttls` | — | SMTP-Zugang |
| `from_addr` | — | Absender |
| `to_addrs` | `[]` | Empfängerliste |
| `on` | `"errors"` | `always` \| `errors` \| `never` |
### `[logging]`
| Key | Default | Bedeutung |
|-----|---------|-----------|
| `level` | `"INFO"` | `DEBUG` \| `INFO` \| `WARNING` \| `ERROR` |
Der Dienst schreibt **kein eigenes Logfile** — alles geht nach stdout und damit
ins journal.
---
## Exit-Codes
Der Dienst und die CLI benutzen vier Codes. `systemctl status` und
`journalctl` zeigen sie als `status=<n>`.
| Exit | Bedeutung | Startet systemd neu? | Was zu tun ist |
|------|-----------|----------------------|----------------|
| **0** | regulärer Stopp (SIGTERM/SIGINT); bei `--once`: alles verarbeitet, auch „nichts da"; bei `--check-config`: Config sauber | — | nichts |
| **1** | nur im Einmal-Betrieb: mindestens eine PDF ist fehlgeschlagen. Bei `--check-config`: Config nutzbar, aber mit **Warnungen** | — | `error/` ansehen bzw. Warnungen nachziehen |
| **2** | **Config- oder Preflight-Fehler** — kaputtes/unlesbares TOML, fehlender Pflicht-Key, relativer Pfad, ungültige `[output]`-Werte, fehlendes `tesseract`/`gs`, betroffene Ghostscript-Version, nicht aufrufbares veraPDF | **nein** — `RestartPreventExitStatus=2` in der Unit | Config korrigieren, mit `--check-config` gegenprüfen, dann `systemctl start` |
| **3** | **Der Verzeichnis-Watch ist gestorben** — es würden keine neuen Dateien mehr erkannt | **ja**, und genau darum geht es | meist nichts; häuft es sich, `fs.inotify.max_user_watches` und den Mount von `incoming/` prüfen |
**Zu Exit 2:** Ein Neustart heilt einen Config-Fehler nicht. Ohne
`RestartPreventExitStatus=2` startete `Restart=on-failure` die Instanz endlos
im 5-Sekunden-Takt neu (das Start-Rate-Limit greift bei `RestartSec=5` nie).
Seit 0.7.0 bleibt sie stattdessen sichtbar `failed` stehen — das ist gewollt
und soll beim Nachsehen auffallen.
**Zu Exit 3:** Stirbt der watchdog-Observer im Betrieb (erschöpftes
`fs.inotify.max_user_watches`, ersetztes oder neu gemountetes Verzeichnis),
blieb die Unit früher `active (running)` und verarbeitete stumm nichts mehr —
für einen Hotfolder der schlechteste denkbare Zustand. Der Dienst prüft den
Observer jetzt sekündlich mit, loggt eine `ERROR`-Zeile mit den möglichen
Ursachen und beendet sich mit 3, damit systemd ihn neu startet und der Watch
neu aufgesetzt wird. Ein einzelnes Vorkommnis ist damit selbstheilend; ein
steigendes `systemctl show -p NRestarts` ist der Hinweis, dass man nachsehen
sollte.
---
## Troubleshooting
### Tesseract findet die Sprache nicht
```bash
sudo apt install tesseract-ocr-deu tesseract-ocr-eng
```
Danach `[ocr].languages` prüfen. Der Installer nimmt einem das beim Anlegen
einer Instanz ab, ein nachträglich in die Config geschriebener Sprachcode wird
aber nicht geprüft — `--check-config` zeigt die eingestellten Sprachen an.
### "PriorOcrFoundError"
ocrmypdf erkennt bereits vorhandenen OCR-Text. `skip_text = true` in der Config
setzen (Default).
### Berechtigungsprobleme bei AD-User
Der Service-User braucht **rw** auf alle vier Verzeichnisse der Instanz (und auf
das Archiv, falls konfiguriert):
```bash
sudo chown -R DOMAIN\\scanuser:DOMAIN\\scangroup /var/lib/pdf-ocr-hotfolder/<instanz>
```
### veraPDF: Dienst startet nicht / Dateien landen in `error/`
Seit 0.7.0 prüft der Preflight `[verapdf].binary`. Startet die Instanz mit
**Exit 2** nicht mehr, nachdem vorher „alles lief", ist das die gute Nachricht:
der Pfad war schon vorher falsch, nur hat es bisher niemand gemerkt. Vorher
wurde jede PDF als ungültig gewertet und das Original laut
`original_on_success` entsorgt.
```bash
ls -l /opt/verapdf/verapdf # vorhanden? ausführbar (chmod +x)?
```
Korrigieren — oder, wenn die Validierung nicht zwingend gebraucht wird,
`[verapdf].enabled = false` setzen. Landen **Original und OCR-Ergebnis
gemeinsam** in `error/`, war veraPDF im laufenden Betrieb nicht mehr
ansprechbar; das Original ist dann unangetastet, siehe
[`[verapdf]`](#verapdf).
### Dienst startet nicht (Exit 2)
Exit 2 heißt immer: Config oder Preflight — siehe [Exit-Codes](#exit-codes).
Die Instanz bleibt bewusst `failed` stehen und wird **nicht** neu gestartet.
Die Ursache steht im journal und ausführlicher in:
```bash
cd /opt/pdf-ocr-hotfolder && sudo ./venv/bin/python -m pdf_ocr_hotfolder \
--check-config --config /etc/pdf-ocr-hotfolder/<instanz>.toml
```
Typische Fälle: kaputtes TOML (die Meldung nennt Zeile und Spalte, sofern der
Interpreter sie liefert), ein relativer Pfad in `[paths]`,
`[output].archive_dir` oder `[upload.folder].target`, ein leeres `archive_dir`
bei `original_on_success = "archive"`, ein nicht aufrufbares veraPDF oder eine
betroffene Ghostscript-Version.
### Dienst läuft, verarbeitet aber nichts mehr
`systemctl status` sagt `active (running)`, in `incoming/` stapeln sich die
PDFs, im Journal passiert nichts. Drei Ursachen, in dieser Reihenfolge prüfen:
1. **`incoming/` liegt auf einem CIFS/NFS-Mount.** Dann liefert inotify
grundsätzlich keine Events — der Dienst verarbeitet nur noch beim Start.
`findmnt -T /var/lib/pdf-ocr-hotfolder/<instanz>/incoming` zeigt den Typ;
Hintergrund und richtiger Aufbau unter
[Dateisystem](#dateisystem-ext4-xfs-oder-zfs).
2. **Der Verzeichnis-Watch ist gestorben.** Seit 0.7.0 fällt das auf: der
Dienst beendet sich mit [Exit 3](#exit-codes) und systemd startet ihn neu.
Im Journal steht die `ERROR`-Zeile, `systemctl show -p NRestarts` steigt.
Häuft sich das, ist meist das inotify-Limit erschöpft:
```bash
cat /proc/sys/fs/inotify/max_user_watches
```
3. **Es sind gar keine PDFs.** Dateien ohne `.pdf`-Endung werden ignoriert.
Beim Start-Scan meldet der Dienst sie seit 0.7.0 als Sammelzeile mit Anzahl
und bis zu drei Beispielnamen:
```bash
journalctl -u pdf-ocr-hotfolder@<instanz> | grep 'ohne .pdf-Endung'
```
### Dienst startet nicht (203/EXEC)
Der Interpreter der venv ist weg — fast immer nach einem Distributions-Upgrade.
Siehe [OS-UPGRADE.md](OS-UPGRADE.md).
### Keine Logs: "No journal files were found"
**Symptom:** `journalctl -u pdf-ocr-hotfolder@<instanz>` bleibt leer oder meldet
`No journal files were found.` — auch dann, wenn der Dienst nachweislich läuft
und Dateien verarbeitet.
**Erste Prüfung:**
```bash
systemctl status systemd-journald
```
Ist `systemd-journald.service` selbst `failed` (beobachtet mit
`status=243/CREDENTIALS`), gibt es schlicht kein journal, in das geschrieben
werden könnte.
**Einordnung.** Der Dienst loggt seit v0.4.1 **ausschließlich** nach journald —
es gibt bewusst kein eigenes Logfile und kein Logverzeichnis. Ein kaputtes
journald ist damit ein blinder Fleck: jede Fehlersuche läuft ins Leere, und der
Ausfall sieht aus wie ein stummer Dienst. **Deshalb vor jeder Fehlersuche
zuerst journald prüfen**, nicht erst, wenn nichts anderes mehr passt.
Die häufigste Ursache ist **kein** Schaden an dieser einen Maschine, sondern
systematisch: **Debian 13 in einer LXC auf Proxmox** — siehe den nächsten
Abschnitt. Auf Debian 12 tritt sie nicht auf.
`install.sh` warnt beim Erstinstall in einem Container von sich aus, wenn
`systemd-journald` nicht läuft, nennt den Drop-in-Befehl und fragt, ob
fortgefahren werden soll.
**Notbehelf, solange journald nicht zu retten ist:** die Instanz einmal im
Vordergrund laufen lassen — dann geht die Ausgabe direkt ins Terminal, am
journal vorbei.
```bash
sudo systemctl stop pdf-ocr-hotfolder@<instanz>
cd /opt/pdf-ocr-hotfolder && sudo -u pdfocr ./venv/bin/python -m pdf_ocr_hotfolder \
--config /etc/pdf-ocr-hotfolder/<instanz>.toml
```
Das `cd` ist zwingend: das Paket wird nicht pip-installiert, sondern nach
`/opt/pdf-ocr-hotfolder` kopiert und nur über das Arbeitsverzeichnis gefunden
(ohne `cd` gibt es `No module named pdf_ocr_hotfolder`, v0.6.2). Beenden mit
`Strg+C`, danach `sudo systemctl start pdf-ocr-hotfolder@<instanz>`. Wer nur den
Bestand abarbeiten und dann aussteigen will, hängt `--once` an (siehe
[Manueller Lauf](#manueller-lauf-one-shot)).
### Debian 13 in LXC auf Proxmox: journald scheitert (243/CREDENTIALS)
Verifizierter Befund. Er betrifft **jede** Debian-13-LXC auf Proxmox 8.4, nicht
nur eine einzelne Maschine — und er trifft nicht nur dieses Tool, sondern
alles, was auf dem Container-journal aufsetzt.
**Symptom im Container:**
```bash
systemctl status systemd-journald
# ● systemd-journald.service - Journal Service
# Active: failed
# Process: ... (code=exited, status=243/CREDENTIALS)
# Main PID exited, status=243/CREDENTIALS
journalctl -u pdf-ocr-hotfolder@<instanz>
# No journal files were found.
```
**Ursache.** Ab systemd 255 (Debian 13 liefert **257**) setzt die
journald-Unit `ImportCredential=journal.*`. Zum Einlesen dieser Credentials
startet systemd den Hilfsprozess `(sd-mkdcreds)`, und der **mountet** dafür.
Genau diesen Mount verbietet das AppArmor-Profil des Proxmox-Hosts. Im Log des
**Hosts** steht dazu:
```
apparmor="DENIED" operation="mount" profile="lxc-<id>_</var/lib/lxc>" name="/dev/" comm="(sd-mkdcreds)"
```
Debian 12 hat systemd 252, kennt `ImportCredential` in dieser Unit nicht und
ist deshalb **nicht** betroffen. Das Upgrade 12 → 13 ist damit der Auslöser,
nicht der Container an sich.
**Dieselbe Ursache legt weitere Units lahm** — beobachtet bei
`systemd-logind`, `systemd-networkd`, `console-getty` und
`systemd-tmpfiles-setup`. Wer nur journald repariert, hat die übrigen noch vor
sich; ein Blick auf `systemctl --failed` lohnt sich.
**Abhilfe im Container** (reboot-fest verifiziert) — `ImportCredential` wird
per Drop-in auf leer gesetzt und damit abgeschaltet:
```bash
sudo mkdir -p /etc/systemd/system/systemd-journald.service.d
printf '[Service]\nImportCredential=\n' | \
sudo tee /etc/systemd/system/systemd-journald.service.d/no-credentials.conf
sudo systemctl daemon-reload
sudo systemctl restart systemd-journald
sudo journalctl --flush
```
Danach `systemctl status systemd-journald` (muss `active (running)` sein) und
`journalctl -u pdf-ocr-hotfolder@<instanz>` gegenprüfen. Für die anderen
betroffenen Units gilt dasselbe Muster mit deren Unit-Namen.
> **Der saubere Weg liegt host-seitig.** Das Drop-in kuriert das Symptom im
> Container. Richtig behoben wird es auf dem Proxmox-Host: Update von
> `pve-container`/`lxc-pve` auf eine Fassung mit passenden AppArmor-Regeln —
> oder, als grobes Mittel, `lxc.apparmor.profile: unconfined` in der
> Container-Config, was die AppArmor-Isolation dieses Containers allerdings
> komplett aufgibt.
### Dienst bricht mitten in der Verarbeitung weg
Datei bleibt in `working/`, kein Traceback, `NRestarts` steigt: das ist fast
immer der OOM-Killer, kein Anwendungsfehler. Nachweis und Dimensionierung unter
[Systemanforderungen](#wie-sich-ein-oom-kill-äußert).
---
## Manueller Lauf (One-Shot)
Bestehende PDFs einer Instanz einmalig verarbeiten und beenden — greift auch
Dateien auf, die in `working/` liegen geblieben sind:
```bash
cd /opt/pdf-ocr-hotfolder && sudo -u pdfocr ./venv/bin/python -m pdf_ocr_hotfolder \
--config /etc/pdf-ocr-hotfolder/kunde-a.toml --once
```
Exit-Code: `0` = alles verarbeitet (auch "nichts da"), `1` = mindestens eine
Datei fehlgeschlagen, `2` = Config- oder Preflight-Fehler. Exit 3 gibt es hier
nicht — der gehört zum Dauerbetrieb, siehe [Exit-Codes](#exit-codes).
+236
View File
@@ -0,0 +1,236 @@
# Debian-Major-Upgrade
Wie der Hotfolder ein Distributions-Upgrade übersteht (12 → 13, später 13 → 14).
Verwandte Dokumente: [README](../README.md) · [Installation](INSTALLATION.md) · [Update](UPDATE.md)
---
## Warum das ein eigener Ablauf ist
Die venv unter `/opt/pdf-ocr-hotfolder/venv/` hängt an der **Python-Version der
Distribution**. Ein `apt full-upgrade` von Debian 12 auf 13 tauscht Python 3.11
gegen 3.13 aus. Danach zeigt `venv/bin/python` auf einen Interpreter, den es so
nicht mehr gibt — systemd quittiert den Start jeder Instanz mit `203/EXEC`, und
auch wenn der Interpreter noch existiert, passen die installierten Pakete nicht
mehr zum System-Python.
**Die venv muss nach dem Sprung neu gebaut werden.** Ein normales
`sudo ./update.sh` genügt dafür nicht sicher genug — es gibt den ausdrücklichen
Schalter `--rebuild-venv`.
---
## Der Ablauf
### 1. Vorher updaten
```bash
cd /pfad/zum/repo
git pull
sudo ./update.sh
```
Das bringt die Installation auf den aktuellen Stand und erzeugt vor allem ein
**frisches Backup** inklusive `pip-freeze.txt` — die Liste der Paketversionen,
die auf dem alten System liefen. Inhalt und Ort des Backups:
[UPDATE.md](UPDATE.md#backup).
### 2. Instanzen stoppen
```bash
sudo systemctl stop 'pdf-ocr-hotfolder@*'
```
Während des Upgrades darf kein OCR laufen: Ghostscript, Tesseract und die
Python-Pakete werden mitten im Betrieb ausgetauscht.
> **Geduld.** Die Unit hat `TimeoutStopSec=300`, damit ein laufendes OCR sauber
> zu Ende kommt. **Ein Stop kann pro Instanz bis zu 5 Minuten dauern** — bei
> mehreren Instanzen entsprechend länger. Nicht mit `kill -9` nachhelfen; ein
> harter Stopp lässt das Original in `working/` liegen (der Dienst nimmt es beim
> nächsten Start zwar wieder auf, aber der Durchlauf ist verloren).
Prüfen, dass wirklich alles steht:
```bash
systemctl status 'pdf-ocr-hotfolder@*'
```
### 3. Distribution upgraden
Der übliche Debian-Weg — Sources auf das neue Release umstellen, dann:
```bash
sudo apt update
sudo apt full-upgrade
sudo reboot
```
Die Instanzen sind `enabled` und starten nach dem Reboot mit; mit der alten venv
scheitern sie (`203/EXEC`). Das ist erwartet und wird im nächsten Schritt
behoben.
### 4. venv neu bauen
```bash
cd /pfad/zum/repo
git pull
sudo ./update.sh --rebuild-venv
```
`--rebuild-venv` erzwingt den Neubau. Der Rest des Updates läuft wie gewohnt
(siehe [UPDATE.md](UPDATE.md#was-das-skript-tut--in-dieser-reihenfolge)) — die
System-Pakete werden dabei ebenfalls abgeglichen, auch `python3-venv` für das
neue Python.
Der Neubau ist **ganz oder gar nicht**:
1. Die alte venv wird nach `venv.old-<timestamp>` verschoben.
2. `python3 -m venv` baut neu.
3. Die Requirements werden installiert.
4. **Erst bei Erfolg** wird die alte venv gelöscht.
### 5. Wenn ein Pin nicht mehr passt
Scheitert `pip install` an einer gepinnten Version, bricht das Skript ab und
sagt genau, was los ist:
- die letzten 25 Zeilen der pip-Ausgabe,
- das **gescheiterte Paket** ("Gescheitertes Paket: …"),
- die Diagnose: "eine in requirements.txt fest gepinnte Version gibt es für
Python \<version\> nicht (mehr)",
- den nächsten Schritt: **requirements.txt anheben** und
`update.sh --rebuild-venv` erneut laufen lassen.
**Die alte venv ist dann zurückgerollt** — es liegt keine halb gefüllte venv
herum. Sie hängt zwar weiterhin am alten Interpreter und die Instanzen laufen
damit nicht (das sagt das Skript auch), aber der Zustand ist eindeutig.
Also:
```bash
# im Repo, auf einer Testmaschine
vim requirements.txt # Version des genannten Pakets anheben
pytest # Suite muss grün bleiben
git commit -am 'requirements: <paket> auf <version> anheben'
git push
# auf dem Zielsystem
git pull
sudo ./update.sh --rebuild-venv
```
### 6. `--rebuild-venv` vergessen?
Halb so wild: `update.sh` prüft die venv auch ohne den Schalter und baut sie bei
Versions-Drift von selbst neu. Geprüft wird
- ob das Verzeichnis und `venv/bin/python` überhaupt existieren,
- ob der Interpreter der venv noch **läuft** (toter Symlink nach dem Upgrade),
- ob seine `major.minor` zum System-`python3` passt,
- ob `pyvenv.cfg` dieselbe Version nennt wie der Interpreter.
Stimmt eines davon nicht, nennt das Skript den Grund und baut neu.
`--rebuild-venv` ist also nicht der einzige, aber der **ausdrückliche** Weg —
und der, den man nach einem Distributions-Upgrade nimmt, statt sich auf die
Erkennung zu verlassen.
---
## Danach prüfen
**Laufen alle Instanzen?**
```bash
systemctl status 'pdf-ocr-hotfolder@*'
```
`update.sh` hat das schon verifiziert (Wartezeit, `is-failed`, Crash-Loop) und
in der Zusammenfassung Soll gegen Ist gestellt — ein Exit 0 heißt, dass jede
Instanz, die vorher lief, auch wieder läuft.
**Sind die Configs sauber?**
```bash
cd /opt/pdf-ocr-hotfolder
for f in /etc/pdf-ocr-hotfolder/*.toml; do
sudo ./venv/bin/python -m pdf_ocr_hotfolder --check-config --config "$f"
done
```
Exit 0/1/2 und was bei Warnungen zu tun ist:
[UPDATE.md](UPDATE.md#config-prüfung-per---check-config).
**Läuft eine echte PDF durch?**
```bash
cp test.pdf /var/lib/pdf-ocr-hotfolder/<instanz>/incoming/
journalctl -u pdf-ocr-hotfolder@<instanz> -f
```
Im `outgoing/` muss das OCR-PDF liegen, im Journal steht `OCR done`. Das ist der
einzige Test, der die neue venv **und** die neuen System-Binaries (Tesseract,
Ghostscript) wirklich anfasst — `systemctl status` sagt darüber nichts.
**Ghostscript-Version auf dem neuen Release ansehen:**
```bash
gs --version
```
Auf Debian 13 ist der Ghostscript-Bug aus Debian 12 kein Thema mehr; ein
bookworm-backports-Eintrag unter `/etc/apt/sources.list.d/` gehört nach dem
Upgrade entfernt. Hintergrund:
[INSTALLATION.md](INSTALLATION.md#ghostscript-bug-auf-debian-12).
---
## Pins in `requirements.txt`
Die Python-Abhängigkeiten sind **bewusst fest gepinnt**:
```
ocrmypdf==17.4.1
watchdog==6.0.0
requests==2.33.1
paramiko==4.0.0
```
Ohne Pins würde ein `pip install --upgrade` bei jedem Update ungefragt eine neue
Major-Version ziehen — der nächste Sprung wäre ocrmypdf **17 auf 18**, und der
reißt sonst alle Instanzen auf einmal, und zwar im Moment des Updates, nicht zu
einem Zeitpunkt, den man sich ausgesucht hat.
> ⚠️ **ocrmypdf darf nicht unter 17 fallen.** Bis einschließlich 16.x prüft
> ocrmypdf die Ghostscript-Version auch dann, wenn gar kein PDF/A erzeugt wird —
> auf Debian 12 (Ghostscript 10.0.0) scheitert damit **jede** PDF, weil
> `skip_text = true` unser Default ist. Genau das war der Ausfall in 0.6.0.
> Hintergrund: [INSTALLATION.md](INSTALLATION.md#ghostscript-bug-auf-debian-12).
Die aktuellen Pins sind gegen Python 3.11 (Debian 12) und 3.13 (Debian 13)
geprüft; für beide gibt es fertige Wheels, es wird nichts kompiliert. Das gilt
auch für die Abhängigkeiten, die ocrmypdf 17 zusätzlich mitbringt (`pydantic`,
`pypdfium2`, `fpdf2`, `uharfbuzz`).
**Beim Anheben:**
1. **Testmaschine benutzen** — nie direkt auf dem produktiven Hotfolder.
2. Prüfen, dass es für die neue Version auf **beiden** Python-Versionen fertige
Wheels gibt, sonst wird auf dem Zielsystem kompiliert:
```bash
pip install --dry-run --only-binary=:all: --python-version 3.11 \
--target /tmp/wheelcheck ocrmypdf==<version>
pip install --dry-run --only-binary=:all: --python-version 3.13 \
--target /tmp/wheelcheck ocrmypdf==<version>
```
3. Dort `update.sh --rebuild-venv` fahren, damit die Pakete wirklich frisch
aufgelöst werden.
4. `pytest` muss grün bleiben (254 Tests).
5. Eine echte PDF durchschieben — die Test-Suite mockt ocrmypdf, ein Major-Sprung
fällt dort also nicht auf. Der [Rauchtest](UPDATE.md#rauchtest) in `update.sh`
macht genau das automatisch.
6. Erst dann committen und auf die produktiven Systeme geben.
Der ocrmypdf-Sprung 16 → 17 ist ein **Major-Sprung** und gehört in einen eigenen
Vorgang mit eigenem Test, nicht in ein OS-Upgrade.
+433
View File
@@ -0,0 +1,433 @@
# Update
Aktualisieren des OCR-Tools mit `update.sh` — Code, venv, System-Pakete und
systemd-Unit.
Verwandte Dokumente: [README](../README.md) · [Installation](INSTALLATION.md) · [Debian-Major-Upgrade](OS-UPGRADE.md)
> Für ein **Debian-Major-Upgrade** (12 → 13) gilt ein eigener Ablauf — die venv
> muss danach neu gebaut werden. Siehe [OS-UPGRADE.md](OS-UPGRADE.md).
---
## Der Ablauf
```bash
cd /pfad/zum/repo
git pull
sudo ./update.sh
```
```
sudo ./update.sh --help # Optionen anzeigen
sudo ./update.sh --rebuild-venv # venv zwingend neu bauen (nach dist-upgrade)
sudo ./update.sh --no-smoke-test # ohne Rauchtest durchlaufen
```
`update.sh` muss aus dem Repo laufen. Findet es sich nicht selbst im Repo, liest
es den gespeicherten Pfad aus `/opt/pdf-ocr-hotfolder/.repo_path` — **das Repo
muss also liegen bleiben**, das Tool kopiert daraus.
`install.sh` und `update.sh` teilen sich seit 0.7.0 die Datei `lib/common.sh`
(Log-Funktionen, Root-Prüfung, Layout-Pfade, apt-Paketliste, venv-Prüfung).
`update.sh` sourct bevorzugt die Fassung **neben sich** im Repo und fällt auf
die installierte unter `/opt/pdf-ocr-hotfolder/lib/` zurück; fehlt sie
überall, bricht es sofort ab statt mitten im Lauf. Die apt-Paketliste schneidet
es zusätzlich noch einmal per `sed` aus der Repo-Fassung heraus — beim Update
soll die neue Liste gelten, nicht die eventuell ältere installierte Kopie.
## Was das Skript tut — in dieser Reihenfolge
| # | Schritt | Anmerkung |
|---|---------|-----------|
| 1 | **Instanzen erfassen** | aktiv / kaputt / bewusst gestoppt, siehe [unten](#instanz-erfassung) |
| 2 | **System-Pakete abgleichen** | Liste wird aus `lib/common.sh` des Repos extrahiert, `apt-get install` ist idempotent |
| 3 | **venv prüfen** | passt sie noch zum System-Python? Läuft **vor** dem Stoppen, damit man es früh sieht |
| 4 | **Instanzen stoppen** | nur die, die vorher liefen oder kaputt waren |
| 5 | **Backup** | [Inhalt und Ort](#backup) |
| 6 | **Code kopieren** | `pdf_ocr_hotfolder/`, **`lib/`**, `requirements.txt`, `VERSION`, `config.example.toml`, `.repo_path` |
| 7 | **Dependencies** | `pip install --upgrade -r requirements.txt` — oder venv-Neubau, falls nötig. Versionssprünge der gepinnten Pakete werden [benannt](#versionssprünge-der-kernabhängigkeiten) |
| 8 | **systemd-Units** | Template-Unit aus dem Repo, LXC-Drop-in nachziehen, `daemon-reload` |
| 9 | **Berechtigungen** | Code gehört dem primären User (i.d.R. `pdfocr`) |
| 10 | **Configs prüfen** | `--check-config` je Instanz, siehe [unten](#config-prüfung-per---check-config) |
| 11 | **Instanzen starten + verifizieren** | mit Wartezeit und Crash-Loop-Erkennung |
| 12 | **Rauchtest** | eine Test-PDF durch die echte Pipeline, siehe [unten](#rauchtest) |
| 13 | **Zusammenfassung** | Soll gegen Ist |
Ab Schritt 2 gilt: **System-Pakete werden auch beim Update nachgezogen**, nicht
nur bei der Installation. Bereits installierte Tesseract-Sprachpakete bleiben
unangetastet — es gibt kein `purge` und kein `autoremove`.
Die Schritte 1–3 verändern nichts auf der Platte. Erst ab Schritt 4 wird
angefasst.
## Was das Skript NICHT anfasst
| Bleibt unverändert | Warum |
|--------------------|-------|
| `/etc/pdf-ocr-hotfolder/*.toml` | Instanz-Configs werden **nie** überschrieben — weder neu geschrieben noch gemerged. Neue Config-Keys greifen über ihre Defaults, siehe [Config-Drift](#config-drift-nach-einem-update) |
| `/var/lib/pdf-ocr-hotfolder/…` | Datenverzeichnisse (`incoming`, `working`, `outgoing`, `error`, Archiv) — nichts wird verschoben oder gelöscht |
| Instanz-Drop-ins (`…@<instanz>.service.d/user.conf`) | Service-User pro Instanz bleibt |
| Nachinstallierte Tesseract-Sprachpakete | werden nicht entfernt |
| Bewusst gestoppte Instanzen | bleiben gestoppt |
Neue Config-Optionen muss man also selbst nachtragen, wenn man sie nutzen will.
`config.example.toml` liegt nach dem Update aktuell unter
`/opt/pdf-ocr-hotfolder/config.example.toml` und ist die Vorlage dafür.
---
## Instanz-Erfassung
Eine Instanz gilt als bekannt, wenn sie **irgendwo** auftaucht: als geladene
Unit (`systemctl list-units --all`, also auch `activating` und `failed`), in
`list-unit-files` (enabled), oder als Config unter `/etc/pdf-ocr-hotfolder/`.
Damit fällt auch eine Instanz auf, die gerade in einem Crash-Loop hängt.
Aus dem Zustand vor dem Update ergeben sich drei Gruppen:
| Gruppe | Zustand vorher | Behandlung |
|--------|----------------|------------|
| **lief sauber** | `active`, nicht `failed` | wird gestoppt und muss nachher wieder laufen — sonst ist das eine **Regression** und das Update meldet Exit 1 |
| **war kaputt** | `failed`, `activating`, `reloading`, `deactivating` | wird mitgestartet; läuft sie danach, meldet das Skript "vorher kaputt, läuft jetzt". Läuft sie weiterhin nicht, Exit 1, aber ohne Regressions-Alarm |
| **bewusst gestoppt** | `inactive` und nicht `failed` | bleibt gestoppt |
### Verifikation nach dem Start
Ein `systemctl start` sagt bei `Type=simple` noch nichts. Deshalb prüft
`verify_unit()` nach einer Wartezeit (`VERIFY_WAIT`, Default 6 s) drei Dinge:
1. `is-active` muss `active` sein
2. `is-failed` darf nicht `failed` melden
3. `NRestarts` darf nicht gestiegen sein — das entlarvt den Crash-Loop, der sich
hinter einem sofortigen "active" versteckt
Vorher wird `systemctl reset-failed` gefahren, damit der alte Zustand die
Prüfung nicht verfälscht. Scheitert eine Instanz, nennt das Skript direkt den
passenden `journalctl`-Aufruf.
Die Zusammenfassung stellt am Ende **Soll gegen Ist** und liefert Exit 1, wenn
eine Instanz fehlt, eine Config einen Fehler hat oder eine vorher kaputte
Instanz immer noch kaputt ist.
---
## Backup
Vor dem ersten Eingriff auf der Platte schreibt `update.sh` ein Archiv:
```
/var/backups/pdf-ocr-hotfolder/backup-YYYYmmdd-HHMMSS.tar.gz
```
| Enthalten | Nicht enthalten |
|-----------|-----------------|
| `/opt/pdf-ocr-hotfolder/` (Code **inkl. `lib/`**) | die **venv** (`venv/`, `venv.old-*`) |
| `/etc/pdf-ocr-hotfolder/` (alle Instanz-Configs) | die **Datenverzeichnisse** `/var/lib/pdf-ocr-hotfolder/` |
| Template-Unit `pdf-ocr-hotfolder@.service` | `__pycache__`, `*.pyc` |
| alle Drop-in-Verzeichnisse `…@*.service.d` | |
| `pip-freeze.txt` — `pip freeze` der **alten** venv plus Zeitstempel und Versionssprung | |
`pip-freeze.txt` ist die Versicherung für den Fall, dass ein neuer Pin Ärger
macht: man sieht schwarz auf weiß, welche Paketversionen vorher liefen.
**Rechte:** Das Archiv enthält die Instanz-Configs und damit **Klartext-Passwörter**
(SMTP, Nextcloud, SFTP). Es wird deshalb mit `umask 077` erzeugt und danach auf
`0600 root:root` gesetzt; das Verzeichnis selbst bekommt `700`. Backups nicht in
Tickets anhängen und nicht in allgemein lesbare Pfade kopieren.
**Rotation:** Es werden die **letzten 5** Archive behalten (`BACKUP_KEEP`),
ältere löscht das Skript nach dem Schreiben des neuen. Was entfernt wurde, steht
im Log.
Scheitert das Backup (typisch: volle Platte), bricht das Update ab, **bevor**
etwas getauscht wurde.
---
## Rollback
Das Backup-Archiv ist wurzelrelativ gepackt und lässt sich direkt zurückspielen:
```bash
sudo systemctl stop 'pdf-ocr-hotfolder@*'
sudo tar -xzf /var/backups/pdf-ocr-hotfolder/backup-YYYYmmdd-HHMMSS.tar.gz -C /
sudo systemctl daemon-reload
sudo systemctl start 'pdf-ocr-hotfolder@<instanz>'
```
Das Skript nennt diesen Befehl mit dem konkreten Archivnamen selbst — sowohl
beim Abbruch als auch bei einer erkannten Regression.
### Grenzen des Rollbacks
Ein Rollback ist ein **Overlay**, kein exaktes Zurücksetzen:
- **Die venv ist nicht im Backup.** Wurde sie beim Update neu gebaut oder
hat `pip install --upgrade` Pakete angehoben, holt das Rollback den alten
Stand der Pakete **nicht** zurück. Dafür ist `pip-freeze.txt` aus dem Archiv
da: die dort genannten Versionen lassen sich von Hand wiederherstellen
(`venv/bin/pip install -r …`).
- **Dateien, die es vorher nicht gab, bleiben liegen.** `tar -x` legt nur an und
überschreibt; es löscht nichts. Eine mit dem neuen Stand hinzugekommene Datei
im Code-Verzeichnis überlebt das Rollback. Sauberer ist deshalb
`rm -rf /opt/pdf-ocr-hotfolder/pdf_ocr_hotfolder` **vor** dem Entpacken.
- **Die Datenverzeichnisse sind nicht im Backup** — gewollt. Ein Rollback
verändert keine PDFs, weder in `incoming/` noch in `error/`.
- **System-Pakete werden nicht zurückgenommen.** Ein per apt angehobenes
Ghostscript oder ein neues Sprachpaket bleibt.
Der einfachere Weg zurück ist deshalb in den meisten Fällen: alten Stand im Repo
auschecken (`git checkout v<version>`) und `sudo ./update.sh` erneut fahren.
### Der ERR-Trap
`update.sh` läuft mit `set -Eeuo pipefail` und hat ab dem Moment, in dem
Instanzen gestoppt werden, einen Trap auf `ERR`, `INT` und `TERM`. Bricht
irgendetwas ab — Fehler, Strg-C, `kill` —, dann:
1. sagt das Skript laut, bei welchem Exit-Code und in welcher Zeile es aufhörte,
2. sagt es, **ob auf der Platte schon getauscht wurde** oder ob der alte Stand
unverändert ist,
3. **startet es die vorher laufenden Instanzen wieder** und meldet jede einzeln,
4. nennt es das Backup-Archiv und den Rollback-Befehl — oder sagt ausdrücklich,
dass noch kein Backup geschrieben wurde.
Ein abgebrochenes Update lässt also keinen Hotfolder stumm gestoppt zurück.
---
## Versionssprünge der Kernabhängigkeiten
`update.sh` misst die Versionen der in `requirements.txt` gepinnten Pakete
**vor** und **nach** `pip install` und benennt jede Änderung:
```
[WARN] DOWNGRADE: ocrmypdf: 17.4.1 -> 16.13.0
[INFO] Upgrade: watchdog: 5.0.0 -> 6.0.0
[INFO] Neu: requests 2.33.1
```
Beides steht auch noch einmal in der Abschluss-Zusammenfassung, weil es im
Fließtext zwischen den pip-Ausgaben untergeht.
**Downgrades sind der interessante Fall.** Sie entstehen, wenn ein Pin in
`requirements.txt` gesenkt wurde. Genau so ist der Totalausfall in 0.6.0
entstanden: `ocrmypdf` wurde von 17.4.1 auf 16.13.0 heruntergezogen, das Update
lief mit Exit 0 durch, der Dienst meldete `active` — und jede PDF landete in
`error/`. Sichtbar war davon nichts außer `[INFO] Dependencies ok ✓`.
War ein Downgrade nicht beabsichtigt: Pin korrigieren und
`sudo ./update.sh --rebuild-venv` erneut fahren.
---
## Rauchtest
Nach dem Start schiebt `update.sh` pro Instanz eine winzige Test-PDF durch die
**echte** Pipeline und prüft, ob sie in `outgoing/` ankommt.
Das ist der Schritt, den 0.6.0 gefehlt hat: `systemctl` sagt `active`,
`--check-config` sagt `Preflight ok` — und trotzdem scheitert jede einzelne
Datei. Ein laufender Dienst ist eben kein Beleg dafür, dass er etwas
verarbeitet.
- Die Test-PDF steckt als base64 **im Skript** (694 Bytes, eine Seite). Es
braucht also kein Pillow, kein `gs` und kein `convert` auf dem Zielsystem.
- Der Dateiname ist eindeutig (`__smoketest_update_<zeitstempel>_<pid>.pdf`) und
kann mit keiner Kundendatei kollidieren.
- Wartezeit: `SMOKE_TIMEOUT` Sekunden (Standard 90), danach gilt der Test als
durchgefallen. Das Skript hängt nicht.
### Aufräumen
Test-PDF **und** Ergebnis werden danach restlos entfernt — in jedem Ausgang,
auch bei Fehlschlag und Timeout. Angefasst werden dabei ausschließlich Dateien
mit dem Testnamen, in `incoming/`, `working/` (inkl. `__ocr_`-Zwischendatei),
`outgoing/`, `error/` und im Archivverzeichnis. In keinem dieser Verzeichnisse
bleibt etwas vom Test liegen.
### Wann der Rauchtest übersprungen wird
Hat eine Instanz ein aktives Ziel, würde die Testdatei **nach außen** gehen —
im Zweifel zum Kunden. Solche Instanzen werden mit klarer Meldung übersprungen:
| Übersprungen bei | Grund |
|------------------|-------|
| `[upload.nextcloud].enabled = true` | Testdatei landete in der Nextcloud |
| `[upload.sftp].enabled = true` | Testdatei landete auf dem SFTP-Ziel |
| `[upload.folder]` mit gesetztem `target` | Zielordner liegt außerhalb von `outgoing/`, oft eine Kundenfreigabe |
| `[notify.email].enabled = true` | löst eine Benachrichtigungs-Mail aus |
`[upload.folder]` **ohne** `target` schreibt nach `outgoing/` und ist damit
harmlos — dort läuft der Test normal.
Für diese Instanzen bleibt der manuelle Weg: eine eigene PDF in `incoming/`
legen und `journalctl -u pdf-ocr-hotfolder@<instanz> -f` mitlesen.
### Wenn der Rauchtest fehlschlägt
Der Rauchtest setzt den **Exit-Code** des Updates auf 1 und nennt den
Journal-Befehl:
```
[ERROR] RAUCHTEST FEHLGESCHLAGEN: kunde1
[ERROR] Diese Instanzen laufen, verarbeiten aber keine PDFs.
[ERROR] Es wurde NICHT zurueckgerollt. Journal ansehen:
[ERROR] journalctl -u pdf-ocr-hotfolder@kunde1.service -n 80 --no-pager
```
**Es wird nichts automatisch zurückgerollt.** Der Code ist getauscht, die
Instanzen laufen. Rollback nur von Hand und nur bewusst — siehe
[Rollback](#rollback).
Abschalten: `sudo ./update.sh --no-smoke-test`. Dann fällt ein Totalausfall
erst der ersten echten Kundendatei auf.
---
## Config-Prüfung per `--check-config`
Nach dem Code-Update und vor dem Start prüft `update.sh` jede Instanz-Config mit
dem neuen Code:
```bash
cd /opt/pdf-ocr-hotfolder && ./venv/bin/python -m pdf_ocr_hotfolder \
--check-config --config /etc/pdf-ocr-hotfolder/<instanz>.toml
```
`--check-config` verarbeitet nichts, es hat sogar Vorrang vor `--once`. Es lädt
die Config, zeigt die vier Pfade (inkl. Hinweis, falls ein Verzeichnis noch
fehlt), Sprachen, Seiten-Timeout, PDF/A-Level, `skip_text` sowie die
installierte ocrmypdf- und Ghostscript-Version, fährt den Preflight
(`tesseract`, `gs`, und die Ghostscript-Version gegen die tatsächliche
ocrmypdf-Bedingung — siehe [Rauchtest](#rauchtest) und
[INSTALLATION.md](INSTALLATION.md#ghostscript-bug-auf-debian-12)) und
validiert die `[output]`-Sektion.
| Exit | Bedeutung | Was der Admin tun soll |
|------|-----------|------------------------|
| **0** | Config sauber | nichts |
| **1** | Config nutzbar, aber mit **Warnungen** | Kein Abbruchgrund, der Dienst läuft. Die Warnungen aber **nachziehen** — sie nennen entweder einen Key, dessen Bedeutung sich geändert hat, oder einen Eintrag, der ins Leere läuft (s. [Config-Drift](#config-drift-nach-einem-update)) |
| **2** | Config **unbrauchbar** — der Dienst würde nicht starten | Sofort korrigieren. Die Instanz gilt als **nicht** erfolgreich aktualisiert, das Update endet mit Exit 1 |
`--check-config` selbst kennt nur 0/1/2. Der **Dienst** kennt seit 0.7.0
zusätzlich **Exit 3** (Verzeichnis-Watch gestorben, Neustart erwünscht) — und
die Unit startet bei **Exit 2** absichtlich **nicht** mehr neu
(`RestartPreventExitStatus=2`), die Instanz bleibt sichtbar `failed` stehen.
Für das Update heißt das: eine Instanz mit Config-Fehler verschwindet nicht
mehr in einem stillen 5-Sekunden-Crash-Loop, sondern fällt in der
Zusammenfassung auf. Die vollständige Tabelle:
[INSTALLATION.md](INSTALLATION.md#exit-codes).
Kennt der installierte Code `--check-config` noch nicht (Update von einem Stand
vor 0.6.0), erkennt `update.sh` das an der argparse-Meldung, überspringt die
Prüfung mit einer Warnung und läuft weiter.
Dieselben Warnungen schreibt der Dienst beim Start ins Journal — man sieht sie
also auch ohne Update:
```bash
journalctl -u pdf-ocr-hotfolder@<instanz> | grep 'Config-Warnung'
```
---
## Config-Drift nach einem Update
Instanz-Configs werden nie überschrieben. Das ist bequem, hat aber zwei
Konsequenzen.
**Neue Keys sind unkritisch.** Fehlt ein Key, greift der Default aus der
Dataclass — genau der Wert, der auch in `config.example.toml` steht. Eine Config
von 0.2.x läuft unter 0.6.0 weiter, ohne dass etwas nachgetragen werden muss.
Wer die neue Option nutzen will, trägt sie nach; die aktuelle Vorlage liegt
nach jedem Update unter `/opt/pdf-ocr-hotfolder/config.example.toml`.
Zwei Fälle brauchen aber Handarbeit — beide meldet `--check-config` von selbst:
### `[ocr].timeout` — Bedeutung geändert seit 0.4.0
Vor 0.4.0 war `timeout` ein **Gesamt**-Timeout pro PDF mit Default `1800` — und
wurde nirgends ausgewertet, war also wirkungslos. Seit 0.4.0 geht der Wert als
`tesseract_timeout` an ocrmypdf und ist damit das Limit **pro Seite**; ein
Dokument-Timeout kennt ocrmypdf nicht.
Wer den Altwert `1800` stehen hat, gibt Tesseract jetzt **30 Minuten je Seite**.
Ab `900` meldet `--check-config` deshalb eine Warnung. **Richtwert: 300.**
```toml
[ocr]
timeout = 300 # Sekunden pro SEITE
```
`0` heißt "kein eigenes Limit": der Wert wird dann gar nicht durchgereicht, weil
ocrmypdf `tesseract_timeout=0` als "OCR komplett überspringen" interpretiert.
### `[ocr].pdfa_level` — sollte leer sein
`pdfa_level` gehört auf `""` (reines PDF, kein PDF/A). Ist es gesetzt, warnt
`--check-config`, weil Ghostscript 10.0.0–10.02.0 — der Debian-12-Default — in
Kombination mit `skip_text` von ocrmypdf abgelehnt wird. Der Preflight bricht in
dem Fall mit Exit 2 ab; ab Ghostscript 10.02.1 ist PDF/A unproblematisch.
> **`pdfa_level = ""` allein ist kein Schutz gegen den Ghostscript-Bug.** Das
> gilt erst zusammen mit **ocrmypdf ≥ 17**. Bis ocrmypdf 16.x läuft dieselbe
> Prüfung auch ohne PDF/A, und dann scheitert mit `skip_text = true` jede
> einzelne Datei. Die Entwarnung hängt also an der ocrmypdf-Version.
> `requirements.txt` pinnt darum 17.x, und der Preflight prüft beides zusammen.
> Hintergrund: [INSTALLATION.md](INSTALLATION.md#ghostscript-bug-auf-debian-12).
### Relative Pfade — Fehler seit 0.7.0
Bis 0.6.3 wurde ein relativer Pfad in `[paths]`, `[output].archive_dir` oder
`[upload.folder].target` klaglos angenommen und gegen das `WorkingDirectory`
der Unit aufgelöst, also unter `/opt/pdf-ocr-hotfolder/`. Seit 0.7.0 ist das
ein **Config-Fehler mit Exit 2**: `--check-config` meldet ihn beim Update, und
der Dienst startet nicht.
Das ist der einzige Fall, in dem ein Update von 0.6.x eine bisher „laufende"
Instanz stoppen kann. Er ist gewollt — eine solche Instanz schrieb an einer
Stelle, an der niemand sie gesucht hat. Abhilfe: den Pfad absolut eintragen
und, falls dort Dateien liegen, den Inhalt von `/opt/pdf-ocr-hotfolder/<pfad>`
vorher herüberholen.
### Unbekannte Keys
Einträge, die zu keiner Sektion und keinem Key gehören, werden beim Laden
ignoriert — aber **gemeldet**, mit Pfad (`[ocr].langauges`). Das deckt
Tippfehler und Optionen aus älteren Versionen ab. Die Meldung ist eine Warnung,
kein Fehler: der Dienst startet, der Eintrag tut nur nichts.
---
## Nach dem Update prüfen
```bash
systemctl status 'pdf-ocr-hotfolder@*'
journalctl -u 'pdf-ocr-hotfolder@*' --since '5 min ago'
```
Und einmal eine Test-PDF durchschieben:
```bash
cp test.pdf /var/lib/pdf-ocr-hotfolder/<instanz>/incoming/
journalctl -u pdf-ocr-hotfolder@<instanz> -f
```
Im `outgoing/` muss das OCR-PDF auftauchen.
---
## Wiederaufnahme aus `working/`
Beim Start greift der Dienst nicht nur `incoming/` auf, sondern zuerst
`working/`: Dateien, die ein harter Stopp dort liegen ließ, werden
wiederaufgenommen und das OCR läuft für sie neu. Unvollständige Zwischendateien
des abgebrochenen Laufs (Präfix `__ocr_`) werden dabei gelöscht.
Für das Update heißt das: ein `systemctl stop` mitten im OCR kostet den
angefangenen Durchlauf, aber keine Datei. Die Unit gibt einem laufenden OCR
`TimeoutStopSec=300` Zeit, sauber fertig zu werden — **ein Stop kann damit pro
Instanz bis zu 5 Minuten dauern.** Bei mehreren Instanzen entsprechend länger;
`update.sh` stoppt sie nacheinander.
+277 -38
View File
@@ -12,27 +12,27 @@
set -euo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $*"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
log_error() { echo -e "${RED}[ERROR]${NC} $*"; }
log_step() { echo -e "\n${BLUE}==>${NC} $*"; }
if [ "${EUID}" -ne 0 ]; then
log_error "Bitte als root ausführen: sudo ./install.sh"
exit 1
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"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$SCRIPT_DIR"
# ============================================================
# Gemeinsame Funktionen (Logging, Pfade, venv-Pruefung, Paketliste)
# ============================================================
# Relativ zum Skript aufgeloest, nicht zum Arbeitsverzeichnis — install.sh
# wird auch mit absolutem Pfad aufgerufen. Fehlt die Datei, ist hier Schluss;
# ein "command not found" mitten im Lauf waere die schlechtere Nachricht.
COMMON_LIB="$SCRIPT_DIR/lib/common.sh"
if [ ! -r "$COMMON_LIB" ]; then
echo "[ERROR] Gemeinsame Funktionsbibliothek nicht gefunden: $COMMON_LIB" >&2
echo " install.sh braucht lib/common.sh aus demselben Repo." >&2
echo " Repo vollstaendig auschecken und erneut ausfuehren." >&2
exit 1
fi
# shellcheck source=lib/common.sh
. "$COMMON_LIB"
require_root "sudo ./install.sh"
if [ ! -f "$REPO_DIR/pdf_ocr_hotfolder/__init__.py" ]; then
log_error "Repo-Layout nicht erkannt. install.sh aus dem Repo ausführen."
exit 1
@@ -44,15 +44,13 @@ fi
install_base() {
log_step "System-Pakete installieren"
local -a PKGS
mapfile -t PKGS < <(pdf_ocr_apt_packages)
apt-get update -qq
apt-get install -y --no-install-recommends \
python3 python3-venv python3-pip \
tesseract-ocr tesseract-ocr-deu tesseract-ocr-eng \
ghostscript qpdf unpaper pngquant \
icc-profiles-free ca-certificates curl
apt-get install -y --no-install-recommends "${PKGS[@]}"
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 +60,74 @@ 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
mkdir -p "$LXC_DROPIN_DIR"
cp "$REPO_DIR/systemd/lxc-compat.conf" "$LXC_DROPIN"
systemctl daemon-reload
log_info "LXC-Kompatibilitäts-Drop-in installiert ✓"
fi
# Der Dienst loggt ausschliesslich nach journald. Ist journald kaputt,
# gibt es gar keine Logs — das faellt sonst erst bei der ersten
# Fehlersuche auf. Bekannter Fall: Debian 13 (systemd >= 255) in einer
# LXC auf Proxmox 8.4 — das AppArmor-Profil des Hosts blockiert den
# Credential-Mount von (sd-mkdcreds), journald scheitert mit
# 243/CREDENTIALS. Debian 12 (systemd 252) ist nicht betroffen.
if ! systemctl is-active --quiet systemd-journald 2>/dev/null; then
log_warn "ACHTUNG: systemd-journald laeuft nicht."
log_warn "Der Dienst loggt NUR nach journald — es gaebe hier keine Logs."
log_warn "Pruefen: systemctl status systemd-journald"
log_warn "Scheitert es mit 243/CREDENTIALS (Debian 13 in LXC auf"
log_warn "Proxmox), hilft ein Drop-in im Container:"
log_warn " mkdir -p /etc/systemd/system/systemd-journald.service.d"
log_warn " printf '[Service]\\nImportCredential=\\n' > \\"
log_warn " /etc/systemd/system/systemd-journald.service.d/no-credentials.conf"
log_warn " systemctl daemon-reload && systemctl restart systemd-journald"
log_warn "Details: docs/INSTALLATION.md, Abschnitt Troubleshooting."
read -r -p "Trotzdem fortfahren? [J/n]: " JOURNAL_GO
JOURNAL_GO="${JOURNAL_GO:-J}"
if [[ ! "$JOURNAL_GO" =~ ^[JjYy]$ ]]; then
log_error "Abbruch. Erst journald reparieren, dann erneut starten."
exit 1
fi
fi
fi
log_step "Default-User '$DEFAULT_USER' prüfen"
if id "$DEFAULT_USER" &>/dev/null; then
log_info "'$DEFAULT_USER' existiert bereits"
@@ -81,32 +137,51 @@ 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"
log_step "Code kopieren"
rm -rf "$INSTALL_DIR/pdf_ocr_hotfolder"
cp -r "$REPO_DIR/pdf_ocr_hotfolder" "$INSTALL_DIR/"
# lib/ muss mit: update.sh sucht die gemeinsamen Funktionen zuerst neben
# sich und danach in der Installation.
rm -rf "${INSTALL_DIR:?}/lib"
cp -r "$REPO_DIR/lib" "$INSTALL_DIR/"
cp "$REPO_DIR/requirements.txt" "$INSTALL_DIR/"
cp "$REPO_DIR/VERSION" "$INSTALL_DIR/"
cp "$REPO_DIR/config.example.toml" "$INSTALL_DIR/"
echo "$REPO_DIR" > "$INSTALL_DIR/.repo_path"
log_step "Python venv"
# Eine vorhandene, aber kaputte venv (z.B. nach Debian 12 -> 13) wird
# weggesichert und neu gebaut — ein reines "-d"-Vorhandensein reicht nicht.
if [ -d "$INSTALL_DIR/venv" ] && ! venv_is_healthy "$INSTALL_DIR/venv"; then
local VENV_SAVED
VENV_SAVED="$INSTALL_DIR/venv.old-$(date +%Y%m%d-%H%M%S)"
log_warn "Vorhandene venv passt nicht mehr zum System-Python (Distributions-Upgrade?)."
report_venv_issues
log_warn "Sie wird gesichert nach: $VENV_SAVED"
mv "$INSTALL_DIR/venv" "$VENV_SAVED"
fi
if [ ! -d "$INSTALL_DIR/venv" ]; then
python3 -m venv "$INSTALL_DIR/venv"
fi
"$INSTALL_DIR/venv/bin/pip" install --upgrade pip -q
"$INSTALL_DIR/venv/bin/pip" install -r "$INSTALL_DIR/requirements.txt" -q
if ! "$INSTALL_DIR/venv/bin/pip" install -r "$INSTALL_DIR/requirements.txt" -q; then
log_error "Requirements liessen sich nicht installieren."
log_error "Wahrscheinlich passt eine in requirements.txt gepinnte Version nicht"
log_error "zu Python $(python3 -c 'import sys; print("%d.%d" % sys.version_info[:2])' 2>/dev/null || echo '?')."
exit 1
fi
log_info "venv ok ✓"
log_step "systemd Template-Unit installieren"
cp "$REPO_DIR/systemd/$SERVICE_TEMPLATE" "/etc/systemd/system/$SERVICE_TEMPLATE"
cp "$REPO_DIR/systemd/$SERVICE_TEMPLATE" "$SYSTEMD_DIR/$SERVICE_TEMPLATE"
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"
}
# ============================================================
@@ -136,6 +211,66 @@ show_existing_instances() {
echo
}
# Liest den Wert eines Keys (erste Zuweisung am Zeilenanfang) aus einer Config
config_value() {
local file="$1" key="$2"
sed -n "s|^${key}[[:space:]]*=[[:space:]]*\"\(.*\)\"[[:space:]]*$|\1|p" "$file" | head -n1
}
# Maskiert Sonderzeichen, damit ein Pfad gefahrlos in eine sed-Ersetzung darf
# (Trennzeichen '|', Rueckverweis '&', Backslash).
sed_escape_repl() {
printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g'
}
# Prueft jeden Tesseract-Sprachcode gegen die installierten Sprachdateien und
# bietet fehlende Pakete zur Installation an.
# Rueckgabe: 0 = alle Sprachen verfuegbar (oder Pruefung nicht moeglich),
# 1 = mindestens eine Sprache fehlt weiterhin.
ensure_tesseract_langs() {
local langs="$1"
local raw installed code pkg answer rc=0
local -a codes
if ! command -v tesseract >/dev/null 2>&1; then
log_warn "tesseract ist nicht aufrufbar — Sprachpruefung wird uebersprungen."
log_warn "Eingabe '$langs' wird unveraendert uebernommen."
return 0
fi
if ! raw="$(tesseract --list-langs 2>/dev/null)"; then
log_warn "'tesseract --list-langs' schlug fehl — Sprachpruefung wird uebersprungen."
log_warn "Eingabe '$langs' wird unveraendert uebernommen."
return 0
fi
installed="$(printf '%s\n' "$raw" | grep -vi '^List of available' || true)"
IFS='+' read -r -a codes <<< "$langs"
for code in "${codes[@]}"; do
[ -n "$code" ] || continue
if printf '%s\n' "$installed" | grep -qxF "$code"; then
log_info "Sprache '$code' ist installiert ✓"
continue
fi
pkg="tesseract-ocr-${code//_/-}"
log_warn "Sprache '$code' ist nicht installiert (Paket: $pkg)."
read -r -p "Paket '$pkg' jetzt installieren? [J/n]: " answer
answer="${answer:-J}"
if [[ "$answer" =~ ^[JjYy]$ ]]; then
if ! apt-get install -y --no-install-recommends "$pkg"; then
log_error "Paket '$pkg' liess sich nicht installieren."
elif tesseract --list-langs 2>/dev/null | grep -qxF "$code"; then
log_info "Paket '$pkg' installiert ✓"
continue
else
log_error "Paket '$pkg' ist da, aber tesseract kennt '$code' weiterhin nicht."
fi
fi
log_warn "Ohne die Sprachdatei '$code' scheitert das OCR bei JEDER Datei."
rc=1
done
return $rc
}
create_instance() {
echo
read -r -p "Instanz-Name (nur a-z, 0-9, -): " INST
@@ -173,23 +308,114 @@ create_instance() {
fi
fi
# --- OCR-Sprachen ---
echo
log_info "Tesseract-Sprachen — gelten NUR fuer diese Instanz '$INST'."
log_info "Jede zusaetzliche Sprache kostet Laufzeit und verschlechtert zugleich"
log_info "die Erkennung — also so eng wie moeglich waehlen (z.B. nur 'deu')."
local LANGS
while true; do
read -r -p "Tesseract-Sprachen [deu+eng]: " LANGS
LANGS="${LANGS:-deu+eng}"
if [[ ! "$LANGS" =~ ^[a-z]{3}(_[A-Za-z]+)?(\+[a-z]{3}(_[A-Za-z]+)?)*$ ]]; then
log_error "Ungueltiges Format. Erwartet: Sprachcodes mit '+' verbunden,"
log_error "z.B. 'deu', 'deu+eng' oder 'chi_sim+eng'."
continue
fi
if ensure_tesseract_langs "$LANGS"; then
break
fi
log_warn "Bitte Sprachen erneut angeben (fehlende Sprache einfach weglassen)."
echo
done
# --- Original archivieren? ---
echo
local ORIG_MODE="delete"
local ARCHIVE_DIR=""
local ARCHIVE_ANS
read -r -p "Original nach erfolgreichem OCR archivieren? [j/N]: " ARCHIVE_ANS
ARCHIVE_ANS="${ARCHIVE_ANS:-N}"
if [[ "$ARCHIVE_ANS" =~ ^[JjYy]$ ]]; then
ORIG_MODE="archive"
local default_archive="$BASE/archive"
while true; do
read -r -p "Archiv-Verzeichnis [$default_archive]: " ARCHIVE_DIR
ARCHIVE_DIR="${ARCHIVE_DIR:-$default_archive}"
if [[ "$ARCHIVE_DIR" != /* ]]; then
log_error "Bitte einen absoluten Pfad angeben (beginnt mit '/')."
continue
fi
# Das Archiv darf keines der Arbeitsverzeichnisse sein: im Eingang
# wuerde das Original endlos neu aufgegriffen, in den uebrigen
# kollidiert es mit der Verarbeitung.
case "${ARCHIVE_DIR%/}" in
"$BASE/incoming"|"$BASE/outgoing"|"$BASE/working"|"$BASE/error")
log_error "Das Archiv darf nicht incoming/outgoing/working/error sein."
continue
;;
esac
break
done
else
log_info "Original wird nach erfolgreichem OCR geloescht (original_on_success = \"delete\")."
fi
log_info "Lege Datenverzeichnisse unter $BASE an..."
mkdir -p "$BASE"/{incoming,outgoing,working,error}
if [ -n "$ARCHIVE_DIR" ]; then
mkdir -p "$ARCHIVE_DIR"
fi
chown -R "$SVC_USER":"$SVC_GROUP" "$BASE"
# Innerhalb von $BASE erledigt das chown -R oben schon alles; nur ein Archiv
# ausserhalb braucht eigenes mkdir/chown.
if [ -n "$ARCHIVE_DIR" ] && [[ "$ARCHIVE_DIR" != "$BASE"/* ]] && [ "$ARCHIVE_DIR" != "$BASE" ]; then
chown -R "$SVC_USER":"$SVC_GROUP" "$ARCHIVE_DIR"
log_info "Archiv-Verzeichnis $ARCHIVE_DIR angelegt (liegt ausserhalb von $BASE)"
fi
log_info "Erstelle Config $CONFIG_DIR/$INST.toml..."
# Verankerte Ausdruecke (Zeilenanfang + Key + '='), damit die deutschen
# Kommentarzeilen ueber den Keys unangetastet bleiben.
local ESC_BASE ESC_ARCHIVE ESC_LANGS
ESC_BASE="$(sed_escape_repl "$BASE")"
ESC_ARCHIVE="$(sed_escape_repl "$ARCHIVE_DIR")"
ESC_LANGS="$(sed_escape_repl "$LANGS")"
sed \
-e "s|/var/lib/pdf-ocr-hotfolder/incoming|$BASE/incoming|" \
-e "s|/var/lib/pdf-ocr-hotfolder/outgoing|$BASE/outgoing|" \
-e "s|/var/lib/pdf-ocr-hotfolder/working|$BASE/working|" \
-e "s|/var/lib/pdf-ocr-hotfolder/error|$BASE/error|" \
-e "s|^incoming[[:space:]]*=.*|incoming = \"$ESC_BASE/incoming\"|" \
-e "s|^outgoing[[:space:]]*=.*|outgoing = \"$ESC_BASE/outgoing\"|" \
-e "s|^working[[:space:]]*=.*|working = \"$ESC_BASE/working\"|" \
-e "s|^error[[:space:]]*=.*|error = \"$ESC_BASE/error\"|" \
-e "s|^languages[[:space:]]*=.*|languages = \"$ESC_LANGS\"|" \
-e "s|^original_on_success[[:space:]]*=.*|original_on_success = \"$ORIG_MODE\"|" \
-e "s|^archive_dir[[:space:]]*=.*|archive_dir = \"$ESC_ARCHIVE\"|" \
"$INSTALL_DIR/config.example.toml" > "$CONFIG_DIR/$INST.toml"
chown root:"$SVC_GROUP" "$CONFIG_DIR/$INST.toml"
chmod 640 "$CONFIG_DIR/$INST.toml"
# Erzeugte Config gegenpruefen: tragen die drei Keys wirklich die Auswahl?
local CFG_OK=1 got key want
for key in languages original_on_success archive_dir; do
case "$key" in
languages) want="$LANGS" ;;
original_on_success) want="$ORIG_MODE" ;;
archive_dir) want="$ARCHIVE_DIR" ;;
esac
got="$(config_value "$CONFIG_DIR/$INST.toml" "$key")"
if [ "$got" != "$want" ]; then
log_error "Config-Pruefung: $key ist \"$got\", erwartet \"$want\""
CFG_OK=0
fi
done
if [ "$CFG_OK" -eq 1 ]; then
log_info "Config-Pruefung ok ✓ (languages / original_on_success / archive_dir)"
else
log_warn "Bitte $CONFIG_DIR/$INST.toml von Hand nachziehen."
fi
# Drop-in für abweichenden Service-User
if [ "$SVC_USER" != "$DEFAULT_USER" ]; then
local DROPIN_DIR="/etc/systemd/system/pdf-ocr-hotfolder@${INST}.service.d"
local DROPIN_DIR="$SYSTEMD_DIR/pdf-ocr-hotfolder@${INST}.service.d"
mkdir -p "$DROPIN_DIR"
cat > "$DROPIN_DIR/user.conf" <<EOF
[Service]
@@ -213,6 +439,12 @@ EOF
echo " Eingang: $BASE/incoming"
echo " Ausgang: $BASE/outgoing"
echo " User: $SVC_USER ($SVC_GROUP)"
if [ "$CFG_OK" -eq 1 ]; then
echo " Sprachen: $LANGS"
if [ "$ORIG_MODE" = "archive" ]; then
echo " Archiv: $ARCHIVE_DIR"
fi
fi
echo
}
@@ -225,9 +457,16 @@ echo "=========================================="
echo " PDF OCR Hotfolder — Installer"
echo "=========================================="
if [ ! -d "$INSTALL_DIR/venv" ] || [ ! -f "/etc/systemd/system/$SERVICE_TEMPLATE" ]; then
# Auch eine vorhandene, aber kaputte venv loest die Basis-Installation aus
# (sonst wuerde install.sh nach einem Distributions-Upgrade nichts reparieren).
if [ ! -d "$INSTALL_DIR/venv" ] || [ ! -f "$SYSTEMD_DIR/$SERVICE_TEMPLATE" ]; then
log_step "Basis-Installation"
install_base
elif ! venv_is_healthy "$INSTALL_DIR/venv"; then
log_warn "Basis-Installation vorhanden, aber die venv passt nicht mehr zum System-Python."
report_venv_issues
log_step "Basis-Installation wird zur Reparatur erneut ausgefuehrt"
install_base
else
log_info "Basis-Installation bereits vorhanden ($INSTALL_DIR)"
log_info "Überspringe Basis-Setup (nutze update.sh für Code-Updates)"
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env bash
#
# PDF OCR Hotfolder — gemeinsame Shell-Funktionen
#
# Wird von install.sh und update.sh gesourct:
# source "$SCRIPT_DIR/lib/common.sh"
#
# Diese Datei fuehrt beim Sourcen NICHTS aus, was das System anfasst — sie
# enthaelt nur Definitionen und Vorgabewerte. Alle Pfade sind ueber die
# Umgebung ueberschreibbar, damit sich die Funktionen gegen eine Fake-Umgebung
# testen lassen (siehe PDF_OCR_UPDATE_LIB_ONLY in update.sh).
#
# Die Datei wird bei Installation und Update nach $INSTALL_DIR/lib/ kopiert,
# damit auch ein Lauf ausserhalb des Repos sie findet.
#
# shellcheck shell=bash
# Mehrfaches Sourcen ist harmlos, aber unnoetig.
if [ -n "${PDF_OCR_COMMON_LOADED:-}" ]; then
# shellcheck disable=SC2317 # 'exit' greift nur, wenn die Datei nicht gesourct wurde
return 0 2>/dev/null || exit 0
fi
PDF_OCR_COMMON_LOADED=1
# ============================================================
# Ausgabe
# ============================================================
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $*"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
log_error() { echo -e "${RED}[ERROR]${NC} $*"; }
log_step() { echo -e "\n${BLUE}==>${NC} $*"; }
# Bricht ab, wenn nicht root. $1 = der Aufruf, der gemeint ist.
require_root() {
if [ "${EUID:-$(id -u)}" -ne 0 ]; then
log_error "Bitte als root ausfuehren: ${1:-sudo $0}"
exit 1
fi
}
# ============================================================
# Installations-Layout — einzige Quelle der Wahrheit
# ============================================================
: "${INSTALL_DIR:=/opt/pdf-ocr-hotfolder}"
: "${CONFIG_DIR:=/etc/pdf-ocr-hotfolder}"
: "${DATA_ROOT:=/var/lib/pdf-ocr-hotfolder}"
: "${SYSTEMD_DIR:=/etc/systemd/system}"
: "${DEFAULT_USER:=pdfocr}"
SERVICE_TEMPLATE="pdf-ocr-hotfolder@.service"
LXC_DROPIN_DIR="$SYSTEMD_DIR/${SERVICE_TEMPLATE}.d"
# shellcheck disable=SC2034 # wird von install.sh und update.sh genutzt
LXC_DROPIN="$LXC_DROPIN_DIR/lxc-compat.conf"
# Pfad dieser Datei relativ zum Repo- bzw. Installationsverzeichnis. update.sh
# schneidet damit die Paketliste aus der Repo-Fassung heraus (siehe unten).
# shellcheck disable=SC2034 # wird von update.sh genutzt
COMMON_LIB_REL="lib/common.sh"
# ============================================================
# System-Pakete
# ============================================================
# Der folgende Block wird von update.sh aus DIESER Datei herausgeschnitten
# (sed auf die BEGIN/END-Marken) und dort ausgewertet: beim Update soll die
# Liste aus dem Repo gelten, nicht die vielleicht aeltere, bereits gesourcte
# aus dem Installationsverzeichnis. Die Marken und der Funktionsname duerfen
# sich deshalb nicht aendern, ohne update.sh anzupassen.
# --- BEGIN apt-packages (wird von update.sh extrahiert) ---
pdf_ocr_apt_packages() {
cat <<'PKGLIST'
python3
python3-venv
python3-pip
tesseract-ocr
tesseract-ocr-deu
tesseract-ocr-eng
ghostscript
qpdf
unpaper
pngquant
icc-profiles-free
ca-certificates
curl
PKGLIST
}
# --- END apt-packages ---
# ============================================================
# Python / venv
# ============================================================
# major.minor des uebergebenen Interpreters; leer, wenn er nicht laeuft.
py_mm() {
local py="$1"
"$py" -c 'import sys; print("%d.%d" % sys.version_info[:2])' 2>/dev/null || true
}
# major.minor aus pyvenv.cfg (version = / version_info =); leer, wenn unlesbar.
pyvenv_cfg_mm() {
local cfg="$1/pyvenv.cfg"
[ -f "$cfg" ] || return 0
sed -n 's/^[[:space:]]*version\(_info\)\?[[:space:]]*=[[:space:]]*\([0-9]\+\.[0-9]\+\).*/\2/p' "$cfg" | head -n1
}
# Prueft die venv gegen das aktuelle System-Python.
# Setzt VENV_ISSUES (Array) und gibt 0 zurueck, wenn alles passt.
#
# Drei Faelle fuehren zum Neubau: (1) der Interpreter der venv laeuft gar nicht
# mehr (toter Symlink nach einem Distributions-Upgrade, systemd: 203/EXEC),
# (2) er laeuft noch, ist aber eine andere Version als das System-Python
# (Debian 12 -> 13), (3) pyvenv.cfg und Interpreter widersprechen sich.
# Keine Versionsnummer ist hier hartcodiert.
VENV_ISSUES=()
venv_is_healthy() {
local venv="$1"
local sys_mm venv_mm cfg_mm
VENV_ISSUES=()
if [ ! -d "$venv" ]; then
VENV_ISSUES+=("venv-Verzeichnis fehlt: $venv")
return 1
fi
if [ ! -x "$venv/bin/python" ]; then
VENV_ISSUES+=("$venv/bin/python fehlt oder ist nicht ausfuehrbar")
return 1
fi
venv_mm="$(py_mm "$venv/bin/python")"
if [ -z "$venv_mm" ]; then
VENV_ISSUES+=("$venv/bin/python laeuft nicht (toter Symlink nach einem Distributions-Upgrade?)")
return 1
fi
sys_mm="$(py_mm "$(command -v python3 || echo /usr/bin/python3)")"
if [ -z "$sys_mm" ]; then
VENV_ISSUES+=("System-python3 laeuft nicht — venv-Pruefung nicht moeglich")
return 1
fi
if [ "$venv_mm" != "$sys_mm" ]; then
VENV_ISSUES+=("venv haengt an Python $venv_mm, das System liefert Python $sys_mm")
return 1
fi
cfg_mm="$(pyvenv_cfg_mm "$venv")"
if [ -n "$cfg_mm" ] && [ "$cfg_mm" != "$venv_mm" ]; then
VENV_ISSUES+=("pyvenv.cfg nennt Python $cfg_mm, der Interpreter meldet $venv_mm")
return 1
fi
return 0
}
# Gibt die von venv_is_healthy gesammelten Befunde als Warnungen aus.
report_venv_issues() {
local issue
for issue in "${VENV_ISSUES[@]:-}"; do
[ -n "$issue" ] || continue
log_warn " - $issue"
done
}
+1 -1
View File
@@ -1,3 +1,3 @@
"""PDF OCR Hotfolder — Scanner-PDFs automatisch durchsuchbar machen."""
__version__ = "0.1.0"
__version__ = "0.7.0"
+152 -4
View File
@@ -4,21 +4,143 @@ from __future__ import annotations
import argparse
import logging
import sys
import tomllib
from pathlib import Path
from . import __version__
from .config import load_config
from .service import HotfolderService, PreflightError
from .config import Config, ConfigError, config_warnings, load_config
from .service import (
HotfolderService,
PreflightError,
check_output_config,
check_preflight,
detect_ghostscript_version,
detect_ocrmypdf_version,
)
log = logging.getLogger(__name__)
# Exit-Codes von --check-config (werden vom Updater ausgewertet)
CHECK_OK = 0
CHECK_WARN = 1
CHECK_ERROR = 2
def _setup_logging(level: str) -> None:
# stream explizit auf stdout: der Default von basicConfig() ist stderr,
# README und docs/INSTALLATION.md versprechen aber stdout. Für journald
# ist das egal, für den dort beschriebenen Vordergrund-Notbehelf und für
# jeden, der die Ausgabe weiterleitet, nicht.
logging.basicConfig(
level=getattr(logging, level.upper(), logging.INFO),
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
stream=sys.stdout,
)
def _toml_error_text(cfg_path: Path, exc: tomllib.TOMLDecodeError) -> str:
"""Formuliert die Meldung für kaputtes TOML — mit Zeile/Spalte, wenn möglich.
`TOMLDecodeError.lineno`/`.colno` gibt es erst ab Python 3.14. Auf Debian
12 (Python 3.11) fehlen die Attribute, dort steht die Position nur im
Meldungstext ("... (at line 3, column 12)") — deshalb `getattr` statt
direktem Zugriff.
"""
lineno = getattr(exc, "lineno", None)
colno = getattr(exc, "colno", None)
pos = f" (Zeile {lineno}, Spalte {colno})" if lineno is not None else ""
return f"{cfg_path} ist kein gültiges TOML{pos}: {exc}"
def _log_config_warnings(cfg: Config) -> None:
"""Schreibt Legacy- und Unbekannt-Warnungen beim Dienststart ins Log."""
for warning in config_warnings(cfg):
log.warning("Config-Warnung: %s", warning)
def check_config(cfg_path: Path) -> int:
"""Lädt und prüft die Config, ohne irgendetwas zu verarbeiten.
Returns:
0 = alles sauber, 1 = nur Warnungen, 2 = Fehler (Config unbrauchbar
oder Preflight scheitert).
"""
print(f"Prüfe Konfiguration: {cfg_path}")
try:
cfg = load_config(cfg_path)
except ConfigError as e:
print(f"FEHLER: {e}", file=sys.stderr)
return CHECK_ERROR
except tomllib.TOMLDecodeError as e:
print(f"FEHLER: {_toml_error_text(cfg_path, e)}", file=sys.stderr)
return CHECK_ERROR
except OSError as e:
print(f"FEHLER: {cfg_path} nicht lesbar: {e}", file=sys.stderr)
return CHECK_ERROR
print(" Config gelesen.")
for label, path in (("incoming", cfg.paths.incoming),
("outgoing", cfg.paths.outgoing),
("working ", cfg.paths.working),
("error ", cfg.paths.error)):
hint = "" if path.is_dir() else " (existiert noch nicht, "\
"wird beim Start angelegt)"
print(f" {label} = {path}{hint}")
print(f" OCR-Sprachen = {cfg.ocr.languages}")
print(f" Seiten-Timeout= {cfg.ocr.timeout} s")
print(f" PDF/A-Level = {cfg.ocr.pdfa_level or '(aus)'}")
print(f" skip_text = {'an' if cfg.ocr.skip_text else 'aus'}")
print(f" ocrmypdf = {detect_ocrmypdf_version() or '(nicht installiert)'}")
print(f" Ghostscript = {detect_ghostscript_version() or '(nicht gefunden)'}")
if cfg.verapdf.enabled:
print(f" veraPDF = {cfg.verapdf.binary} (Flavour "
f"{cfg.verapdf.flavour})")
else:
print(" veraPDF = (aus)")
errors: list[str] = []
try:
check_preflight(cfg.ocr.pdfa_level, cfg.ocr.skip_text,
cfg.verapdf.enabled, cfg.verapdf.binary)
print(" Preflight ok (tesseract, gs"
+ (", veraPDF" if cfg.verapdf.enabled else "")
+ " vorhanden, Ghostscript-Version "
"passt zu ocrmypdf + [ocr]-Einstellungen).")
except PreflightError as e:
errors.append(str(e))
try:
check_output_config(cfg.output.original_on_success,
cfg.output.archive_dir,
cfg.output.name_mode)
print(" [output]-Sektion ok.")
except PreflightError as e:
errors.append(str(e))
warnings = config_warnings(cfg)
if warnings:
print(f"\n{len(warnings)} Warnung(en):")
for w in warnings:
print(f" WARNUNG: {w}")
if errors:
print(f"\n{len(errors)} Fehler:", file=sys.stderr)
for e in errors:
print(f" FEHLER: {e}", file=sys.stderr)
print("\nErgebnis: Config unbrauchbar — der Dienst würde nicht starten.",
file=sys.stderr)
return CHECK_ERROR
if warnings:
print("\nErgebnis: Config nutzbar, aber mit Warnungen.")
return CHECK_WARN
print("\nErgebnis: Config sauber.")
return CHECK_OK
def main() -> int:
parser = argparse.ArgumentParser(
prog="pdf-ocr-hotfolder",
@@ -29,6 +151,9 @@ def main() -> int:
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
parser.add_argument("--once", action="store_true",
help="Nur bestehende Dateien verarbeiten und beenden")
parser.add_argument("--check-config", action="store_true", dest="check_config",
help="Config nur prüfen, nichts verarbeiten "
"(Exit 0 = sauber, 1 = Warnungen, 2 = Fehler)")
args = parser.parse_args()
cfg_path = Path(args.config)
@@ -36,8 +161,29 @@ def main() -> int:
print(f"Config nicht gefunden: {cfg_path}", file=sys.stderr)
return 2
if args.check_config:
# Hat Vorrang vor --once: es wird nichts verarbeitet.
return check_config(cfg_path)
try:
cfg = load_config(cfg_path)
except ConfigError as e:
print(f"FEHLER: {e}", file=sys.stderr)
return 2
except tomllib.TOMLDecodeError as e:
# Ohne diesen Zweig endet ein Tippfehler in der Config (unbalancierte
# Anführungszeichen o.ä.) beim Dienststart in einem nackten Traceback.
# Dieselbe Behandlung wie in --check-config: verständliche Meldung,
# Exit 2 = Config-Fehler.
print(f"FEHLER: {_toml_error_text(cfg_path, e)}", file=sys.stderr)
print("Der Dienst startet nicht. Config korrigieren und mit "
"--check-config gegenprüfen.", file=sys.stderr)
return 2
except OSError as e:
print(f"FEHLER: {cfg_path} nicht lesbar: {e}", file=sys.stderr)
return 2
_setup_logging(cfg.log_level)
_log_config_warnings(cfg)
service = HotfolderService(cfg)
@@ -50,12 +196,14 @@ def main() -> int:
return 1 if errors > 0 else 0
try:
service.run()
# run() liefert 0 bei regulärem Stopp und EXIT_OBSERVER_DEAD, wenn der
# Verzeichnis-Watch gestorben ist — Letzteres muss nach außen
# durchschlagen, sonst startet systemd den Dienst nicht neu.
return service.run()
except PreflightError as e:
print(f"FEHLER: {e}", file=sys.stderr)
return 2
except KeyboardInterrupt:
pass
return 0
+189 -7
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,31 @@ class OcrConfig:
jobs: int = 4
skip_text: bool = True
oversample: int = 300
pdfa_level: str = "2"
# Default bewusst leer: mit Ghostscript 10.0.0-10.02.0 (Debian-12-Default)
# lehnt ocrmypdf die Kombination pdfa_level + skip_text ab (Issue #3).
# ACHTUNG, kein Freibrief: pdfa_level = "" allein schützt nur zusammen mit
# ocrmypdf >= 17. Bis 16.x läuft dieselbe Prüfung auch ohne PDF/A und
# blockiert dann JEDE Datei — deshalb pinnt requirements.txt 17.x und der
# Preflight prüft die installierte ocrmypdf-Version mit (siehe
# service._gs_block_reason).
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
class OutputConfig:
# "prefix" | "suffix" | "none"
name_mode: str = "prefix"
# Tag-String, verbatim eingefügt (Leerstring = kein Tag)
name_tag: str = "OCR_"
# "delete" | "archive"
original_on_success: str = "delete"
# Absoluter Pfad; Pflicht wenn original_on_success == "archive"
archive_dir: str = ""
@dataclass
@@ -79,12 +103,25 @@ class EmailNotify:
class Config:
paths: Paths
ocr: OcrConfig
output: OutputConfig
verapdf: VeraPdfConfig
folder: FolderUpload
nextcloud: NextcloudUpload
sftp: SftpUpload
email: EmailNotify
log_level: str = "INFO"
# Einträge der TOML, die zu keiner Dataclass gehören (Tippfehler oder
# Optionen aus älteren Versionen). load_config() sammelt sie hier ein,
# statt sie stumm zu verwerfen — geloggt wird erst weiter oben, damit
# load_config() ohne konfiguriertes Logging benutzbar bleibt.
unknown_keys: list[str] = field(default_factory=list)
# Bekannte Sektionen — alles andere landet in Config.unknown_keys
_KNOWN_SECTIONS = ("paths", "ocr", "output", "verapdf", "upload", "notify", "logging")
_KNOWN_UPLOAD_TARGETS = ("folder", "nextcloud", "sftp")
_KNOWN_NOTIFY_TARGETS = ("email",)
_KNOWN_LOGGING_KEYS = ("level",)
def _section(data: dict[str, Any], *keys: str) -> dict[str, Any]:
@@ -94,21 +131,108 @@ def _section(data: dict[str, Any], *keys: str) -> dict[str, Any]:
return cur if isinstance(cur, dict) else {}
def _require_absolute(value: str, label: str, cfg_path: Path,
beispiel: str) -> None:
"""Weist relative Pfadangaben zurück.
Ein relativer Pfad wird gegen das Arbeitsverzeichnis des Prozesses
aufgelöst — bei der systemd-Unit also gegen `WorkingDirectory`
(/opt/pdf-ocr-hotfolder). `incoming = "in"` legte damit stillschweigend
/opt/pdf-ocr-hotfolder/in an: der Scanner schreibt woanders hin als der
Dienst schaut, und niemand sieht einen Fehler. Absolute Pfade sind die
einzige sinnvolle Angabe; install.sh erzeugt ohnehin nur solche.
"""
if not value or Path(value).is_absolute():
return
raise ConfigError(
f"{cfg_path}: {label} = {value!r} ist ein relativer Pfad. Hier sind "
f"nur absolute Pfade zulässig — ein relativer würde gegen das "
f"Arbeitsverzeichnis des Dienstes aufgelöst "
f"(WorkingDirectory, also z.B. /opt/pdf-ocr-hotfolder/{value}) und "
f"nicht gegen das Verzeichnis, in dem die Config liegt. "
f'Bitte absolut angeben, z.B. "{beispiel}".'
)
def _require_path(p: dict[str, Any], key: str, cfg_path: Path) -> Path:
"""Holt einen Pflicht-Pfad aus der [paths]-Sektion.
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."
)
value = str(value)
_require_absolute(value, f"In der Sektion [paths] der Eintrag '{key}'",
cfg_path, f"/var/lib/pdf-ocr-hotfolder/{key}")
return Path(value)
def _unknown_in(data: dict[str, Any], keys: tuple[str, ...],
known: tuple[str, ...]) -> list[str]:
"""Listet alle Keys einer Sektion auf, die nicht in `known` stehen."""
label = ".".join(keys)
return [f"[{label}].{k}" for k in _section(data, *keys) if k not in known]
def _collect_unknown_keys(data: dict[str, Any]) -> list[str]:
"""Sammelt alle TOML-Einträge, die nirgends ausgewertet werden.
Dazu zählen Tippfehler (`[ocr].langauges`), Optionen aus älteren
Versionen und komplett unbekannte Sektionen. Die Reihenfolge entspricht
der Datei, damit die Meldung reproduzierbar bleibt.
"""
unknown: list[str] = []
for name, value in data.items():
if name not in _KNOWN_SECTIONS:
unknown.append(f"[{name}]" if isinstance(value, dict) else name)
unknown += _unknown_in(data, ("paths",), tuple(Paths.__annotations__))
unknown += _unknown_in(data, ("ocr",), tuple(OcrConfig.__annotations__))
unknown += _unknown_in(data, ("output",), tuple(OutputConfig.__annotations__))
unknown += _unknown_in(data, ("verapdf",), tuple(VeraPdfConfig.__annotations__))
for sub in _section(data, "upload"):
if sub not in _KNOWN_UPLOAD_TARGETS:
unknown.append(f"[upload.{sub}]")
unknown += _unknown_in(data, ("upload", "folder"), tuple(FolderUpload.__annotations__))
unknown += _unknown_in(data, ("upload", "nextcloud"), tuple(NextcloudUpload.__annotations__))
unknown += _unknown_in(data, ("upload", "sftp"), tuple(SftpUpload.__annotations__))
for sub in _section(data, "notify"):
if sub not in _KNOWN_NOTIFY_TARGETS:
unknown.append(f"[notify.{sub}]")
unknown += _unknown_in(data, ("notify", "email"), tuple(EmailNotify.__annotations__))
unknown += _unknown_in(data, ("logging",), _KNOWN_LOGGING_KEYS)
return unknown
def load_config(path: str | Path) -> Config:
path = Path(path)
with path.open("rb") as f:
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()
if k in OcrConfig.__annotations__})
output = OutputConfig(**{k: v for k, v in _section(data, "output").items()
if k in OutputConfig.__annotations__})
verapdf = VeraPdfConfig(**{k: v for k, v in _section(data, "verapdf").items()
if k in VeraPdfConfig.__annotations__})
folder = FolderUpload(**{k: v for k, v in _section(data, "upload", "folder").items()
@@ -120,10 +244,68 @@ def load_config(path: str | Path) -> Config:
email = EmailNotify(**{k: v for k, v in _section(data, "notify", "email").items()
if k in EmailNotify.__annotations__})
# Dieselbe Regel wie für [paths]: beides sind Verzeichnisse, in die der
# Dienst schreibt, und beide wären relativ aufgelöst schlicht falsch.
_require_absolute(str(output.archive_dir), "[output].archive_dir", path,
"/var/lib/pdf-ocr-hotfolder/archive")
_require_absolute(str(folder.target), "[upload.folder].target", path,
"/srv/scans/fertig")
log_level = _section(data, "logging").get("level", "INFO")
return Config(
paths=paths, ocr=ocr, verapdf=verapdf,
paths=paths, ocr=ocr, output=output, verapdf=verapdf,
folder=folder, nextcloud=nextcloud, sftp=sftp, email=email,
log_level=log_level,
unknown_keys=_collect_unknown_keys(data),
)
# ---- Legacy- und Plausibilitätswarnungen ----
# [ocr].timeout war vor 0.4.0 ein (wirkungsloses) Gesamt-Timeout mit Default
# 1800. Ab diesem Wert gehen wir von einem Altwert aus.
LEGACY_TIMEOUT_THRESHOLD = 900
# Richtwert für das Seiten-Timeout seit 0.4.0
RECOMMENDED_PAGE_TIMEOUT = 300
def legacy_warnings(cfg: Config) -> list[str]:
"""Warnt vor Einträgen, deren Bedeutung sich geändert hat.
Die Meldungen werden sowohl beim Dienststart ins Log geschrieben als auch
von `--check-config` ausgegeben — deshalb steht der Text nur hier.
"""
out: list[str] = []
if cfg.ocr.timeout >= LEGACY_TIMEOUT_THRESHOLD:
out.append(
f"[ocr].timeout = {cfg.ocr.timeout}: Seit Version 0.4.0 sind das "
"Sekunden PRO SEITE (vorher ein wirkungsloses Gesamt-Timeout mit "
f"Default 1800). Ein Wert >= {LEGACY_TIMEOUT_THRESHOLD} stammt fast "
"sicher aus einer alten Config und lässt eine einzelne Seite "
f"unnötig lange laufen. Richtwert: {RECOMMENDED_PAGE_TIMEOUT}."
)
if cfg.ocr.pdfa_level:
out.append(
f"[ocr].pdfa_level = {cfg.ocr.pdfa_level!r}: PDF/A-Ausgabe ist "
"aktiv. Ghostscript 10.0.0-10.02.0 (Debian-12-Default) hat einen "
"Bug, wegen dem ocrmypdf die Kombination mit skip_text ablehnt "
"(Issue #3). Der Preflight bricht ab, falls die installierte "
"Ghostscript-Version betroffen ist; ab 10.02.1 ist alles in "
"Ordnung."
)
return out
def unknown_key_warnings(cfg: Config) -> list[str]:
"""Macht die beim Laden verworfenen Einträge sichtbar."""
return [
f"Unbekannter Config-Eintrag {key} — wird ignoriert (Tippfehler oder "
"Option aus einer älteren Version?)"
for key in cfg.unknown_keys
]
def config_warnings(cfg: Config) -> list[str]:
"""Alle Warnungen zu einer geladenen Config (Legacy + unbekannte Keys)."""
return legacy_warnings(cfg) + unknown_key_warnings(cfg)
+288 -16
View File
@@ -2,15 +2,71 @@
from __future__ import annotations
import logging
import os
import shutil
import subprocess
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from .config import OcrConfig, VeraPdfConfig
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")
# Präfix der Zwischendatei, in die ocrmypdf schreibt. Bleibt sie nach einem
# harten Stopp in working/ liegen, ist sie ein unvollständiges Fragment.
OCR_TEMP_PREFIX = "__ocr_"
def build_output_name(src_name: str, mode: str, tag: str) -> str:
"""Erzeugt den Ziel-Dateinamen für ein OCR-PDF.
Args:
src_name: Original-Dateiname (z.B. "scan.pdf")
mode: "prefix" | "suffix" | "none"
tag: Einzufügender String (verbatim, leer = kein Tag)
Beispiele:
prefix "OCR_": "scan.pdf" -> "OCR_scan.pdf"
suffix "_OCR": "scan.pdf" -> "scan_OCR.pdf"
suffix "_OCR": "scan.tar.gz.pdf" -> "scan.tar.gz_OCR.pdf"
none: "scan.pdf" -> "scan.pdf"
"""
if mode == "none" or not tag:
return src_name
if mode == "prefix":
return f"{tag}{src_name}"
if mode == "suffix":
# Nur die letzte Extension abspalten, sonst "foo.bar.pdf" kaputt gemacht
p = Path(src_name)
stem, ext = p.stem, p.suffix
return f"{stem}{tag}{ext}"
raise ValueError(f"Unbekannter name_mode: {mode!r}")
class VeraPdfUnavailable(RuntimeError):
"""veraPDF konnte nicht befragt werden — Programm fehlt, startet nicht, Timeout.
Ausdrücklich KEIN inhaltliches Urteil über die PDF. Der Unterschied ist
existenziell: ein nicht startbares veraPDF, das wie ein FAIL behandelt
wird, schiebt JEDES OCR-Ergebnis nach error/ und entsorgt das Original
laut [output].original_on_success — bei dessen Default `delete` also
Scan für Scan die Vorlage, während der Dienst als "läuft" dasteht.
"""
# veraPDF schreibt mit `--format text` pro Datei eine Zeile, die mit dem
# Urteil beginnt. Steht in der Ausgabe weder PASS noch FAIL, hat veraPDF gar
# nichts geprüft (fehlendes Java, kaputter Wrapper, falsches Flavour) — das
# ist kein "nicht konform", sondern ein fehlendes Urteil.
_VERAPDF_VERDICTS = ("PASS", "FAIL")
# Sekunden, die veraPDF pro Datei laufen darf
VERAPDF_TIMEOUT = 300
@dataclass
class ProcessResult:
@@ -19,6 +75,9 @@ class ProcessResult:
success: bool
error: str = ""
verapdf_passed: bool | None = None
# Gesetzt, wenn der Durchlauf erfolgreich war, aber etwas Nennenswertes
# danebenlief (aktuell: das Original ließ sich nicht entsorgen).
warning: str = ""
def run_ocr(src: Path, dst: Path, cfg: OcrConfig) -> None:
@@ -39,29 +98,79 @@ 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)
def resolve_verapdf_binary(binary: str) -> str | None:
"""Sucht das veraPDF-Programm und prüft, ob es ausführbar ist.
Beide Schreibweisen sind zulässig: ein Pfad (`/opt/verapdf/verapdf`, der
Default) wird direkt geprüft, ein nackter Name (`verapdf`) im PATH
gesucht.
Returns:
Der aufrufbare Pfad oder None.
"""
if not binary:
return None
if os.sep in binary:
p = Path(binary)
return str(p) if p.is_file() and os.access(p, os.X_OK) else None
return shutil.which(binary)
def run_verapdf(pdf: Path, cfg: VeraPdfConfig) -> bool:
"""Validiert PDF/A mit veraPDF (CLI). Gibt True zurück, wenn konform."""
"""Validiert PDF/A mit veraPDF (CLI). Gibt True zurück, wenn konform.
Returns:
True = konform (PASS), False = nicht konform (FAIL).
Raises:
VeraPdfUnavailable: veraPDF ließ sich nicht befragen. Das ist kein
FAIL — siehe Klassen-Docstring.
"""
if not cfg.enabled:
return True
if not Path(cfg.binary).exists():
log.warning("veraPDF binary nicht gefunden: %s", cfg.binary)
return False
binary = resolve_verapdf_binary(cfg.binary)
if binary is None:
raise VeraPdfUnavailable(
f"[verapdf].binary = {cfg.binary!r} existiert nicht oder ist nicht "
"ausführbar"
)
try:
result = subprocess.run(
[cfg.binary, "--flavour", cfg.flavour, "--format", "text", str(pdf)],
capture_output=True, text=True, timeout=300,
[binary, "--flavour", cfg.flavour, "--format", "text", str(pdf)],
capture_output=True, text=True, timeout=VERAPDF_TIMEOUT,
)
except subprocess.TimeoutExpired as e:
raise VeraPdfUnavailable(
f"veraPDF hat für {pdf.name} nach {VERAPDF_TIMEOUT} s nicht "
"geantwortet"
) from e
except OSError as e:
raise VeraPdfUnavailable(f"veraPDF ({binary}) nicht startbar: {e}") from e
if not any(v in result.stdout for v in _VERAPDF_VERDICTS):
ausgabe = (result.stdout + result.stderr).strip().replace("\n", " ")
raise VeraPdfUnavailable(
f"veraPDF ({binary}) hat kein Urteil geliefert "
f"(Exit {result.returncode}): {ausgabe[:300] or '(keine Ausgabe)'}"
)
ok = result.returncode == 0 and "PASS" in result.stdout
log.info("veraPDF %s: %s", "PASS" if ok else "FAIL", pdf.name)
return ok
except subprocess.TimeoutExpired:
log.error("veraPDF Timeout: %s", pdf.name)
return False
def process_pdf(
@@ -71,12 +180,30 @@ def process_pdf(
error_dir: Path,
ocr_cfg: OcrConfig,
vera_cfg: VeraPdfConfig,
output_cfg: OutputConfig,
) -> ProcessResult:
"""Verarbeitet eine einzelne PDF: move→OCR→validate→outgoing/error."""
out_name = build_output_name(src.name, output_cfg.name_mode, output_cfg.name_tag)
work_src = working_dir / src.name
work_out = working_dir / f"OCR_{src.name}"
final_out = outgoing_dir / f"OCR_{src.name}"
work_out = working_dir / f"{OCR_TEMP_PREFIX}{out_name}" # Temp-Name, damit er != src.name ist
final_out = outgoing_dir / out_name
if _is_same_file(src, work_src):
# Wiederaufnahme: die Datei liegt bereits in working/, weil ein
# früherer Lauf hart abgebrochen wurde. Kein zweiter Move — der würde
# die Datei bestenfalls auf sich selbst schieben.
log.warning("Wiederaufnahme aus %s: %s wird erneut per OCR verarbeitet",
working_dir, src.name)
elif work_src.exists():
# Gleicher Dateiname, andere Datei: ein Move würde den laufenden bzw.
# wiederaufgenommenen Vorgang in working/ stillschweigend überschreiben.
return ProcessResult(
src, final_out, False,
f"in {working_dir} liegt bereits eine andere Datei namens "
f"{src.name} — Original bleibt in {src.parent} liegen und wird "
"beim nächsten Lauf erneut versucht",
)
else:
try:
shutil.move(str(src), str(work_src))
except OSError as e:
@@ -91,22 +218,167 @@ def process_pdf(
vera_ok: bool | None = None
if vera_cfg.enabled:
try:
vera_ok = run_verapdf(work_out, vera_cfg)
if not vera_ok:
except VeraPdfUnavailable as e:
# Kein Urteil über die Datei — also darf auch nichts entsorgt
# werden. Original UND OCR-Ergebnis gehen nach error/; das
# Original bleibt damit unabhängig von
# [output].original_on_success erhalten.
log.error(
"veraPDF nicht aufrufbar (%s) — %s wird NICHT als ungültig "
"gewertet: Original und OCR-Ergebnis liegen in %s, das "
"Original wurde weder gelöscht noch archiviert. "
"[verapdf].binary prüfen (--check-config)",
e, src.name, error_dir,
)
_move_to_error(work_out, error_dir)
work_src.unlink(missing_ok=True)
_move_to_error(work_src, error_dir)
return ProcessResult(src, final_out, False,
f"veraPDF nicht aufrufbar: {e}")
if not vera_ok:
# Das OCR-Ergebnis ist unbrauchbar und wandert nach error/. Das
# Original wird aber NICHT bedingungslos gelöscht: es folgt derselben
# [output].original_on_success-Regel wie im Erfolgsfall, sonst
# verliert man es ausgerechnet im Fehlerfall (archive!).
_move_to_error(work_out, error_dir)
_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)
outgoing_dir.mkdir(parents=True, exist_ok=True)
# Liegt in outgoing/ schon eine Datei desselben Namens (Scanner liefert
# denselben Dateinamen ein zweites Mal, oder das Vorgängerergebnis wurde
# noch nicht abgeholt), würde der move sie kommentarlos überschreiben.
# Stattdessen derselbe Zeitstempel-Ausweg wie im Archiv.
final_out = _collision_free_path(final_out)
if final_out.name != out_name:
log.warning(
"In %s liegt bereits eine Datei %s — das neue OCR-Ergebnis wird "
"als %s abgelegt, damit das ältere nicht überschrieben wird",
outgoing_dir, out_name, final_out.name,
)
shutil.move(str(work_out), str(final_out))
# Scheitert die Entsorgung des Originals (Platte voll, read-only), ist der
# Durchlauf trotzdem gelungen: das fertige PDF liegt bereits in outgoing/.
# Der Fehler darf ihn deshalb nicht entwerten — sonst unterbleibt der
# Upload und das Ergebnis bleibt liegen. Er wird als Warnung
# weitergereicht und landet in der Benachrichtigung.
warning = _dispose_original(work_src, src.name, output_cfg)
return ProcessResult(src, final_out, True, verapdf_passed=vera_ok,
warning=warning)
def _is_same_file(a: Path, b: Path) -> bool:
"""Zeigen beide Pfade auf dieselbe Datei? (verträgt fehlende Dateien)"""
try:
return a.resolve() == b.resolve()
except OSError:
return False
def _collision_free_path(dest: Path) -> Path:
"""Weicht einem schon belegten Zielnamen per Zeitstempel-Suffix aus.
Einheitlich für outgoing/ und Archiv: `scan.pdf` wird zu
`scan_20260923-081500.pdf`. Ist auch der Zeitstempel-Name belegt (zwei
Dateien innerhalb derselben Sekunde, z.B. bei mehreren Workern), wird
zusätzlich hochgezählt — sonst überschriebe der anschließende `move` doch
wieder still.
Der Rest bleibt unverändert: existiert das Ziel nicht, kommt es
unverändert zurück.
"""
if not dest.exists():
return dest
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
candidate = dest.with_name(f"{dest.stem}_{ts}{dest.suffix}")
counter = 2
while candidate.exists():
candidate = dest.with_name(f"{dest.stem}_{ts}-{counter}{dest.suffix}")
counter += 1
return candidate
def _dispose_original(work_src: Path, original_name: str, cfg: OutputConfig) -> str:
"""Entsorgt das Original laut [output].original_on_success — löschen oder archivieren.
Wird nach erfolgreichem OCR aufgerufen und ebenso, wenn veraPDF die
Validierung ablehnt: auch dann soll `archive` das Original erhalten.
Wirft bewusst NICHT: zum Aufrufzeitpunkt liegt das fertige PDF schon in
outgoing/. Eine Exception von hier würde den gelungenen Durchlauf im
Catch-all des Service in einen Fehler verwandeln — mitsamt
ausgefallenem Upload.
Returns:
Leerer String = erledigt. Sonst die Fehlermeldung (bereits geloggt).
"""
if not work_src.exists():
return ""
mode = cfg.original_on_success
if mode == "archive" and cfg.archive_dir:
archive = Path(cfg.archive_dir)
try:
archive.mkdir(parents=True, exist_ok=True)
# Bei Namens-Kollision mit Timestamp umbenennen (gleicher Weg wie
# für das Ergebnis in outgoing/)
dest = _collision_free_path(archive / original_name)
shutil.move(str(work_src), str(dest))
except OSError as e:
return _disposal_failed(work_src, original_name,
f"nicht nach {archive} archiviert", e)
log.info("Original archiviert: %s", dest)
return ""
if mode == "archive":
log.error("original_on_success=archive aber archive_dir ist leer — "
"lösche stattdessen")
elif mode != "delete":
log.warning("Unbekannter original_on_success=%r — lösche stattdessen", mode)
try:
work_src.unlink(missing_ok=True)
return ProcessResult(src, final_out, True, verapdf_passed=vera_ok)
except OSError as e:
return _disposal_failed(work_src, original_name, "nicht gelöscht", e)
return ""
def _disposal_failed(work_src: Path, original_name: str, was: str,
exc: OSError) -> str:
"""Einheitliche Meldung, wenn das Original nicht entsorgt werden konnte."""
msg = (
f"Original {original_name} konnte {was} werden ({exc}). Das OCR-PDF ist "
f"fertig und wird normal ausgeliefert, das Original liegt aber "
f"weiterhin in {work_src.parent} — es wird beim nächsten Start dort "
f"aufgegriffen und ein zweites Mal durch das OCR geschickt. Bitte "
f"{work_src} von Hand aufräumen und die Ursache beheben "
f"(Plattenplatz, Schreibrechte)."
)
log.error("%s", msg)
return msg
def _move_to_error(p: Path, error_dir: Path) -> None:
"""Verschiebt eine Datei ins error-Verzeichnis, ohne dort etwas zu überschreiben.
Scheitert dieselbe `scan.pdf` zweimal, ersetzte die zweite bisher still die
erste — dieselbe Datenverlust-Klasse wie in outgoing/. Deshalb derselbe
Zeitstempel-Ausweg über `_collision_free_path()`.
"""
error_dir.mkdir(parents=True, exist_ok=True)
dest = _collision_free_path(error_dir / p.name)
if dest.name != p.name:
log.warning(
"In %s liegt bereits eine Datei %s — die neue wird als %s abgelegt, "
"damit die ältere nicht überschrieben wird",
error_dir, p.name, dest.name,
)
try:
shutil.move(str(p), str(error_dir / p.name))
shutil.move(str(p), str(dest))
except OSError:
log.exception("Konnte %s nicht in error-Verzeichnis verschieben", p)
+441 -35
View File
@@ -9,13 +9,21 @@ import subprocess
import threading
import time
from concurrent.futures import Future, ThreadPoolExecutor
from datetime import datetime
from pathlib import Path
from watchdog.events import FileSystemEvent, FileSystemEventHandler
from watchdog.observers import Observer
from .config import Config
from .processor import ProcessResult, process_pdf
from .processor import (
OCR_TEMP_PREFIX,
VALID_NAME_MODES,
ProcessResult,
_move_to_error,
process_pdf,
resolve_verapdf_binary,
)
from .uploaders import notify_email, upload_folder, upload_nextcloud, upload_sftp
log = logging.getLogger(__name__)
@@ -25,14 +33,30 @@ class PreflightError(RuntimeError):
"""Erforderliche externe Binaries fehlen."""
# Exit-Code, mit dem sich der Dienst bei totem watchdog-Observer beendet.
# Bewusst NICHT 2: die Unit setzt RestartPreventExitStatus=2 für Config- und
# Preflight-Fehler, die ein Neustart nicht heilt. Ein toter Observer soll
# dagegen genau das — neu starten, damit der inotify-Watch neu aufgesetzt wird.
EXIT_OBSERVER_DEAD = 3
# Pflicht-Binaries für ocrmypdf
_REQUIRED_BINARIES = ("tesseract", "gs")
# Ghostscript-Versionen mit bekanntem PDF/A+skip_text Bug (Issue #3):
# Ghostscript-Versionen mit bekanntem Bug (Issue #3):
# 10.0.0 .. 10.02.0 (inklusive). Ab 10.02.1 wieder nutzbar.
_GS_BROKEN_MIN = (10, 0, 0)
_GS_BROKEN_MAX = (10, 2, 0)
# Ab dieser ocrmypdf-Major steht die Ghostscript-Pruefung hinter
# `if options.output_type.startswith('pdfa')` — mit pdfa_level = "" wird
# Ghostscript gar nicht angefasst und die Pruefung greift nicht.
# Darunter (16.x und aelter) laeuft sie BEDINGUNGSLOS, also auch bei
# output_type="pdf": dort reicht skip_text=true, um auf Debian 12 jede
# einzelne PDF scheitern zu lassen. Quelle jeweils
# ocrmypdf/builtin_plugins/ghostscript.py::check_options().
_OCRMYPDF_GS_GUARD_MAJOR = 17
def _parse_version(text: str) -> tuple[int, ...] | None:
"""Extrahiert die erste X.Y[.Z] Version aus einem String."""
@@ -43,7 +67,7 @@ def _parse_version(text: str) -> tuple[int, ...] | None:
def is_ghostscript_broken(version: str | None) -> bool:
"""Prüft, ob eine Ghostscript-Version vom PDF/A+skip_text Bug betroffen ist.
"""Prüft, ob eine Ghostscript-Version vom bekannten Bug betroffen ist.
Betrifft 10.0.0 bis einschließlich 10.02.0. Ab 10.02.1 wieder sicher.
"""
@@ -72,12 +96,99 @@ def detect_ghostscript_version() -> str | None:
return result.stdout.strip() or None
def check_preflight(pdfa_level: str = "") -> None:
def detect_ocrmypdf_version() -> str | None:
"""Liest die installierte ocrmypdf-Version aus den Paket-Metadaten.
Bewusst über `importlib.metadata` statt über einen Import: das ist
billiger und funktioniert auch in den Tests, in denen ocrmypdf gar nicht
installiert ist (dann None).
"""
try:
from importlib.metadata import version
return version("ocrmypdf")
except Exception: # noqa: BLE001 - fehlende Metadaten dürfen nichts umwerfen
return None
def ocrmypdf_checks_gs_always(version: str | None) -> bool:
"""True, wenn ocrmypdf die Ghostscript-Pruefung unabhaengig vom output_type fährt.
Das ist bei 16.x und aelter der Fall (siehe `_OCRMYPDF_GS_GUARD_MAJOR`).
Ist die Version unbekannt, wird `False` angenommen: der Pin in
requirements.txt steht auf 17.x, und ein Fehlalarm, der den Dienst nicht
starten laesst, waere schlimmer als die fehlende Warnung.
"""
if not version:
return False
parsed = _parse_version(version)
if parsed is None:
return False
return parsed[0] < _OCRMYPDF_GS_GUARD_MAJOR
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:
raise PreflightError(
f"[output].original_on_success={mode!r} ungültig. "
f"Erlaubt: {sorted(valid_modes)}"
)
if mode == "archive" and not archive_dir:
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_verapdf_binary(enabled: bool, binary: str) -> None:
"""Prüft das in [verapdf].binary konfigurierte Programm — wenn aktiviert.
Ohne diese Prüfung ist ein Tippfehler im Pfad der gefährlichste Fehler des
ganzen Dienstes: `run_verapdf()` findet das Programm für JEDE Datei nicht,
das OCR-Ergebnis wandert nach error/, und `_dispose_original()` löscht bei
`original_on_success = "delete"` (dem Default) das Original. Scan für Scan
verschwinden so die Vorlagen, während die Unit als `active (running)`
dasteht.
"""
if not enabled:
return
if not binary:
raise PreflightError(
"[verapdf].enabled = true, aber [verapdf].binary ist leer. "
"Entweder den Pfad zum veraPDF-Programm eintragen oder "
"[verapdf].enabled = false setzen."
)
if resolve_verapdf_binary(binary) is None:
raise PreflightError(
f"[verapdf].enabled = true, aber [verapdf].binary = {binary!r} "
"existiert nicht oder ist nicht ausführbar. Der Dienst startet "
"bewusst nicht: ein nicht aufrufbares veraPDF würde sonst jede "
"einzelne PDF als ungültig werten, das OCR-Ergebnis nach error/ "
"schieben und das Original laut [output].original_on_success "
"entsorgen. Pfad korrigieren (chmod +x nicht vergessen) oder "
"[verapdf].enabled = false setzen."
)
def check_preflight(pdfa_level: str = "", skip_text: bool = False,
verapdf_enabled: bool = False,
verapdf_binary: str = "") -> None:
"""Prüft externe Abhängigkeiten.
- Tesseract und Ghostscript müssen im PATH sein
- Bei gesetztem pdfa_level wird die Ghostscript-Version gegen den
bekannten 10.0.0–10.02.0 Bug geprüft
- Die Ghostscript-Version wird gegen den bekannten 10.0.0–10.02.0 Bug
geprüft, und zwar genau unter der Bedingung, unter der ocrmypdf selbst
abbricht (siehe `_gs_block_reason`).
- Ist [verapdf].enabled gesetzt, muss auch das dort konfigurierte
Programm vorhanden und ausführbar sein (siehe `check_verapdf_binary`).
Wirft PreflightError bei fehlenden Binaries oder unsicherem Ghostscript.
"""
@@ -88,14 +199,74 @@ def check_preflight(pdfa_level: str = "") -> None:
+ ". Bitte installieren: sudo apt install tesseract-ocr ghostscript"
)
if pdfa_level:
reason = _gs_block_reason(pdfa_level, skip_text)
if reason:
raise PreflightError(reason)
check_verapdf_binary(verapdf_enabled, verapdf_binary)
def _gs_block_reason(pdfa_level: str, skip_text: bool) -> str | None:
"""Liefert die Fehlermeldung, wenn ocrmypdf mit diesem Ghostscript abbricht.
Abgebildet wird die reale Bedingung aus
`ocrmypdf/builtin_plugins/ghostscript.py::check_options()`:
betroffene GS-Version UND (skip_text ODER redo_ocr)
UND (PDF/A-Ausgabe ODER ocrmypdf < 17)
Die letzte Klammer ist der Teil, der v0.6.0 durchrutschen ließ: bis
einschließlich ocrmypdf 16.x steht die Pruefung ohne jeden Guard in
`check_options()` und schlaegt deshalb auch bei `output_type="pdf"` zu.
Ab 17.0.0 umschliesst sie ein
`if options.output_type.startswith('pdfa'):` — ohne PDF/A wird
Ghostscript nicht angefasst.
`redo_ocr` kennt unsere Config nicht (es gibt keinen entsprechenden Key in
`OcrConfig`), deshalb steht es hier bewusst nicht in der Bedingung.
Returns:
Fehlermeldung oder None, wenn die Kombination unkritisch ist.
"""
if not skip_text:
# Weder skip_text noch redo_ocr — ocrmypdf fasst den Pfad nicht an.
return None
gs_version = detect_ghostscript_version()
if is_ghostscript_broken(gs_version):
raise PreflightError(
f"Ghostscript {gs_version} ist mit pdfa_level='{pdfa_level}' nicht "
"kompatibel (bekannter Bug in 10.0.0–10.02.0). "
"Entweder ghostscript auf >=10.02.1 upgraden (z.B. via bookworm-backports) "
"oder in der Config [ocr].pdfa_level = \"\" setzen."
if not is_ghostscript_broken(gs_version):
return None
ocrmypdf_version = detect_ocrmypdf_version()
always = ocrmypdf_checks_gs_always(ocrmypdf_version)
if not pdfa_level and not always:
return None
if pdfa_level:
ursache = (
f"[ocr].pdfa_level = {pdfa_level!r} (PDF/A-Ausgabe) zusammen mit "
"[ocr].skip_text = true"
)
else:
ursache = (
f"[ocr].skip_text = true und ocrmypdf {ocrmypdf_version} — bis "
f"einschließlich {_OCRMYPDF_GS_GUARD_MAJOR - 1}.x prüft ocrmypdf "
"Ghostscript auch dann, wenn gar kein PDF/A erzeugt wird. Jede "
"einzelne PDF würde in error/ landen"
)
return (
f"Ghostscript {gs_version} ist von einem bekannten Fehler betroffen "
"(10.0.0–10.02.0, der Debian-12-Standard) und wird von ocrmypdf "
f"abgelehnt: {ursache}. "
"Abhilfe — eines von beidem: "
"(1) Ghostscript >= 10.02.1 aus bookworm-backports installieren "
"(install.sh bietet das an): "
"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 — "
"oder (2) in der Config [ocr].skip_text = false setzen "
"(dann wird vorhandener Text neu erkannt statt übersprungen)"
+ (" bzw. [ocr].pdfa_level = \"\"." if pdfa_level else ".")
)
@@ -171,8 +342,20 @@ class HotfolderService:
# ---- Lifecycle ----
def run(self) -> None:
check_preflight(self.cfg.ocr.pdfa_level)
def _preflight(self) -> None:
check_preflight(self.cfg.ocr.pdfa_level, self.cfg.ocr.skip_text,
self.cfg.verapdf.enabled, self.cfg.verapdf.binary)
check_output_config(self.cfg.output.original_on_success,
self.cfg.output.archive_dir,
self.cfg.output.name_mode)
def run(self) -> int:
"""Startet den Dienst und läuft, bis gestoppt wird.
Returns:
0 bei regulärem Stopp (SIGTERM/SIGINT), sonst `EXIT_OBSERVER_DEAD`.
"""
self._preflight()
self.ensure_dirs()
self._scan_existing()
@@ -185,18 +368,49 @@ class HotfolderService:
signal.signal(signal.SIGINT, lambda *_: self._stop.set())
try:
while not self._stop.is_set():
self._stop.wait(1.0)
return self._wait_loop()
finally:
self.shutdown()
def _wait_loop(self) -> int:
"""Hauptschleife: wartet auf den Stopp und bewacht den Observer.
Stirbt der watchdog-Observer im Betrieb (erschöpftes
inotify-Watch-Limit, ersetztes oder neu gemountetes Verzeichnis),
blieb die Unit bisher `active (running)` und verarbeitete nichts mehr:
kein Log, keine Mail, niemand merkt es. Für einen Hotfolder ist das
der schlechteste denkbare Zustand. Deshalb wird der Observer
sekündlich mitgeprüft und der Dienst im Ernstfall mit
`EXIT_OBSERVER_DEAD` beendet, damit systemd ihn per
`Restart=on-failure` neu startet und den Watch neu aufsetzt.
"""
while not self._stop.is_set():
self._stop.wait(1.0)
if self._stop.is_set():
# Regulärer Stopp — hier darf kein Fehlalarm entstehen, auch
# wenn der Observer planmäßig schon gestoppt wurde.
break
if self._observer is not None and not self._observer.is_alive():
log.error(
"Der Verzeichnis-Watch auf %s ist gestorben — es werden "
"KEINE neuen Dateien mehr erkannt. Mögliche Ursachen: "
"erschöpftes inotify-Watch-Limit "
"(fs.inotify.max_user_watches), ersetztes oder neu "
"gemountetes Verzeichnis. Der Dienst beendet sich mit "
"Exit %d, damit systemd ihn neu startet und der Watch "
"neu aufgesetzt wird.",
self.cfg.paths.incoming, EXIT_OBSERVER_DEAD,
)
return EXIT_OBSERVER_DEAD
return 0
def run_once(self) -> int:
"""Verarbeitet alle bereits im incoming-Ordner liegenden PDFs und beendet sich.
"""Verarbeitet alle bereits liegenden PDFs (incoming/ + working/) und beendet sich.
Returns:
Anzahl fehlgeschlagener PDFs (0 = alles ok).
"""
check_preflight(self.cfg.ocr.pdfa_level)
self._preflight()
self.ensure_dirs()
self._scan_existing()
self._executor.shutdown(wait=True)
@@ -215,10 +429,106 @@ class HotfolderService:
# ---- Queue ----
def _scan_existing(self) -> None:
"""Beim Start: bereits liegende PDFs aufgreifen."""
for p in self.cfg.paths.incoming.iterdir():
"""Beim Start: bereits liegende PDFs aufgreifen.
Zuerst working/ (abgebrochene Läufe, siehe `_scan_working`), danach
incoming/. Die Reihenfolge ist wichtig, damit eine Namenskollision
zwischen beiden Verzeichnissen aufgelöst ist, bevor die
incoming-Datei nach working/ will.
"""
self._scan_working()
fremd: list[str] = []
for p in sorted(self.cfg.paths.incoming.iterdir()):
if _is_pdf(p):
self.enqueue(p)
elif p.is_file():
fremd.append(p.name)
self._report_non_pdf(fremd)
def _report_non_pdf(self, names: list[str]) -> None:
"""Meldet einmalig, wie viele Fremddateien in incoming/ liegen.
Alles ohne .pdf-Endung wird ignoriert und sammelte sich bisher stumm
an — Scanner-Fehlablagen, abgebrochene Uploads, Thumbnails. Eine
Sammelmeldung beim Start-Scan, keine Zeile pro Datei und nichts im
laufenden Betrieb: das soll auffallen, nicht spammen.
"""
if not names:
return
beispiele = ", ".join(names[:3])
if len(names) > 3:
beispiele += f", … (+{len(names) - 3} weitere)"
log.warning(
"In %s liegen %d Datei(en) ohne .pdf-Endung — sie werden nicht "
"verarbeitet und bleiben dort liegen: %s",
self.cfg.paths.incoming, len(names), beispiele,
)
def _scan_working(self) -> None:
"""Greift Dateien auf, die ein harter Stopp in working/ liegen ließ.
`process_pdf()` verschiebt das Original vor dem OCR nach working/.
Wird der Dienst dort abgeschossen (SIGKILL nach TimeoutStopSec),
bleibt es liegen und wurde bisher nie wieder angefasst — stiller
Datenverlust. Die Datei wird deshalb an Ort und Stelle
wiederaufgenommen; `process_pdf()` erkennt das und verschiebt sie
nicht erneut.
Die Zwischendateien des abgebrochenen OCR-Laufs (Präfix `__ocr_`)
sind unvollständige Fragmente: als Eingabe unbrauchbar und als
Ergebnis wertlos. Sie werden gelöscht, damit sie niemand für ein
fertiges PDF hält und damit der neue Lauf sauber startet.
"""
working = self.cfg.paths.working
if not working.is_dir():
return
for p in sorted(working.iterdir()):
if not p.is_file():
continue
if p.name.startswith(OCR_TEMP_PREFIX):
log.warning(
"Unvollständiges OCR-Fragment aus abgebrochenem Lauf "
"gefunden und gelöscht: %s", p,
)
try:
p.unlink()
except OSError:
log.exception("Konnte OCR-Fragment %s nicht löschen", p)
continue
if not _is_pdf(p):
continue
target = self._free_resume_name(p)
log.warning(
"Abgebrochener Lauf wird fortgesetzt: %s lag noch in %s "
"(Dienst wurde vermutlich hart gestoppt) — OCR startet neu",
target.name, working,
)
self.enqueue(target)
def _free_resume_name(self, p: Path) -> Path:
"""Entschärft eine Namenskollision zwischen working/ und incoming/.
Liegt in incoming/ eine gleichnamige (aber andere) Datei, würden beide
dieselbe working- und dieselbe outgoing-Datei beanspruchen. Die
wiederaufgenommene Datei bekommt deshalb einen Zeitstempel angehängt —
dann laufen beide durch, statt dass eine überschrieben wird.
"""
if not (self.cfg.paths.incoming / p.name).exists():
return p
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
renamed = p.with_name(f"{p.stem}_{ts}{p.suffix}")
try:
p.rename(renamed)
except OSError:
log.exception("Konnte %s nicht umbenennen — Wiederaufnahme unter "
"Originalnamen", p)
return p
log.warning(
"In %s liegt eine gleichnamige Datei %s — die wiederaufgenommene "
"Datei wurde nach %s umbenannt, damit sich beide nicht "
"überschreiben", self.cfg.paths.incoming, p.name, renamed.name,
)
return renamed
def enqueue(self, path: Path) -> None:
if not _is_pdf(path):
@@ -240,13 +550,33 @@ 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
try:
result: ProcessResult = process_pdf(
src=path,
working_dir=self.cfg.paths.working,
@@ -254,26 +584,102 @@ class HotfolderService:
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 and result.warning:
# Erfolgreich verarbeitet, aber das Original blieb liegen. Der
# Durchlauf zählt als Erfolg (das PDF ist fertig und ausgeliefert),
# die Mail geht aber als Nicht-Erfolg raus, damit sie auch bei
# [notify.email].on = "errors" zugestellt wird — sonst wäre das
# genau wieder ein stiller Fehlerpfad.
subject = f"[pdf-ocr] OK mit Warnung: {result.source.name}"
body = (
f"Datei verarbeitet: {result.output}\n\n"
f"ACHTUNG: {result.warning}\n"
)
notify_email(self.cfg.email, subject, body, False)
return
if result.success:
subject = f"[pdf-ocr] OK: {result.source.name}"
body = f"Datei verarbeitet: {result.output}\n"
+14 -1
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
import shutil
import smtplib
import ssl
from email.message import EmailMessage
@@ -12,6 +13,7 @@ import paramiko
import requests
from .config import EmailNotify, FolderUpload, NextcloudUpload, SftpUpload
from .processor import _collision_free_path
log = logging.getLogger(__name__)
@@ -25,7 +27,18 @@ 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())
# Gleichnamige Datei im Ziel wurde bisher kommentarlos ersetzt.
# Derselbe Zeitstempel-Ausweg wie in outgoing/, archive/ und error/.
dest = _collision_free_path(dest)
if dest.name != pdf.name:
log.warning(
"In %s liegt bereits eine Datei %s — die Kopie wird als %s "
"abgelegt, damit die ältere nicht überschrieben wird",
target, pdf.name, dest.name,
)
# copyfile statt read_bytes/write_bytes: große PDFs nicht komplett
# in den Speicher laden
shutil.copyfile(pdf, dest)
log.info("Folder upload OK: %s", dest)
return True
except OSError as e:
+2
View File
@@ -0,0 +1,2 @@
[pytest]
testpaths = tests
+24 -4
View File
@@ -1,4 +1,24 @@
ocrmypdf>=16.0
watchdog>=4.0
requests>=2.31
paramiko>=3.4
# Feste Pins: ein Update darf nicht ungefragt einen Major-Sprung einziehen
# (der naechste waere ocrmypdf 18 — der reisst sonst alle Instanzen auf einmal).
# Geprueft gegen Python 3.11 (Debian 12) und 3.13 (Debian 13) — fuer beide
# gibt es fertige Wheels, es wird nichts kompiliert.
# Beim Anheben: update.sh --rebuild-venv auf einer Testmaschine fahren.
#
# ocrmypdf: MUSS 17.x sein, 16.x ist fuer uns unbrauchbar (v0.6.1).
# In ocrmypdf 16.x laeuft die Ghostscript-Pruefung in
# builtin_plugins/ghostscript.py:check_options() BEDINGUNGSLOS, also auch bei
# output_type="pdf". Auf Debian 12 (Ghostscript 10.0.0) bricht damit
# JEDE PDF ab, sobald skip_text=true gesetzt ist — und das ist unser Default:
# if Version('10.0.0') <= gs_version < Version('10.02.1') and (
# options.skip_text or options.redo_ocr
# ): raise MissingDependencyError(...)
# Ab 17.0.0 steckt genau dieser Block in einem
# `if options.output_type.startswith('pdfa'):` — bei pdfa_level = "" wird
# Ghostscript gar nicht erst angefasst und die Pruefung greift nicht mehr.
# Deshalb hier 17.x. 17.4.1 ist die im Feld auf Debian 12 + gs 10.0.0
# verifizierte Version; 17.12.1 traegt denselben Guard und waere der
# naechste Kandidat, ist aber noch nicht auf einer Testmaschine gefahren.
ocrmypdf==17.4.1
watchdog==6.0.0
requests==2.33.1
paramiko==4.0.0
+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
+11 -1
View File
@@ -7,11 +7,21 @@ 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
# Exit 2 = Konfigurations- oder Preflight-Fehler. Den behebt kein Neustart,
# also nicht endlos im 5-Sekunden-Takt neu starten, sondern stehenbleiben —
# die Instanz steht dann als 'failed' da und faellt beim Nachsehen auf.
# (Restart=on-failure wuerde sonst JEDEN Exit != 0 neu starten; das
# Start-Rate-Limit greift bei RestartSec=5 nie.)
RestartPreventExitStatus=2
KillMode=mixed
TimeoutStopSec=30
# Ein laufendes OCR soll beim Stoppen zu Ende laufen duerfen. Bei SIGKILL
# bliebe das Original in working/ liegen (wird beim naechsten Start zwar
# wiederaufgenommen, kostet aber den kompletten Durchlauf).
TimeoutStopSec=300
# Hardening (lockerer wegen AD-User & Datei-ACLs)
NoNewPrivileges=true
+2
View File
@@ -11,6 +11,7 @@ from pdf_ocr_hotfolder.config import (
FolderUpload,
NextcloudUpload,
OcrConfig,
OutputConfig,
Paths,
SftpUpload,
VeraPdfConfig,
@@ -32,6 +33,7 @@ def tmp_config(tmp_path: Path) -> Config:
return Config(
paths=paths,
ocr=OcrConfig(max_workers=1),
output=OutputConfig(),
verapdf=VeraPdfConfig(enabled=False),
folder=FolderUpload(enabled=False),
nextcloud=NextcloudUpload(enabled=False),
+165
View File
@@ -0,0 +1,165 @@
"""Tests für `--check-config`.
Die Exit-Codes werden vom Updater ausgewertet und müssen verlässlich sein:
0 = sauber, 1 = nur Warnungen, 2 = Fehler.
"""
from __future__ import annotations
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
from pdf_ocr_hotfolder.__main__ import CHECK_ERROR, CHECK_OK, CHECK_WARN, main
def _cfg_file(tmp_path: Path, tmp_config, extra: str = "") -> Path:
cfg_file = tmp_path / "cfg.toml"
cfg_file.write_text(f"""
[paths]
incoming = "{tmp_config.paths.incoming}"
outgoing = "{tmp_config.paths.outgoing}"
working = "{tmp_config.paths.working}"
error = "{tmp_config.paths.error}"
""" + extra)
return cfg_file
def _check(monkeypatch, cfg_file: Path, binaries_present: bool = True) -> int:
monkeypatch.setattr(sys, "argv",
["pdf-ocr-hotfolder", "--config", str(cfg_file),
"--check-config"])
which = "/usr/bin/fake" if binaries_present else None
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value=which):
return main()
# ---------------- Exit 0 ----------------
def test_clean_config_returns_0(tmp_path, tmp_config, monkeypatch, capsys) -> None:
rc = _check(monkeypatch, _cfg_file(tmp_path, tmp_config))
assert rc == CHECK_OK
out = capsys.readouterr().out
assert "Config sauber" in out
def test_check_does_not_process_files(tmp_path, tmp_config, monkeypatch) -> None:
"""Der Check darf nichts verarbeiten und nichts verschieben."""
pdf = tmp_config.paths.incoming / "scan.pdf"
pdf.write_bytes(b"%PDF-1.4\n")
assert _check(monkeypatch, _cfg_file(tmp_path, tmp_config)) == CHECK_OK
assert pdf.exists()
assert list(tmp_config.paths.outgoing.iterdir()) == []
# ---------------- Exit 1 (nur Warnungen) ----------------
def test_legacy_timeout_returns_1(tmp_path, tmp_config, monkeypatch, capsys) -> None:
rc = _check(monkeypatch, _cfg_file(tmp_path, tmp_config,
"\n[ocr]\ntimeout = 1800\n"))
assert rc == CHECK_WARN
out = capsys.readouterr().out
assert "WARNUNG" in out
assert "PRO SEITE" in out
def test_unknown_key_returns_1(tmp_path, tmp_config, monkeypatch, capsys) -> None:
rc = _check(monkeypatch, _cfg_file(tmp_path, tmp_config,
'\n[ocr]\nlangauges = "deu"\n'))
assert rc == CHECK_WARN
assert "[ocr].langauges" in capsys.readouterr().out
def test_pdfa_level_with_healthy_ghostscript_returns_1(
tmp_path, tmp_config, monkeypatch, capsys) -> None:
"""Gesundes Ghostscript: nur Hinweis (Exit 1), kein Abbruch."""
monkeypatch.setattr(sys, "argv",
["pdf-ocr-hotfolder", "--config",
str(_cfg_file(tmp_path, tmp_config,
'\n[ocr]\npdfa_level = "2"\n')),
"--check-config"])
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"), \
patch("pdf_ocr_hotfolder.service.detect_ghostscript_version",
return_value="10.02.1"):
rc = main()
assert rc == CHECK_WARN
assert "Ghostscript" in capsys.readouterr().out
# ---------------- Exit 2 (Fehler) ----------------
def test_missing_config_file_returns_2(tmp_path, monkeypatch, capsys) -> None:
monkeypatch.setattr(sys, "argv",
["pdf-ocr-hotfolder", "--config",
str(tmp_path / "gibtsnicht.toml"), "--check-config"])
assert main() == CHECK_ERROR
assert "nicht gefunden" in capsys.readouterr().err
def test_broken_paths_section_returns_2(tmp_path, monkeypatch, capsys) -> None:
cfg_file = tmp_path / "cfg.toml"
cfg_file.write_text('[ocr]\nlanguages = "deu"\n')
assert _check(monkeypatch, cfg_file) == CHECK_ERROR
assert "[paths]" in capsys.readouterr().err
def test_invalid_toml_returns_2(tmp_path, monkeypatch, capsys) -> None:
"""Kaputtes TOML: saubere Meldung statt Traceback."""
cfg_file = tmp_path / "cfg.toml"
cfg_file.write_text("[paths\nincoming = ")
assert _check(monkeypatch, cfg_file) == CHECK_ERROR
assert "TOML" in capsys.readouterr().err
def test_missing_binaries_return_2(tmp_path, tmp_config, monkeypatch, capsys) -> None:
rc = _check(monkeypatch, _cfg_file(tmp_path, tmp_config),
binaries_present=False)
assert rc == CHECK_ERROR
err = capsys.readouterr().err
assert "tesseract" in err
def test_invalid_name_mode_returns_2(tmp_path, tmp_config, monkeypatch, capsys) -> None:
rc = _check(monkeypatch, _cfg_file(tmp_path, tmp_config,
'\n[output]\nname_mode = "praefix"\n'))
assert rc == CHECK_ERROR
assert "name_mode" in capsys.readouterr().err
def test_archive_without_dir_returns_2(tmp_path, tmp_config, monkeypatch, capsys) -> None:
rc = _check(monkeypatch, _cfg_file(tmp_path, tmp_config,
'\n[output]\noriginal_on_success = "archive"\n'))
assert rc == CHECK_ERROR
assert "archive_dir" in capsys.readouterr().err
def test_error_beats_warning(tmp_path, tmp_config, monkeypatch) -> None:
"""Fehler + Warnung → Exit 2, nicht 1."""
rc = _check(monkeypatch,
_cfg_file(tmp_path, tmp_config,
'\n[ocr]\ntimeout = 1800\n\n[output]\nname_mode = "x"\n'))
assert rc == CHECK_ERROR
# ---------------- Zusammenspiel mit anderen Optionen ----------------
def test_check_config_wins_over_once(tmp_path, tmp_config, monkeypatch) -> None:
"""--check-config hat Vorrang: es wird nichts verarbeitet."""
pdf = tmp_config.paths.incoming / "scan.pdf"
pdf.write_bytes(b"%PDF-1.4\n")
monkeypatch.setattr(sys, "argv",
["pdf-ocr-hotfolder", "--config",
str(_cfg_file(tmp_path, tmp_config)),
"--once", "--check-config"])
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"):
assert main() == CHECK_OK
assert pdf.exists()
@pytest.mark.parametrize("code,expected", [(CHECK_OK, 0), (CHECK_WARN, 1),
(CHECK_ERROR, 2)])
def test_exit_code_constants(code: int, expected: int) -> None:
"""Die Konstanten sind Teil der Schnittstelle zum Updater."""
assert code == expected
+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
+198
View File
@@ -0,0 +1,198 @@
"""Tests für Legacy-Warnungen und unbekannte Config-Keys.
Zwei stille Fallen:
- [ocr].timeout bedeutet seit 0.4.0 Sekunden pro SEITE (vorher Gesamtlauf)
- unbekannte Keys (Tippfehler!) wurden beim Laden stumm verworfen
"""
from __future__ import annotations
import logging
import sys
from pathlib import Path
from unittest.mock import patch
from pdf_ocr_hotfolder.config import (
config_warnings,
legacy_warnings,
load_config,
unknown_key_warnings,
)
_PATHS = """
[paths]
incoming = "/tmp/in"
outgoing = "/tmp/out"
working = "/tmp/work"
error = "/tmp/err"
"""
def _write(tmp_path: Path, extra: str = "") -> Path:
cfg = tmp_path / "config.toml"
cfg.write_text(_PATHS + extra)
return cfg
# ---------------- Legacy: [ocr].timeout ----------------
def test_legacy_timeout_warns(tmp_path: Path) -> None:
"""Ein Altwert (Gesamt-Timeout 1800) muss deutlich benannt werden."""
cfg = load_config(_write(tmp_path, "\n[ocr]\ntimeout = 1800\n"))
warnings = legacy_warnings(cfg)
assert len(warnings) == 1
assert "timeout" in warnings[0]
assert "PRO SEITE" in warnings[0]
assert "300" in warnings[0]
def test_timeout_at_threshold_warns(tmp_path: Path) -> None:
cfg = load_config(_write(tmp_path, "\n[ocr]\ntimeout = 900\n"))
assert legacy_warnings(cfg)
def test_sane_timeout_does_not_warn(tmp_path: Path) -> None:
cfg = load_config(_write(tmp_path, "\n[ocr]\ntimeout = 300\n"))
assert legacy_warnings(cfg) == []
def test_default_config_has_no_warnings(tmp_path: Path) -> None:
cfg = load_config(_write(tmp_path))
assert config_warnings(cfg) == []
# ---------------- Legacy: [ocr].pdfa_level ----------------
def test_pdfa_level_warns_about_ghostscript(tmp_path: Path) -> None:
cfg = load_config(_write(tmp_path, '\n[ocr]\npdfa_level = "2"\n'))
warnings = legacy_warnings(cfg)
assert len(warnings) == 1
assert "pdfa_level" in warnings[0]
assert "Ghostscript" in warnings[0]
assert "10.02.0" in warnings[0]
def test_empty_pdfa_level_does_not_warn(tmp_path: Path) -> None:
cfg = load_config(_write(tmp_path, '\n[ocr]\npdfa_level = ""\n'))
assert legacy_warnings(cfg) == []
def test_both_legacy_warnings_together(tmp_path: Path) -> None:
cfg = load_config(_write(tmp_path, '\n[ocr]\ntimeout = 1800\npdfa_level = "1"\n'))
assert len(legacy_warnings(cfg)) == 2
# ---------------- Unbekannte Keys ----------------
def test_typo_key_is_collected(tmp_path: Path) -> None:
"""`langauges` statt `languages` darf nicht mehr stumm verschwinden."""
cfg = load_config(_write(tmp_path, '\n[ocr]\nlangauges = "deu"\n'))
assert cfg.unknown_keys == ["[ocr].langauges"]
assert "[ocr].langauges" in unknown_key_warnings(cfg)[0]
# Der Rest wird weiterhin normal geladen
assert cfg.ocr.languages == "deu+eng"
def test_known_keys_are_not_reported(tmp_path: Path) -> None:
cfg = load_config(_write(tmp_path, '\n[ocr]\nlanguages = "deu"\njobs = 2\n'))
assert cfg.unknown_keys == []
assert cfg.ocr.languages == "deu"
def test_unknown_keys_in_all_sections(tmp_path: Path) -> None:
cfg = load_config(_write(tmp_path, """
[ocr]
foo = 1
[output]
bar = "x"
[verapdf]
baz = true
[upload.folder]
qux = ""
[upload.nextcloud]
quux = ""
[upload.sftp]
corge = 0
[notify.email]
grault = ""
[logging]
level = "INFO"
garply = 1
"""))
assert cfg.unknown_keys == [
"[ocr].foo", "[output].bar", "[verapdf].baz",
"[upload.folder].qux", "[upload.nextcloud].quux", "[upload.sftp].corge",
"[notify.email].grault", "[logging].garply",
]
def test_unknown_top_level_key_is_reported(tmp_path: Path) -> None:
"""Ein Key ausserhalb jeder Sektion (z.B. vergessene Sektionszeile)."""
cfg_file = tmp_path / "config.toml"
cfg_file.write_text('log_level = "DEBUG"\n' + _PATHS)
cfg = load_config(cfg_file)
assert cfg.unknown_keys == ["log_level"]
def test_unknown_section_is_reported(tmp_path: Path) -> None:
cfg = load_config(_write(tmp_path, '\n[ocrr]\nlanguages = "deu"\n\n[upload.ftp]\nhost = "x"\n'))
assert "[ocrr]" in cfg.unknown_keys
assert "[upload.ftp]" in cfg.unknown_keys
def test_unknown_key_inside_paths(tmp_path: Path) -> None:
"""Ein zusätzlicher Key direkt in [paths] wird ebenfalls gemeldet."""
cfg_file = tmp_path / "config.toml"
cfg_file.write_text(_PATHS + 'archive = "/tmp/a"\n')
cfg = load_config(cfg_file)
assert cfg.unknown_keys == ["[paths].archive"]
def test_load_config_works_without_logging(tmp_path: Path) -> None:
"""load_config() darf nichts loggen müssen — Tests rufen sie direkt auf."""
cfg_file = _write(tmp_path, '\n[ocr]\nlangauges = "deu"\n')
with patch("logging.Logger.warning") as warn:
cfg = load_config(cfg_file)
warn.assert_not_called()
assert cfg.unknown_keys
# ---------------- Warnungen beim Dienststart ----------------
def _argv(monkeypatch, cfg_file: Path, *extra: str) -> None:
monkeypatch.setattr(sys, "argv",
["pdf-ocr-hotfolder", "--config", str(cfg_file), *extra])
def test_warnings_are_logged_on_service_start(tmp_path: Path, tmp_config,
monkeypatch, caplog) -> None:
"""Beim normalen Start landen die Warnungen im Log (nicht nur im Check)."""
cfg_file = tmp_path / "cfg.toml"
cfg_file.write_text(f"""
[paths]
incoming = "{tmp_config.paths.incoming}"
outgoing = "{tmp_config.paths.outgoing}"
working = "{tmp_config.paths.working}"
error = "{tmp_config.paths.error}"
[ocr]
timeout = 1800
langauges = "deu"
""")
_argv(monkeypatch, cfg_file, "--once")
from pdf_ocr_hotfolder.__main__ import main
with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.__main__"), \
patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"):
assert main() == 0
text = caplog.text
assert "PRO SEITE" in text
assert "[ocr].langauges" in text
+211
View File
@@ -0,0 +1,211 @@
"""Punkt 4: Ein Archivierungsfehler darf einen Erfolg nicht in einen Fehler kippen.
Lief der `shutil.move` ins Archiv auf einen OSError (Platte voll, read-only),
flog die Exception NACH dem erfolgreichen Move nach outgoing/. `_process()`
fing sie im Catch-all, zählte einen Fehler — und `_dispatch_uploads()` lief
nie. Das fertige PDF lag da und wurde nie hochgeladen.
Jetzt: `_dispose_original()` wirft nicht mehr, meldet den Fehler deutlich und
reicht ihn als `ProcessResult.warning` durch. Der Durchlauf zählt als Erfolg
(das PDF ist fertig und wird ausgeliefert), die Benachrichtigung geht aber als
Nicht-Erfolg raus, damit sie auch bei on = "errors" zugestellt wird.
"""
from __future__ import annotations
import logging
from pathlib import Path
from unittest.mock import patch
from pdf_ocr_hotfolder.config import FolderUpload, OcrConfig, OutputConfig, VeraPdfConfig
from pdf_ocr_hotfolder.processor import _dispose_original, process_pdf
from pdf_ocr_hotfolder.service import HotfolderService
ORIGINAL = b"%PDF-1.4 original\n"
def _fake_ocr(src: Path, dst: Path, cfg: OcrConfig) -> None:
dst.write_bytes(b"%PDF-1.4 OCRed\n")
def _blocked_archive(tmp_path: Path) -> str:
"""Ein Archivpfad, dessen mkdir garantiert scheitert (Elternteil = Datei).
Steht stellvertretend für read-only/volle Platte, ohne mocken zu müssen.
"""
blocker = tmp_path / "blocker"
blocker.write_bytes(b"keine Verzeichnis\n")
return str(blocker / "archiv")
def _prepare(tmp_path: Path) -> dict:
dirs = {name: tmp_path / name
for name in ("incoming", "working", "outgoing", "error")}
for d in dirs.values():
d.mkdir(parents=True, exist_ok=True)
src = dirs["incoming"] / "scan.pdf"
src.write_bytes(ORIGINAL)
return {"src": src, **dirs}
# ---------------- _dispose_original wirft nicht mehr ----------------
def test_dispose_archive_failure_returns_message(tmp_path: Path) -> None:
work_src = tmp_path / "working" / "scan.pdf"
work_src.parent.mkdir()
work_src.write_bytes(ORIGINAL)
msg = _dispose_original(work_src, "scan.pdf",
OutputConfig(original_on_success="archive",
archive_dir=_blocked_archive(tmp_path)))
assert msg
assert "scan.pdf" in msg
# Das Original liegt noch da — nichts wurde verloren
assert work_src.read_bytes() == ORIGINAL
def test_dispose_archive_failure_names_working_dir(tmp_path: Path) -> None:
"""Die Meldung muss sagen, wo das Original liegen geblieben ist."""
work_src = tmp_path / "working" / "scan.pdf"
work_src.parent.mkdir()
work_src.write_bytes(ORIGINAL)
msg = _dispose_original(work_src, "scan.pdf",
OutputConfig(original_on_success="archive",
archive_dir=_blocked_archive(tmp_path)))
assert str(work_src.parent) in msg
def test_dispose_delete_failure_returns_message(tmp_path: Path) -> None:
work_src = tmp_path / "working" / "scan.pdf"
work_src.parent.mkdir()
work_src.write_bytes(ORIGINAL)
with patch.object(Path, "unlink", side_effect=OSError("read-only")):
msg = _dispose_original(work_src, "scan.pdf",
OutputConfig(original_on_success="delete"))
assert msg
assert "gelöscht" in msg
def test_dispose_success_returns_empty(tmp_path: Path) -> None:
work_src = tmp_path / "working" / "scan.pdf"
work_src.parent.mkdir()
work_src.write_bytes(ORIGINAL)
archive = tmp_path / "archiv"
assert _dispose_original(work_src, "scan.pdf",
OutputConfig(original_on_success="archive",
archive_dir=str(archive))) == ""
assert (archive / "scan.pdf").read_bytes() == ORIGINAL
def test_dispose_missing_file_returns_empty(tmp_path: Path) -> None:
assert _dispose_original(tmp_path / "gibtsnicht.pdf", "scan.pdf",
OutputConfig()) == ""
# ---------------- process_pdf bleibt erfolgreich ----------------
def _run(env: dict, out_cfg: OutputConfig):
with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_fake_ocr):
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=False),
output_cfg=out_cfg,
)
def test_archive_failure_keeps_run_successful(tmp_path: Path) -> None:
env = _prepare(tmp_path)
result = _run(env, OutputConfig(name_mode="prefix", name_tag="OCR_",
original_on_success="archive",
archive_dir=_blocked_archive(tmp_path)))
assert result.success is True
assert result.warning
# Das fertige PDF liegt in outgoing/ und wird normal ausgeliefert
assert (env["outgoing"] / "OCR_scan.pdf").exists()
assert result.output == env["outgoing"] / "OCR_scan.pdf"
# Das Original ist nicht verloren, sondern liegt noch in working/
assert (env["working"] / "scan.pdf").read_bytes() == ORIGINAL
def test_archive_failure_logs_error(tmp_path: Path, caplog) -> None:
env = _prepare(tmp_path)
with caplog.at_level(logging.ERROR, logger="pdf_ocr_hotfolder.processor"):
_run(env, OutputConfig(original_on_success="archive",
archive_dir=_blocked_archive(tmp_path)))
assert str(env["working"]) in caplog.text
def test_successful_run_has_no_warning(tmp_path: Path) -> None:
env = _prepare(tmp_path)
result = _run(env, OutputConfig(original_on_success="delete"))
assert result.success is True
assert result.warning == ""
# ---------------- Service: Upload läuft trotzdem ----------------
def _run_once(tmp_config, **patches):
stack = [
patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None),
patch("pdf_ocr_hotfolder.service._wait_until_stable", return_value=True),
patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_fake_ocr),
]
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
def test_upload_still_runs_after_archive_failure(tmp_config, tmp_path) -> None:
"""Der Kern des Punktes: das fertige PDF muss trotzdem hochgeladen werden."""
ziel = tmp_path / "upload-ziel"
tmp_config.folder = FolderUpload(enabled=True, target=str(ziel))
tmp_config.output = OutputConfig(name_mode="prefix", name_tag="OCR_",
original_on_success="archive",
archive_dir=_blocked_archive(tmp_path))
(tmp_config.paths.incoming / "scan.pdf").write_bytes(ORIGINAL)
service = _run_once(tmp_config)
assert (ziel / "OCR_scan.pdf").exists()
# Der Durchlauf zählt als Erfolg: das PDF ist fertig und ausgeliefert.
assert service.success_count == 1
assert service.error_count == 0
def test_warning_notification_goes_out_as_error(tmp_config, tmp_path) -> None:
"""Die Mail muss auch bei on = 'errors' zugestellt werden."""
from pdf_ocr_hotfolder.processor import ProcessResult
service = HotfolderService(tmp_config)
try:
with patch("pdf_ocr_hotfolder.service.notify_email") as mail:
service._notify(ProcessResult(
tmp_path / "scan.pdf", tmp_path / "OCR_scan.pdf", True,
warning="Original konnte nicht archiviert werden",
))
finally:
service._executor.shutdown(wait=False)
mail.assert_called_once()
args = mail.call_args[0]
assert "OK mit Warnung" in args[1]
assert "Original konnte nicht archiviert werden" in args[2]
assert args[3] is False # -> wird auch bei on="errors" verschickt
+140
View File
@@ -0,0 +1,140 @@
"""Punkt 2: error/ darf nichts mehr still überschreiben.
Scheiterte dieselbe `scan.pdf` zweimal, ersetzte die zweite die erste in
error/ — dieselbe Datenverlust-Klasse, die für outgoing/ bereits geschlossen
ist. Betrifft `_move_to_error()` und damit auch `_rescue_to_error()`.
"""
from __future__ import annotations
import logging
from pathlib import Path
from unittest.mock import patch
from pdf_ocr_hotfolder.config import OcrConfig, OutputConfig, VeraPdfConfig
from pdf_ocr_hotfolder.processor import _move_to_error, process_pdf
from pdf_ocr_hotfolder.service import HotfolderService
ERSTE = b"%PDF-1.4 erste\n"
ZWEITE = b"%PDF-1.4 zweite\n"
# ---------------- _move_to_error direkt ----------------
def test_move_to_error_keeps_existing_file(tmp_path: Path) -> None:
error_dir = tmp_path / "error"
error_dir.mkdir()
(error_dir / "scan.pdf").write_bytes(ERSTE)
zweite = tmp_path / "scan.pdf"
zweite.write_bytes(ZWEITE)
_move_to_error(zweite, error_dir)
assert (error_dir / "scan.pdf").read_bytes() == ERSTE
ausweich = list(error_dir.glob("scan_*.pdf"))
assert len(ausweich) == 1
assert ausweich[0].read_bytes() == ZWEITE
assert not zweite.exists()
def test_move_to_error_without_collision_keeps_name(tmp_path: Path) -> None:
error_dir = tmp_path / "error"
src = tmp_path / "scan.pdf"
src.write_bytes(ERSTE)
_move_to_error(src, error_dir)
assert (error_dir / "scan.pdf").read_bytes() == ERSTE
assert list(error_dir.iterdir()) == [error_dir / "scan.pdf"]
def test_move_to_error_logs_warning_on_collision(tmp_path: Path, caplog) -> None:
error_dir = tmp_path / "error"
error_dir.mkdir()
(error_dir / "scan.pdf").write_bytes(ERSTE)
src = tmp_path / "scan.pdf"
src.write_bytes(ZWEITE)
with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.processor"):
_move_to_error(src, error_dir)
assert "scan.pdf" in caplog.text
assert "überschrieben" in caplog.text
def test_move_to_error_creates_dir(tmp_path: Path) -> None:
src = tmp_path / "scan.pdf"
src.write_bytes(ERSTE)
error_dir = tmp_path / "tief" / "error"
_move_to_error(src, error_dir)
assert (error_dir / "scan.pdf").exists()
def test_move_to_error_survives_oserror(tmp_path: Path, caplog) -> None:
"""Ein fehlgeschlagener Move darf weiterhin nur geloggt werden."""
src = tmp_path / "scan.pdf"
src.write_bytes(ERSTE)
error_dir = tmp_path / "error"
with patch("pdf_ocr_hotfolder.processor.shutil.move",
side_effect=OSError("read-only")):
_move_to_error(src, error_dir) # darf nicht werfen
assert src.exists()
# ---------------- über process_pdf: zweimal dieselbe Datei kaputt ----------------
def _prepare(tmp_path: Path) -> dict:
dirs = {name: tmp_path / name
for name in ("incoming", "working", "outgoing", "error")}
for d in dirs.values():
d.mkdir(parents=True, exist_ok=True)
return dirs
def _run_failing_ocr(dirs: dict, src: Path):
with patch("pdf_ocr_hotfolder.processor.run_ocr",
side_effect=RuntimeError("ocr kaputt")):
return process_pdf(
src=src,
working_dir=dirs["working"],
outgoing_dir=dirs["outgoing"],
error_dir=dirs["error"],
ocr_cfg=OcrConfig(),
vera_cfg=VeraPdfConfig(enabled=False),
output_cfg=OutputConfig(),
)
def test_same_name_failing_twice_keeps_both(tmp_path: Path) -> None:
dirs = _prepare(tmp_path)
for inhalt in (ERSTE, ZWEITE):
src = dirs["incoming"] / "scan.pdf"
src.write_bytes(inhalt)
result = _run_failing_ocr(dirs, src)
assert not result.success
dateien = sorted(p.read_bytes() for p in dirs["error"].iterdir())
assert len(dateien) == 2
assert sorted([ERSTE, ZWEITE]) == dateien
# ---------------- _rescue_to_error erbt den Schutz ----------------
def test_rescue_to_error_keeps_existing_file(tmp_config) -> None:
"""Der Rettungspfad nach einer unerwarteten Exception ebenso."""
(tmp_config.paths.error / "boom.pdf").write_bytes(ERSTE)
src = tmp_config.paths.incoming / "boom.pdf"
src.write_bytes(ZWEITE)
service = HotfolderService(tmp_config)
try:
service._rescue_to_error(src)
finally:
service._executor.shutdown(wait=False)
assert (tmp_config.paths.error / "boom.pdf").read_bytes() == ERSTE
ausweich = list(tmp_config.paths.error.glob("boom_*.pdf"))
assert len(ausweich) == 1
assert ausweich[0].read_bytes() == ZWEITE
+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
+148 -19
View File
@@ -1,6 +1,16 @@
"""Tests für Issue #3: Ghostscript 10.0.0–10.02.0 PDF/A-Bug-Erkennung."""
"""Tests für Issue #3: Ghostscript 10.0.0–10.02.0 Bug-Erkennung.
Seit v0.6.1 bildet der Preflight die reale ocrmypdf-Bedingung ab:
betroffene GS-Version UND skip_text UND (pdfa_level ODER ocrmypdf < 17)
Der letzte Teil ist der Fall, der in v0.6.0 durchrutschte: mit ocrmypdf 16.x
greift die Ghostscript-Prüfung auch ohne PDF/A, und der Dienst meldete
trotzdem "Preflight ok", während jede einzelne PDF in error/ landete.
"""
from __future__ import annotations
from contextlib import contextmanager
from unittest.mock import patch
import pytest
@@ -9,9 +19,21 @@ from pdf_ocr_hotfolder.service import (
PreflightError,
check_preflight,
is_ghostscript_broken,
ocrmypdf_checks_gs_always,
)
@contextmanager
def _env(gs_version: str, ocrmypdf_version: str):
"""Binaries vorhanden, Ghostscript- und ocrmypdf-Version vorgegeben."""
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"), \
patch("pdf_ocr_hotfolder.service.detect_ghostscript_version",
return_value=gs_version), \
patch("pdf_ocr_hotfolder.service.detect_ocrmypdf_version",
return_value=ocrmypdf_version):
yield
@pytest.mark.parametrize("version,expected", [
# Betroffene Versionen
("10.0.0", True),
@@ -36,29 +58,92 @@ def test_is_ghostscript_broken(version, expected) -> None:
assert is_ghostscript_broken(version) is expected
def test_check_preflight_without_pdfa_passes_with_broken_gs() -> None:
"""Ohne pdfa_level darf der betroffene GS verwendet werden."""
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"), \
patch("pdf_ocr_hotfolder.service.detect_ghostscript_version",
return_value="10.0.0"):
check_preflight(pdfa_level="") # darf nicht werfen
@pytest.mark.parametrize("version,expected", [
("16.13.0", True), # der Pin aus v0.6.0, der den Ausfall ausgeloest hat
("16.0.0", True),
("15.4.4", True),
("17.0.0", False), # ab hier steckt die Pruefung hinter output_type
("17.4.1", False), # unser Pin
("17.12.1", False),
("18.0.0", False),
(None, False), # unbekannt -> kein Fehlalarm
("", False),
("garbage", False),
])
def test_ocrmypdf_checks_gs_always(version, expected) -> None:
assert ocrmypdf_checks_gs_always(version) is expected
def test_check_preflight_with_pdfa_fails_on_broken_gs() -> None:
"""Mit pdfa_level + kaputtem GS → PreflightError mit hilfreicher Meldung."""
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"), \
patch("pdf_ocr_hotfolder.service.detect_ghostscript_version",
return_value="10.0.0"):
# ---------------- Preflight: ocrmypdf 17.x (unser Pin) ----------------
def test_broken_gs_skip_text_without_pdfa_passes_on_ocrmypdf_17() -> None:
"""Der Debian-12-Standardfall: ohne PDF/A fasst ocrmypdf 17 gs nicht an.
Das ist die Default-Config (skip_text=true, pdfa_level="") auf Debian 12 —
sie muss laufen, sonst startet keine einzige Bestandsinstanz mehr.
"""
with _env("10.0.0", "17.4.1"):
check_preflight(pdfa_level="", skip_text=True) # darf nicht werfen
def test_broken_gs_with_pdfa_and_skip_text_fails() -> None:
"""Mit pdfa_level + skip_text + kaputtem GS → PreflightError."""
with _env("10.0.0", "17.4.1"):
with pytest.raises(PreflightError, match="Ghostscript 10.0.0"):
check_preflight(pdfa_level="2")
check_preflight(pdfa_level="2", skip_text=True)
def test_check_preflight_with_pdfa_passes_on_fixed_gs() -> None:
"""Mit pdfa_level + gefixtem GS → ok."""
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"), \
patch("pdf_ocr_hotfolder.service.detect_ghostscript_version",
return_value="10.02.1"):
check_preflight(pdfa_level="2") # darf nicht werfen
def test_broken_gs_with_pdfa_without_skip_text_passes() -> None:
"""Ohne skip_text greift die ocrmypdf-Bedingung nicht — kein Abbruch."""
with _env("10.0.0", "17.4.1"):
check_preflight(pdfa_level="2", skip_text=False) # darf nicht werfen
def test_healthy_gs_with_pdfa_and_skip_text_passes() -> None:
"""Nicht betroffene GS-Version → nie ein Abbruch."""
with _env("10.02.1", "17.4.1"):
check_preflight(pdfa_level="2", skip_text=True) # darf nicht werfen
# ---------------- Preflight: ocrmypdf 16.x (der Ausfall aus v0.6.0) ----------------
def test_broken_gs_skip_text_without_pdfa_fails_on_ocrmypdf_16() -> None:
"""DER Fall, der in v0.6.0 durchrutschte.
ocrmypdf 16.13.0 + Ghostscript 10.0.0 + skip_text=true + pdfa_level="":
"Preflight ok", Dienst active — und jede PDF landete in error/.
Jetzt muss der Dienst beim START abbrechen.
"""
with _env("10.0.0", "16.13.0"):
with pytest.raises(PreflightError) as exc_info:
check_preflight(pdfa_level="", skip_text=True)
msg = str(exc_info.value)
assert "10.0.0" in msg
assert "16.13.0" in msg
def test_broken_gs_without_skip_text_passes_on_ocrmypdf_16() -> None:
"""skip_text=false → auch 16.x prüft Ghostscript nicht."""
with _env("10.0.0", "16.13.0"):
check_preflight(pdfa_level="", skip_text=False) # darf nicht werfen
def test_healthy_gs_passes_on_ocrmypdf_16() -> None:
"""Nicht betroffene GS-Version → auch mit 16.x kein Abbruch."""
with _env("10.02.1", "16.13.0"):
check_preflight(pdfa_level="", skip_text=True) # darf nicht werfen
# ---------------- Meldungstext ----------------
def test_error_message_names_both_remedies() -> None:
"""Der Admin muss aus der Meldung heraus handeln können."""
with _env("10.0.0", "16.13.0"):
with pytest.raises(PreflightError) as exc_info:
check_preflight(pdfa_level="", skip_text=True)
msg = str(exc_info.value)
assert "backports" in msg, "Weg 1: Ghostscript aus bookworm-backports"
assert "skip_text = false" in msg, "Weg 2: skip_text abschalten"
def test_default_config_pdfa_level_is_empty() -> None:
@@ -70,3 +155,47 @@ def test_default_config_pdfa_level_is_empty() -> None:
data = tomllib.load(f)
assert data["ocr"]["pdfa_level"] == "", \
"config.example.toml muss pdfa_level='' als sicheren Default haben"
# ---------------- Der Dienst muss beim START abbrechen ----------------
def test_run_once_aborts_on_ocrmypdf_16_with_broken_gs(tmp_config) -> None:
"""Abbruch beim Start statt Totalausfall bei der ersten Datei."""
from pdf_ocr_hotfolder.service import HotfolderService
assert tmp_config.ocr.skip_text is True
assert tmp_config.ocr.pdfa_level == ""
service = HotfolderService(tmp_config)
try:
with _env("10.0.0", "16.13.0"):
with pytest.raises(PreflightError):
service.run_once()
finally:
service._executor.shutdown(wait=False)
def test_check_config_returns_2_on_ocrmypdf_16_with_broken_gs(
tmp_path, tmp_config, monkeypatch, capsys) -> None:
"""--check-config meldet den Zustand als Fehler (Exit 2) — auch mitten im Update."""
import sys
from pdf_ocr_hotfolder.__main__ import CHECK_ERROR, main
cfg_file = tmp_path / "cfg.toml"
cfg_file.write_text(f"""
[paths]
incoming = "{tmp_config.paths.incoming}"
outgoing = "{tmp_config.paths.outgoing}"
working = "{tmp_config.paths.working}"
error = "{tmp_config.paths.error}"
[ocr]
skip_text = true
pdfa_level = ""
""")
monkeypatch.setattr(sys, "argv",
["pdf-ocr-hotfolder", "--config", str(cfg_file),
"--check-config"])
with _env("10.0.0", "16.13.0"):
assert main() == CHECK_ERROR
assert "Ghostscript" in capsys.readouterr().err
+88
View File
@@ -0,0 +1,88 @@
"""Punkt 6: Fremddateien in incoming/ verschwinden nicht mehr lautlos.
Alles ohne .pdf-Endung wurde kommentarlos ignoriert und sammelte sich an.
Jetzt gibt es beim Start-Scan genau EINE Sammelmeldung — kein Spam im
laufenden Betrieb, keine Zeile pro Datei.
"""
from __future__ import annotations
import logging
from unittest.mock import patch
from pdf_ocr_hotfolder.service import HotfolderService
LOGGER = "pdf_ocr_hotfolder.service"
def _scan(tmp_config, caplog) -> str:
service = HotfolderService(tmp_config)
try:
with caplog.at_level(logging.WARNING, logger=LOGGER), \
patch.object(HotfolderService, "enqueue"):
service._scan_existing()
finally:
service._executor.shutdown(wait=False)
return caplog.text
def test_non_pdf_files_are_reported(tmp_config, caplog) -> None:
(tmp_config.paths.incoming / "notizen.txt").write_text("x")
(tmp_config.paths.incoming / "bild.jpg").write_bytes(b"x")
(tmp_config.paths.incoming / "scan.pdf").write_bytes(b"%PDF-1.4\n")
text = _scan(tmp_config, caplog)
assert "2 Datei(en) ohne .pdf-Endung" in text
assert "notizen.txt" in text
assert "bild.jpg" in text
assert "scan.pdf" not in text
def test_single_aggregate_line(tmp_config, caplog) -> None:
"""Eine Sammelmeldung, nicht eine pro Datei."""
for i in range(7):
(tmp_config.paths.incoming / f"datei{i}.txt").write_text("x")
text = _scan(tmp_config, caplog)
assert text.count("ohne .pdf-Endung") == 1
assert "7 Datei(en)" in text
# Nur die ersten drei werden namentlich genannt
assert "+4 weitere" in text
def test_no_message_without_foreign_files(tmp_config, caplog) -> None:
(tmp_config.paths.incoming / "scan.pdf").write_bytes(b"%PDF-1.4\n")
assert "ohne .pdf-Endung" not in _scan(tmp_config, caplog)
def test_empty_incoming_is_quiet(tmp_config, caplog) -> None:
assert "ohne .pdf-Endung" not in _scan(tmp_config, caplog)
def test_directories_are_not_counted(tmp_config, caplog) -> None:
"""Ein Unterverzeichnis ist keine liegengebliebene Fremddatei."""
(tmp_config.paths.incoming / "unterordner").mkdir()
assert "ohne .pdf-Endung" not in _scan(tmp_config, caplog)
def test_uppercase_pdf_is_not_foreign(tmp_config, caplog) -> None:
"""`_is_pdf()` ist case-insensitiv — SCAN.PDF ist eine PDF."""
(tmp_config.paths.incoming / "SCAN.PDF").write_bytes(b"%PDF-1.4\n")
assert "ohne .pdf-Endung" not in _scan(tmp_config, caplog)
def test_no_spam_during_runtime(tmp_config, caplog) -> None:
"""Im laufenden Betrieb bleibt enqueue() für Fremddateien stumm."""
fremd = tmp_config.paths.incoming / "notizen.txt"
fremd.write_text("x")
service = HotfolderService(tmp_config)
try:
with caplog.at_level(logging.DEBUG, logger=LOGGER):
for _ in range(5):
service.enqueue(fremd)
finally:
service._executor.shutdown(wait=False)
assert caplog.text == ""
+117
View File
@@ -0,0 +1,117 @@
"""Log-Ziel: der Dienst loggt nach stdout, nicht nach stderr.
README und docs/INSTALLATION.md versprechen stdout. `logging.basicConfig()`
ohne `stream=` nimmt aber stderr. Für journald ist das egal, für den in der
Doku beschriebenen Vordergrund-Notbehelf und für jede Weiterleitung der
Ausgabe nicht.
Gleichzeitig muss die Trennung in `--check-config` bleiben: Infos und
Warnungen nach stdout, Fehler nach stderr.
"""
from __future__ import annotations
import io
import logging
import sys
from contextlib import contextmanager
from pathlib import Path
from unittest.mock import patch
from pdf_ocr_hotfolder.__main__ import _setup_logging, main
@contextmanager
def _fresh_root_logger():
"""Root-Logger wie beim echten Dienststart: ohne Handler.
`logging.basicConfig()` tut nichts, solange der Root-Logger Handler hat —
und pytest hängt seinen Capture-Handler dort ein, nachdem die Fixtures
gelaufen sind. Deshalb erst hier, direkt um den Aufruf herum, leeren.
"""
root = logging.getLogger()
saved_handlers, saved_level = root.handlers[:], root.level
root.handlers = []
try:
yield root
finally:
for h in root.handlers:
h.close()
root.handlers = saved_handlers
root.level = saved_level
def test_setup_logging_uses_stdout() -> None:
with _fresh_root_logger() as root:
_setup_logging("INFO")
streams = [h.stream for h in root.handlers
if isinstance(h, logging.StreamHandler)]
assert streams, "kein StreamHandler konfiguriert"
assert all(s is sys.stdout for s in streams)
assert not any(s is sys.stderr for s in streams)
def test_log_records_land_on_stdout(monkeypatch) -> None:
"""Ein echter Log-Satz muss im stdout-Puffer stehen, nicht im stderr."""
out, err = io.StringIO(), io.StringIO()
monkeypatch.setattr(sys, "stdout", out)
monkeypatch.setattr(sys, "stderr", err)
with _fresh_root_logger():
_setup_logging("INFO")
logging.getLogger("pdf_ocr_hotfolder.test").warning("Testmeldung 4711")
assert "Testmeldung 4711" in out.getvalue()
assert "Testmeldung 4711" not in err.getvalue()
def test_setup_logging_respects_level() -> None:
with _fresh_root_logger() as root:
_setup_logging("WARNING")
assert root.level == logging.WARNING
def test_setup_logging_falls_back_on_garbage_level() -> None:
"""Ein Tippfehler in [logging].level darf den Start nicht verhindern."""
with _fresh_root_logger() as root:
_setup_logging("LAUT")
assert root.level == logging.INFO
# ---------------- Trennung in --check-config ----------------
def _cfg(tmp_path: Path) -> Path:
cfg = tmp_path / "cfg.toml"
cfg.write_text(f"""
[paths]
incoming = "{tmp_path / 'in'}"
outgoing = "{tmp_path / 'out'}"
working = "{tmp_path / 'work'}"
error = "{tmp_path / 'err'}"
""")
return cfg
def test_check_config_keeps_info_on_stdout(tmp_path: Path, monkeypatch,
capsys) -> None:
monkeypatch.setattr(sys, "argv",
["pdf-ocr-hotfolder", "--config", str(_cfg(tmp_path)),
"--check-config"])
with patch("pdf_ocr_hotfolder.service.shutil.which",
return_value="/usr/bin/fake"):
assert main() == 0
captured = capsys.readouterr()
assert "Config sauber" in captured.out
assert captured.err == ""
def test_check_config_keeps_errors_on_stderr(tmp_path: Path, monkeypatch,
capsys) -> None:
cfg = tmp_path / "cfg.toml"
cfg.write_text('[ocr]\nlanguages = "deu"\n')
monkeypatch.setattr(sys, "argv",
["pdf-ocr-hotfolder", "--config", str(cfg),
"--check-config"])
assert main() == 2
captured = capsys.readouterr()
assert "FEHLER" in captured.err
assert "FEHLER" not in captured.out
+199
View File
@@ -0,0 +1,199 @@
"""Punkt 7: Ein toter watchdog-Observer muss auffallen.
Die Hauptschleife wartete nur auf `_stop` und fragte nie `is_alive()`. Stirbt
der Observer im Betrieb (erschöpftes inotify-Watch-Limit, ersetztes oder neu
gemountetes Verzeichnis), blieb die Unit `active (running)` und verarbeitete
nichts mehr — kein Log, keine Mail, niemand merkt es.
Jetzt endet der Dienst mit `EXIT_OBSERVER_DEAD` (3), damit systemd ihn per
`Restart=on-failure` neu startet. Bewusst nicht 2: die Unit setzt
`RestartPreventExitStatus=2` für Config-/Preflight-Fehler.
"""
from __future__ import annotations
import logging
import sys
import threading
from unittest.mock import MagicMock, patch
from pdf_ocr_hotfolder.service import EXIT_OBSERVER_DEAD, HotfolderService
class _NoSleepEvent(threading.Event):
"""Event, dessen wait() nicht schläft — hält die Tests schnell."""
def wait(self, timeout: float | None = None) -> bool: # noqa: D102
return self.is_set()
class _StopAfter(_NoSleepEvent):
"""Setzt sich nach n Warteschritten selbst — simuliert ein SIGTERM."""
def __init__(self, n: int) -> None:
super().__init__()
self._left = n
def wait(self, timeout: float | None = None) -> bool:
self._left -= 1
if self._left <= 0:
self.set()
return self.is_set()
def _service(tmp_config, stop: threading.Event, alive) -> HotfolderService:
service = HotfolderService(tmp_config)
service._stop = stop
observer = MagicMock()
if isinstance(alive, list):
observer.is_alive.side_effect = alive
else:
observer.is_alive.return_value = alive
service._observer = observer
return service
def _wait_loop(service: HotfolderService) -> int:
try:
return service._wait_loop()
finally:
service._executor.shutdown(wait=False)
# ---------------- _wait_loop ----------------
def test_dead_observer_returns_exit_code(tmp_config) -> None:
service = _service(tmp_config, _NoSleepEvent(), alive=False)
assert _wait_loop(service) == EXIT_OBSERVER_DEAD
def test_exit_code_is_not_2(tmp_config) -> None:
"""2 ist für Config-/Preflight-Fehler reserviert (RestartPreventExitStatus)."""
assert EXIT_OBSERVER_DEAD != 2
assert EXIT_OBSERVER_DEAD != 0
def test_dead_observer_logs_clearly(tmp_config, caplog) -> None:
service = _service(tmp_config, _NoSleepEvent(), alive=False)
with caplog.at_level(logging.ERROR, logger="pdf_ocr_hotfolder.service"):
_wait_loop(service)
text = caplog.text
assert str(tmp_config.paths.incoming) in text
assert "KEINE neuen Dateien" in text
assert "inotify" in text
def test_observer_is_checked_repeatedly(tmp_config) -> None:
"""Der Observer wird nicht nur einmal beim Start geprüft."""
service = _service(tmp_config, _NoSleepEvent(),
alive=[True, True, True, False])
assert _wait_loop(service) == EXIT_OBSERVER_DEAD
assert service._observer.is_alive.call_count == 4
def test_regular_stop_returns_zero(tmp_config) -> None:
"""SIGTERM/SIGINT bei lebendem Observer: kein Fehlalarm."""
service = _service(tmp_config, _StopAfter(3), alive=True)
assert _wait_loop(service) == 0
def test_stop_wins_over_dead_observer(tmp_config) -> None:
"""Beim planmäßigen Stoppen darf ein gestoppter Observer nichts auslösen.
`shutdown()` stoppt den Observer — läuft die Schleife danach noch einen
Takt, wäre das sonst ein Fehlalarm mit Exit 3 beim normalen Beenden.
"""
stop = _NoSleepEvent()
stop.set()
service = _service(tmp_config, stop, alive=False)
assert _wait_loop(service) == 0
service._observer.is_alive.assert_not_called()
def test_missing_observer_does_not_crash(tmp_config) -> None:
service = HotfolderService(tmp_config)
service._stop = _StopAfter(2)
service._observer = None
assert _wait_loop(service) == 0
# ---------------- run() / main() reichen den Code durch ----------------
def test_run_returns_exit_code(tmp_config) -> None:
observer = MagicMock()
observer.is_alive.return_value = False
service = HotfolderService(tmp_config)
service._stop = _NoSleepEvent()
try:
with patch("pdf_ocr_hotfolder.service.Observer", return_value=observer), \
patch("pdf_ocr_hotfolder.service.check_preflight"):
assert service.run() == EXIT_OBSERVER_DEAD
finally:
service._executor.shutdown(wait=False)
# Auch im Fehlerfall wird sauber heruntergefahren
observer.stop.assert_called_once()
def test_run_returns_zero_on_regular_stop(tmp_config) -> None:
observer = MagicMock()
observer.is_alive.return_value = True
service = HotfolderService(tmp_config)
service._stop = _StopAfter(2)
try:
with patch("pdf_ocr_hotfolder.service.Observer", return_value=observer), \
patch("pdf_ocr_hotfolder.service.check_preflight"):
assert service.run() == 0
finally:
service._executor.shutdown(wait=False)
def test_main_passes_exit_code_through(tmp_path, tmp_config, monkeypatch) -> None:
from pdf_ocr_hotfolder.__main__ import main
cfg_file = tmp_path / "cfg.toml"
cfg_file.write_text(f"""
[paths]
incoming = "{tmp_config.paths.incoming}"
outgoing = "{tmp_config.paths.outgoing}"
working = "{tmp_config.paths.working}"
error = "{tmp_config.paths.error}"
""")
monkeypatch.setattr(sys, "argv",
["pdf-ocr-hotfolder", "--config", str(cfg_file)])
with patch.object(HotfolderService, "run", return_value=EXIT_OBSERVER_DEAD):
assert main() == EXIT_OBSERVER_DEAD
def test_main_returns_zero_on_regular_stop(tmp_path, tmp_config, monkeypatch) -> None:
from pdf_ocr_hotfolder.__main__ import main
cfg_file = tmp_path / "cfg.toml"
cfg_file.write_text(f"""
[paths]
incoming = "{tmp_config.paths.incoming}"
outgoing = "{tmp_config.paths.outgoing}"
working = "{tmp_config.paths.working}"
error = "{tmp_config.paths.error}"
""")
monkeypatch.setattr(sys, "argv",
["pdf-ocr-hotfolder", "--config", str(cfg_file)])
with patch.object(HotfolderService, "run", return_value=0):
assert main() == 0
def test_main_returns_zero_on_keyboard_interrupt(tmp_path, tmp_config,
monkeypatch) -> None:
from pdf_ocr_hotfolder.__main__ import main
cfg_file = tmp_path / "cfg.toml"
cfg_file.write_text(f"""
[paths]
incoming = "{tmp_config.paths.incoming}"
outgoing = "{tmp_config.paths.outgoing}"
working = "{tmp_config.paths.working}"
error = "{tmp_config.paths.error}"
""")
monkeypatch.setattr(sys, "argv",
["pdf-ocr-hotfolder", "--config", str(cfg_file)])
with patch.object(HotfolderService, "run", side_effect=KeyboardInterrupt):
assert main() == 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
+2 -2
View File
@@ -8,7 +8,7 @@ from pdf_ocr_hotfolder.processor import ProcessResult
from pdf_ocr_hotfolder.service import HotfolderService
def _fake_success(src: Path, working_dir, outgoing_dir, error_dir, ocr_cfg, vera_cfg):
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")
@@ -16,7 +16,7 @@ def _fake_success(src: Path, working_dir, outgoing_dir, error_dir, ocr_cfg, vera
return ProcessResult(src, out, True)
def _fake_failure(src: Path, working_dir, outgoing_dir, error_dir, ocr_cfg, vera_cfg):
def _fake_failure(src: Path, working_dir, outgoing_dir, error_dir, **kwargs):
error_dir.mkdir(parents=True, exist_ok=True)
dest = error_dir / src.name
src.rename(dest)
+185
View File
@@ -0,0 +1,185 @@
"""Namens-Kollision in outgoing/ darf kein Ergebnis mehr überschreiben.
`process_pdf()` beendete mit `shutil.move(work_out, final_out)`. Lag dort
bereits eine Datei desselben Namens (Scanner liefert denselben Dateinamen ein
zweites Mal, oder das Vorgängerergebnis wurde noch nicht abgeholt), war das
ältere Ergebnis kommentarlos weg. Jetzt gilt derselbe Zeitstempel-Ausweg wie
im Archiv.
"""
from __future__ import annotations
import logging
from pathlib import Path
from unittest.mock import patch
import pytest
from pdf_ocr_hotfolder.config import OcrConfig, OutputConfig, VeraPdfConfig
from pdf_ocr_hotfolder.processor import _collision_free_path, process_pdf
OLD = b"%PDF-1.4 altes ergebnis\n"
ORIGINAL = b"%PDF-1.4 original\n"
def _fake_ocr(src: Path, dst: Path, cfg: OcrConfig) -> None:
dst.write_bytes(b"%PDF-1.4 OCRed\n" + src.read_bytes())
def _prepare(tmp_path: Path) -> dict:
dirs = {name: tmp_path / name
for name in ("incoming", "working", "outgoing", "error", "archive")}
for d in dirs.values():
d.mkdir(parents=True, exist_ok=True)
src = dirs["incoming"] / "scan.pdf"
src.write_bytes(ORIGINAL)
return {"src": src, **dirs}
def _run(env: dict, out_cfg: OutputConfig):
with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_fake_ocr):
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=False),
output_cfg=out_cfg,
)
# ---------------- Kollision in outgoing/ ----------------
def test_existing_result_is_not_overwritten(tmp_path: Path) -> None:
"""Beide Dateien müssen hinterher existieren."""
env = _prepare(tmp_path)
(env["outgoing"] / "OCR_scan.pdf").write_bytes(OLD)
result = _run(env, OutputConfig(name_mode="prefix", name_tag="OCR_",
original_on_success="delete"))
assert result.success
# Altes Ergebnis unverändert
assert (env["outgoing"] / "OCR_scan.pdf").read_bytes() == OLD
# Neues Ergebnis unter Zeitstempel-Namen daneben
neu = [p for p in env["outgoing"].glob("OCR_scan_*.pdf")]
assert len(neu) == 1
assert neu[0].read_bytes() == b"%PDF-1.4 OCRed\n" + ORIGINAL
assert len(list(env["outgoing"].iterdir())) == 2
def test_result_output_points_to_written_file(tmp_path: Path) -> None:
"""ProcessResult.output muss den TATSÄCHLICH geschriebenen Pfad tragen.
Sonst melden Uploads und die E-Mail-Benachrichtigung die falsche (nämlich
die fremde, ältere) Datei.
"""
env = _prepare(tmp_path)
(env["outgoing"] / "OCR_scan.pdf").write_bytes(OLD)
result = _run(env, OutputConfig(name_mode="prefix", name_tag="OCR_",
original_on_success="delete"))
assert result.output.exists()
assert result.output.name != "OCR_scan.pdf"
assert result.output.parent == env["outgoing"]
assert result.output.read_bytes() != OLD
def test_collision_logs_warning_with_both_names(tmp_path: Path, caplog) -> None:
env = _prepare(tmp_path)
(env["outgoing"] / "OCR_scan.pdf").write_bytes(OLD)
with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.processor"):
result = _run(env, OutputConfig(name_mode="prefix", name_tag="OCR_",
original_on_success="delete"))
text = caplog.text
assert "OCR_scan.pdf" in text
assert result.output.name in text
assert "überschrieben" in text
def test_no_collision_keeps_plain_name(tmp_path: Path, caplog) -> None:
"""Ohne Kollision bleibt alles wie bisher — kein Suffix, keine Warnung."""
env = _prepare(tmp_path)
with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.processor"):
result = _run(env, OutputConfig(name_mode="prefix", name_tag="OCR_",
original_on_success="delete"))
assert result.output == env["outgoing"] / "OCR_scan.pdf"
assert result.output.exists()
assert "überschrieben" not in caplog.text
def test_collision_with_name_mode_none(tmp_path: Path) -> None:
"""name_mode='none': Ergebnis heißt wie das Original — Kollision ist dort
der Normalfall, nicht die Ausnahme."""
env = _prepare(tmp_path)
(env["outgoing"] / "scan.pdf").write_bytes(OLD)
result = _run(env, OutputConfig(name_mode="none", name_tag="",
original_on_success="delete"))
assert result.success
assert (env["outgoing"] / "scan.pdf").read_bytes() == OLD
assert result.output.name.startswith("scan_")
assert result.output.suffix == ".pdf"
def test_original_is_still_disposed_after_collision(tmp_path: Path) -> None:
"""Der Ausweichname darf die Entsorgung des Originals nicht aushebeln."""
env = _prepare(tmp_path)
(env["outgoing"] / "OCR_scan.pdf").write_bytes(OLD)
_run(env, OutputConfig(name_mode="prefix", name_tag="OCR_",
original_on_success="archive",
archive_dir=str(env["archive"])))
assert list(env["working"].iterdir()) == []
assert (env["archive"] / "scan.pdf").read_bytes() == ORIGINAL
# ---------------- _collision_free_path ----------------
def test_collision_free_path_passes_through_free_name(tmp_path: Path) -> None:
dest = tmp_path / "frei.pdf"
assert _collision_free_path(dest) == dest
def test_collision_free_path_appends_timestamp(tmp_path: Path) -> None:
dest = tmp_path / "belegt.pdf"
dest.write_bytes(b"x")
out = _collision_free_path(dest)
assert out != dest
assert out.name.startswith("belegt_")
assert out.suffix == ".pdf"
assert not out.exists()
def test_collision_free_path_counts_up_within_same_second(tmp_path: Path) -> None:
"""Zwei Ergebnisse in derselben Sekunde (mehrere Worker) kollidieren sonst
erneut — und der move überschriebe wieder still."""
dest = tmp_path / "belegt.pdf"
dest.write_bytes(b"x")
first = _collision_free_path(dest)
first.write_bytes(b"y")
with patch("pdf_ocr_hotfolder.processor.datetime") as dt:
# Zeitstempel einfrieren: erzwingt denselben Namen wie `first`
dt.now.return_value.strftime.return_value = first.stem.split("_", 1)[1]
second = _collision_free_path(dest)
assert second != first
assert not second.exists()
assert second.suffix == ".pdf"
@pytest.mark.parametrize("name", ["ohne_extension", "zwei.punkte.pdf"])
def test_collision_free_path_keeps_extension(tmp_path: Path, name: str) -> None:
dest = tmp_path / name
dest.write_bytes(b"x")
out = _collision_free_path(dest)
assert out.suffix == dest.suffix
assert out.name != dest.name
+315
View File
@@ -0,0 +1,315 @@
"""Tests für Feature: konfigurierbare Dateinamen und Original-Behandlung."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from pdf_ocr_hotfolder.config import OcrConfig, OutputConfig, VeraPdfConfig
from pdf_ocr_hotfolder.processor import build_output_name, process_pdf
from pdf_ocr_hotfolder.service import PreflightError, check_output_config
# ---------------- build_output_name ----------------
@pytest.mark.parametrize("src,mode,tag,expected", [
# prefix
("scan.pdf", "prefix", "OCR_", "OCR_scan.pdf"),
("scan.pdf", "prefix", "[OCR] ", "[OCR] scan.pdf"),
# suffix (Tag vor Extension)
("scan.pdf", "suffix", "_OCR", "scan_OCR.pdf"),
("scan.pdf", "suffix", "-ocr", "scan-ocr.pdf"),
# none
("scan.pdf", "none", "OCR_", "scan.pdf"),
# leerer Tag = none
("scan.pdf", "prefix", "", "scan.pdf"),
("scan.pdf", "suffix", "", "scan.pdf"),
# Mehrfach-Punkte im Namen: nur letzte Extension zählt
("rechnung.2026.pdf", "suffix", "_OCR", "rechnung.2026_OCR.pdf"),
("rechnung.2026.pdf", "prefix", "OCR_", "OCR_rechnung.2026.pdf"),
# Name ohne Extension
("NO_EXT", "suffix", "_OCR", "NO_EXT_OCR"),
])
def test_build_output_name(src, mode, tag, expected) -> None:
assert build_output_name(src, mode, tag) == expected
def test_build_output_name_invalid_mode() -> None:
with pytest.raises(ValueError, match="name_mode"):
build_output_name("x.pdf", "bogus", "OCR_")
# ---------------- check_output_config ----------------
def test_check_output_config_delete_ok() -> None:
check_output_config("delete", "") # ok
def test_check_output_config_archive_requires_dir() -> None:
with pytest.raises(PreflightError, match="archive_dir"):
check_output_config("archive", "")
def test_check_output_config_archive_with_dir_ok() -> None:
check_output_config("archive", "/var/archive") # ok
def test_check_output_config_invalid_mode() -> None:
with pytest.raises(PreflightError, match="ungültig"):
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:
"""Simuliert ocrmypdf: kopiert Inhalt, erzeugt Zieldatei."""
dst.write_bytes(b"%PDF-1.4 OCRed\n" + src.read_bytes())
def _prepare(tmp_path: Path) -> dict:
dirs = {
"working": tmp_path / "working",
"outgoing": tmp_path / "outgoing",
"error": tmp_path / "error",
"archive": tmp_path / "archive",
"incoming": tmp_path / "incoming",
}
for d in dirs.values():
d.mkdir(parents=True, exist_ok=True)
src = dirs["incoming"] / "scan.pdf"
src.write_bytes(b"%PDF-1.4 original\n")
return {"src": src, **dirs}
def test_process_pdf_prefix_delete(tmp_path: Path) -> None:
env = _prepare(tmp_path)
out_cfg = OutputConfig(name_mode="prefix", name_tag="OCR_",
original_on_success="delete")
with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_fake_ocr):
result = process_pdf(
src=env["src"],
working_dir=env["working"],
outgoing_dir=env["outgoing"],
error_dir=env["error"],
ocr_cfg=OcrConfig(),
vera_cfg=VeraPdfConfig(enabled=False),
output_cfg=out_cfg,
)
assert result.success
assert (env["outgoing"] / "OCR_scan.pdf").exists()
# Original ist weg, weder in incoming noch in working
assert not env["src"].exists()
assert not (env["working"] / "scan.pdf").exists()
def test_process_pdf_suffix_delete(tmp_path: Path) -> None:
env = _prepare(tmp_path)
out_cfg = OutputConfig(name_mode="suffix", name_tag="_OCR",
original_on_success="delete")
with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_fake_ocr):
result = process_pdf(
src=env["src"],
working_dir=env["working"],
outgoing_dir=env["outgoing"],
error_dir=env["error"],
ocr_cfg=OcrConfig(),
vera_cfg=VeraPdfConfig(enabled=False),
output_cfg=out_cfg,
)
assert result.success
assert (env["outgoing"] / "scan_OCR.pdf").exists()
def test_process_pdf_none_mode(tmp_path: Path) -> None:
env = _prepare(tmp_path)
out_cfg = OutputConfig(name_mode="none", name_tag="OCR_",
original_on_success="delete")
with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_fake_ocr):
result = process_pdf(
src=env["src"],
working_dir=env["working"],
outgoing_dir=env["outgoing"],
error_dir=env["error"],
ocr_cfg=OcrConfig(),
vera_cfg=VeraPdfConfig(enabled=False),
output_cfg=out_cfg,
)
assert result.success
# Ausgang hat GLEICHEN Namen wie Original
assert (env["outgoing"] / "scan.pdf").exists()
def test_process_pdf_archive_original(tmp_path: Path) -> None:
env = _prepare(tmp_path)
out_cfg = OutputConfig(name_mode="prefix", name_tag="OCR_",
original_on_success="archive",
archive_dir=str(env["archive"]))
with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_fake_ocr):
result = process_pdf(
src=env["src"],
working_dir=env["working"],
outgoing_dir=env["outgoing"],
error_dir=env["error"],
ocr_cfg=OcrConfig(),
vera_cfg=VeraPdfConfig(enabled=False),
output_cfg=out_cfg,
)
assert result.success
assert (env["outgoing"] / "OCR_scan.pdf").exists()
# Original liegt jetzt im Archiv
archived = env["archive"] / "scan.pdf"
assert archived.exists()
assert archived.read_bytes() == b"%PDF-1.4 original\n"
def test_process_pdf_archive_name_collision(tmp_path: Path) -> None:
"""Bei Namens-Kollision im Archiv wird Timestamp angehängt."""
env = _prepare(tmp_path)
# Vorhandene Kollisions-Datei
(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"]))
with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_fake_ocr):
process_pdf(
src=env["src"],
working_dir=env["working"],
outgoing_dir=env["outgoing"],
error_dir=env["error"],
ocr_cfg=OcrConfig(),
vera_cfg=VeraPdfConfig(enabled=False),
output_cfg=out_cfg,
)
# Alte Datei unverändert
assert (env["archive"] / "scan.pdf").read_bytes() == b"old"
# Neue Datei mit Timestamp-Suffix
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"
+111
View File
@@ -0,0 +1,111 @@
"""Punkt 5: Relative Pfade in der Config sind ein Fehler, keine stille Annahme.
`incoming = "in"` legte das Verzeichnis unter dem WorkingDirectory des
Dienstes an (/opt/pdf-ocr-hotfolder/in) statt dort, wo der Scanner ablegt —
ohne Warnung. Der Dienst schaute dann dauerhaft ins Leere.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
from pdf_ocr_hotfolder.config import ConfigError, load_config
_ABS = {
"incoming": "/var/lib/pdf-ocr-hotfolder/incoming",
"outgoing": "/var/lib/pdf-ocr-hotfolder/outgoing",
"working": "/var/lib/pdf-ocr-hotfolder/working",
"error": "/var/lib/pdf-ocr-hotfolder/error",
}
def _write(tmp_path: Path, paths: dict[str, str], extra: str = "") -> Path:
cfg = tmp_path / "config.toml"
zeilen = "\n".join(f'{k} = "{v}"' for k, v in paths.items())
cfg.write_text(f"[paths]\n{zeilen}\n{extra}")
return cfg
@pytest.mark.parametrize("key", list(_ABS))
def test_relative_path_key_is_rejected(tmp_path: Path, key: str) -> None:
paths = dict(_ABS)
paths[key] = "in"
with pytest.raises(ConfigError) as exc:
load_config(_write(tmp_path, paths))
msg = str(exc.value)
assert key in msg
assert "absolut" in msg.lower()
assert "WorkingDirectory" in msg
def test_dot_relative_path_is_rejected(tmp_path: Path) -> None:
"""Auch './scans' und '../scans' sind relativ."""
paths = dict(_ABS, incoming="./scans")
with pytest.raises(ConfigError, match="incoming"):
load_config(_write(tmp_path, paths))
def test_absolute_paths_load(tmp_path: Path) -> None:
cfg = load_config(_write(tmp_path, _ABS))
assert cfg.paths.incoming == Path(_ABS["incoming"])
def test_relative_archive_dir_is_rejected(tmp_path: Path) -> None:
cfg = _write(tmp_path, _ABS,
'\n[output]\noriginal_on_success = "archive"\n'
'archive_dir = "archiv"\n')
with pytest.raises(ConfigError) as exc:
load_config(cfg)
assert "archive_dir" in str(exc.value)
def test_relative_upload_target_is_rejected(tmp_path: Path) -> None:
cfg = _write(tmp_path, _ABS,
'\n[upload.folder]\nenabled = true\ntarget = "fertig"\n')
with pytest.raises(ConfigError) as exc:
load_config(cfg)
assert "target" in str(exc.value)
def test_empty_archive_dir_and_target_are_fine(tmp_path: Path) -> None:
"""Leer heißt 'nicht gesetzt' und bleibt erlaubt (das ist der Default)."""
cfg = load_config(_write(tmp_path, _ABS,
'\n[output]\narchive_dir = ""\n'
'\n[upload.folder]\ntarget = ""\n'))
assert cfg.output.archive_dir == ""
assert cfg.folder.target == ""
def test_absolute_archive_dir_and_target_load(tmp_path: Path) -> None:
cfg = load_config(_write(tmp_path, _ABS,
'\n[output]\narchive_dir = "/srv/archiv"\n'
'\n[upload.folder]\ntarget = "/srv/fertig"\n'))
assert cfg.output.archive_dir == "/srv/archiv"
assert cfg.folder.target == "/srv/fertig"
# ---------------- CLI ----------------
def test_main_returns_2_on_relative_path(tmp_path: Path, monkeypatch, capsys) -> None:
cfg = _write(tmp_path, dict(_ABS, incoming="in"))
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 "incoming" in err
def test_check_config_returns_2_on_relative_path(tmp_path: Path, monkeypatch,
capsys) -> None:
from pdf_ocr_hotfolder.__main__ import CHECK_ERROR, main
cfg = _write(tmp_path, dict(_ABS, outgoing="raus"))
monkeypatch.setattr(sys, "argv",
["pdf-ocr-hotfolder", "--config", str(cfg),
"--check-config"])
assert main() == CHECK_ERROR
assert "outgoing" in capsys.readouterr().err
+204
View File
@@ -0,0 +1,204 @@
"""Tests für die Wiederaufnahme abgebrochener Läufe aus working/.
Hintergrund: `process_pdf()` verschiebt das Original vor dem OCR nach
working/. Wird der Dienst dort hart gestoppt (SIGKILL nach TimeoutStopSec),
blieb die Datei bisher für immer liegen — weder outgoing/, noch error/, noch
eine Mail. ocrmypdf läuft in diesen Tests nie wirklich.
"""
from __future__ import annotations
import logging
from pathlib import Path
from unittest.mock import patch
from pdf_ocr_hotfolder.config import OcrConfig, OutputConfig, VeraPdfConfig
from pdf_ocr_hotfolder.processor import OCR_TEMP_PREFIX, ProcessResult, process_pdf
from pdf_ocr_hotfolder.service import HotfolderService
def _fake_success(src: Path, working_dir, outgoing_dir, error_dir, **kwargs):
"""Simuliert einen erfolgreichen Durchlauf inkl. Entsorgung des Originals."""
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 _run_once(tmp_config, fake_process=_fake_success):
"""run_once() mit gemocktem Preflight und gemocktem process_pdf."""
seen: list[Path] = []
def spy(src, *args, **kwargs):
seen.append(src)
return fake_process(src, *args, **kwargs)
with patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None), \
patch("pdf_ocr_hotfolder.service.process_pdf", side_effect=spy), \
patch("pdf_ocr_hotfolder.service._wait_until_stable", return_value=True):
service = HotfolderService(tmp_config)
try:
service.run_once()
finally:
service._executor.shutdown(wait=False)
return service, seen
# ---------------- Aufgreifen aus working/ ----------------
def test_leftover_in_working_is_picked_up(tmp_config) -> None:
"""Eine in working/ liegen gebliebene PDF wird wieder verarbeitet."""
leftover = tmp_config.paths.working / "abgebrochen.pdf"
leftover.write_bytes(b"%PDF-1.4\n")
service, seen = _run_once(tmp_config)
assert [p.name for p in seen] == ["abgebrochen.pdf"]
assert seen[0].parent == tmp_config.paths.working
assert service.success_count == 1
assert not leftover.exists()
assert (tmp_config.paths.outgoing / "OCR_abgebrochen.pdf").exists()
def test_resume_logs_warning(tmp_config, caplog) -> None:
"""Die Wiederaufnahme muss deutlich im Log stehen."""
(tmp_config.paths.working / "abgebrochen.pdf").write_bytes(b"%PDF-1.4\n")
with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.service"):
_run_once(tmp_config)
text = caplog.text
assert "Abgebrochener Lauf wird fortgesetzt" in text
assert "abgebrochen.pdf" in text
def test_ocr_fragment_is_removed_and_not_processed(tmp_config, caplog) -> None:
"""__ocr_-Fragmente sind unbrauchbar: löschen, nicht als Eingabe nehmen."""
leftover = tmp_config.paths.working / "scan.pdf"
leftover.write_bytes(b"%PDF-1.4\n")
fragment = tmp_config.paths.working / f"{OCR_TEMP_PREFIX}OCR_scan.pdf"
fragment.write_bytes(b"%PDF-1.4 halbfertig\n")
with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.service"):
service, seen = _run_once(tmp_config)
assert [p.name for p in seen] == ["scan.pdf"]
assert not fragment.exists()
assert service.error_count == 0
assert "Fragment" in caplog.text
def test_non_pdf_in_working_is_ignored(tmp_config) -> None:
"""Fremddateien in working/ werden nicht angefasst."""
junk = tmp_config.paths.working / "notizen.txt"
junk.write_text("kein PDF")
_service, seen = _run_once(tmp_config)
assert seen == []
assert junk.exists()
def test_incoming_and_working_both_scanned(tmp_config) -> None:
"""incoming/ wird weiterhin gescannt — zusätzlich zu working/."""
(tmp_config.paths.incoming / "neu.pdf").write_bytes(b"%PDF-1.4\n")
(tmp_config.paths.working / "alt.pdf").write_bytes(b"%PDF-1.4\n")
service, seen = _run_once(tmp_config)
assert sorted(p.name for p in seen) == ["alt.pdf", "neu.pdf"]
assert service.success_count == 2
def test_name_collision_between_working_and_incoming(tmp_config, caplog) -> None:
"""Gleicher Name in beiden Ordnern: die working-Datei wird umbenannt.
Sonst würden sich beide dieselbe working- und dieselbe outgoing-Datei
teilen und eine der beiden ginge verloren.
"""
(tmp_config.paths.incoming / "scan.pdf").write_bytes(b"%PDF-1.4 neu\n")
(tmp_config.paths.working / "scan.pdf").write_bytes(b"%PDF-1.4 alt\n")
with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.service"):
service, seen = _run_once(tmp_config)
names = sorted(p.name for p in seen)
assert len(names) == 2
assert "scan.pdf" in names
# Die wiederaufgenommene Datei hat einen Zeitstempel bekommen
renamed = [n for n in names if n != "scan.pdf"][0]
assert renamed.startswith("scan_") and renamed.endswith(".pdf")
assert service.success_count == 2
assert "umbenannt" in caplog.text
# ---------------- process_pdf: kein zweiter Move ----------------
def _ocr_ok(src: Path, dst: Path, cfg) -> None:
dst.write_bytes(b"%PDF-1.4 ocr\n")
def test_process_pdf_resumes_without_second_move(tmp_config) -> None:
"""Eine Datei aus working/ darf nicht erneut nach working/ verschoben werden."""
src = tmp_config.paths.working / "scan.pdf"
src.write_bytes(b"%PDF-1.4\n")
with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_ocr_ok):
result = process_pdf(
src=src,
working_dir=tmp_config.paths.working,
outgoing_dir=tmp_config.paths.outgoing,
error_dir=tmp_config.paths.error,
ocr_cfg=OcrConfig(),
vera_cfg=VeraPdfConfig(enabled=False),
output_cfg=OutputConfig(),
)
assert result.success
assert (tmp_config.paths.outgoing / "OCR_scan.pdf").exists()
# Original entsorgt, keine Reste in working/
assert list(tmp_config.paths.working.iterdir()) == []
def test_process_pdf_resume_logs_warning(tmp_config, caplog) -> None:
src = tmp_config.paths.working / "scan.pdf"
src.write_bytes(b"%PDF-1.4\n")
with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.processor"), \
patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_ocr_ok):
process_pdf(
src=src,
working_dir=tmp_config.paths.working,
outgoing_dir=tmp_config.paths.outgoing,
error_dir=tmp_config.paths.error,
ocr_cfg=OcrConfig(),
vera_cfg=VeraPdfConfig(enabled=False),
output_cfg=OutputConfig(),
)
assert "Wiederaufnahme" in caplog.text
def test_process_pdf_refuses_to_overwrite_working_file(tmp_config) -> None:
"""Belegter Name in working/: lieber Fehler als stilles Überschreiben."""
busy = tmp_config.paths.working / "scan.pdf"
busy.write_bytes(b"%PDF-1.4 laeuft gerade\n")
src = tmp_config.paths.incoming / "scan.pdf"
src.write_bytes(b"%PDF-1.4 neu\n")
with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_ocr_ok):
result = process_pdf(
src=src,
working_dir=tmp_config.paths.working,
outgoing_dir=tmp_config.paths.outgoing,
error_dir=tmp_config.paths.error,
ocr_cfg=OcrConfig(),
vera_cfg=VeraPdfConfig(enabled=False),
output_cfg=OutputConfig(),
)
assert not result.success
assert "scan.pdf" in result.error
# Beide Dateien unangetastet
assert busy.read_bytes() == b"%PDF-1.4 laeuft gerade\n"
assert src.read_bytes() == b"%PDF-1.4 neu\n"
+110
View File
@@ -0,0 +1,110 @@
"""Kaputtes TOML beim NORMALEN Dienststart (nicht nur bei --check-config).
`--check-config` fing `tomllib.TOMLDecodeError` schon immer ab, der Startpfad
in `main()` aber nicht: ein Tippfehler in der Instanz-Config ergab einen
nackten Traceback. Zusammen mit `Restart=on-failure` in der Unit lief die
Instanz damit in einen Neustart-Loop.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
from pdf_ocr_hotfolder.__main__ import main
# Verschiedene Arten, eine TOML kaputt zu machen
BROKEN_TOMLS = [
pytest.param('[paths]\nincoming = "/tmp/in\n', id="unbalancierte-quotes"),
pytest.param("[paths\nincoming = \n", id="unvollstaendige-sektion"),
pytest.param('[paths]\nincoming "/tmp/in"\n', id="fehlendes-gleich"),
pytest.param('[paths]\nincoming = "/a"\n[paths]\nincoming = "/b"\n',
id="doppelte-sektion"),
]
def _run(monkeypatch, cfg: Path, *extra_args: str) -> int:
monkeypatch.setattr(
sys, "argv",
["pdf-ocr-hotfolder", "--config", str(cfg), *extra_args],
)
return main()
@pytest.mark.parametrize("content", BROKEN_TOMLS)
def test_broken_toml_returns_2_on_normal_start(
tmp_path: Path, monkeypatch, capsys, content: str) -> None:
"""Dienststart ohne --once: Exit 2, keine Exception nach außen."""
cfg = tmp_path / "instanz.toml"
cfg.write_text(content)
assert _run(monkeypatch, cfg) == 2
err = capsys.readouterr().err
assert "FEHLER" in err
assert "TOML" in err
assert str(cfg) in err
@pytest.mark.parametrize("content", BROKEN_TOMLS)
def test_broken_toml_returns_2_with_once(
tmp_path: Path, monkeypatch, capsys, content: str) -> None:
"""Auch --once darf nicht mit Traceback aussteigen."""
cfg = tmp_path / "instanz.toml"
cfg.write_text(content)
assert _run(monkeypatch, cfg, "--once") == 2
assert "TOML" in capsys.readouterr().err
def test_broken_toml_message_names_position(
tmp_path: Path, monkeypatch, capsys) -> None:
"""Die Meldung muss dem Kunden sagen, WO es klemmt.
Zeile/Spalte kommen ab Python 3.14 aus den Exception-Attributen, darunter
stecken sie im Meldungstext von tomllib. Beide Wege müssen in der Ausgabe
landen.
"""
cfg = tmp_path / "instanz.toml"
cfg.write_text('[paths]\nincoming = "/tmp/in"\nworking = "/tmp/w\n')
assert _run(monkeypatch, cfg) == 2
err = capsys.readouterr().err.lower()
assert "zeile 3" in err or "line 3" in err
def test_broken_toml_start_and_check_config_agree(
tmp_path: Path, monkeypatch, capsys) -> None:
"""Startpfad und --check-config liefern denselben Exit-Code und Text."""
cfg = tmp_path / "instanz.toml"
cfg.write_text('[paths]\nincoming = "/tmp/in\n')
assert _run(monkeypatch, cfg) == 2
start_err = capsys.readouterr().err
assert _run(monkeypatch, cfg, "--check-config") == 2
check_err = capsys.readouterr().err
marker = f"{cfg} ist kein gültiges TOML"
assert marker in start_err
assert marker in check_err
def test_unreadable_config_returns_2(tmp_path: Path, monkeypatch, capsys) -> None:
"""Config existiert, ist aber nicht lesbar → Exit 2 statt Traceback."""
cfg = tmp_path / "instanz.toml"
cfg.write_text('[paths]\nincoming = "/tmp/in"\n')
cfg.chmod(0o000)
try:
# Als root greifen Dateirechte nicht — dann ist der Test gegenstandslos
try:
cfg.open("rb").close()
pytest.skip("Datei trotz chmod 000 lesbar (root?)")
except PermissionError:
pass
assert _run(monkeypatch, cfg) == 2
assert "nicht lesbar" in capsys.readouterr().err
finally:
cfg.chmod(0o644)
+134
View File
@@ -0,0 +1,134 @@
"""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
# ---------------- Punkt 3: kein stilles Überschreiben im Ziel ----------------
def test_upload_folder_does_not_overwrite_existing(tmp_path: Path) -> None:
"""Gleichnamige Datei im abweichenden target wurde bisher still ersetzt."""
src = tmp_path / "out" / "OCR_scan.pdf"
src.parent.mkdir()
src.write_bytes(b"%PDF-1.4 neu\n")
target = tmp_path / "ziel"
target.mkdir()
(target / "OCR_scan.pdf").write_bytes(b"%PDF-1.4 alt\n")
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 alt\n"
neu = list(target.glob("OCR_scan_*.pdf"))
assert len(neu) == 1
assert neu[0].read_bytes() == b"%PDF-1.4 neu\n"
def test_upload_folder_logs_warning_on_collision(tmp_path: Path, caplog) -> None:
import logging
src = tmp_path / "out" / "OCR_scan.pdf"
src.parent.mkdir()
src.write_bytes(b"%PDF-1.4 neu\n")
target = tmp_path / "ziel"
target.mkdir()
(target / "OCR_scan.pdf").write_bytes(b"%PDF-1.4 alt\n")
with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.uploaders"):
upload_folder(src, FolderUpload(enabled=True, target=str(target)),
tmp_path / "out")
assert "OCR_scan.pdf" in caplog.text
assert "überschrieben" in caplog.text
def test_upload_folder_without_collision_logs_nothing(tmp_path: Path, caplog) -> None:
import logging
src = tmp_path / "out" / "OCR_scan.pdf"
src.parent.mkdir()
src.write_bytes(b"%PDF-1.4\n")
with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.uploaders"):
upload_folder(src, FolderUpload(enabled=True, target=str(tmp_path / "ziel")),
tmp_path / "out")
assert "überschrieben" not in caplog.text
assert (tmp_path / "ziel" / "OCR_scan.pdf").exists()
def test_upload_folder_self_target_is_not_renamed(tmp_path: Path) -> None:
"""Default (leeres target -> outgoing/): der resolve()-Kurzschluss greift.
Ohne ihn würde die Datei hier gegen sich selbst kollidieren und eine
Zeitstempel-Kopie neben sich selbst erzeugen.
"""
out = tmp_path / "out"
out.mkdir()
src = out / "OCR_scan.pdf"
src.write_bytes(b"%PDF-1.4\n")
assert upload_folder(src, FolderUpload(enabled=True, target=""), out) is True
assert list(out.iterdir()) == [src]
+290
View File
@@ -0,0 +1,290 @@
"""Punkt 1: veraPDF-Binary wird geprüft — sonst vernichtet es die Originale.
Mit `[verapdf].enabled = true` und falschem Pfad lieferte `run_verapdf()` für
JEDE Datei False: OCR-Ergebnis nach error/, Original laut
`original_on_success = "delete"` gelöscht. Ein Tippfehler im Pfad vernichtete
so Scan für Scan die Vorlagen, während die Unit als "läuft" dastand.
Zwei Absicherungen:
1. Der Preflight lässt den Dienst gar nicht erst starten (Exit 2).
2. `run_verapdf()` unterscheidet "nicht konform" (False) von "nicht
aufrufbar" (`VeraPdfUnavailable`) — Letzteres entsorgt nichts.
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
from pdf_ocr_hotfolder.config import OcrConfig, OutputConfig, VeraPdfConfig
from pdf_ocr_hotfolder.processor import (
VeraPdfUnavailable,
process_pdf,
resolve_verapdf_binary,
run_verapdf,
)
from pdf_ocr_hotfolder.service import (
HotfolderService,
PreflightError,
check_preflight,
check_verapdf_binary,
)
ORIGINAL = b"%PDF-1.4 original\n"
def _executable(tmp_path: Path, name: str = "verapdf") -> Path:
"""Legt eine echte, ausführbare Datei an (wird nie wirklich aufgerufen)."""
b = tmp_path / name
b.write_text("#!/bin/sh\nexit 0\n")
b.chmod(0o755)
return b
# ---------------- check_verapdf_binary ----------------
def test_disabled_verapdf_ignores_binary() -> None:
"""Solange veraPDF aus ist, darf ein unsinniger Pfad nichts blockieren."""
check_verapdf_binary(False, "/gibt/es/nicht/verapdf")
def test_existing_executable_passes(tmp_path: Path) -> None:
check_verapdf_binary(True, str(_executable(tmp_path)))
def test_missing_binary_raises(tmp_path: Path) -> None:
with pytest.raises(PreflightError) as exc:
check_verapdf_binary(True, str(tmp_path / "tippfehler"))
msg = str(exc.value)
assert "verapdf" in msg.lower()
assert "tippfehler" in msg
def test_non_executable_binary_raises(tmp_path: Path) -> None:
"""Vorhanden, aber ohne x-Bit: genauso tödlich wie gar nicht vorhanden."""
b = tmp_path / "verapdf"
b.write_text("#!/bin/sh\n")
b.chmod(0o644)
with pytest.raises(PreflightError, match="ausführbar"):
check_verapdf_binary(True, str(b))
def test_empty_binary_raises() -> None:
with pytest.raises(PreflightError, match=r"\[verapdf\].binary"):
check_verapdf_binary(True, "")
def test_resolve_finds_binary_in_path(tmp_path: Path, monkeypatch) -> None:
"""Ein nackter Name wird im PATH gesucht, nicht nur ein absoluter Pfad."""
_executable(tmp_path, "verapdf")
monkeypatch.setenv("PATH", str(tmp_path))
assert resolve_verapdf_binary("verapdf") == str(tmp_path / "verapdf")
def test_resolve_returns_none_for_empty() -> None:
assert resolve_verapdf_binary("") is None
# ---------------- check_preflight reicht die veraPDF-Prüfung durch ----------------
def test_check_preflight_checks_verapdf(tmp_path: Path) -> None:
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"):
with pytest.raises(PreflightError, match="verapdf"):
check_preflight(verapdf_enabled=True,
verapdf_binary=str(tmp_path / "weg"))
def test_check_preflight_ok_with_verapdf(tmp_path: Path) -> None:
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"):
check_preflight(verapdf_enabled=True,
verapdf_binary=str(_executable(tmp_path)))
def test_run_once_aborts_on_broken_verapdf(tmp_config, tmp_path: Path) -> None:
"""Der Dienst startet nicht — statt Datei für Datei Originale zu löschen."""
tmp_config.verapdf = VeraPdfConfig(enabled=True,
binary=str(tmp_path / "gibtsnicht"))
service = HotfolderService(tmp_config)
try:
with patch("pdf_ocr_hotfolder.service.shutil.which",
return_value="/usr/bin/fake"):
with pytest.raises(PreflightError, match="verapdf"):
service.run_once()
finally:
service._executor.shutdown(wait=False)
def test_check_config_returns_2_for_broken_verapdf(tmp_path, tmp_config,
monkeypatch, capsys) -> None:
"""--check-config meldet Exit 2 (der Updater wertet das aus)."""
from pdf_ocr_hotfolder.__main__ import CHECK_ERROR, main
cfg_file = tmp_path / "cfg.toml"
cfg_file.write_text(f"""
[paths]
incoming = "{tmp_config.paths.incoming}"
outgoing = "{tmp_config.paths.outgoing}"
working = "{tmp_config.paths.working}"
error = "{tmp_config.paths.error}"
[verapdf]
enabled = true
binary = "{tmp_path / 'nicht-da'}"
""")
monkeypatch.setattr(sys, "argv",
["pdf-ocr-hotfolder", "--config", str(cfg_file),
"--check-config"])
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"):
assert main() == CHECK_ERROR
assert "nicht-da" in capsys.readouterr().err
def test_check_config_ok_with_working_verapdf(tmp_path, tmp_config,
monkeypatch) -> None:
from pdf_ocr_hotfolder.__main__ import CHECK_OK, main
cfg_file = tmp_path / "cfg.toml"
cfg_file.write_text(f"""
[paths]
incoming = "{tmp_config.paths.incoming}"
outgoing = "{tmp_config.paths.outgoing}"
working = "{tmp_config.paths.working}"
error = "{tmp_config.paths.error}"
[verapdf]
enabled = true
binary = "{_executable(tmp_path)}"
""")
monkeypatch.setattr(sys, "argv",
["pdf-ocr-hotfolder", "--config", str(cfg_file),
"--check-config"])
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"):
assert main() == CHECK_OK
# ---------------- run_verapdf: Urteil vs. Nicht-Aufrufbarkeit ----------------
def _completed(returncode: int, stdout: str = "", stderr: str = ""):
return subprocess.CompletedProcess([], returncode, stdout, stderr)
def test_run_verapdf_pass(tmp_path: Path) -> None:
cfg = VeraPdfConfig(enabled=True, binary=str(_executable(tmp_path)))
with patch("pdf_ocr_hotfolder.processor.subprocess.run",
return_value=_completed(0, "PASS /tmp/x.pdf\n")):
assert run_verapdf(tmp_path / "x.pdf", cfg) is True
def test_run_verapdf_fail_is_a_verdict(tmp_path: Path) -> None:
"""Echtes FAIL bleibt False — das ist ein inhaltliches Urteil."""
cfg = VeraPdfConfig(enabled=True, binary=str(_executable(tmp_path)))
with patch("pdf_ocr_hotfolder.processor.subprocess.run",
return_value=_completed(1, "FAIL /tmp/x.pdf\n")):
assert run_verapdf(tmp_path / "x.pdf", cfg) is False
def test_run_verapdf_missing_binary_raises(tmp_path: Path) -> None:
"""Fehlendes Programm ist KEIN FAIL mehr, sondern ein Fehler."""
cfg = VeraPdfConfig(enabled=True, binary=str(tmp_path / "weg"))
with pytest.raises(VeraPdfUnavailable, match="weg"):
run_verapdf(tmp_path / "x.pdf", cfg)
def test_run_verapdf_timeout_raises(tmp_path: Path) -> None:
cfg = VeraPdfConfig(enabled=True, binary=str(_executable(tmp_path)))
with patch("pdf_ocr_hotfolder.processor.subprocess.run",
side_effect=subprocess.TimeoutExpired("verapdf", 300)):
with pytest.raises(VeraPdfUnavailable, match="nicht geantwortet"):
run_verapdf(tmp_path / "x.pdf", cfg)
def test_run_verapdf_oserror_raises(tmp_path: Path) -> None:
cfg = VeraPdfConfig(enabled=True, binary=str(_executable(tmp_path)))
with patch("pdf_ocr_hotfolder.processor.subprocess.run",
side_effect=OSError("Exec format error")):
with pytest.raises(VeraPdfUnavailable, match="nicht startbar"):
run_verapdf(tmp_path / "x.pdf", cfg)
def test_run_verapdf_without_verdict_raises(tmp_path: Path) -> None:
"""Startet der Wrapper nicht durch (fehlendes Java), steht kein Urteil da.
Exit != 0 ohne PASS/FAIL in der Ausgabe darf nicht als "nicht konform"
durchgehen — genau so würde der Original-Löschpfad wieder aufgehen.
"""
cfg = VeraPdfConfig(enabled=True, binary=str(_executable(tmp_path)))
with patch("pdf_ocr_hotfolder.processor.subprocess.run",
return_value=_completed(127, "", "java: command not found")):
with pytest.raises(VeraPdfUnavailable, match="kein Urteil"):
run_verapdf(tmp_path / "x.pdf", cfg)
def test_run_verapdf_disabled_returns_true(tmp_path: Path) -> None:
assert run_verapdf(tmp_path / "x.pdf", VeraPdfConfig(enabled=False)) is True
# ---------------- process_pdf: nicht aufrufbares veraPDF entsorgt nichts ----------------
def _fake_ocr(src: Path, dst: Path, cfg: OcrConfig) -> None:
dst.write_bytes(b"%PDF-1.4 OCRed\n")
def _prepare(tmp_path: Path) -> dict:
dirs = {name: tmp_path / name
for name in ("incoming", "working", "outgoing", "error", "archive")}
for d in dirs.values():
d.mkdir(parents=True, exist_ok=True)
src = dirs["incoming"] / "scan.pdf"
src.write_bytes(ORIGINAL)
return {"src": src, **dirs}
def _run_unavailable(env: dict, out_cfg: OutputConfig):
with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_fake_ocr), \
patch("pdf_ocr_hotfolder.processor.run_verapdf",
side_effect=VeraPdfUnavailable("Binary weg")):
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_unavailable_verapdf_keeps_original_despite_delete(tmp_path: Path) -> None:
"""Der gefährliche Fall: original_on_success='delete' darf nicht greifen."""
env = _prepare(tmp_path)
result = _run_unavailable(env, OutputConfig(name_mode="prefix",
name_tag="OCR_",
original_on_success="delete"))
assert not result.success
# Original ist NICHT weg, sondern in error/ gesichert
gesichert = env["error"] / "scan.pdf"
assert gesichert.exists()
assert gesichert.read_bytes() == ORIGINAL
assert not (env["working"] / "scan.pdf").exists()
# Kein fertiges Ergebnis in outgoing/
assert list(env["outgoing"].iterdir()) == []
def test_unavailable_verapdf_result_is_not_a_fail_verdict(tmp_path: Path) -> None:
"""verapdf_passed bleibt None: es gab kein Urteil, nur einen Fehler."""
env = _prepare(tmp_path)
result = _run_unavailable(env, OutputConfig(original_on_success="delete"))
assert result.verapdf_passed is None
assert "veraPDF" in result.error
def test_unavailable_verapdf_also_keeps_ocr_result(tmp_path: Path) -> None:
env = _prepare(tmp_path)
_run_unavailable(env, OutputConfig(name_mode="prefix", name_tag="OCR_",
original_on_success="delete"))
assert (env["error"] / "__ocr_OCR_scan.pdf").exists()
assert list(env["working"].iterdir()) == []
+1219 -60
View File
File diff suppressed because it is too large Load Diff