2 Commits

Author SHA1 Message Date
techadmin 465ff8873f fix: Ghostscript-Angebot log nicht mehr, fehlende Voraussetzungen dokumentiert (v0.7.1)
Befunde aus dem ersten echten Erstinstallations-Test auf frischen Debian-12-
und Debian-13-Containern. Der Weg selbst hat getragen (Basis-Install,
Instanz-Anlage, zweite Instanz, Update mit Rauchtest, Rollback) — diese
Stellen haben gelogen oder gefehlt:

- Das Ghostscript-Backports-Angebot auf Debian 12 war ein garantierter
  Leerlauf, der "aktualisiert ✓" meldete: bookworm-backports enthaelt gar
  kein ghostscript (am Paketindex verifiziert). Die Routine sucht jetzt den
  echten Kandidaten, vergleicht vorher/nachher und raeumt eine nur zur Probe
  angelegte Quelle wieder weg.
- Derselbe untaugliche Rat stand in der Preflight-Meldung, der
  pdfa_level-Warnung, config.example.toml und vier Doku-Dateien — ueberall
  ersetzt durch die echten Optionen.
- Mehrzeilige Log-Hinweise waren durch "echo -e" zerrissen und nicht
  kopierbar; log_* nutzt jetzt printf mit %s.
- pip-freeze.txt landete beim Rollback als /pip-freeze.txt im
  Wurzelverzeichnis, liegt jetzt unter opt/pdf-ocr-hotfolder/.
- git und sudo fehlen auf dem Proxmox-Debian-Template; "sudo ./install.sh"
  scheitert dort. Beide Wege dokumentiert, git als Voraussetzung ergaenzt,
  HTTPS-Clone als Normalfall.
- Rollback: systemctl start kann kein Glob. journald-Reparatur: Instanzen
  danach neu starten, sonst bleibt das Journal leer.

254 Tests gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 01:36:42 +02:00
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
29 changed files with 3499 additions and 373 deletions
+163 -46
View File
@@ -1,8 +1,8 @@
# AI Agent Briefing — PDF OCR Hotfolder
**Zuletzt aktualisiert:** 2026-09-23
**Version:** 0.6.3
**Status:** Multi-Instanz-Betrieb, Preflight-Checks, Fehlerzählung, Wiederaufnahme aus `working/` und ein abgesicherter Updater (venv-Rebuild, Backup, Verifikation, Versionssprung-Meldung, Rauchtest). Test-Suite grün (152 pytest-Tests). Ein Produktiv-Einsatz ist im Repo (README/CHANGELOG) nicht dokumentiert — die bisherigen Fixes stammen aus Issues #1–#6 und aus Vorbereitungen auf Debian 13, nicht aus einem belegten Dauerbetrieb.
**Version:** 0.7.1
**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) ·
@@ -23,33 +23,46 @@ pdf-ocr-hotfolder/
│ ├── __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
│ ├── processor.py # ocrmypdf-Call, veraPDF, Ausgabename, Original-Entsorgung
│ ├── 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
├── tests/ # pytest-Suite (152 Tests, ocrmypdf wird gemockt)
├── 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_upload_folder.py
│ ├── 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 # Template-Unit (Instanz = %i), TimeoutStopSec=300
│ ├── 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
│ ├── 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 + Instanz-Manager
├── update.sh # Updater (--help, --rebuild-venv), ~860 Zeilen
├── 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
@@ -82,6 +95,7 @@ Instanzen auf einmal reißen). Geprüft gegen Python 3.11 (Debian 12) und 3.13
| 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 |
@@ -92,7 +106,9 @@ Instanzen auf einmal reißen). Geprüft gegen Python 3.11 (Debian 12) und 3.13
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.
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
@@ -116,7 +132,12 @@ Arbeit am Code zählt:
- 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()` in `install.sh`, schlankere Variante der Prüfung in `update.sh`).
(`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.
@@ -132,15 +153,50 @@ Arbeit am Code zählt:
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 `install.sh` zwischen den
- 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()`. **`update.sh` schneidet diesen Block per `sed` heraus
und evaluiert ihn** — Marken und Funktionsname dürfen sich nicht ändern, ohne
`update.sh` anzupassen.
`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).
## 🧰 `lib/common.sh` — gemeinsame Shell-Bibliothek
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:
| 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()` |
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:
@@ -159,10 +215,10 @@ Vollständig: [docs/UPDATE.md](docs/UPDATE.md). Für die Arbeit am Skript wichti
- **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()` in `update.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.
- **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.
@@ -172,7 +228,9 @@ Vollständig: [docs/UPDATE.md](docs/UPDATE.md). Für die Arbeit am Skript wichti
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
alle Drop-ins und ein `pip-freeze.txt` der alten venv (im Archiv unter
`opt/pdf-ocr-hotfolder/`, damit es beim Entpacken nach `/` nicht im
Wurzelverzeichnis landet). **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
@@ -182,7 +240,9 @@ Vollständig: [docs/UPDATE.md](docs/UPDATE.md). Für die Arbeit am Skript wichti
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.
`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)
@@ -191,16 +251,24 @@ Vollständiges Beispiel mit Kommentaren: `config.example.toml`.
| Sektion | Zweck |
|---------|-------|
| `[paths]` | `incoming`, `outgoing`, `working`, `error` — **Pflicht**, fehlt einer → `ConfigError` + Exit 2 |
| `[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` |
| `[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) |
| `[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),
@@ -221,19 +289,37 @@ Unbekannte Keys werden beim Laden zwar ignoriert, aber **nicht mehr still**:
### `--check-config`
`python -m pdf_ocr_hotfolder --check-config --config <datei>` lädt die Config,
zeigt Pfade/Sprachen/Timeout/PDF/A, 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.
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
**Beim Start (`run()` wie `run_once()`), vor allem anderen:**
1. `check_preflight(pdfa_level, skip_text)` — `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_output_config()` — validiert `original_on_success`, `archive_dir` (Pflicht bei `archive`) und `name_mode`
3. Scheitert eines davon → `PreflightError`, CLI beendet sich mit **Exit-Code 2** (ebenso bei kaputter/fehlender Config)
4. `ensure_dirs()`, dann `_scan_existing()`: **zuerst `working/`**, danach `incoming/`
**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
**Wiederaufnahme aus `working/` (`_scan_working()`):**
`process_pdf()` verschiebt das Original vor dem OCR nach `working/`. Wird der
@@ -252,16 +338,34 @@ liegen und wurde nie wieder angefasst — stiller Datenverlust. Jetzt:
`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) — bei FAIL geht das OCR-Ergebnis nach `error/`, das Original folgt `original_on_success` (wird also bei `archive` **nicht** gelöscht)
6. Move nach `outgoing/` unter dem laut `[output]` gebauten Namen (`build_output_name()`)
7. Original in `working/` wird laut `original_on_success` **gelöscht** oder nach `archive_dir` **archiviert** (Kollision → Timestamp-Suffix)
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`
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:**
@@ -272,6 +376,8 @@ liegen und wurde nie wieder angefasst — stiller Datenverlust. Jetzt:
| 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 |
@@ -293,14 +399,22 @@ Der Service läuft in allen Fällen weiter (kein `exit 1` wie im alten Bash-Tool
- **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`.
`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 — und die Falle dabei:** `pdfa_level = ""` lassen (Default), `skip_text = false` setzen, oder eine Distribution mit neuerem Ghostscript (**Debian 13: 10.05.1**). **Ein Ghostscript-Upgrade auf Debian 12 gibt es nicht** — `bookworm-backports` enthält kein Ghostscript-Paket (am echten Paketindex geprüft: 2606 Pakete, `ghostscript` nicht darunter). Bis einschließlich v0.7.0 haben Preflight-Meldung, Config-Warnung, `config.example.toml`, README und alle drei docs-Dateien genau dieses Upgrade empfohlen — ein Rat, der nie funktionieren konnte. Falls er irgendwo wieder auftaucht: **ersatzlos streichen**. Planungsaussage für den Admin: **wer auf Debian 12 PDF/A mit `skip_text = true` braucht, hat dort keinen Weg** — das muss *vor* der Installation entschieden werden, nicht beim Preflight-Abbruch.
- **`[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 `install.sh` und `update.sh` das je mit einem eigenen `venv_is_healthy()` (die Variante in `update.sh` ist die gründlichere und schaut zusätzlich in `pyvenv.cfg`).
- **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. **Nach der Reparatur die Instanzen einmal neu starten** (`systemctl restart 'pdf-ocr-hotfolder@*'`): wer gestartet wurde, während journald tot war, hat danach ein leeres Journal (`-- No entries --`) — das sieht aus wie eine gescheiterte Reparatur, ist aber nur der fehlende Neustart.
- **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).
- **Auf einem frischen Proxmox-Debian-Template (12 und 13) fehlen `sudo` und `git`.** `sudo ./install.sh` scheitert dort mit `sudo: command not found` — als `root` direkt (`./install.sh`) laeuft alles; die Skripte brauchen root-Rechte, nicht `sudo`. Und ohne `git` gibt es keinen Clone. Beides steht jetzt in den Voraussetzungen ([docs/INSTALLATION.md](docs/INSTALLATION.md#pakete-die-auf-einem-frischen-system-fehlen-können)). Wer Doku aendert: `sudo` **nicht** ueberall streichen — die meisten Admins haben es; beide Wege nennen.
- **`systemctl start` kann kein Glob.** `stop 'pdf-ocr-hotfolder@*'` trifft alle laufenden Instanzen (die Units sind geladen), `start 'pdf-ocr-hotfolder@*'` versucht eine Instanz namens `*` zu starten und scheitert. Nach einem Rollback muss deshalb **jede Instanz einzeln** gestartet werden ([docs/UPDATE.md](docs/UPDATE.md#rollback)). `restart` geht wieder mit Glob.
## 🛠️ Entwicklung
@@ -316,18 +430,20 @@ python -m pdf_ocr_hotfolder --config /tmp/config.toml
Tests (aus dem Repo-Root, `pytest.ini` setzt `testpaths = tests`):
```bash
pytest # aktuell 152 Tests
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.
`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
- [x] Tests (`pytest`) für `processor` und `uploaders` — 152 Tests
- [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)
- [ ] Test-Lücken schließen: der watchdog-Eventpfad (`_Handler`/`Observer`) wird nirgends getestet, `run_verapdf()` ebenso wenig (der FAIL-*Pfad* in `process_pdf()` ist getestet, die veraPDF-CLI-Anbindung selbst nicht), und `run_ocr()` nur gegen ein gemocktes ocrmypdf — es gibt keinen Test mit einer echten PDF-Datei. Auch `upload_nextcloud()` und `upload_sftp()` sind ungetestet (nur `upload_folder()`). **`install.sh`/`update.sh` haben keine automatisierten Tests** — die `LIB_ONLY`-Schnittstelle in `update.sh` ist dafür vorbereitet, aber ungenutzt.
- [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
@@ -338,7 +454,8 @@ pytest # aktuell 152 Tests
- **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`
- **Clone per HTTPS (Normalfall, ohne Credentials):** `https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder.git` — das ist der Weg, den die Installations-Doku nennt und der im Erstinstallations-Test benutzt wurde.
- **SSH nur mit Deploy-Key — und der 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
+231
View File
@@ -1,5 +1,236 @@
# Changelog
## [0.7.1] - 2026-09-23
Gefunden beim ersten echten Erstinstallations-Test auf frischen Debian-12-
und Debian-13-Containern. Der Installationsweg selbst hat getragen; diese
drei Stellen haben gelogen oder gefehlt.
### Fixed
- **Das Ghostscript-Angebot auf Debian 12 war ein No-Op, der sich als Erfolg
meldete** (`Ghostscript aktualisiert: 10.00.0 -> 10.00.0 ✓`). Grund:
`bookworm-backports` enthaelt ueberhaupt kein `ghostscript` — am echten
Paketindex verifiziert (2606 Pakete, ghostscript nicht dabei, Kontroll-
pakete wie systemd/golang-go sehr wohl). Zurueck blieb eine nutzlose
`sources.list.d`-Quelle. Die Routine (jetzt `check_ghostscript` in
`lib/common.sh`) sucht nun erst den tatsaechlichen Kandidaten, vergleicht
die Version vorher/nachher, meldet "unveraendert" als Warnung statt als
Erfolg und entfernt eine nur zur Probe angelegte Quelle wieder. Eine
bereits vorhandene Backports-Quelle bleibt unangetastet.
- **Der Rat "Ghostscript aus bookworm-backports" ist ueberall raus** — er
stand auch in der Preflight-Fehlermeldung, der pdfa_level-Warnung,
config.example.toml und vier Doku-Dateien. Ersetzt durch die echten
Optionen: pdfa_level leer lassen (Default), skip_text = false, oder
Debian 13 (gs 10.05.1). Ein Test prueft negativ, dass der Rat nicht
zurueckkommt.
- **Mehrzeilige Log-Hinweise waren zerrissen und nicht kopierbar**: die
log-Funktionen nutzten `echo -e`, wodurch `\n` in Hinweistexten zu echten
Zeilenumbruechen ohne `[WARN]`-Praefix wurden. Jetzt `printf` mit `%s`
fuer den Text — das Problem kann strukturell nicht wiederkommen.
- **`pip-freeze.txt` landete beim Rollback im Wurzelverzeichnis.** Sie liegt
im Backup jetzt unter `opt/pdf-ocr-hotfolder/` und damit nach dem
Entpacken neben der Installation.
### Added (Doku)
- Voraussetzungen, die auf einem frischen Proxmox-Debian-Template fehlen:
**`git` und `sudo`** sind dort nicht installiert — der dokumentierte
Aufruf `sudo ./install.sh` scheitert mit `sudo: command not found`.
Beide Wege (root direkt / sudo) sind jetzt beschrieben.
- HTTPS- statt SSH-Clone als Normalfall (funktioniert ohne Credentials);
SSH-Variante mit dem Hinweis, dass der User `gitea` heisst, nicht `git`.
- Entscheidungstabelle zu PDF/A auf Debian 12 vs. 13. Praezisierung: die
Sackgasse ist PDF/A **zusammen mit** `skip_text = true`; mit
`skip_text = false` geht PDF/A auch auf Debian 12, nur langsamer.
- Rollback: `systemctl start` kann kein Glob (anders als `stop`), bei
mehreren Instanzen jede einzeln starten. Dazu, was ein Rollback
nachweislich zurueckholt und was nicht.
- journald-Reparatur: die Instanzen danach einmal neu starten, sonst bleibt
das Journal leer und die Reparatur sieht gescheitert aus.
- "So sieht ein Erstlauf aus" — die Reihenfolge der Abfragen.
## [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
+42 -8
View File
@@ -19,12 +19,14 @@ Weiter: [CHANGELOG.md](CHANGELOG.md) · [AI_AGENT_BRIEFING.md](AI_AGENT_BRIEFING
- 🧠 **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
- ✅ **PDF/A-Output** (1, 2 oder 3) optional — **aber nicht auf Debian 12**: dessen Ghostscript (10.0.0–10.02.0) hat einen Bug, ocrmypdf lehnt PDF/A zusammen mit `skip_text = true` ab, und ein neueres Ghostscript gibt es dort nicht (`bookworm-backports` enthält kein Ghostscript-Paket). Wer PDF/A braucht, installiert auf **Debian 13** (Ghostscript 10.05.1) oder setzt `skip_text = false` ([Details](docs/INSTALLATION.md#ghostscript-bug-auf-debian-12))
- 🛡️ **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, **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
@@ -32,11 +34,30 @@ Weiter: [CHANGELOG.md](CHANGELOG.md) · [AI_AGENT_BRIEFING.md](AI_AGENT_BRIEFING
**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)).
Außerdem **`git`** (für den Clone) und, falls nicht als `root` gearbeitet wird,
**`sudo`** — beides fehlt im Proxmox-Debian-Standard-Template
([Details](docs/INSTALLATION.md#voraussetzungen)):
```bash
apt update && apt install -y git # als root; ggf. zusätzlich: sudo
```
```bash
# HTTPS — funktioniert ohne Credentials, das ist der Normalfall
git clone https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder.git
cd pdf-ocr-hotfolder
./install.sh # als root; mit sudo: sudo ./install.sh
```
Wer einen Deploy-Key hinterlegt hat, klont per SSH — **der SSH-User heißt
`gitea`, nicht `git`**:
```bash
git clone gitea@gitea.sonith.de:sonith_ug/pdf-ocr-hotfolder.git
cd pdf-ocr-hotfolder
sudo ./install.sh
```
Der Installer legt einmalig Code, venv und die systemd-Template-Unit an und
@@ -56,7 +77,8 @@ Nach wenigen Sekunden liegt das OCR-PDF im `outgoing/`-Ordner der Instanz.
Alle Details zu den Abfragen, zum Multi-Instanz-Betrieb und zu den Fallstricken
(LXC, Ghostscript): **[docs/INSTALLATION.md](docs/INSTALLATION.md)**.
Update: `git pull && sudo ./update.sh` — siehe **[docs/UPDATE.md](docs/UPDATE.md)**.
Update: `git pull && ./update.sh` (als root; mit `sudo`:
`sudo ./update.sh`) — siehe **[docs/UPDATE.md](docs/UPDATE.md)**.
Nach einem Debian-Major-Upgrade: **[docs/OS-UPGRADE.md](docs/OS-UPGRADE.md)**.
## Verzeichnisse
@@ -64,6 +86,7 @@ Nach einem Debian-Major-Upgrade: **[docs/OS-UPGRADE.md](docs/OS-UPGRADE.md)**.
| Pfad | Zweck |
|------|-------|
| `/opt/pdf-ocr-hotfolder/` | Code + venv (für alle Instanzen gemeinsam) |
| `/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) |
@@ -83,7 +106,7 @@ Key-für-Key-Referenz: [docs/INSTALLATION.md](docs/INSTALLATION.md#konfiguration
| Sektion | Zweck |
|---------|-------|
| `[paths]` | `incoming`, `outgoing`, `working`, `error` — **Pflicht** |
| `[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 |
@@ -104,6 +127,17 @@ cd /opt/pdf-ocr-hotfolder && sudo ./venv/bin/python -m pdf_ocr_hotfolder \
Exit 0 = sauber, 1 = Warnungen, 2 = Fehler. Details:
[docs/UPDATE.md](docs/UPDATE.md#config-prüfung-per---check-config).
### Exit-Codes des Dienstes
| 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
```bash
@@ -150,7 +184,7 @@ gelöscht.
## Tests
```bash
pytest # 152 Tests
pytest # 254 Tests
```
`ocrmypdf` muss dafür nicht installiert sein — der Import ist lazy und wird in
@@ -162,5 +196,5 @@ MIT — © Sonith UG
---
**Version:** 0.6.3
**Version:** 0.7.1
**Repo:** https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder
+1 -1
View File
@@ -1 +1 @@
0.6.3
0.7.1
+23 -2
View File
@@ -2,6 +2,11 @@
# 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
@@ -25,6 +30,12 @@ oversample = 300
# 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.
#
# Auf Debian 12 lässt sich Ghostscript NICHT anheben: bookworm-backports
# enthält kein Ghostscript-Paket. Wer dort PDF/A braucht, hat auf dieser
# Distribution keinen Weg — es bleiben pdfa_level = "" (kein PDF/A),
# skip_text = false oder eine Distribution mit neuerem Ghostscript
# (Debian 13 liefert 10.05.1).
#
# 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
@@ -60,11 +71,19 @@ name_tag = "OCR_"
# "delete" : Original wird gelöscht (alter Standard)
# "archive" : Original wird in archive_dir verschoben
original_on_success = "delete"
# Absoluter Pfad; nur relevant wenn original_on_success = "archive"
# 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"
@@ -74,7 +93,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]
+400 -39
View File
@@ -13,14 +13,53 @@ Verwandte Dokumente: [README](../README.md) · [Update](UPDATE.md) · [Debian-Ma
| 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) |
| Rechte | `root` (`sudo ./install.sh`) |
| Dateisystem | **ext4, xfs oder zfs**; `incoming/` **lokal**, kein CIFS/NFS — siehe [Dateisystem](#dateisystem-ext4-xfs-oder-zfs) |
| Rechte | `root` — als root direkt `./install.sh`, sonst `sudo ./install.sh` (s. [root oder sudo](#root-oder-sudo)) |
| `git` | zum Klonen des Repos — **nicht** vorinstalliert, s. [Pakete, die fehlen können](#pakete-die-auf-einem-frischen-system-fehlen-können) |
| 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)) |
### Pakete, die auf einem frischen System fehlen können
Auf dem **Proxmox-Debian-Standard-Template** (12 **und** 13) fehlen zwei Dinge,
die jede Anleitung stillschweigend voraussetzt — am frisch angelegten Container
verifiziert:
| Fehlt | Folge | Abhilfe |
|-------|-------|---------|
| **`git`** | `git clone …` schlägt mit `git: command not found` fehl — und ohne Clone gibt es kein Repo, aus dem `install.sh` läuft | `apt install git` |
| **`sudo`** | der überall dokumentierte Aufruf `sudo ./install.sh` schlägt mit `sudo: command not found` fehl | entweder `apt install sudo`, oder **einfach als `root` ohne `sudo` arbeiten** |
```bash
apt update
apt install -y git # zwingend
apt install -y sudo # nur, wenn nicht als root gearbeitet wird
```
### root oder sudo
Installer und Updater brauchen **root-Rechte** — *wie* man dahin kommt, ist
ihnen gleich. Beide Wege sind gleichwertig:
```bash
# (a) man ist bereits root — im frischen Container der Normalfall
./install.sh
# (b) man arbeitet als normaler Benutzer und hat sudo
sudo ./install.sh
```
In dieser Doku steht durchgehend die Variante mit `sudo`, weil die meisten
Systeme es haben. **Wer als `root` arbeitet, lässt das `sudo` bei jedem Befehl
einfach weg** — das gilt für alle Kommandos in diesem und den übrigen
Dokumenten. Ein fehlendes `sudo` ist kein Grund, es nachzuinstallieren.
Die System-Pakete installiert der Installer selbst. Die Liste steht als
einzige Quelle in `install.sh` (Funktion `pdf_ocr_apt_packages()`, zwischen den
Marken `# --- BEGIN apt-packages` / `# --- END apt-packages`) und wird von
`update.sh` von dort ausgelesen:
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
@@ -30,7 +69,7 @@ ca-certificates curl
```
Weitere Tesseract-Sprachpakete installiert der Installer bei Bedarf pro Instanz
nach (siehe [OCR-Sprachen](#4-ocr-sprachen)).
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
@@ -47,6 +86,50 @@ 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,
@@ -127,21 +210,58 @@ System mehr RAM bekommt.
## Installation
```bash
git clone gitea@gitea.sonith.de:sonith_ug/pdf-ocr-hotfolder.git
apt update && apt install -y git # fehlt im Proxmox-Standard-Template
git clone https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder.git
cd pdf-ocr-hotfolder
sudo ./install.sh
sudo ./install.sh # als root: ./install.sh
```
#### Welche Clone-URL?
| Variante | URL | Wann |
|----------|-----|------|
| **HTTPS** (Normalfall) | `https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder.git` | braucht **keine** Credentials und funktioniert auf einem frischen Server sofort — der Weg, der im Installations-Test benutzt wurde |
| **SSH** | `gitea@gitea.sonith.de:sonith_ug/pdf-ocr-hotfolder.git` | nur sinnvoll, wenn auf dem Zielsystem ein **Deploy-Key** hinterlegt ist (z.B. weil auch gepusht werden soll) |
> ⚠️ **Der SSH-User bei `gitea.sonith.de` heißt `gitea`, nicht `git`.**
> `git@gitea.sonith.de:…` ist der Default anderer Git-Hoster und hier **falsch**
> — der Clone scheitert dann mit `Permission denied (publickey)`.
`install.sh` ist **Installer und Instanz-Manager in einem** und idempotent —
jeder weitere Aufruf überspringt, was schon steht.
### So sieht ein Erstlauf aus
Verifiziert auf frischen Debian-12- und Debian-13-Containern. Die Reihenfolge
der Abfragen:
1. **LXC-Drop-in** — nur im Container: „systemd-Hardening-Drop-in für LXC
installieren?" → **ja**, sonst scheitert der Start mit `226/NAMESPACE`
([warum](#lxccontainer-error-226namespace)).
2. **Ghostscript-Hinweis** — nur auf Debian 12 mit betroffener Version. Kein
Abbruch: mit dem Default `pdfa_level = ""` ist die Installation
unproblematisch ([Hintergrund](#ghostscript-bug-auf-debian-12)).
3. **journald-Warnung** — nur, wenn `systemd-journald` nicht läuft. Typisch für
Debian 13 in LXC auf Proxmox. Hier **abbrechen**, journald reparieren und neu
anfangen — sonst hat der Dienst kein Log
([Abhilfe](#debian-13-in-lxc-auf-proxmox-journald-scheitert-243credentials)).
4. Dann die **fünf Fragen pro Instanz**: Instanz-Name → Basis-Pfad →
Service-User → OCR-Sprachen → Original-Behandlung (Archiv/löschen/liegen
lassen). Im Detail: [Die Abfragen pro Instanz](#die-abfragen-pro-instanz).
5. Zum Schluss `Weitere Instanz anlegen? [j/N]:` — hier entsteht die zweite
Instanz, ohne dass irgendetwas am Basis-Install noch einmal angefasst wird.
Danach läuft `pdf-ocr-hotfolder@<instanz>.service`. Gegenprobe: eine PDF nach
`incoming/` kopieren und `journalctl -u pdf-ocr-hotfolder@<instanz> -f`
mitlesen.
### 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, Default-User `pdfocr`, Code nach `/opt/pdf-ocr-hotfolder/`, venv, systemd-Template-Unit |
| **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
@@ -157,10 +277,12 @@ Installer in der Schleife `Weitere Instanz anlegen? [j/N]:`.
## Die Abfragen pro Instanz
`create_instance()` stellt fünf Fragen. Alle Antworten gelten **nur für diese
Instanz** — nichts davon ist global.
`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.
### 1. Instanz-Name
### Instanz-Name
```
Instanz-Name (nur a-z, 0-9, -):
@@ -171,7 +293,7 @@ Muster `^[a-z0-9][a-z0-9-]*$`. Der Name wird zum Unit-Suffix
(`/etc/pdf-ocr-hotfolder/<name>.toml`). Existiert die Config schon, bricht die
Anlage ab — ein versehentliches Überschreiben gibt es nicht.
### 2. Basis-Pfad für die Daten
### Basis-Pfad für die Daten
```
Basis-Pfad für Daten [/var/lib/pdf-ocr-hotfolder/<name>]:
@@ -180,7 +302,7 @@ Basis-Pfad für Daten [/var/lib/pdf-ocr-hotfolder/<name>]:
Darunter entstehen `incoming/`, `outgoing/`, `working/`, `error/` und werden auf
den Service-User gechownt.
### 3. Service-User
### Service-User
```
Service-User [pdfocr]:
@@ -198,7 +320,7 @@ Service-User [pdfocr]:
Bei AD-Usern mit lokaler UID werden die Datei-Berechtigungen über die UID
gesetzt — das läuft transparent.
### 4. OCR-Sprachen
### OCR-Sprachen
```
Tesseract-Sprachen [deu+eng]:
@@ -236,7 +358,7 @@ Was der Installer damit macht:
4. Ist `tesseract` gar nicht aufrufbar, wird die Prüfung übersprungen und die
Eingabe unverändert übernommen.
### 5. Original archivieren?
### Original archivieren?
```
Original nach erfolgreichem OCR archivieren? [j/N]:
@@ -375,21 +497,40 @@ ocrmypdf-Version:
### Abhilfe
**Weg 1 — Ghostscript anheben** (empfohlen). Der Installer erkennt betroffene
Versionen und bietet auf Debian 12 bookworm-backports an. Manuell:
> 🚫 **Es gibt auf Debian 12 kein neueres Ghostscript.** `bookworm-backports`
> führt **kein** Ghostscript-Paket — am Paketindex geprüft
> (`bookworm-backports/main/binary-amd64`: 2606 Pakete, `ghostscript` nicht
> darunter, `systemd`/`golang-go`/`linux-image-amd64` schon). Ältere Fassungen
> dieser Anleitung und frühere Versionen des Installers haben genau das
> empfohlen — der Rat konnte nie funktionieren.
>
> **Planungsaussage:** Wer auf Debian 12 **PDF/A in der schnellen Betriebsart**
> (`skip_text = true`) braucht, hat dort **keinen Weg** — weder über Backports
> noch sonst. Es bleibt: PDF/A aufgeben, `skip_text` aufgeben (Weg 2, kostet
> Laufzeit) oder Debian 13. **Das gehört vor die Installation**, nicht in den
> Moment, in dem der Preflight abbricht.
```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 1 — `pdfa_level = ""` lassen** (Default, empfohlen). Ohne PDF/A-Ausgabe
fasst ocrmypdf ≥ 17 Ghostscript gar nicht an; die betroffene Version ist dann
völlig unproblematisch. Der Preis ist PDF/A — das Ergebnis ist eine normale
durchsuchbare PDF. Für die übliche Anwendung (Scan wird durchsuchbar) reicht
das.
**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.
statt übersprungen, und die Bedingung greift nicht mehr — PDF/A ist damit auch
auf Debian 12 möglich. Das kostet Laufzeit bei PDFs, die bereits eine Textebene
haben, und OCRt sie ein zweites Mal.
**Weg 3 — Distribution mit neuerem Ghostscript.** **Debian 13 liefert
Ghostscript 10.05.1** und ist vom Bug nicht betroffen; dort ist PDF/A zusammen
mit `skip_text = true` ohne Einschränkung nutzbar. Wer PDF/A verbindlich
braucht, installiert von vornherein auf Debian 13.
| Ich brauche … | Debian 12 | Debian 13 |
|---------------|-----------|-----------|
| durchsuchbare PDF, kein PDF/A | ✅ Default (`pdfa_level = ""`) | ✅ |
| PDF/A **und** `skip_text = true` | ❌ kein Weg | ✅ |
| PDF/A mit `skip_text = false` | ✅ (langsamer) | ✅ |
---
@@ -436,6 +577,20 @@ 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 |
@@ -467,17 +622,34 @@ ocrmypdf-Default greift. Siehe auch
| `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, **Pflicht** bei `archive`; Namenskollision → Zeitstempel-Suffix |
| `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 |
| `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
@@ -485,6 +657,27 @@ 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
@@ -492,7 +685,7 @@ PDF einfach in `outgoing/` liegen.
| Sektion | Keys |
|---------|------|
| `[upload.folder]` | `enabled`, `target` — leer heißt `[paths].outgoing`, dann No-op |
| `[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` |
@@ -500,6 +693,10 @@ 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 |
@@ -521,6 +718,36 @@ 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
@@ -547,21 +774,65 @@ das Archiv, falls konfiguriert):
sudo chown -R DOMAIN\\scanuser:DOMAIN\\scangroup /var/lib/pdf-ocr-hotfolder/<instanz>
```
### veraPDF-Validierung schlägt immer fehl
### veraPDF: Dienst startet nicht / Dateien landen in `error/`
`[verapdf].binary` prüfen. Wenn die Validierung nicht zwingend gebraucht wird:
`enabled = false`.
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. Die Ursache steht im journal und
ausführlicher in:
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.
@@ -583,15 +854,26 @@ Ist `systemd-journald.service` selbst `failed` (beobachtet mit
`status=243/CREDENTIALS`), gibt es schlicht kein journal, in das geschrieben
werden könnte.
Läuft journald dagegen `active (running)` und das Journal der Instanz ist
trotzdem leer (`-- No entries --`), ist meist journald **erst nach dem Dienst**
wieder hochgekommen: die Startmeldungen hatten in der Zwischenzeit kein Ziel und
sind weg. `sudo systemctl restart 'pdf-ocr-hotfolder@*'` schreibt sie neu —
siehe die Warnung im
[nächsten Abschnitt](#debian-13-in-lxc-auf-proxmox-journald-scheitert-243credentials).
**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.
Das ist **kein** generelles LXC-Muster. Von zwei Testcontainern war genau einer
betroffen; auf dem zweiten lief journald einwandfrei. Es handelt sich um einen
Schaden auf dieser einen Maschine, nicht um eine Eigenschaft von Containern.
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
@@ -610,6 +892,84 @@ Das `cd` ist zwingend: das Paket wird nicht pip-installiert, sondern nach
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` gegenprüfen — muss `active
(running)` sein. Für die anderen betroffenen Units gilt dasselbe Muster mit
deren Unit-Namen.
> ⚠️ **Jetzt die Instanzen einmal neu starten — sonst steht man vor einem
> leeren Journal.** Wurde der Dienst gestartet, **während** journald tot war,
> sind seine Startmeldungen unwiederbringlich weg: sie hatten kein Ziel. Nach
> der Reparatur meldet `journalctl -u pdf-ocr-hotfolder@<instanz>` dann
> `-- No entries --` — und das sieht exakt so aus wie ein gescheiterter
> Reparaturversuch, obwohl journald längst wieder läuft. Der Neustart schreibt
> die Startmeldungen neu ins frische Journal:
>
> ```bash
> sudo systemctl restart 'pdf-ocr-hotfolder@*'
> journalctl -u pdf-ocr-hotfolder@<instanz> -n 20
> ```
>
> Erst wenn hier die Startmeldungen stehen, ist die Reparatur belegt. (`restart`
> kann das Glob, weil die Units geladen sind — `start` nicht, siehe
> [UPDATE.md](UPDATE.md#rollback).)
> **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
@@ -629,4 +989,5 @@ cd /opt/pdf-ocr-hotfolder && sudo -u pdfocr ./venv/bin/python -m pdf_ocr_hotfold
```
Exit-Code: `0` = alles verarbeitet (auch "nichts da"), `1` = mindestens eine
Datei fehlgeschlagen, `2` = Config- oder Preflight-Fehler.
Datei fehlgeschlagen, `2` = Config- oder Preflight-Fehler. Exit 3 gibt es hier
nicht — der gehört zum Dauerbetrieb, siehe [Exit-Codes](#exit-codes).
+25 -5
View File
@@ -6,6 +6,14 @@ Verwandte Dokumente: [README](../README.md) · [Installation](INSTALLATION.md)
---
> **`sudo` oder direkt als `root`.** Alle Befehle dieser Seite brauchen
> root-Rechte, nicht `sudo`. Wer als `root` arbeitet — im
> Proxmox-Debian-Standard-Template der Normalfall, dort ist `sudo` gar nicht
> installiert —, lässt das `sudo` einfach weg. Siehe
> [INSTALLATION.md](INSTALLATION.md#root-oder-sudo).
---
## Warum das ein eigener Ablauf ist
Die venv unter `/opt/pdf-ocr-hotfolder/venv/` hängt an der **Python-Version der
@@ -179,11 +187,23 @@ Ghostscript) wirklich anfasst — `systemctl status` sagt darüber nichts.
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:
Auf Debian 13 ist der Ghostscript-Bug aus Debian 12 kein Thema mehr — **Debian
13 liefert Ghostscript 10.05.1**. Damit ist PDF/A (`[ocr].pdfa_level = "1"`,
`"2"` oder `"3"`) zusammen mit `skip_text = true` erstmals ohne Einschränkung
nutzbar; auf Debian 12 gab es dafür **keinen** Weg (ein Upgrade aus
`bookworm-backports` existiert nicht, dort liegt kein Ghostscript-Paket). Genau
das ist oft der Grund für das Upgrade. Hintergrund:
[INSTALLATION.md](INSTALLATION.md#ghostscript-bug-auf-debian-12).
Hat jemand auf dem alten System nach der früheren, falschen Empfehlung einen
`bookworm-backports`-Eintrag unter `/etc/apt/sources.list.d/` angelegt, gehört
er nach dem Upgrade entfernt — Ghostscript kam ohnehin nie daher:
```bash
sudo rm -f /etc/apt/sources.list.d/bookworm-backports.list
sudo apt update
```
---
## Pins in `requirements.txt`
@@ -226,11 +246,11 @@ auch für die Abhängigkeiten, die ocrmypdf 17 zusätzlich mitbringt (`pydantic`
```
3. Dort `update.sh --rebuild-venv` fahren, damit die Pakete wirklich frisch
aufgelöst werden.
4. `pytest` muss grün bleiben (152 Tests).
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.
5. Erst dann committen und auf die produktiven Systeme geben.
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.
+92 -9
View File
@@ -24,20 +24,35 @@ sudo ./update.sh --rebuild-venv # venv zwingend neu bauen (nach dist-upgrade)
sudo ./update.sh --no-smoke-test # ohne Rauchtest durchlaufen
```
> **`sudo` oder direkt als `root`.** `update.sh` braucht root-Rechte, nicht
> `sudo`. Wer als `root` arbeitet — im Proxmox-Debian-Standard-Template der
> Normalfall, dort ist `sudo` gar nicht installiert —, lässt das `sudo` bei
> jedem Befehl dieser Seite einfach weg: `./update.sh`. Ebenso setzt `git pull`
> ein installiertes **`git`** voraus; siehe
> [INSTALLATION.md](INSTALLATION.md#pakete-die-auf-einem-frischen-system-fehlen-können).
`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 `install.sh` extrahiert, `apt-get install` ist idempotent |
| 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/`, `requirements.txt`, `VERSION`, `config.example.toml`, `.repo_path` |
| 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`) |
@@ -114,14 +129,18 @@ Vor dem ersten Eingriff auf der Platte schreibt `update.sh` ein Archiv:
| Enthalten | Nicht enthalten |
|-----------|-----------------|
| `/opt/pdf-ocr-hotfolder/` (Code) | die **venv** (`venv/`, `venv.old-*`) |
| `/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 | |
| `opt/pdf-ocr-hotfolder/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.
macht: man sieht schwarz auf weiß, welche Paketversionen vorher liefen. Sie
liegt im Archiv unter `opt/pdf-ocr-hotfolder/` und landet beim Entpacken nach
`/` folglich als `/opt/pdf-ocr-hotfolder/pip-freeze.txt` — also neben der
Installation statt im Wurzelverzeichnis. Ein Code-Tausch beim nächsten Update
löscht sie nicht (dort fliegen nur `pdf_ocr_hotfolder/` und `lib/`).
**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
@@ -145,24 +164,65 @@ Das Backup-Archiv ist wurzelrelativ gepackt und lässt sich direkt zurückspiele
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>'
sudo systemctl start pdf-ocr-hotfolder@kunde-a
sudo systemctl start pdf-ocr-hotfolder@kunde-b # jede Instanz einzeln!
```
Das Skript nennt diesen Befehl mit dem konkreten Archivnamen selbst — sowohl
beim Abbruch als auch bei einer erkannten Regression.
> ⚠️ **`start` kann kein Glob.** `systemctl stop 'pdf-ocr-hotfolder@*'` trifft
> alle laufenden Instanzen, weil systemd dafür die **bereits geladenen** Units
> auflösen kann. Beim Starten gibt es nichts aufzulösen:
> `systemctl start 'pdf-ocr-hotfolder@*'` startet eine Instanz mit dem
> wörtlichen Namen `*` — und scheitert. **Bei mehreren Instanzen muss jede
> einzeln gestartet werden.** Welche es sind:
> `ls /etc/pdf-ocr-hotfolder/*.toml`. Am Stück:
>
> ```bash
> for f in /etc/pdf-ocr-hotfolder/*.toml; do
> n=$(basename "$f" .toml)
> sudo systemctl start "pdf-ocr-hotfolder@$n"
> done
> systemctl status 'pdf-ocr-hotfolder@*' --no-pager
> ```
### Was ein Rollback nachweislich zurückholt
Einmal real durchgespielt (Debian 12 **und** 13, Update auf den neuen Stand,
danach Rollback auf das Backup). Zurück kamen korrekt:
- **der Code** unter `/opt/pdf-ocr-hotfolder/` inklusive `lib/` — die alte
Version lief danach wieder,
- **alle Instanz-Configs** unter `/etc/pdf-ocr-hotfolder/` — **mit den
`640`-Rechten und dem `root:<service-gruppe>`-Eigentum**; die
Klartext-Passwörter bleiben also geschützt, `tar` stellt Modus und Eigentümer
mit her,
- die **Template-Unit** `pdf-ocr-hotfolder@.service` und
- die **Drop-ins** unter `…@*.service.d/` (LXC-Kompat, User-Drop-in).
Nach `daemon-reload` und dem Einzelstart liefen die Instanzen wieder mit dem
alten Stand. Was dabei **nicht** zurückkommt, steht im nächsten Abschnitt.
### Grenzen des Rollbacks
Ein Rollback ist ein **Overlay**, kein exaktes Zurücksetzen:
Ein Rollback ist ein **Overlay**, kein exaktes Zurücksetzen — beides im Test
bestätigt:
- **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
da — nach dem Entpacken unter `/opt/pdf-ocr-hotfolder/pip-freeze.txt`: die
dort genannten Versionen lassen sich von Hand wiederherstellen
(`venv/bin/pip install -r …`).
Im Test lief nach dem Rollback die **alte** Code-Version in der **neuen**
venv — was hier gutging, weil sich die Pins nicht geändert hatten. Verlassen
darf man sich darauf nicht: nach einem Update mit Versionssprung gehört die
venv nach dem Rollback von Hand auf den alten Paketstand gebracht.
- **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
im Code-Verzeichnis überlebt das Rollback — im Test nachgestellt und
bestätigt. 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/`.
@@ -303,6 +363,15 @@ validiert die `[output]`-Sektion.
| **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.
@@ -361,6 +430,20 @@ dem Fall mit Exit 2 ab; ab Ghostscript 10.02.1 ist PDF/A unproblematisch.
> `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
+60 -100
View File
@@ -12,73 +12,32 @@
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"
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
fi
# ============================================================
# System-Pakete — einzige Quelle der Wahrheit
# ============================================================
# Der folgende Block wird von update.sh aus dieser Datei herausgeschnitten
# (sed auf die BEGIN/END-Marken) und dort ausgewertet, damit ein Update
# neue Pakete nachzieht. 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 ---
# Prueft, ob die venv noch zum aktuellen System-Python passt.
# Zwei Faelle: (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).
# Beides heisst: neu bauen. Keine Versionsnummer ist hier hartcodiert.
venv_is_healthy() {
local venv="$1" venv_mm sys_mm
[ -x "$venv/bin/python" ] || return 1
venv_mm="$("$venv/bin/python" -c 'import sys; print("%d.%d" % sys.version_info[:2])' 2>/dev/null || true)"
[ -n "$venv_mm" ] || return 1
sys_mm="$(python3 -c 'import sys; print("%d.%d" % sys.version_info[:2])' 2>/dev/null || true)"
[ -n "$sys_mm" ] || return 1
[ "$venv_mm" = "$sys_mm" ]
}
# ============================================================
# Basis-Installation (idempotent)
# ============================================================
@@ -92,42 +51,11 @@ install_base() {
log_info "System-Pakete ok ✓"
# 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"
case "$GS_VER" in
10.0.0|10.00.0|10.01.*|10.02.0)
echo
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 "═══════════════════════════════════════════════════════════════"
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
# Die eigentliche Logik steckt in lib/common.sh (check_ghostscript): sie
# prueft nur, was wirklich verfuegbar ist, meldet Erfolg ausschliesslich
# nach einem Versionsvergleich und laesst keine nutzlose apt-Quelle
# zurueck. Ein betroffenes Ghostscript ist kein Abbruchgrund.
check_ghostscript
# LXC/Container-Erkennung (Issue #4)
if systemd-detect-virt --container -q 2>/dev/null; then
@@ -137,12 +65,38 @@ install_base() {
read -r -p "LXC-Kompatibilitäts-Drop-in installieren? [J/n]: " LXC_FIX
LXC_FIX="${LXC_FIX:-J}"
if [[ "$LXC_FIX" =~ ^[JjYy]$ ]]; then
local LXC_DROPIN_DIR="/etc/systemd/system/pdf-ocr-hotfolder@.service.d"
mkdir -p "$LXC_DROPIN_DIR"
cp "$REPO_DIR/systemd/lxc-compat.conf" "$LXC_DROPIN_DIR/lxc-compat.conf"
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:"
# Jede Zeile ein eigener log_warn und ohne '\n'-Escapes, damit der
# Block zeilenweise mit [WARN]-Praefix herauskommt und sich sauber
# kopieren laesst (das Schreiben der Datei bewusst als Einzeiler).
log_warn " mkdir -p /etc/systemd/system/systemd-journald.service.d"
log_warn " { echo '[Service]'; echo 'ImportCredential='; } > /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"
@@ -161,6 +115,10 @@ install_base() {
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/"
@@ -173,6 +131,7 @@ install_base() {
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
@@ -189,7 +148,7 @@ install_base() {
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 ✓"
@@ -427,7 +386,7 @@ create_instance() {
# 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]
@@ -471,11 +430,12 @@ echo "=========================================="
# 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 "/etc/systemd/system/$SERVICE_TEMPLATE" ]; then
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
+369
View File
@@ -0,0 +1,369 @@
#!/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'
# Die Meldung selbst geht als %s durch, NICHT als %b (frueher: echo -e). Sonst
# wird ein '\n' im Text — etwa in einem Befehl, den wir zum Kopieren anzeigen —
# zu einem echten Zeilenumbruch: die Folgezeilen haetten dann kein [WARN] davor
# und wer den Block kopiert, schleppt das Praefix mit. Nur die Farbcodes
# brauchen %b. Eine Meldung pro Zeile — mehrzeilige Hinweise bitte als mehrere
# Aufrufe schreiben.
log_info() { printf '%b[INFO]%b %s\n' "$GREEN" "$NC" "$*"; }
log_warn() { printf '%b[WARN]%b %s\n' "$YELLOW" "$NC" "$*"; }
log_error() { printf '%b[ERROR]%b %s\n' "$RED" "$NC" "$*"; }
log_step() { printf '\n%b==>%b %s\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}"
# Systempfade, die nur die Ghostscript-Pruefung braucht. Ueberschreibbar,
# damit sich die Pruefung gegen eine Fake-Umgebung testen laesst.
: "${OS_RELEASE_FILE:=/etc/os-release}"
: "${APT_SOURCES_LIST:=/etc/apt/sources.list}"
: "${APT_SOURCES_DIR:=/etc/apt/sources.list.d}"
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 ---
# ============================================================
# Ghostscript
# ============================================================
# Ghostscript 10.0.0 bis 10.02.0 erzeugt fehlerhafte PDF/A-Ausgaben: zusammen
# mit [ocr].pdfa_level UND skip_text = true scheitert ocrmypdf an jeder Datei.
# Mit leerem pdfa_level (der Default) ist so ein Ghostscript voellig
# unproblematisch — die Pruefung ist deshalb ein Hinweis, kein Abbruchgrund.
# Betroffen? $1 = Ausgabe von 'gs --version'.
gs_version_affected() {
case "$1" in
10.0.0|10.00.0|10.01.*|10.02.0) return 0 ;;
*) return 1 ;;
esac
}
# Version, die gs meldet; leer, wenn gs nicht aufrufbar ist.
gs_version() {
command -v gs >/dev/null 2>&1 || return 0
gs --version 2>/dev/null || true
}
# Codename der Distribution (VERSION_CODENAME aus os-release); leer, wenn
# unbekannt.
os_codename() {
[ -r "$OS_RELEASE_FILE" ] || return 0
sed -n 's/^VERSION_CODENAME=//p' "$OS_RELEASE_FILE" | tr -d '"' | head -n1
}
# Ist $1 (z.B. bookworm-backports) schon irgendwo als apt-Quelle eingetragen?
apt_release_configured() {
grep -RqsF -- "$1" "$APT_SOURCES_LIST" "$APT_SOURCES_DIR" 2>/dev/null
}
# Installierte Paketversion laut dpkg; leer, wenn das Paket nicht da ist.
apt_installed_version() {
command -v dpkg-query >/dev/null 2>&1 || return 0
dpkg-query -W -f '${Version}' "$1" 2>/dev/null || true
}
# Hoechste Version von Paket $1, die aus Release $2 (z.B. bookworm-backports)
# zu haben ist — gelesen aus der Versionstabelle von 'apt-cache policy'.
# Leer, wenn das Release das Paket nicht fuehrt. Bewusst nicht hart verdrahtet:
# liefert Debian spaeter doch ein Backports-Ghostscript, greift der Pfad.
apt_version_in_release() {
local pkg="$1" release="$2"
command -v apt-cache >/dev/null 2>&1 || return 0
LC_ALL=C apt-cache policy "$pkg" 2>/dev/null | awk -v rel="$release" '
$1 == "***" { ver = $2; next }
NF == 2 && $1 ~ /^[0-9]/ { ver = $1; next }
ver != "" && index($0, rel) { print ver; exit }
'
}
# Entfernt eine Quelle, die NUR fuer die Pruefung angelegt wurde ($2 = 1).
# War sie vorher schon da, bleibt sie unangetastet — sie kann von etwas
# anderem stammen.
gs_drop_probe_source() {
local list_file="$1" added="$2"
[ "$added" = "1" ] || return 0
rm -f "$list_file" 2>/dev/null || true
log_info "Quelle wieder entfernt: $list_file (sie hat nichts gebracht)"
apt-get update -qq || log_warn "'apt-get update' nach dem Aufraeumen schlug fehl."
}
# Versucht, Ghostscript aus Release $1 zu aktualisieren. $2 = bisherige
# gs-Version. Rueckgabe 0 NUR, wenn hinterher nachweislich eine andere,
# nicht mehr betroffene Version laeuft.
gs_try_release_upgrade() {
local release="$1" old_ver="$2"
local list_file="$APT_SOURCES_DIR/${release}.list"
local added=0 cand installed new_ver new_installed
# Ohne apt-cache/dpkg laesst sich nicht feststellen, was dort ueberhaupt
# liegt — dann wird auch nichts eingetragen.
if ! command -v apt-cache >/dev/null 2>&1 || ! command -v dpkg >/dev/null 2>&1; then
log_warn "apt-cache oder dpkg fehlt — der Paketstand von $release ist nicht pruefbar."
return 1
fi
if apt_release_configured "$release"; then
log_info "$release ist bereits eingetragen — die Quelle bleibt, wie sie ist."
else
log_info "Trage $release voruebergehend ein, um den Paketstand zu pruefen..."
mkdir -p "$APT_SOURCES_DIR" 2>/dev/null || true
if ! printf 'deb http://deb.debian.org/debian %s main\n' "$release" > "$list_file" 2>/dev/null; then
log_warn "$list_file liess sich nicht schreiben — Pruefung nicht moeglich."
return 1
fi
added=1
if ! apt-get update -qq; then
log_warn "'apt-get update' schlug fehl — $release nicht auswertbar."
gs_drop_probe_source "$list_file" "$added"
return 1
fi
fi
installed="$(apt_installed_version ghostscript)"
cand="$(apt_version_in_release ghostscript "$release")"
if [ -z "$cand" ]; then
log_warn "$release fuehrt gar kein Paket 'ghostscript' — von dort kommt nichts."
gs_drop_probe_source "$list_file" "$added"
return 1
fi
if [ -n "$installed" ] && ! dpkg --compare-versions "$cand" gt "$installed"; then
log_warn "$release hat ghostscript $cand — nicht neuer als das installierte $installed."
gs_drop_probe_source "$list_file" "$added"
return 1
fi
log_info "$release bietet ghostscript $cand (installiert: ${installed:-unbekannt}) — installiere..."
if ! apt-get install -y -t "$release" ghostscript; then
log_warn "Installation aus $release schlug fehl — Ghostscript bleibt, wie es war."
gs_drop_probe_source "$list_file" "$added"
return 1
fi
# Erfolg wird NUR gemeldet, wenn sich die Version wirklich geaendert hat.
new_ver="$(gs_version)"
new_installed="$(apt_installed_version ghostscript)"
if [ "$new_ver" = "$old_ver" ] && [ "$new_installed" = "$installed" ]; then
log_warn "Ghostscript unveraendert: $old_ver (Paket ${installed:-unbekannt})."
log_warn "Das Upgrade aus $release hat nichts bewirkt."
gs_drop_probe_source "$list_file" "$added"
return 1
fi
log_info "Ghostscript: $old_ver -> $new_ver (Paket ${installed:-unbekannt} -> ${new_installed:-unbekannt})"
if gs_version_affected "$new_ver"; then
log_warn "Auch $new_ver liegt noch im betroffenen Bereich (10.0.0-10.02.0)."
return 1
fi
log_info "Ghostscript ist jetzt frei vom PDF/A-Fehler ✓"
return 0
}
# Sagt dem Admin, was wirklich zur Auswahl steht. Kein Verweis auf Wege, die
# es nicht gibt.
gs_report_options() {
log_warn "Es bleibt bei der betroffenen Ghostscript-Version. Echte Optionen:"
log_warn " 1. Nichts tun: [ocr].pdfa_level leer lassen (Default). Dann ist der"
log_warn " Fehler folgenlos — die Ausgabe ist nur kein PDF/A."
log_warn " 2. PDF/A wirklich noetig? Dann [ocr].skip_text = false setzen."
log_warn " 3. Neuere Distribution: Debian 13 (trixie) liefert Ghostscript 10.05.1."
log_warn "Die Installation laeuft normal weiter."
}
# Vollstaendige Pruefung fuer den Installer. Gibt immer 0 zurueck: ein
# betroffenes Ghostscript ist ein Hinweis, kein Abbruch.
check_ghostscript() {
local ver codename release answer
ver="$(gs_version)"
if [ -z "$ver" ]; then
log_warn "Ghostscript ist nicht aufrufbar — Versionspruefung uebersprungen."
return 0
fi
log_info "Ghostscript: $ver"
gs_version_affected "$ver" || return 0
echo
log_warn "═══════════════════════════════════════════════════════════════"
log_warn "Ghostscript $ver ist vom PDF/A-Fehler betroffen (10.0.0-10.02.0)."
log_warn "Betroffen sind nur Instanzen mit [ocr].pdfa_level UND skip_text = true;"
log_warn "mit leerem pdfa_level (Default) ist die Version unauffaellig."
log_warn "═══════════════════════════════════════════════════════════════"
echo
codename="$(os_codename)"
if [ "$codename" = "bookworm" ]; then
release="bookworm-backports"
# Bewusst als Frage nach dem PRUEFEN formuliert: ob dort ueberhaupt ein
# neueres Ghostscript liegt, steht erst nach 'apt-cache policy' fest.
read -r -p "In $release nach einem neueren Ghostscript suchen? [J/n]: " answer || answer=""
answer="${answer:-J}"
if [[ "$answer" =~ ^[JjYy]$ ]]; then
if gs_try_release_upgrade "$release" "$ver"; then
echo
return 0
fi
else
log_info "Uebersprungen."
fi
else
log_warn "Kein Debian 12 (bookworm) erkannt${codename:+ (Codename: $codename)} — es gibt hier keinen Backports-Weg."
fi
gs_report_options
echo
return 0
}
# ============================================================
# 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.6.3"
__version__ = "0.7.1"
+47 -5
View File
@@ -27,13 +27,32 @@ 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):
@@ -55,7 +74,7 @@ def check_config(cfg_path: Path) -> int:
print(f"FEHLER: {e}", file=sys.stderr)
return CHECK_ERROR
except tomllib.TOMLDecodeError as e:
print(f"FEHLER: {cfg_path} ist kein gültiges TOML: {e}", file=sys.stderr)
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)
@@ -76,10 +95,19 @@ def check_config(cfg_path: Path) -> int:
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)
print(" Preflight ok (tesseract, gs vorhanden, Ghostscript-Version "
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))
@@ -142,6 +170,18 @@ def main() -> int:
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)
@@ -156,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
+41 -2
View File
@@ -32,6 +32,9 @@ class OcrConfig:
# 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).
# Auf Debian 12 gibt es KEIN neueres Ghostscript (bookworm-backports führt
# kein Ghostscript-Paket) — PDF/A ist dort mit skip_text schlicht nicht zu
# haben.
pdfa_level: str = ""
deskew: bool = True
clean: bool = False
@@ -131,6 +134,29 @@ 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.
@@ -144,7 +170,10 @@ def _require_path(p: dict[str, Any], key: str, cfg_path: Path) -> Path:
f'{key} = "/var/lib/pdf-ocr-hotfolder/{key}" '
f"— siehe config.example.toml."
)
return Path(str(value))
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, ...],
@@ -218,6 +247,13 @@ 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(
@@ -259,7 +295,10 @@ def legacy_warnings(cfg: Config) -> list[str]:
"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."
"Ordnung. Auf Debian 12 lässt sich Ghostscript nicht anheben — "
"bookworm-backports enthält kein Ghostscript. Dort bleiben nur "
"pdfa_level = \"\" (kein PDF/A), skip_text = false oder eine "
"Distribution mit neuerem Ghostscript (Debian 13: 10.05.1)."
)
return out
+192 -29
View File
@@ -2,9 +2,11 @@
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, OutputConfig, VeraPdfConfig
@@ -45,6 +47,27 @@ def build_output_name(src_name: str, mode: str, tag: str) -> str:
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:
source: Path
@@ -52,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:
@@ -86,24 +112,65 @@ def run_ocr(src: Path, dst: Path, cfg: OcrConfig) -> None:
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(
@@ -151,7 +218,24 @@ def process_pdf(
vera_ok: bool | None = None
if vera_cfg.enabled:
try:
vera_ok = run_verapdf(work_out, vera_cfg)
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)
_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
@@ -168,9 +252,26 @@ def process_pdf(
"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))
_dispose_original(work_src, src.name, output_cfg)
return ProcessResult(src, final_out, True, verapdf_passed=vera_ok)
# 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:
@@ -181,41 +282,103 @@ def _is_same_file(a: Path, b: Path) -> bool:
return False
def _dispose_original(work_src: Path, original_name: str, cfg: OutputConfig) -> None:
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
return ""
mode = cfg.original_on_success
if mode == "delete":
work_src.unlink(missing_ok=True)
return
if mode == "archive":
if not cfg.archive_dir:
log.error("original_on_success=archive aber archive_dir ist leer — lösche stattdessen")
work_src.unlink(missing_ok=True)
return
if mode == "archive" and cfg.archive_dir:
archive = Path(cfg.archive_dir)
try:
archive.mkdir(parents=True, exist_ok=True)
dest = archive / original_name
# Bei Namens-Kollision mit Timestamp umbenennen
if dest.exists():
from datetime import datetime
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
dest = archive / f"{dest.stem}_{ts}{dest.suffix}"
# 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
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)
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)
+153 -18
View File
@@ -22,6 +22,7 @@ from .processor import (
ProcessResult,
_move_to_error,
process_pdf,
resolve_verapdf_binary,
)
from .uploaders import notify_email, upload_folder, upload_nextcloud, upload_sftp
@@ -32,6 +33,13 @@ 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")
@@ -140,13 +148,47 @@ def check_output_config(mode: str, archive_dir: str,
)
def check_preflight(pdfa_level: str = "", skip_text: bool = False) -> None:
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
- 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.
"""
@@ -161,6 +203,8 @@ def check_preflight(pdfa_level: str = "", skip_text: bool = False) -> None:
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.
@@ -210,19 +254,37 @@ def _gs_block_reason(pdfa_level: str, skip_text: bool) -> str | None:
"einzelne PDF würde in error/ landen"
)
if pdfa_level:
abhilfe = (
"Abhilfe — eines davon: "
"(1) [ocr].pdfa_level = \"\" setzen (der Default; ohne PDF/A-Ausgabe "
"fasst ocrmypdf >= 17 Ghostscript gar nicht an, die betroffene "
"Version ist dann unproblematisch) — kostet allerdings PDF/A; "
"(2) [ocr].skip_text = false setzen (dann wird vorhandener Text neu "
"erkannt statt übersprungen, das kostet Laufzeit); "
"(3) eine Distribution mit neuerem Ghostscript einsetzen — "
"Debian 13 liefert 10.05.1."
)
else:
abhilfe = (
"Abhilfe — eines davon: "
"(1) ocrmypdf >= 17 einsetzen (requirements.txt pinnt 17.x; dort "
"wird Ghostscript ohne PDF/A-Ausgabe gar nicht angefasst) — nach "
"einem Downgrade also die venv neu bauen; "
"(2) [ocr].skip_text = false setzen (dann wird vorhandener Text neu "
"erkannt statt übersprungen, das kostet Laufzeit); "
"(3) eine Distribution mit neuerem Ghostscript einsetzen — "
"Debian 13 liefert 10.05.1."
)
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 ".")
+ abhilfe
+ " Ein Ghostscript-Upgrade auf Debian 12 gibt es NICHT: "
"bookworm-backports enthält kein Ghostscript-Paket. Wer auf Debian 12 "
"PDF/A zusammen mit skip_text = true braucht, hat dort keinen Weg."
)
@@ -298,11 +360,20 @@ class HotfolderService:
# ---- Lifecycle ----
def run(self) -> None:
check_preflight(self.cfg.ocr.pdfa_level, self.cfg.ocr.skip_text)
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()
@@ -315,21 +386,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 liegenden PDFs (incoming/ + working/) und beendet sich.
Returns:
Anzahl fehlgeschlagener PDFs (0 = alles ok).
"""
check_preflight(self.cfg.ocr.pdfa_level, self.cfg.ocr.skip_text)
check_output_config(self.cfg.output.original_on_success,
self.cfg.output.archive_dir,
self.cfg.output.name_mode)
self._preflight()
self.ensure_dirs()
self._scan_existing()
self._executor.shutdown(wait=True)
@@ -356,9 +455,32 @@ class HotfolderService:
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ß.
@@ -563,6 +685,19 @@ class HotfolderService:
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"
+10
View File
@@ -13,6 +13,7 @@ import paramiko
import requests
from .config import EmailNotify, FolderUpload, NextcloudUpload, SftpUpload
from .processor import _collision_free_path
log = logging.getLogger(__name__)
@@ -26,6 +27,15 @@ def upload_folder(pdf: Path, cfg: FolderUpload, default_target: Path) -> bool:
try:
if pdf.resolve() == dest.resolve():
return True
# 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)
+6
View File
@@ -11,6 +11,12 @@ 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
# Ein laufendes OCR soll beim Stoppen zu Ende laufen duerfen. Bei SIGKILL
# bliebe das Original in working/ liegen (wird beim naechsten Start zwar
+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
+30 -4
View File
@@ -137,13 +137,39 @@ def test_healthy_gs_passes_on_ocrmypdf_16() -> None:
# ---------------- Meldungstext ----------------
def test_error_message_names_both_remedies() -> None:
"""Der Admin muss aus der Meldung heraus handeln können."""
"""Der Admin muss aus der Meldung heraus handeln können.
Die Wege sind die **echten**: `pdfa_level = ""` (Default), `skip_text =
false` oder eine Distribution mit neuerem Ghostscript (Debian 13: 10.05.1).
Bis v0.7.0 stand hier „Ghostscript aus bookworm-backports" als Weg 1 — das
konnte nie funktionieren, denn bookworm-backports führt gar kein
Ghostscript-Paket (am echten Paketindex verifiziert: 2606 Pakete,
`ghostscript` nicht darunter). Der Rat darf nicht zurückkommen.
"""
# Fall A: ocrmypdf 16.x ohne PDF/A — Weg 1 ist hier, ocrmypdf anzuheben.
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"
msg_no_pdfa = str(exc_info.value)
assert "ocrmypdf >= 17" in msg_no_pdfa, "Weg 1: ocrmypdf anheben"
assert "skip_text = false" in msg_no_pdfa, "Weg 2: skip_text abschalten"
assert "Debian 13" in msg_no_pdfa, "Weg 3: neuere Distribution"
# Fall B: PDF/A gewünscht — Weg 1 ist hier, PDF/A aufzugeben.
with _env("10.0.0", "17.4.1"):
with pytest.raises(PreflightError) as exc_info:
check_preflight(pdfa_level="2", skip_text=True)
msg_pdfa = str(exc_info.value)
assert 'pdfa_level = ""' in msg_pdfa, "Weg 1: PDF/A abschalten"
assert "skip_text = false" in msg_pdfa, "Weg 2: skip_text abschalten"
assert "Debian 13" in msg_pdfa, "Weg 3: neuere Distribution"
# Und in keiner der beiden Meldungen der widerlegte Backports-Rat.
for msg in (msg_no_pdfa, msg_pdfa):
assert "apt install -t bookworm-backports" not in msg
assert "sources.list.d/bookworm-backports.list" not in msg
# Erwähnt werden darf es — aber nur als ausdrückliche Absage.
assert "NICHT" in msg and "kein Ghostscript-Paket" in msg
def test_default_config_pdfa_level_is_empty() -> None:
+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
+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
+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
+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)
+68
View File
@@ -64,3 +64,71 @@ def test_upload_folder_reports_failure(tmp_path: Path) -> None:
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()) == []
+84 -94
View File
@@ -18,17 +18,40 @@
#
set -Eeuo 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} $*"; }
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ============================================================
# Gemeinsame Funktionen (Logging, Pfade, venv-Pruefung, Paketliste)
# ============================================================
# Relativ zum Skript aufgeloest, nicht zum Arbeitsverzeichnis — update.sh wird
# auch mit absolutem Pfad aufgerufen. Zuerst neben diesem Skript suchen (Repo),
# danach in der Installation, wohin install.sh/update.sh die Datei mitkopieren.
# Fehlt sie ueberall, ist hier Schluss: ein "command not found" mitten im Lauf
# waere die schlechtere Nachricht.
COMMON_LIB=""
for _pdf_ocr_cand in \
"$SCRIPT_DIR/lib/common.sh" \
"${INSTALL_DIR:-/opt/pdf-ocr-hotfolder}/lib/common.sh"
do
if [ -r "$_pdf_ocr_cand" ]; then
COMMON_LIB="$_pdf_ocr_cand"
break
fi
done
unset _pdf_ocr_cand
if [ -z "$COMMON_LIB" ]; then
echo "[ERROR] Gemeinsame Funktionsbibliothek lib/common.sh nicht gefunden." >&2
echo " Gesucht in: $SCRIPT_DIR/lib/ und ${INSTALL_DIR:-/opt/pdf-ocr-hotfolder}/lib/" >&2
echo " update.sh aus dem Repo ausfuehren (dort liegt lib/common.sh)." >&2
# shellcheck disable=SC2317 # 'exit' greift nur, wenn das Skript nicht gesourct wurde
return 1 2>/dev/null || exit 1
fi
# shellcheck source=lib/common.sh
. "$COMMON_LIB"
# Pfade sind ueberschreibbar, damit die Funktionen dieses Skripts isoliert
# gegen eine Fake-Umgebung getestet werden koennen (siehe LIB_ONLY unten).
: "${INSTALL_DIR:=/opt/pdf-ocr-hotfolder}"
: "${CONFIG_DIR:=/etc/pdf-ocr-hotfolder}"
: "${SYSTEMD_DIR:=/etc/systemd/system}"
# INSTALL_DIR/CONFIG_DIR/SYSTEMD_DIR und die Unit-Namen kommen aus lib/common.sh.
: "${BACKUP_DIR:=/var/backups/pdf-ocr-hotfolder}"
: "${TAR_ROOT:=/}"
: "${VERIFY_WAIT:=6}"
@@ -37,17 +60,14 @@ log_step() { echo -e "\n${BLUE}==>${NC} $*"; }
# Mini-PDF braucht ein paar Sekunden (Datei-Stabilisierung + OCR), unter Last
# auch mal laenger.
: "${SMOKE_TIMEOUT:=90}"
SERVICE_TEMPLATE="pdf-ocr-hotfolder@.service"
UNIT_GLOB='pdf-ocr-hotfolder@*.service'
LXC_DROPIN_DIR="$SYSTEMD_DIR/${SERVICE_TEMPLATE}.d"
LXC_DROPIN="$LXC_DROPIN_DIR/lxc-compat.conf"
REBUILD_VENV=0 # per --rebuild-venv erzwungen
VENV_REBUILT=0 # wurde tatsaechlich neu gebaut?
APT_WARN=0 # apt-Sync hat gemeckert
LXC_SYNCED=0
BACKUP_FILE=""
PRIMARY_USER="pdfocr"
PRIMARY_USER="$DEFAULT_USER"
TOUCHED=0 # 1, sobald auf der Platte etwas getauscht wurde
SMOKE_TEST=1 # per --no-smoke-test abschaltbar
@@ -156,61 +176,9 @@ die() { log_error "$*"; abort_handler 1 "${BASH_LINENO[0]:-?}"; }
# ============================================================
# 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.
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
}
#
# py_mm(), pyvenv_cfg_mm() und venv_is_healthy() stehen in lib/common.sh —
# install.sh braucht dieselbe Pruefung.
# Installiert die Requirements und uebersetzt pip-Fehler in eine Ansage, mit
# der man etwas anfangen kann (typisch: Pin passt nicht mehr zum Python).
@@ -388,21 +356,26 @@ rebuild_venv() {
# System-Pakete
# ============================================================
# Holt die Paketliste aus install.sh (einzige Quelle) und installiert sie.
# Holt die Paketliste aus lib/common.sh (einzige Quelle) und installiert sie.
# apt-get install ist idempotent; bereits vorhandene Pakete bleiben unberuehrt.
# Nachinstallierte Tesseract-Sprachpakete werden NICHT angefasst (kein purge,
# kein autoremove).
#
# Die Liste wird bewusst noch einmal aus der REPO-Fassung herausgeschnitten:
# gesourct wurde evtl. die aeltere Kopie aus der Installation, beim Update
# soll aber die neue Liste aus dem Repo gelten.
sync_system_packages() {
local block pkgs
local block pkgs repo_lib
log_step "System-Pakete abgleichen"
block="$(sed -n '/^# --- BEGIN apt-packages/,/^# --- END apt-packages/p' "$REPO_DIR/install.sh" 2>/dev/null || true)"
if [ -z "$block" ] || ! printf '%s' "$block" | grep -q 'pdf_ocr_apt_packages()'; then
log_warn "Paketliste in install.sh nicht gefunden — System-Pakete werden nicht abgeglichen."
APT_WARN=1
return 0
fi
repo_lib="$REPO_DIR/$COMMON_LIB_REL"
block="$(sed -n '/^# --- BEGIN apt-packages/,/^# --- END apt-packages/p' "$repo_lib" 2>/dev/null || true)"
if [ -n "$block" ] && printf '%s' "$block" | grep -q 'pdf_ocr_apt_packages()'; then
eval "$block"
else
log_warn "Paketliste in $repo_lib nicht gefunden — es gilt die geladene Fassung."
APT_WARN=1
fi
if ! declare -F pdf_ocr_apt_packages >/dev/null; then
log_warn "pdf_ocr_apt_packages() liess sich nicht laden — uebersprungen."
APT_WARN=1
@@ -928,14 +901,24 @@ strip_root() {
# Das Archiv enthaelt Klartext-Passwoerter aus den Instanz-Configs und wird
# deshalb root-only (0600) abgelegt.
create_backup() {
local stage tmp d
local stage tmp tar_tmp d freeze_rel
local -a items=()
log_step "Backup erstellen"
mkdir -p "$BACKUP_DIR" || die "Backup-Verzeichnis $BACKUP_DIR nicht anlegbar."
chmod 700 "$BACKUP_DIR" 2>/dev/null || true
# Die pip-freeze-Liste liegt im Archiv UNTER dem Installationsverzeichnis.
# Der dokumentierte Rollback (tar -xzf ... -C /) legt sie damit nach
# $INSTALL_DIR/pip-freeze.txt statt als /pip-freeze.txt in die Wurzel.
# Dort ueberlebt sie auch den naechsten Update-Lauf: der raeumt nur
# $INSTALL_DIR/pdf_ocr_hotfolder und $INSTALL_DIR/lib weg.
freeze_rel="$(strip_root "$INSTALL_DIR")/pip-freeze.txt"
stage="$(mktemp -d)" || die "mktemp -d fehlgeschlagen."
if ! mkdir -p "$stage/$(dirname "$freeze_rel")"; then
rm -rf "$stage"
die "Backup-Zwischenverzeichnis nicht anlegbar."
fi
{
echo "# pip freeze der alten venv vor dem Update"
echo "# Zeitpunkt: $(date -Is)"
@@ -945,7 +928,7 @@ create_backup() {
else
echo "# keine funktionierende venv vorhanden"
fi
} > "$stage/pip-freeze.txt"
} > "$stage/$freeze_rel"
[ -d "$INSTALL_DIR" ] && items+=("$(strip_root "$INSTALL_DIR")")
[ -d "$CONFIG_DIR" ] && items+=("$(strip_root "$CONFIG_DIR")")
@@ -962,17 +945,24 @@ create_backup() {
fi
tmp="$BACKUP_DIR/backup-$(date +%Y%m%d-%H%M%S).tar.gz"
tar_tmp="${tmp%.gz}"
local old_umask; old_umask="$(umask)"
umask 077
if ! tar -czf "$tmp" \
# Zwei tar-Aufrufe statt einem: --exclude wirkt global, wuerde also auch
# die frische Liste aus $stage verschlucken. So faellt eine von einem
# frueheren Rollback liegengebliebene pip-freeze.txt im ersten Aufruf raus,
# und der zweite haengt die frische an — genau ein Eintrag im Archiv.
if ! tar -cf "$tar_tmp" \
--exclude="$(strip_root "$INSTALL_DIR")/venv" \
--exclude="$(strip_root "$INSTALL_DIR")/venv.old-*" \
--exclude="$freeze_rel" \
--exclude='*/__pycache__' \
--exclude='*.pyc' \
-C "$TAR_ROOT" "${items[@]}" \
-C "$stage" pip-freeze.txt; then
|| ! tar -rf "$tar_tmp" -C "$stage" "$freeze_rel" \
|| ! gzip -n "$tar_tmp"; then
umask "$old_umask"
rm -f "$tmp"
rm -f "$tar_tmp" "$tmp"
rm -rf "$stage"
die "Backup fehlgeschlagen (volle Platte?). Es wurde NICHTS veraendert."
fi
@@ -983,8 +973,9 @@ create_backup() {
BACKUP_FILE="$tmp"
log_info "Backup: $tmp ($(du -h "$tmp" 2>/dev/null | awk '{print $1}'))"
log_info " Enthalten: Code, $CONFIG_DIR, Template-Unit, Drop-ins, pip-freeze.txt"
log_info " NICHT enthalten: venv und die Datenverzeichnisse (/var/lib/pdf-ocr-hotfolder)"
log_info " Enthalten: Code, $CONFIG_DIR, Template-Unit, Drop-ins"
log_info " Dazu die pip-Liste der alten venv: $INSTALL_DIR/pip-freeze.txt"
log_info " NICHT enthalten: venv und die Datenverzeichnisse ($DATA_ROOT)"
log_warn " Achtung: die Instanz-Configs enthalten Klartext-Passwoerter"
log_warn " (SMTP/Nextcloud/SFTP) — Archiv ist deshalb root-only (0600)."
rotate_backups
@@ -1057,12 +1048,9 @@ while [ "$#" -gt 0 ]; do
shift
done
if [ "${EUID}" -ne 0 ]; then
log_error "Bitte als root ausfuehren: sudo ./update.sh"
exit 1
fi
require_root "sudo ./update.sh"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# SCRIPT_DIR steht schon ganz oben (fuer das Sourcen von lib/common.sh).
if [ -f "$SCRIPT_DIR/pdf_ocr_hotfolder/__init__.py" ]; then
REPO_DIR="$SCRIPT_DIR"
elif [ -f "$INSTALL_DIR/.repo_path" ]; then
@@ -1099,11 +1087,11 @@ report_instances
# --- Eigentuemer des Codes merken (vor dem venv-Neubau) ---
if [ -d "$INSTALL_DIR/venv" ]; then
PRIMARY_USER="$(stat -c '%U' "$INSTALL_DIR/venv" 2>/dev/null || echo pdfocr)"
PRIMARY_USER="$(stat -c '%U' "$INSTALL_DIR/venv" 2>/dev/null || echo "$DEFAULT_USER")"
else
PRIMARY_USER="$(stat -c '%U' "$INSTALL_DIR" 2>/dev/null || echo pdfocr)"
PRIMARY_USER="$(stat -c '%U' "$INSTALL_DIR" 2>/dev/null || echo "$DEFAULT_USER")"
fi
[ "$PRIMARY_USER" = "root" ] && PRIMARY_USER="pdfocr"
[ "$PRIMARY_USER" = "root" ] && PRIMARY_USER="$DEFAULT_USER"
# --- System-Pakete (auch beim Update, nicht nur bei install.sh) ---
sync_system_packages
@@ -1118,9 +1106,7 @@ elif venv_is_healthy "$INSTALL_DIR/venv"; then
log_info "venv ok ✓ (Python $(py_mm "$INSTALL_DIR/venv/bin/python"))"
else
log_warn "venv passt nicht mehr:"
for issue in "${VENV_ISSUES[@]}"; do
log_warn " - $issue"
done
report_venv_issues
log_warn "Typische Ursache: Debian-Major-Upgrade (z.B. 12 -> 13). Die venv haengt"
log_warn "am alten Interpreter, systemd quittiert das mit 203/EXEC."
log_warn "-> venv wird neu gebaut."
@@ -1145,6 +1131,10 @@ log_step "Code aktualisieren"
TOUCHED=1
rm -rf "$INSTALL_DIR/pdf_ocr_hotfolder"
cp -r "$REPO_DIR/pdf_ocr_hotfolder" "$INSTALL_DIR/"
# lib/ muss mit: ein spaeterer Lauf aus dem Installationsverzeichnis heraus
# sucht die gemeinsamen Funktionen genau dort.
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/"
@@ -1168,7 +1158,7 @@ rm -f "$DEP_BEFORE" "$DEP_AFTER"
install_units
log_step "Berechtigungen setzen"
# Eigentuemer des Codes bleibt der primaere User (pdfocr); Instanzen laufen
# Eigentuemer des Codes bleibt der primaere User (Vorgabe: pdfocr); Instanzen laufen
# ggf. als anderer User, lesen aber nur den Code.
chown -R "$PRIMARY_USER":"$PRIMARY_USER" "$INSTALL_DIR"
log_info "Eigentuemer: $PRIMARY_USER"