Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd803a3dfe |
+153
-43
@@ -1,8 +1,8 @@
|
|||||||
# AI Agent Briefing — PDF OCR Hotfolder
|
# AI Agent Briefing — PDF OCR Hotfolder
|
||||||
|
|
||||||
**Zuletzt aktualisiert:** 2026-09-23
|
**Zuletzt aktualisiert:** 2026-09-23
|
||||||
**Version:** 0.6.3
|
**Version:** 0.7.0
|
||||||
**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.
|
**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:
|
> **Betriebsabläufe stehen nicht hier**, sondern in:
|
||||||
> [README.md](README.md) (Einstieg, Layout, Config-Überblick) ·
|
> [README.md](README.md) (Einstieg, Layout, Config-Überblick) ·
|
||||||
@@ -23,33 +23,46 @@ pdf-ocr-hotfolder/
|
|||||||
│ ├── __init__.py # Versionsstring (__version__)
|
│ ├── __init__.py # Versionsstring (__version__)
|
||||||
│ ├── __main__.py # CLI (argparse: --config, --once, --check-config, --version)
|
│ ├── __main__.py # CLI (argparse: --config, --once, --check-config, --version)
|
||||||
│ ├── config.py # TOML-Loader, Dataclasses, ConfigError, Warnungen
|
│ ├── config.py # TOML-Loader, Dataclasses, ConfigError, Warnungen
|
||||||
│ ├── service.py # HotfolderService (watchdog + ThreadPool), Preflight, Resume, Zähler
|
│ ├── service.py # HotfolderService (watchdog + ThreadPool), Preflight, Resume, Zähler, Observer-Bewachung
|
||||||
│ ├── processor.py # ocrmypdf-Call, veraPDF, Ausgabename, Original-Entsorgung
|
│ ├── processor.py # ocrmypdf-Call, veraPDF, Ausgabename, Original-Entsorgung, Kollisionsschutz
|
||||||
│ └── uploaders.py # folder, nextcloud (WebDAV), sftp, E-Mail-Notify
|
│ └── 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
|
│ ├── conftest.py # Fixtures tmp_config / dummy_pdf
|
||||||
│ ├── test_check_config.py # --check-config, Exit 0/1/2
|
│ ├── test_check_config.py # --check-config, Exit 0/1/2
|
||||||
│ ├── test_config_errors.py
|
│ ├── test_config_errors.py
|
||||||
│ ├── test_config_warnings.py # Legacy- und Unbekannt-Warnungen
|
│ ├── 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_error_counting.py
|
||||||
│ ├── test_ghostscript_version.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_ocr_timeout.py
|
||||||
│ ├── test_once_exit_code.py
|
│ ├── test_once_exit_code.py
|
||||||
|
│ ├── test_outgoing_collision.py # outgoing/ + Archiv ueberschreiben nichts
|
||||||
│ ├── test_output_naming.py
|
│ ├── test_output_naming.py
|
||||||
│ ├── test_preflight.py
|
│ ├── test_preflight.py
|
||||||
|
│ ├── test_relative_paths.py # relative Pfade -> ConfigError
|
||||||
│ ├── test_resume_working.py # Wiederaufnahme + __ocr_-Fragmente
|
│ ├── 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/
|
├── 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
|
│ └── lxc-compat.conf # Drop-in-Vorlage: Hardening für LXC abschalten
|
||||||
├── docs/
|
├── docs/
|
||||||
│ ├── INSTALLATION.md # Erstinstallation + Konfigurationsreferenz
|
│ ├── INSTALLATION.md # Erstinstallation, Konfigurationsreferenz, Exit-Codes, Troubleshooting
|
||||||
│ ├── UPDATE.md # update.sh, Backup/Rollback, --check-config, Config-Drift
|
│ ├── UPDATE.md # update.sh, Backup/Rollback, --check-config, Config-Drift
|
||||||
│ └── OS-UPGRADE.md # Debian 12 -> 13 -> 14, venv-Rebuild, Pins
|
│ └── OS-UPGRADE.md # Debian 12 -> 13 -> 14, venv-Rebuild, Pins
|
||||||
├── pytest.ini # testpaths = tests
|
├── pytest.ini # testpaths = tests
|
||||||
├── config.example.toml
|
├── config.example.toml
|
||||||
├── install.sh # Interaktiver Installer + Instanz-Manager
|
├── install.sh # Interaktiver Installer + Instanz-Manager, ~500 Zeilen
|
||||||
├── update.sh # Updater (--help, --rebuild-venv), ~860 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)
|
├── requirements.txt # feste Pins (ocrmypdf 17.x — 16.x ist unbrauchbar, s. 0.6.1)
|
||||||
├── VERSION
|
├── VERSION
|
||||||
├── CHANGELOG.md
|
├── CHANGELOG.md
|
||||||
@@ -82,6 +95,7 @@ Instanzen auf einmal reißen). Geprüft gegen Python 3.11 (Debian 12) und 3.13
|
|||||||
| Pfad | Inhalt |
|
| Pfad | Inhalt |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| `/opt/pdf-ocr-hotfolder/` | Code + venv (für alle Instanzen gemeinsam) |
|
| `/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`) |
|
| `/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/pdf-ocr-hotfolder/<instanz>.toml` | Config pro Instanz (mode 640, root:<service-group>) |
|
||||||
| `/etc/systemd/system/pdf-ocr-hotfolder@.service` | Template-Unit |
|
| `/etc/systemd/system/pdf-ocr-hotfolder@.service` | 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
|
Ein eigenes Logverzeichnis gibt es **nicht** (seit 0.4.1 auch nicht mehr vom
|
||||||
Installer angelegt): `_setup_logging()` nutzt `logging.basicConfig()` ohne
|
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
|
```bash
|
||||||
journalctl -u pdf-ocr-hotfolder@<instanz> -f # eine Instanz mitlesen
|
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**
|
- 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
|
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**,
|
- Abfragen pro Instanz: Name, Basis-Pfad, Service-User, **OCR-Sprachen**,
|
||||||
**Original archivieren?** — `LANGS`/`ORIG_MODE`/`ARCHIVE_DIR` sind `local` in
|
**Original archivieren?** — `LANGS`/`ORIG_MODE`/`ARCHIVE_DIR` sind `local` in
|
||||||
`create_instance()`, gelten also **instanz-lokal** und nicht global.
|
`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
|
Kommentarzeilen über den Keys nicht getroffen werden; Pfad-Variablen laufen
|
||||||
vorher durch `sed_escape_repl()` (maskiert `\`, `&`, `|`). Nach dem sed-Lauf
|
vorher durch `sed_escape_repl()` (maskiert `\`, `&`, `|`). Nach dem sed-Lauf
|
||||||
liest `config_value()` die drei Keys zurück und vergleicht sie mit der Eingabe.
|
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
|
Marken `# --- BEGIN apt-packages` / `# --- END apt-packages` in der Funktion
|
||||||
`pdf_ocr_apt_packages()`. **`update.sh` schneidet diesen Block per `sed` heraus
|
`pdf_ocr_apt_packages()`. `install.sh` bekommt sie durchs Sourcen;
|
||||||
und evaluiert ihn** — Marken und Funktionsname dürfen sich nicht ändern, ohne
|
**`update.sh` schneidet den Block zusätzlich per `sed` aus der Repo-Fassung
|
||||||
`update.sh` anzupassen.
|
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
|
- Instanz wird sofort `enable --now` gestartet. Löschen macht der Installer
|
||||||
nicht, das steht als Handgriff in
|
nicht, das steht als Handgriff in
|
||||||
[docs/INSTALLATION.md](docs/INSTALLATION.md#instanz-manuell-löschen).
|
[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)
|
## 🔄 Update-Verhalten (Kurzfassung)
|
||||||
|
|
||||||
Vollständig: [docs/UPDATE.md](docs/UPDATE.md). Für die Arbeit am Skript wichtig:
|
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
|
- **Verifikation**: `verify_unit()` wartet `VERIFY_WAIT` (6 s) und prüft
|
||||||
`is-active`, `is-failed` **und** `NRestarts` — sonst würde ein Crash-Loop bei
|
`is-active`, `is-failed` **und** `NRestarts` — sonst würde ein Crash-Loop bei
|
||||||
`Type=simple` als Erfolg durchgehen. Vorher `reset-failed`.
|
`Type=simple` als Erfolg durchgehen. Vorher `reset-failed`.
|
||||||
- **venv-Health** (`venv_is_healthy()` in `update.sh`): Verzeichnis, ausführbarer
|
- **venv-Health** (`venv_is_healthy()` aus `lib/common.sh`): Verzeichnis,
|
||||||
Interpreter, Interpreter **läuft** überhaupt, `major.minor` == System-Python,
|
ausführbarer Interpreter, Interpreter **läuft** überhaupt, `major.minor` ==
|
||||||
`pyvenv.cfg` stimmt mit dem Interpreter überein. Bei Drift wird auch ohne
|
System-Python, `pyvenv.cfg` stimmt mit dem Interpreter überein. Bei Drift wird
|
||||||
`--rebuild-venv` neu gebaut.
|
auch ohne `--rebuild-venv` neu gebaut.
|
||||||
- **`rebuild_venv()` ist ganz oder gar nicht**: alte venv nach
|
- **`rebuild_venv()` ist ganz oder gar nicht**: alte venv nach
|
||||||
`venv.old-<ts>`, neu bauen, Requirements installieren, **erst bei Erfolg** die
|
`venv.old-<ts>`, neu bauen, Requirements installieren, **erst bei Erfolg** die
|
||||||
alte löschen; scheitert etwas, wird zurückgerollt und hart abgebrochen.
|
alte löschen; scheitert etwas, wird zurückgerollt und hart abgebrochen.
|
||||||
@@ -182,7 +238,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`).
|
muss erhalten bleiben — `update.sh` kopiert daraus (`.repo_path`).
|
||||||
- `PDF_OCR_UPDATE_LIB_ONLY=1 source ./update.sh` lädt nur die Funktionen, ohne
|
- `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`,
|
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)
|
## ⚙️ Konfiguration (Überblick)
|
||||||
|
|
||||||
@@ -191,16 +249,24 @@ Vollständiges Beispiel mit Kommentaren: `config.example.toml`.
|
|||||||
|
|
||||||
| Sektion | Zweck |
|
| 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**) |
|
| `[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 |
|
| `[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.nextcloud]` | `enabled`, `url`, `username`, `password`, `remote_path`, `verify_ssl` |
|
||||||
| `[upload.sftp]` | `enabled`, `host`, `port`, `username`, `key_file`, `password`, `remote_path` |
|
| `[upload.sftp]` | `enabled`, `host`, `port`, `username`, `key_file`, `password`, `remote_path` |
|
||||||
| `[notify.email]` | `enabled`, SMTP-Daten, `from_addr`, `to_addrs`, `on` = `always`/`errors`/`never` |
|
| `[notify.email]` | `enabled`, SMTP-Daten, `from_addr`, `to_addrs`, `on` = `always`/`errors`/`never` |
|
||||||
| `[logging]` | `level` = DEBUG/INFO/WARNING/ERROR |
|
| `[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**:
|
Unbekannte Keys werden beim Laden zwar ignoriert, aber **nicht mehr still**:
|
||||||
`_collect_unknown_keys()` sammelt sie in `Config.unknown_keys` (Format
|
`_collect_unknown_keys()` sammelt sie in `Config.unknown_keys` (Format
|
||||||
`[ocr].langauges`, auch ganze unbekannte Sektionen und Upload-/Notify-Targets),
|
`[ocr].langauges`, auch ganze unbekannte Sektionen und Upload-/Notify-Targets),
|
||||||
@@ -221,19 +287,37 @@ Unbekannte Keys werden beim Laden zwar ignoriert, aber **nicht mehr still**:
|
|||||||
### `--check-config`
|
### `--check-config`
|
||||||
|
|
||||||
`python -m pdf_ocr_hotfolder --check-config --config <datei>` lädt die 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
|
zeigt Pfade/Sprachen/Timeout/PDF/A sowie veraPDF-Binary und -Flavour (bzw.
|
||||||
`check_output_config()` und gibt die Warnungen aus. Exit-Codes:
|
`(aus)`), fährt `check_preflight()` und `check_output_config()` und gibt die
|
||||||
`CHECK_OK=0`, `CHECK_WARN=1`, `CHECK_ERROR=2`. Hat Vorrang vor `--once`.
|
Warnungen aus. Exit-Codes: `CHECK_OK=0`, `CHECK_WARN=1`, `CHECK_ERROR=2`. Hat
|
||||||
`update.sh` wertet genau diese Codes aus und erkennt an der argparse-Meldung,
|
Vorrang vor `--once`. `update.sh` wertet genau diese Codes aus und erkennt an
|
||||||
wenn der installierte Code das Flag noch nicht kennt.
|
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
|
## 🔄 Verarbeitungs-Flow
|
||||||
|
|
||||||
**Beim Start (`run()` wie `run_once()`), vor allem anderen:**
|
**Beim Start (`run()` wie `run_once()`), vor allem anderen** — beide rufen
|
||||||
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))
|
dasselbe `_preflight()`:
|
||||||
2. `check_output_config()` — validiert `original_on_success`, `archive_dir` (Pflicht bei `archive`) und `name_mode`
|
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))
|
||||||
3. Scheitert eines davon → `PreflightError`, CLI beendet sich mit **Exit-Code 2** (ebenso bei kaputter/fehlender Config)
|
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`)
|
||||||
4. `ensure_dirs()`, dann `_scan_existing()`: **zuerst `working/`**, danach `incoming/`
|
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()`):**
|
**Wiederaufnahme aus `working/` (`_scan_working()`):**
|
||||||
`process_pdf()` verschiebt das Original vor dem OCR nach `working/`. Wird der
|
`process_pdf()` verschiebt das Original vor dem OCR nach `working/`. Wird der
|
||||||
@@ -252,16 +336,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
|
`process_pdf()` für die neue ab und lässt sie in `incoming/` liegen, statt den
|
||||||
laufenden Vorgang stillschweigend zu überschreiben.
|
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:**
|
**Pro Datei:**
|
||||||
1. `watchdog` triggert auf `created`/`moved`/`closed` in `incoming/`
|
1. `watchdog` triggert auf `created`/`moved`/`closed` in `incoming/`
|
||||||
2. `_wait_until_stable()` wartet, bis die Datei nicht mehr wächst (max. ~60s)
|
2. `_wait_until_stable()` wartet, bis die Datei nicht mehr wächst (max. ~60s)
|
||||||
3. Move nach `working/` (entfällt bei Wiederaufnahme)
|
3. Move nach `working/` (entfällt bei Wiederaufnahme)
|
||||||
4. `ocrmypdf.ocr()` als **Library-Call** (kein Subprozess-Start pro PDF), Ziel ist `working/__ocr_<zielname>`
|
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)
|
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()`)
|
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)
|
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)
|
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:**
|
**Fehlerbehandlung:**
|
||||||
|
|
||||||
@@ -272,6 +374,8 @@ liegen und wurde nie wieder angefasst — stiller Datenverlust. Jetzt:
|
|||||||
| In `working/` liegt schon eine andere Datei gleichen Namens | ja | bleibt in `incoming/` |
|
| In `working/` liegt schon eine andere Datei gleichen Namens | ja | bleibt in `incoming/` |
|
||||||
| OCR wirft (ocrmypdf) | ja | `error/` |
|
| 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 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/` |
|
| 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 |
|
| Mindestens ein Upload-Ziel schlägt fehl | ja | PDF bleibt **bewusst in `outgoing/`** (das OCR war ja erfolgreich), Fehler-Mail nennt die Ziele |
|
||||||
|
|
||||||
@@ -296,9 +400,13 @@ Der Service läuft in allen Fällen weiter (kein `exit 1` wie im alten Bash-Tool
|
|||||||
`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: Ghostscript ≥ 10.02.1 aus bookworm-backports (der Installer bietet das an) oder `skip_text = false`.
|
||||||
- **`[ocr].timeout` ist ein Timeout PRO SEITE**, kein Gesamt-Timeout pro PDF. Der Wert geht als `tesseract_timeout` (ocrmypdf-Option `--tesseract-timeout`) durch; ocrmypdf kennt kein Dokument-Timeout. Wer noch den alten Default `1800` in einer Config stehen hat, gibt Tesseract 30 Minuten **je Seite** — Richtwert ist 300, ab 900 warnt `--check-config`. Ein durchgereichtes `0` würde ocrmypdf dazu bringen, OCR **still zu überspringen**, deshalb wird bei `0` (oder negativ) gar nichts übergeben und der ocrmypdf-Default greift.
|
- **`[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.
|
- **`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.
|
||||||
|
- **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.
|
- **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.
|
- **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.
|
- **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).
|
- **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).
|
||||||
|
|
||||||
@@ -316,18 +424,20 @@ python -m pdf_ocr_hotfolder --config /tmp/config.toml
|
|||||||
|
|
||||||
Tests (aus dem Repo-Root, `pytest.ini` setzt `testpaths = tests`):
|
Tests (aus dem Repo-Root, `pytest.ini` setzt `testpaths = tests`):
|
||||||
```bash
|
```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
|
## 📋 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] Wiederaufnahme abgebrochener Läufe aus `working/`
|
||||||
- [x] Config-Prüfung ohne Verarbeitung (`--check-config`) + Auswertung im Updater
|
- [x] Config-Prüfung ohne Verarbeitung (`--check-config`) + Auswertung im Updater
|
||||||
- [x] Updater übersteht Debian-Major-Upgrades (venv-Rebuild, Pins, Rollback)
|
- [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)
|
- [ ] Prometheus-Metriken (verarbeitete PDFs, Fehlerquote, Laufzeit)
|
||||||
- [ ] CLI-Subkommandos: `pdf-ocr-hotfolder reprocess <error-file>`
|
- [ ] CLI-Subkommandos: `pdf-ocr-hotfolder reprocess <error-file>`
|
||||||
- [ ] Instanz-Löschung in `install.sh` statt als Handarbeit
|
- [ ] Instanz-Löschung in `install.sh` statt als Handarbeit
|
||||||
|
|||||||
+183
@@ -1,5 +1,188 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.7.0] - 2026-09-23
|
||||||
|
|
||||||
|
Schliesst die stillen Datenverlust-Pfade: gleichnamige Dateien werden nirgends
|
||||||
|
mehr ueberschrieben, und ein nicht aufrufbares veraPDF wird nicht mehr als
|
||||||
|
"PDF ist ungueltig" missverstanden. Dazu Robustheit (Exit 2 statt Traceback,
|
||||||
|
toter Verzeichnis-Watch faellt auf, relative Pfade sind ein Config-Fehler) und
|
||||||
|
eine gemeinsame Shell-Bibliothek fuer install.sh und update.sh.
|
||||||
|
Test-Suite: 254 pytest-Tests gruen.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **`lib/common.sh` — gemeinsame Shell-Bibliothek.** `install.sh` und
|
||||||
|
`update.sh` sourcen sie und teilen sich darueber Log-Funktionen
|
||||||
|
(`log_info`/`log_warn`/`log_error`/`log_step`), `require_root`, die
|
||||||
|
Layout-Konstanten (`INSTALL_DIR`, `CONFIG_DIR`, `DATA_ROOT`, `SYSTEMD_DIR`,
|
||||||
|
`DEFAULT_USER`, `SERVICE_TEMPLATE`, `LXC_DROPIN*`), die apt-Paketliste
|
||||||
|
(`pdf_ocr_apt_packages()` zwischen den BEGIN/END-Marken) und die
|
||||||
|
venv-Pruefung (`py_mm`, `pyvenv_cfg_mm`, `venv_is_healthy`,
|
||||||
|
`report_venv_issues`).
|
||||||
|
- Die **Paketliste** steht damit nicht mehr in `install.sh`, sondern in
|
||||||
|
`lib/common.sh`; `update.sh` schneidet sie weiterhin per `sed` aus der
|
||||||
|
**Repo**-Fassung heraus, damit beim Update die neue Liste gilt und nicht
|
||||||
|
die vielleicht aeltere, bereits gesourcte aus der Installation.
|
||||||
|
- `venv_is_healthy()` gab es bisher **zweimal** — die schlanke Variante in
|
||||||
|
`install.sh` haette den Distro-Upgrade-Fall (`pyvenv.cfg` gegen
|
||||||
|
System-Python) nicht erkannt. Jetzt existiert nur noch die gruendliche
|
||||||
|
Fassung, und `install.sh` nennt die Befunde ueber `report_venv_issues`.
|
||||||
|
- `lib/` wird von `install.sh` **und** `update.sh` nach
|
||||||
|
`/opt/pdf-ocr-hotfolder/lib/` mitkopiert und liegt damit im
|
||||||
|
Update-Backup. `update.sh` sourct bevorzugt die Repo-Fassung neben sich
|
||||||
|
und faellt auf die installierte zurueck; fehlt sie ueberall, bricht es
|
||||||
|
sofort ab statt mitten im Lauf mit "command not found".
|
||||||
|
- **veraPDF wird im Preflight geprueft** (`check_verapdf_binary()`,
|
||||||
|
`resolve_verapdf_binary()`). Mit `[verapdf].enabled = true` muss
|
||||||
|
`[verapdf].binary` auf ein vorhandenes, ausfuehrbares Programm zeigen —
|
||||||
|
sonst startet der Dienst gar nicht erst (Exit 2), und `--check-config`
|
||||||
|
meldet den Fehler. Ein Pfad mit `/` wird direkt geprueft, ein nackter Name
|
||||||
|
im `PATH` gesucht. `--check-config` zeigt jetzt ausserdem Binary und Flavour
|
||||||
|
bzw. `(aus)` an.
|
||||||
|
Hintergrund: ein Tippfehler im Pfad war der **gefaehrlichste Fehler des
|
||||||
|
ganzen Dienstes**. `run_verapdf()` fand das Programm fuer JEDE Datei nicht,
|
||||||
|
wertete das als FAIL, schob das OCR-Ergebnis nach `error/` — und
|
||||||
|
`_dispose_original()` entsorgte das Original laut
|
||||||
|
`[output].original_on_success`, bei dessen Default `delete` also Scan fuer
|
||||||
|
Scan die Vorlage. Die Unit stand dabei als `active (running)` da.
|
||||||
|
- **Exit 3: toter Verzeichnis-Watch** (`EXIT_OBSERVER_DEAD` in `service.py`).
|
||||||
|
Stirbt der watchdog-Observer im Betrieb (erschoepftes
|
||||||
|
`fs.inotify.max_user_watches`, ersetztes oder neu gemountetes Verzeichnis),
|
||||||
|
blieb die Unit bisher `active (running)` und verarbeitete nichts mehr — kein
|
||||||
|
Log, keine Mail, niemand merkt es. Die Hauptschleife prueft den Observer
|
||||||
|
jetzt sekuendlich mit, loggt im Ernstfall die moeglichen Ursachen und
|
||||||
|
beendet sich mit Exit 3; `Restart=on-failure` startet den Dienst neu und der
|
||||||
|
Watch wird neu aufgesetzt. Bewusst **nicht** Exit 2 — den unterdrueckt die
|
||||||
|
Unit jetzt beim Neustart (s.u.).
|
||||||
|
- **`ProcessResult.warning`** — erfolgreicher Durchlauf mit Nebenbefund. Bisher
|
||||||
|
gab es nur Erfolg oder Fehler; ein liegengebliebenes Original passte in
|
||||||
|
keine der beiden Schubladen.
|
||||||
|
- **Sammelmeldung fuer Nicht-PDF-Dateien in `incoming/`**
|
||||||
|
(`_report_non_pdf()`). Alles ohne `.pdf`-Endung wurde ignoriert und
|
||||||
|
sammelte sich stumm an (Scanner-Fehlablagen, abgebrochene Uploads,
|
||||||
|
Thumbnails). Beim Start-Scan gibt es jetzt **eine** Warnung mit Anzahl und
|
||||||
|
bis zu drei Beispielnamen — keine Zeile pro Datei und nichts im laufenden
|
||||||
|
Betrieb.
|
||||||
|
- **`install.sh` warnt beim Erstinstall in Containern, wenn
|
||||||
|
`systemd-journald` nicht laeuft.** Der Dienst loggt ausschliesslich nach
|
||||||
|
journald; ist journald kaputt, gibt es gar keine Logs. Die Warnung nennt den
|
||||||
|
Drop-in-Befehl fuer den Debian-13-Fall und fragt, ob fortgefahren werden
|
||||||
|
soll.
|
||||||
|
- **Dokumentation** (siehe unten unter *Docs*): Dateisystem-Festlegung,
|
||||||
|
Debian-13-journald-Befund, konkrete Speicher-Messwerte, Exit-Code-Tabelle.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **`outgoing/`: gleichnamige Datei wird nicht mehr ueberschrieben.** Liefert
|
||||||
|
der Scanner denselben Dateinamen ein zweites Mal (oder wurde das
|
||||||
|
Vorgaengerergebnis noch nicht abgeholt), legte der `shutil.move` die
|
||||||
|
aeltere Datei kommentarlos um. Neu: `_collision_free_path()` haengt einen
|
||||||
|
Zeitstempel an (`scan.pdf` -> `scan_20260923-081500.pdf`), bei Kollision
|
||||||
|
innerhalb derselben Sekunde zusaetzlich einen Zaehler. Es gibt eine
|
||||||
|
`log.warning`, und `ProcessResult.output` traegt den **tatsaechlich**
|
||||||
|
geschriebenen Pfad — die Upload-Ziele und die Mail nennen damit die richtige
|
||||||
|
Datei.
|
||||||
|
- **Dieselbe Klasse in `error/` und beim Ordner-Upload.** `_move_to_error()`
|
||||||
|
(und damit auch `_rescue_to_error()`) sowie `upload_folder()` mit
|
||||||
|
abweichendem `[upload.folder].target` ersetzten bisher still eine
|
||||||
|
gleichnamige Datei im Ziel. Beide nutzen jetzt denselben
|
||||||
|
Zeitstempel-Ausweg. Scheitert dieselbe `scan.pdf` zweimal, liegen jetzt
|
||||||
|
beide Fassungen in `error/`.
|
||||||
|
- **veraPDF: Stoerung wird nicht mehr als FAIL gewertet.** `run_verapdf()`
|
||||||
|
unterscheidet jetzt zwischen einem echten Urteil (PASS/FAIL) und
|
||||||
|
"Programm nicht aufrufbar, nicht startbar, Timeout oder kein PASS/FAIL in
|
||||||
|
der Ausgabe" — Letzteres wirft `VeraPdfUnavailable`. Im Stoerungsfall
|
||||||
|
wandern **Original UND OCR-Ergebnis** nach `error/`, und das Original wird
|
||||||
|
weder geloescht noch archiviert, unabhaengig von
|
||||||
|
`[output].original_on_success`. Vorher lieferte jeder dieser Faelle
|
||||||
|
schlicht `False` und damit ein Fehlurteil ueber die Datei.
|
||||||
|
- **Kaputtes TOML und nicht lesbare Config beenden den Dienststart mit
|
||||||
|
Exit 2** statt mit einem nackten Traceback. `main()` faengt jetzt
|
||||||
|
`tomllib.TOMLDecodeError` und `OSError` genauso ab wie `--check-config`; die
|
||||||
|
Meldung nennt Zeile und Spalte, sofern der Interpreter sie liefert
|
||||||
|
(`TOMLDecodeError.lineno`/`.colno` gibt es erst ab Python 3.14 — auf
|
||||||
|
Debian 12 steht die Position nur im Meldungstext, deshalb `getattr`).
|
||||||
|
- **Relative Pfade in der Config sind jetzt ein Fehler.** `[paths].incoming`,
|
||||||
|
`outgoing`, `working`, `error` sowie `[output].archive_dir` und
|
||||||
|
`[upload.folder].target` muessen absolut sein (`_require_absolute()`,
|
||||||
|
`ConfigError` + Exit 2). Ein relativer Pfad wurde gegen das
|
||||||
|
`WorkingDirectory` der Unit aufgeloest und landete still unter
|
||||||
|
`/opt/pdf-ocr-hotfolder/` — der Scanner schrieb dann woanders hin als der
|
||||||
|
Dienst schaute, ohne dass irgendwo ein Fehler auftauchte. Leere Werte
|
||||||
|
bleiben erlaubt (`archive_dir`/`target` sind optional).
|
||||||
|
- **Ein Fehler beim Entsorgen des Originals entwertet den Durchlauf nicht
|
||||||
|
mehr.** `_dispose_original()` wirft nicht mehr, sondern liefert eine
|
||||||
|
Meldung zurueck: zum Aufrufzeitpunkt liegt das fertige PDF schon in
|
||||||
|
`outgoing/`, eine Exception von dort haette den gelungenen Durchlauf im
|
||||||
|
Catch-all des Service in einen Fehler verwandelt — mitsamt ausgefallenem
|
||||||
|
Upload. Jetzt gilt der Lauf als Erfolg, Upload und Benachrichtigung laufen,
|
||||||
|
es gibt aber eine `log.error` (Original liegt noch in `working/` und wird
|
||||||
|
beim naechsten Start erneut durch das OCR geschickt), und die Mail geht als
|
||||||
|
**"OK mit Warnung"** auch bei `[notify.email].on = "errors"` raus — sonst
|
||||||
|
waere genau das wieder ein stiller Fehlerpfad.
|
||||||
|
- **Log geht explizit nach stdout.** `logging.basicConfig()` schreibt per
|
||||||
|
Default nach **stderr**; README und `docs/INSTALLATION.md` versprachen aber
|
||||||
|
stdout. Fuer journald egal, fuer den dort beschriebenen
|
||||||
|
Vordergrund-Notbehelf und fuer jede Weiterleitung nicht.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **`systemd/pdf-ocr-hotfolder@.service`: `RestartPreventExitStatus=2`.**
|
||||||
|
Exit 2 = Config- oder Preflight-Fehler, den behebt kein Neustart. Bisher
|
||||||
|
startete `Restart=on-failure` die Instanz endlos im 5-Sekunden-Takt neu
|
||||||
|
(das Start-Rate-Limit greift bei `RestartSec=5` nie). Jetzt bleibt die
|
||||||
|
Instanz sichtbar `failed` stehen.
|
||||||
|
- `HotfolderService.run()` gibt einen **Exit-Code** zurueck (0 oder
|
||||||
|
`EXIT_OBSERVER_DEAD`) statt `None`; `main()` reicht ihn durch. Preflight und
|
||||||
|
`check_output_config()` stehen jetzt gebuendelt in `_preflight()`, das
|
||||||
|
`run()` und `run_once()` gemeinsam nutzen.
|
||||||
|
- `check_preflight()` nimmt zwei weitere Parameter (`verapdf_enabled`,
|
||||||
|
`verapdf_binary`) — beide mit Default, bestehende Aufrufe bleiben gueltig.
|
||||||
|
- `config.example.toml`: Warnhinweise zu absoluten Pfaden (`[paths]`,
|
||||||
|
`[output].archive_dir`, `[upload.folder].target`), zur veraPDF-Preflight-
|
||||||
|
Pruefung samt Begruendung und zum Kollisionsverhalten im Upload-Ziel.
|
||||||
|
- `install.sh` ist von ~73 Zeilen Kopf auf das Sourcen von `lib/common.sh`
|
||||||
|
geschrumpft und nutzt durchgehend `SYSTEMD_DIR`/`LXC_DROPIN` statt
|
||||||
|
hartkodierter Pfade.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
- **`docs/INSTALLATION.md`, Systemanforderungen: Dateisystem festgelegt.** Der
|
||||||
|
Dienst laeuft ausschliesslich auf **ext4, xfs oder zfs**. `incoming/` gehoert
|
||||||
|
auf ein lokales, inotify-faehiges Dateisystem — **kein CIFS/NFS-Mount**: dort
|
||||||
|
liefert inotify grundsaetzlich keine Events, weil Schreibzugriffe anderer
|
||||||
|
Rechner am lokalen Kernel vorbeigehen. Der Dienst wuerde dann nur noch beim
|
||||||
|
Start etwas verarbeiten und im laufenden Betrieb nichts mehr bemerken.
|
||||||
|
- **Speicher-Messwerte konkretisiert.** Zur bestehenden 512-MB-Messung kommen
|
||||||
|
Zahlen von einem **2-GB-Container** (Debian 13, 300-dpi-A4-Seite mit
|
||||||
|
`deskew`, `oversample = 300`, `jobs = 4`): Laufzeit **19 s**, `MemoryPeak`
|
||||||
|
des Dienstes **380 MB**, `memory.peak` des ganzen Containers **503 MB**,
|
||||||
|
`oom_kill 0`. Auf derselben Maschine mit 512 MB war genau das der OOM-Kill.
|
||||||
|
Die Empfehlung "mindestens 2 GB" ist damit belegt statt geschaetzt.
|
||||||
|
- **Debian 13 in LXC auf Proxmox: journald scheitert — verifizierter Befund,
|
||||||
|
betrifft jede Debian-13-LXC auf Proxmox 8.4.** In 0.6.3 stand noch, das sei
|
||||||
|
ein Schaden auf genau einer Maschine; das war falsch. Ursache: systemd >= 255
|
||||||
|
(Debian 13 hat 257) setzt `ImportCredential=journal.*` in der
|
||||||
|
journald-Unit, der Hilfsprozess `(sd-mkdcreds)` mountet dafuer, und das
|
||||||
|
AppArmor-Profil des Proxmox-Hosts blockiert das
|
||||||
|
(`apparmor="DENIED" operation="mount" profile="lxc-<id>_</var/lib/lxc>"
|
||||||
|
name="/dev/" comm="(sd-mkdcreds)"` im Host-Log). Debian 12 (systemd 252)
|
||||||
|
kennt `ImportCredential` nicht und ist nicht betroffen. Dokumentiert sind
|
||||||
|
die reboot-feste Abhilfe im Container (Drop-in
|
||||||
|
`systemd-journald.service.d/no-credentials.conf` mit leerem
|
||||||
|
`ImportCredential=`), der Hinweis auf die weiteren betroffenen Units
|
||||||
|
(logind, networkd, console-getty, tmpfiles-setup) und der saubere Weg
|
||||||
|
host-seitig.
|
||||||
|
- **Exit-Code-Tabelle 0/1/2/3** in `docs/INSTALLATION.md`, verlinkt aus
|
||||||
|
README, `docs/UPDATE.md` und dem Troubleshooting — mit dem Hinweis, dass die
|
||||||
|
Unit bei 2 **nicht** neu startet und bei 3 gerade doch.
|
||||||
|
- Die fuenf Ueberschriften der Instanz-Abfragen in `docs/INSTALLATION.md` sind
|
||||||
|
**entnummeriert** (`### 4. OCR-Sprachen` -> `### OCR-Sprachen`). Die Anker
|
||||||
|
hiessen vorher `#4-ocr-sprachen` und waeren bei jeder Umsortierung
|
||||||
|
gebrochen; die Reihenfolge steht weiterhin im Text.
|
||||||
|
- `AI_AGENT_BRIEFING.md` auf den heutigen Stand gezogen: Dateibaum mit `lib/`
|
||||||
|
und allen 20 Test-Dateien, nur noch **ein** `venv_is_healthy()`,
|
||||||
|
Paketliste in `lib/common.sh`, veraPDF-Preflight, Kollisionsschutz,
|
||||||
|
Exit-Codes, Observer-Bewachung, Testzahl 254.
|
||||||
|
- Testzahl ueberall von 152 auf **254** korrigiert (README,
|
||||||
|
`AI_AGENT_BRIEFING.md`, `docs/OS-UPGRADE.md`).
|
||||||
|
|
||||||
## [0.6.3] - 2026-09-23
|
## [0.6.3] - 2026-09-23
|
||||||
|
|
||||||
Reine Doku-Version — kein Code, kein Installer, kein Updater, keine Unit, keine
|
Reine Doku-Version — kein Code, kein Installer, kein Updater, keine Unit, keine
|
||||||
|
|||||||
@@ -20,11 +20,13 @@ Weiter: [CHANGELOG.md](CHANGELOG.md) · [AI_AGENT_BRIEFING.md](AI_AGENT_BRIEFING
|
|||||||
- 🔁 **Parallelverarbeitung** mehrerer PDFs (ThreadPool, konfigurierbar)
|
- 🔁 **Parallelverarbeitung** mehrerer PDFs (ThreadPool, konfigurierbar)
|
||||||
- ♻️ **Wiederaufnahme aus `working/`** nach einem harten Stopp — keine Datei bleibt liegen
|
- ♻️ **Wiederaufnahme aus `working/`** nach einem harten Stopp — keine Datei bleibt liegen
|
||||||
- ✅ **PDF/A-Output** (1, 2 oder 3) optional
|
- ✅ **PDF/A-Output** (1, 2 oder 3) optional
|
||||||
- 🛡️ **veraPDF-Validierung** optional
|
- 🛡️ **veraPDF-Validierung** optional — Binary wird im Preflight geprüft, eine Störung gilt nicht als FAIL
|
||||||
|
- 🚫 **Überschreibt nie eine gleichnamige Datei** — in `outgoing/`, `error/`, Archiv und Ordner-Upload weicht sie mit Zeitstempel aus
|
||||||
- ☁️ **Upload-Ziele**: lokaler Ordner, Nextcloud (WebDAV via Python), SFTP
|
- ☁️ **Upload-Ziele**: lokaler Ordner, Nextcloud (WebDAV via Python), SFTP
|
||||||
- 📧 **E-Mail-Notify** (immer / nur Fehler / nie)
|
- 📧 **E-Mail-Notify** (immer / nur Fehler / nie)
|
||||||
- 🔐 **Service-User-Support** für lokale **und AD-User mit lokaler UID** (SSSD/Winbind)
|
- 🔐 **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
|
- ⚙️ 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
|
- 🩺 **`--check-config`** prüft eine Instanz-Config ohne etwas zu verarbeiten
|
||||||
|
|
||||||
## Schnellstart
|
## Schnellstart
|
||||||
@@ -32,6 +34,10 @@ Weiter: [CHANGELOG.md](CHANGELOG.md) · [AI_AGENT_BRIEFING.md](AI_AGENT_BRIEFING
|
|||||||
**Voraussetzungen:** Debian 12 oder 13, Python 3.11+, root — und **mindestens
|
**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
|
2 GB RAM** (512 MB reichen für 300-dpi-Scans nachweislich nicht, siehe
|
||||||
[Systemanforderungen](docs/INSTALLATION.md#systemanforderungen)).
|
[Systemanforderungen](docs/INSTALLATION.md#systemanforderungen)).
|
||||||
|
Dateisystem **ext4, xfs oder zfs**; `incoming/` muss **lokal** liegen — auf
|
||||||
|
einem CIFS/NFS-Mount liefert inotify keine Events und der Hotfolder bemerkt
|
||||||
|
neue Dateien nur noch beim Start
|
||||||
|
([warum](docs/INSTALLATION.md#dateisystem-ext4-xfs-oder-zfs)).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone gitea@gitea.sonith.de:sonith_ug/pdf-ocr-hotfolder.git
|
git clone gitea@gitea.sonith.de:sonith_ug/pdf-ocr-hotfolder.git
|
||||||
@@ -64,6 +70,7 @@ Nach einem Debian-Major-Upgrade: **[docs/OS-UPGRADE.md](docs/OS-UPGRADE.md)**.
|
|||||||
| Pfad | Zweck |
|
| Pfad | Zweck |
|
||||||
|------|-------|
|
|------|-------|
|
||||||
| `/opt/pdf-ocr-hotfolder/` | Code + venv (für alle Instanzen gemeinsam) |
|
| `/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/pdf-ocr-hotfolder/<instanz>.toml` | Config pro Instanz (640, root:\<service-gruppe\>) |
|
||||||
| `/etc/systemd/system/pdf-ocr-hotfolder@.service` | systemd Template-Unit |
|
| `/etc/systemd/system/pdf-ocr-hotfolder@.service` | systemd Template-Unit |
|
||||||
| `/var/lib/pdf-ocr-hotfolder/<instanz>/incoming` | Eingang (Scanner schreibt hier rein) |
|
| `/var/lib/pdf-ocr-hotfolder/<instanz>/incoming` | Eingang (Scanner schreibt hier rein) |
|
||||||
@@ -83,7 +90,7 @@ Key-für-Key-Referenz: [docs/INSTALLATION.md](docs/INSTALLATION.md#konfiguration
|
|||||||
|
|
||||||
| Sektion | Zweck |
|
| 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**) |
|
| `[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`) |
|
| `[output]` | Dateibenennung (`name_mode`/`name_tag`) und Original-Behandlung (`delete`/`archive`) |
|
||||||
| `[verapdf]` | optionale PDF/A-Validierung per CLI |
|
| `[verapdf]` | optionale PDF/A-Validierung per CLI |
|
||||||
@@ -104,6 +111,17 @@ cd /opt/pdf-ocr-hotfolder && sudo ./venv/bin/python -m pdf_ocr_hotfolder \
|
|||||||
Exit 0 = sauber, 1 = Warnungen, 2 = Fehler. Details:
|
Exit 0 = sauber, 1 = Warnungen, 2 = Fehler. Details:
|
||||||
[docs/UPDATE.md](docs/UPDATE.md#config-prüfung-per---check-config).
|
[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
|
## Service-Verwaltung
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -150,7 +168,7 @@ gelöscht.
|
|||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pytest # 152 Tests
|
pytest # 254 Tests
|
||||||
```
|
```
|
||||||
|
|
||||||
`ocrmypdf` muss dafür nicht installiert sein — der Import ist lazy und wird in
|
`ocrmypdf` muss dafür nicht installiert sein — der Import ist lazy und wird in
|
||||||
@@ -162,5 +180,5 @@ MIT — © Sonith UG
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Version:** 0.6.3
|
**Version:** 0.7.0
|
||||||
**Repo:** https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder
|
**Repo:** https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder
|
||||||
|
|||||||
+17
-2
@@ -2,6 +2,11 @@
|
|||||||
# Vorlage — install.sh erzeugt daraus pro Instanz /etc/pdf-ocr-hotfolder/<instanz>.toml
|
# Vorlage — install.sh erzeugt daraus pro Instanz /etc/pdf-ocr-hotfolder/<instanz>.toml
|
||||||
|
|
||||||
[paths]
|
[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
|
# Eingangsverzeichnis: hier landen gescannte PDFs
|
||||||
incoming = "/var/lib/pdf-ocr-hotfolder/incoming"
|
incoming = "/var/lib/pdf-ocr-hotfolder/incoming"
|
||||||
# Ausgangsverzeichnis: fertige durchsuchbare PDFs
|
# Ausgangsverzeichnis: fertige durchsuchbare PDFs
|
||||||
@@ -60,11 +65,19 @@ name_tag = "OCR_"
|
|||||||
# "delete" : Original wird gelöscht (alter Standard)
|
# "delete" : Original wird gelöscht (alter Standard)
|
||||||
# "archive" : Original wird in archive_dir verschoben
|
# "archive" : Original wird in archive_dir verschoben
|
||||||
original_on_success = "delete"
|
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 = ""
|
archive_dir = ""
|
||||||
|
|
||||||
[verapdf]
|
[verapdf]
|
||||||
# PDF/A-Validierung (optional)
|
# 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
|
enabled = false
|
||||||
binary = "/opt/verapdf/verapdf"
|
binary = "/opt/verapdf/verapdf"
|
||||||
flavour = "1b"
|
flavour = "1b"
|
||||||
@@ -74,7 +87,9 @@ flavour = "1b"
|
|||||||
|
|
||||||
[upload.folder]
|
[upload.folder]
|
||||||
enabled = true
|
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 = ""
|
target = ""
|
||||||
|
|
||||||
[upload.nextcloud]
|
[upload.nextcloud]
|
||||||
|
|||||||
+269
-24
@@ -13,14 +13,17 @@ Verwandte Dokumente: [README](../README.md) · [Update](UPDATE.md) · [Debian-Ma
|
|||||||
| Betriebssystem | Debian 12 (bookworm) oder Debian 13 — systemd wird vorausgesetzt |
|
| Betriebssystem | Debian 12 (bookworm) oder Debian 13 — systemd wird vorausgesetzt |
|
||||||
| Python | 3.11+ (wegen `tomllib` aus der stdlib); kommt aus der Distribution |
|
| Python | 3.11+ (wegen `tomllib` aus der stdlib); kommt aus der Distribution |
|
||||||
| Arbeitsspeicher | **mindestens 2 GB** für den produktiven Betrieb — siehe [Systemanforderungen](#systemanforderungen) |
|
| Arbeitsspeicher | **mindestens 2 GB** für den produktiven Betrieb — siehe [Systemanforderungen](#systemanforderungen) |
|
||||||
|
| Dateisystem | **ext4, xfs oder zfs**; `incoming/` **lokal**, kein CIFS/NFS — siehe [Dateisystem](#dateisystem-ext4-xfs-oder-zfs) |
|
||||||
| Rechte | `root` (`sudo ./install.sh`) |
|
| Rechte | `root` (`sudo ./install.sh`) |
|
||||||
| Netz | apt-Zugriff für die System-Pakete, PyPI-Zugriff für die venv |
|
| 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)) |
|
| Repo | muss dauerhaft liegen bleiben — `update.sh` kopiert daraus (s. [UPDATE.md](UPDATE.md)) |
|
||||||
|
|
||||||
Die System-Pakete installiert der Installer selbst. Die Liste steht als
|
Die System-Pakete installiert der Installer selbst. Die Liste steht als
|
||||||
einzige Quelle in `install.sh` (Funktion `pdf_ocr_apt_packages()`, zwischen den
|
einzige Quelle in `lib/common.sh` (Funktion `pdf_ocr_apt_packages()`, zwischen
|
||||||
Marken `# --- BEGIN apt-packages` / `# --- END apt-packages`) und wird von
|
den Marken `# --- BEGIN apt-packages` / `# --- END apt-packages`); `install.sh`
|
||||||
`update.sh` von dort ausgelesen:
|
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
|
python3 python3-venv python3-pip
|
||||||
@@ -30,7 +33,7 @@ ca-certificates curl
|
|||||||
```
|
```
|
||||||
|
|
||||||
Weitere Tesseract-Sprachpakete installiert der Installer bei Bedarf pro Instanz
|
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`
|
Die Python-Abhängigkeiten stehen **fest gepinnt** in `requirements.txt`
|
||||||
(ocrmypdf, watchdog, requests, paramiko). Warum das so ist und wie man die Pins
|
(ocrmypdf, watchdog, requests, paramiko). Warum das so ist und wie man die Pins
|
||||||
@@ -47,6 +50,50 @@ Seitengröße, nicht an der Dateigröße der PDF.
|
|||||||
**Empfehlung: mindestens 2 GB RAM.** Für Mehr-Instanz-Betrieb oder
|
**Empfehlung: mindestens 2 GB RAM.** Für Mehr-Instanz-Betrieb oder
|
||||||
`max_workers > 2` entsprechend mehr.
|
`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
|
### Warum 512 MB nachweislich nicht reichen
|
||||||
|
|
||||||
Gemessen auf einem LXC-Container mit **512 MB RAM + 512 MB Swap**, Debian 13,
|
Gemessen auf einem LXC-Container mit **512 MB RAM + 512 MB Swap**, Debian 13,
|
||||||
@@ -141,7 +188,7 @@ Der Installer unterscheidet zwei Ebenen:
|
|||||||
|
|
||||||
| Ebene | Wann | Was passiert |
|
| 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` |
|
| **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
|
Ist die Basis-Installation vorhanden, aber die venv passt nicht mehr zum
|
||||||
@@ -157,10 +204,12 @@ Installer in der Schleife `Weitere Instanz anlegen? [j/N]:`.
|
|||||||
|
|
||||||
## Die Abfragen pro Instanz
|
## Die Abfragen pro Instanz
|
||||||
|
|
||||||
`create_instance()` stellt fünf Fragen. Alle Antworten gelten **nur für diese
|
`create_instance()` stellt fünf Fragen, in der Reihenfolge der folgenden
|
||||||
Instanz** — nichts davon ist global.
|
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, -):
|
Instanz-Name (nur a-z, 0-9, -):
|
||||||
@@ -171,7 +220,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
|
(`/etc/pdf-ocr-hotfolder/<name>.toml`). Existiert die Config schon, bricht die
|
||||||
Anlage ab — ein versehentliches Überschreiben gibt es nicht.
|
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>]:
|
Basis-Pfad für Daten [/var/lib/pdf-ocr-hotfolder/<name>]:
|
||||||
@@ -180,7 +229,7 @@ Basis-Pfad für Daten [/var/lib/pdf-ocr-hotfolder/<name>]:
|
|||||||
Darunter entstehen `incoming/`, `outgoing/`, `working/`, `error/` und werden auf
|
Darunter entstehen `incoming/`, `outgoing/`, `working/`, `error/` und werden auf
|
||||||
den Service-User gechownt.
|
den Service-User gechownt.
|
||||||
|
|
||||||
### 3. Service-User
|
### Service-User
|
||||||
|
|
||||||
```
|
```
|
||||||
Service-User [pdfocr]:
|
Service-User [pdfocr]:
|
||||||
@@ -198,7 +247,7 @@ Service-User [pdfocr]:
|
|||||||
Bei AD-Usern mit lokaler UID werden die Datei-Berechtigungen über die UID
|
Bei AD-Usern mit lokaler UID werden die Datei-Berechtigungen über die UID
|
||||||
gesetzt — das läuft transparent.
|
gesetzt — das läuft transparent.
|
||||||
|
|
||||||
### 4. OCR-Sprachen
|
### OCR-Sprachen
|
||||||
|
|
||||||
```
|
```
|
||||||
Tesseract-Sprachen [deu+eng]:
|
Tesseract-Sprachen [deu+eng]:
|
||||||
@@ -236,7 +285,7 @@ Was der Installer damit macht:
|
|||||||
4. Ist `tesseract` gar nicht aufrufbar, wird die Prüfung übersprungen und die
|
4. Ist `tesseract` gar nicht aufrufbar, wird die Prüfung übersprungen und die
|
||||||
Eingabe unverändert übernommen.
|
Eingabe unverändert übernommen.
|
||||||
|
|
||||||
### 5. Original archivieren?
|
### Original archivieren?
|
||||||
|
|
||||||
```
|
```
|
||||||
Original nach erfolgreichem OCR archivieren? [j/N]:
|
Original nach erfolgreichem OCR archivieren? [j/N]:
|
||||||
@@ -436,6 +485,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
|
`ConfigError`-Meldung mit Datei- und Key-Nennung und **Exit 2** — der Dienst
|
||||||
startet nicht.
|
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]`
|
### `[ocr]`
|
||||||
|
|
||||||
| Key | Default | Bedeutung |
|
| Key | Default | Bedeutung |
|
||||||
@@ -467,17 +530,34 @@ ocrmypdf-Default greift. Siehe auch
|
|||||||
| `name_mode` | `"prefix"` | `prefix` → `OCR_scan.pdf`, `suffix` → `scan_OCR.pdf` (vor der Extension), `none` → unverändert |
|
| `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` |
|
| `name_tag` | `"OCR_"` | verbatim eingefügter String; leer wirkt wie `none` |
|
||||||
| `original_on_success` | `"delete"` | `delete` oder `archive` — Installer fragt das ab |
|
| `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
|
Ein Tippfehler in `name_mode` oder `original_on_success` führt beim Start zum
|
||||||
Abbruch mit **Exit 2**, nicht erst bei der ersten Datei.
|
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]`
|
### `[verapdf]`
|
||||||
|
|
||||||
| Key | Default | Bedeutung |
|
| Key | Default | Bedeutung |
|
||||||
|-----|---------|-----------|
|
|-----|---------|-----------|
|
||||||
| `enabled` | `false` | PDF/A-Validierung per veraPDF-CLI |
|
| `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 |
|
| `flavour` | `"1b"` | PDF/A-Flavour |
|
||||||
|
|
||||||
veraPDF startet eine JVM und ist entsprechend teuer — nur einschalten, wenn die
|
veraPDF startet eine JVM und ist entsprechend teuer — nur einschalten, wenn die
|
||||||
@@ -485,6 +565,27 @@ Validierung wirklich gebraucht wird. Bei FAIL wandert das OCR-Ergebnis nach
|
|||||||
`error/`; das Original folgt `original_on_success` (bei `archive` bleibt es also
|
`error/`; das Original folgt `original_on_success` (bei `archive` bleibt es also
|
||||||
erhalten).
|
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]`
|
### `[upload.folder]` / `[upload.nextcloud]` / `[upload.sftp]`
|
||||||
|
|
||||||
Beliebig viele Ziele gleichzeitig aktivierbar. Sind alle aus, bleibt das fertige
|
Beliebig viele Ziele gleichzeitig aktivierbar. Sind alle aus, bleibt das fertige
|
||||||
@@ -492,7 +593,7 @@ PDF einfach in `outgoing/` liegen.
|
|||||||
|
|
||||||
| Sektion | Keys |
|
| 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.nextcloud]` | `enabled`, `url`, `username`, `password`, `remote_path`, `verify_ssl` |
|
||||||
| `[upload.sftp]` | `enabled`, `host`, `port`, `username`, `key_file`, `password`, `remote_path` |
|
| `[upload.sftp]` | `enabled`, `host`, `port`, `username`, `key_file`, `password`, `remote_path` |
|
||||||
|
|
||||||
@@ -500,6 +601,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
|
FEHLER raus — das PDF bleibt aber **bewusst in `outgoing/`** liegen, das OCR
|
||||||
selbst war ja erfolgreich.
|
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]`
|
### `[notify.email]`
|
||||||
|
|
||||||
| Key | Default | Bedeutung |
|
| Key | Default | Bedeutung |
|
||||||
@@ -521,6 +626,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
|
## Troubleshooting
|
||||||
|
|
||||||
### Tesseract findet die Sprache nicht
|
### Tesseract findet die Sprache nicht
|
||||||
@@ -547,21 +682,65 @@ das Archiv, falls konfiguriert):
|
|||||||
sudo chown -R DOMAIN\\scanuser:DOMAIN\\scangroup /var/lib/pdf-ocr-hotfolder/<instanz>
|
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:
|
Seit 0.7.0 prüft der Preflight `[verapdf].binary`. Startet die Instanz mit
|
||||||
`enabled = false`.
|
**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)
|
### Dienst startet nicht (Exit 2)
|
||||||
|
|
||||||
Exit 2 heißt immer: Config oder Preflight. Die Ursache steht im journal und
|
Exit 2 heißt immer: Config oder Preflight — siehe [Exit-Codes](#exit-codes).
|
||||||
ausführlicher in:
|
Die Instanz bleibt bewusst `failed` stehen und wird **nicht** neu gestartet.
|
||||||
|
Die Ursache steht im journal und ausführlicher in:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/pdf-ocr-hotfolder && sudo ./venv/bin/python -m pdf_ocr_hotfolder \
|
cd /opt/pdf-ocr-hotfolder && sudo ./venv/bin/python -m pdf_ocr_hotfolder \
|
||||||
--check-config --config /etc/pdf-ocr-hotfolder/<instanz>.toml
|
--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)
|
### Dienst startet nicht (203/EXEC)
|
||||||
|
|
||||||
Der Interpreter der venv ist weg — fast immer nach einem Distributions-Upgrade.
|
Der Interpreter der venv ist weg — fast immer nach einem Distributions-Upgrade.
|
||||||
@@ -589,9 +768,13 @@ 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
|
Ausfall sieht aus wie ein stummer Dienst. **Deshalb vor jeder Fehlersuche
|
||||||
zuerst journald prüfen**, nicht erst, wenn nichts anderes mehr passt.
|
zuerst journald prüfen**, nicht erst, wenn nichts anderes mehr passt.
|
||||||
|
|
||||||
Das ist **kein** generelles LXC-Muster. Von zwei Testcontainern war genau einer
|
Die häufigste Ursache ist **kein** Schaden an dieser einen Maschine, sondern
|
||||||
betroffen; auf dem zweiten lief journald einwandfrei. Es handelt sich um einen
|
systematisch: **Debian 13 in einer LXC auf Proxmox** — siehe den nächsten
|
||||||
Schaden auf dieser einen Maschine, nicht um eine Eigenschaft von Containern.
|
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
|
**Notbehelf, solange journald nicht zu retten ist:** die Instanz einmal im
|
||||||
Vordergrund laufen lassen — dann geht die Ausgabe direkt ins Terminal, am
|
Vordergrund laufen lassen — dann geht die Ausgabe direkt ins Terminal, am
|
||||||
@@ -610,6 +793,67 @@ Das `cd` ist zwingend: das Paket wird nicht pip-installiert, sondern nach
|
|||||||
Bestand abarbeiten und dann aussteigen will, hängt `--once` an (siehe
|
Bestand abarbeiten und dann aussteigen will, hängt `--once` an (siehe
|
||||||
[Manueller Lauf](#manueller-lauf-one-shot)).
|
[Manueller Lauf](#manueller-lauf-one-shot)).
|
||||||
|
|
||||||
|
### Debian 13 in LXC auf Proxmox: journald scheitert (243/CREDENTIALS)
|
||||||
|
|
||||||
|
Verifizierter Befund. Er betrifft **jede** Debian-13-LXC auf Proxmox 8.4, nicht
|
||||||
|
nur eine einzelne Maschine — und er trifft nicht nur dieses Tool, sondern
|
||||||
|
alles, was auf dem Container-journal aufsetzt.
|
||||||
|
|
||||||
|
**Symptom im Container:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status systemd-journald
|
||||||
|
# ● systemd-journald.service - Journal Service
|
||||||
|
# Active: failed
|
||||||
|
# Process: ... (code=exited, status=243/CREDENTIALS)
|
||||||
|
# Main PID exited, status=243/CREDENTIALS
|
||||||
|
|
||||||
|
journalctl -u pdf-ocr-hotfolder@<instanz>
|
||||||
|
# No journal files were found.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ursache.** Ab systemd 255 (Debian 13 liefert **257**) setzt die
|
||||||
|
journald-Unit `ImportCredential=journal.*`. Zum Einlesen dieser Credentials
|
||||||
|
startet systemd den Hilfsprozess `(sd-mkdcreds)`, und der **mountet** dafür.
|
||||||
|
Genau diesen Mount verbietet das AppArmor-Profil des Proxmox-Hosts. Im Log des
|
||||||
|
**Hosts** steht dazu:
|
||||||
|
|
||||||
|
```
|
||||||
|
apparmor="DENIED" operation="mount" profile="lxc-<id>_</var/lib/lxc>" name="/dev/" comm="(sd-mkdcreds)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Debian 12 hat systemd 252, kennt `ImportCredential` in dieser Unit nicht und
|
||||||
|
ist deshalb **nicht** betroffen. Das Upgrade 12 → 13 ist damit der Auslöser,
|
||||||
|
nicht der Container an sich.
|
||||||
|
|
||||||
|
**Dieselbe Ursache legt weitere Units lahm** — beobachtet bei
|
||||||
|
`systemd-logind`, `systemd-networkd`, `console-getty` und
|
||||||
|
`systemd-tmpfiles-setup`. Wer nur journald repariert, hat die übrigen noch vor
|
||||||
|
sich; ein Blick auf `systemctl --failed` lohnt sich.
|
||||||
|
|
||||||
|
**Abhilfe im Container** (reboot-fest verifiziert) — `ImportCredential` wird
|
||||||
|
per Drop-in auf leer gesetzt und damit abgeschaltet:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /etc/systemd/system/systemd-journald.service.d
|
||||||
|
printf '[Service]\nImportCredential=\n' | \
|
||||||
|
sudo tee /etc/systemd/system/systemd-journald.service.d/no-credentials.conf
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl restart systemd-journald
|
||||||
|
sudo journalctl --flush
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach `systemctl status systemd-journald` (muss `active (running)` sein) und
|
||||||
|
`journalctl -u pdf-ocr-hotfolder@<instanz>` gegenprüfen. Für die anderen
|
||||||
|
betroffenen Units gilt dasselbe Muster mit deren Unit-Namen.
|
||||||
|
|
||||||
|
> **Der saubere Weg liegt host-seitig.** Das Drop-in kuriert das Symptom im
|
||||||
|
> Container. Richtig behoben wird es auf dem Proxmox-Host: Update von
|
||||||
|
> `pve-container`/`lxc-pve` auf eine Fassung mit passenden AppArmor-Regeln —
|
||||||
|
> oder, als grobes Mittel, `lxc.apparmor.profile: unconfined` in der
|
||||||
|
> Container-Config, was die AppArmor-Isolation dieses Containers allerdings
|
||||||
|
> komplett aufgibt.
|
||||||
|
|
||||||
### Dienst bricht mitten in der Verarbeitung weg
|
### Dienst bricht mitten in der Verarbeitung weg
|
||||||
|
|
||||||
Datei bleibt in `working/`, kein Traceback, `NRestarts` steigt: das ist fast
|
Datei bleibt in `working/`, kein Traceback, `NRestarts` steigt: das ist fast
|
||||||
@@ -629,4 +873,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
|
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).
|
||||||
|
|||||||
+2
-2
@@ -226,11 +226,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
|
3. Dort `update.sh --rebuild-venv` fahren, damit die Pakete wirklich frisch
|
||||||
aufgelöst werden.
|
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
|
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`
|
fällt dort also nicht auf. Der [Rauchtest](UPDATE.md#rauchtest) in `update.sh`
|
||||||
macht genau das automatisch.
|
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
|
Der ocrmypdf-Sprung 16 → 17 ist ein **Major-Sprung** und gehört in einen eigenen
|
||||||
Vorgang mit eigenem Test, nicht in ein OS-Upgrade.
|
Vorgang mit eigenem Test, nicht in ein OS-Upgrade.
|
||||||
|
|||||||
+34
-3
@@ -28,16 +28,24 @@ sudo ./update.sh --no-smoke-test # ohne Rauchtest durchlaufen
|
|||||||
es den gespeicherten Pfad aus `/opt/pdf-ocr-hotfolder/.repo_path` — **das Repo
|
es den gespeicherten Pfad aus `/opt/pdf-ocr-hotfolder/.repo_path` — **das Repo
|
||||||
muss also liegen bleiben**, das Tool kopiert daraus.
|
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
|
## Was das Skript tut — in dieser Reihenfolge
|
||||||
|
|
||||||
| # | Schritt | Anmerkung |
|
| # | Schritt | Anmerkung |
|
||||||
|---|---------|-----------|
|
|---|---------|-----------|
|
||||||
| 1 | **Instanzen erfassen** | aktiv / kaputt / bewusst gestoppt, siehe [unten](#instanz-erfassung) |
|
| 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 |
|
| 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 |
|
| 4 | **Instanzen stoppen** | nur die, die vorher liefen oder kaputt waren |
|
||||||
| 5 | **Backup** | [Inhalt und Ort](#backup) |
|
| 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) |
|
| 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` |
|
| 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`) |
|
| 9 | **Berechtigungen** | Code gehört dem primären User (i.d.R. `pdfocr`) |
|
||||||
@@ -114,7 +122,7 @@ Vor dem ersten Eingriff auf der Platte schreibt `update.sh` ein Archiv:
|
|||||||
|
|
||||||
| Enthalten | Nicht enthalten |
|
| 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/` |
|
| `/etc/pdf-ocr-hotfolder/` (alle Instanz-Configs) | die **Datenverzeichnisse** `/var/lib/pdf-ocr-hotfolder/` |
|
||||||
| Template-Unit `pdf-ocr-hotfolder@.service` | `__pycache__`, `*.pyc` |
|
| Template-Unit `pdf-ocr-hotfolder@.service` | `__pycache__`, `*.pyc` |
|
||||||
| alle Drop-in-Verzeichnisse `…@*.service.d` | |
|
| alle Drop-in-Verzeichnisse `…@*.service.d` | |
|
||||||
@@ -303,6 +311,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)) |
|
| **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 |
|
| **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
|
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
|
vor 0.6.0), erkennt `update.sh` das an der argparse-Meldung, überspringt die
|
||||||
Prüfung mit einer Warnung und läuft weiter.
|
Prüfung mit einer Warnung und läuft weiter.
|
||||||
@@ -361,6 +378,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.
|
> `requirements.txt` pinnt darum 17.x, und der Preflight prüft beides zusammen.
|
||||||
> Hintergrund: [INSTALLATION.md](INSTALLATION.md#ghostscript-bug-auf-debian-12).
|
> 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
|
### Unbekannte Keys
|
||||||
|
|
||||||
Einträge, die zu keiner Sektion und keinem Key gehören, werden beim Laden
|
Einträge, die zu keiner Sektion und keinem Key gehören, werden beim Laden
|
||||||
|
|||||||
+53
-64
@@ -12,73 +12,32 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
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)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
REPO_DIR="$SCRIPT_DIR"
|
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
|
if [ ! -f "$REPO_DIR/pdf_ocr_hotfolder/__init__.py" ]; then
|
||||||
log_error "Repo-Layout nicht erkannt. install.sh aus dem Repo ausführen."
|
log_error "Repo-Layout nicht erkannt. install.sh aus dem Repo ausführen."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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)
|
# Basis-Installation (idempotent)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -137,12 +96,36 @@ install_base() {
|
|||||||
read -r -p "LXC-Kompatibilitäts-Drop-in installieren? [J/n]: " LXC_FIX
|
read -r -p "LXC-Kompatibilitäts-Drop-in installieren? [J/n]: " LXC_FIX
|
||||||
LXC_FIX="${LXC_FIX:-J}"
|
LXC_FIX="${LXC_FIX:-J}"
|
||||||
if [[ "$LXC_FIX" =~ ^[JjYy]$ ]]; then
|
if [[ "$LXC_FIX" =~ ^[JjYy]$ ]]; then
|
||||||
local LXC_DROPIN_DIR="/etc/systemd/system/pdf-ocr-hotfolder@.service.d"
|
|
||||||
mkdir -p "$LXC_DROPIN_DIR"
|
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
|
systemctl daemon-reload
|
||||||
log_info "LXC-Kompatibilitäts-Drop-in installiert ✓"
|
log_info "LXC-Kompatibilitäts-Drop-in installiert ✓"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Der Dienst loggt ausschliesslich nach journald. Ist journald kaputt,
|
||||||
|
# gibt es gar keine Logs — das faellt sonst erst bei der ersten
|
||||||
|
# Fehlersuche auf. Bekannter Fall: Debian 13 (systemd >= 255) in einer
|
||||||
|
# LXC auf Proxmox 8.4 — das AppArmor-Profil des Hosts blockiert den
|
||||||
|
# Credential-Mount von (sd-mkdcreds), journald scheitert mit
|
||||||
|
# 243/CREDENTIALS. Debian 12 (systemd 252) ist nicht betroffen.
|
||||||
|
if ! systemctl is-active --quiet systemd-journald 2>/dev/null; then
|
||||||
|
log_warn "ACHTUNG: systemd-journald laeuft nicht."
|
||||||
|
log_warn "Der Dienst loggt NUR nach journald — es gaebe hier keine Logs."
|
||||||
|
log_warn "Pruefen: systemctl status systemd-journald"
|
||||||
|
log_warn "Scheitert es mit 243/CREDENTIALS (Debian 13 in LXC auf"
|
||||||
|
log_warn "Proxmox), hilft ein Drop-in im Container:"
|
||||||
|
log_warn " mkdir -p /etc/systemd/system/systemd-journald.service.d"
|
||||||
|
log_warn " printf '[Service]\\nImportCredential=\\n' > \\"
|
||||||
|
log_warn " /etc/systemd/system/systemd-journald.service.d/no-credentials.conf"
|
||||||
|
log_warn " systemctl daemon-reload && systemctl restart systemd-journald"
|
||||||
|
log_warn "Details: docs/INSTALLATION.md, Abschnitt Troubleshooting."
|
||||||
|
read -r -p "Trotzdem fortfahren? [J/n]: " JOURNAL_GO
|
||||||
|
JOURNAL_GO="${JOURNAL_GO:-J}"
|
||||||
|
if [[ ! "$JOURNAL_GO" =~ ^[JjYy]$ ]]; then
|
||||||
|
log_error "Abbruch. Erst journald reparieren, dann erneut starten."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log_step "Default-User '$DEFAULT_USER' prüfen"
|
log_step "Default-User '$DEFAULT_USER' prüfen"
|
||||||
@@ -161,6 +144,10 @@ install_base() {
|
|||||||
log_step "Code kopieren"
|
log_step "Code kopieren"
|
||||||
rm -rf "$INSTALL_DIR/pdf_ocr_hotfolder"
|
rm -rf "$INSTALL_DIR/pdf_ocr_hotfolder"
|
||||||
cp -r "$REPO_DIR/pdf_ocr_hotfolder" "$INSTALL_DIR/"
|
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/requirements.txt" "$INSTALL_DIR/"
|
||||||
cp "$REPO_DIR/VERSION" "$INSTALL_DIR/"
|
cp "$REPO_DIR/VERSION" "$INSTALL_DIR/"
|
||||||
cp "$REPO_DIR/config.example.toml" "$INSTALL_DIR/"
|
cp "$REPO_DIR/config.example.toml" "$INSTALL_DIR/"
|
||||||
@@ -173,6 +160,7 @@ install_base() {
|
|||||||
local VENV_SAVED
|
local VENV_SAVED
|
||||||
VENV_SAVED="$INSTALL_DIR/venv.old-$(date +%Y%m%d-%H%M%S)"
|
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?)."
|
log_warn "Vorhandene venv passt nicht mehr zum System-Python (Distributions-Upgrade?)."
|
||||||
|
report_venv_issues
|
||||||
log_warn "Sie wird gesichert nach: $VENV_SAVED"
|
log_warn "Sie wird gesichert nach: $VENV_SAVED"
|
||||||
mv "$INSTALL_DIR/venv" "$VENV_SAVED"
|
mv "$INSTALL_DIR/venv" "$VENV_SAVED"
|
||||||
fi
|
fi
|
||||||
@@ -189,7 +177,7 @@ install_base() {
|
|||||||
log_info "venv ok ✓"
|
log_info "venv ok ✓"
|
||||||
|
|
||||||
log_step "systemd Template-Unit installieren"
|
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
|
systemctl daemon-reload
|
||||||
log_info "Template-Unit installiert ✓"
|
log_info "Template-Unit installiert ✓"
|
||||||
|
|
||||||
@@ -427,7 +415,7 @@ create_instance() {
|
|||||||
|
|
||||||
# Drop-in für abweichenden Service-User
|
# Drop-in für abweichenden Service-User
|
||||||
if [ "$SVC_USER" != "$DEFAULT_USER" ]; then
|
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"
|
mkdir -p "$DROPIN_DIR"
|
||||||
cat > "$DROPIN_DIR/user.conf" <<EOF
|
cat > "$DROPIN_DIR/user.conf" <<EOF
|
||||||
[Service]
|
[Service]
|
||||||
@@ -471,11 +459,12 @@ echo "=========================================="
|
|||||||
|
|
||||||
# Auch eine vorhandene, aber kaputte venv loest die Basis-Installation aus
|
# Auch eine vorhandene, aber kaputte venv loest die Basis-Installation aus
|
||||||
# (sonst wuerde install.sh nach einem Distributions-Upgrade nichts reparieren).
|
# (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"
|
log_step "Basis-Installation"
|
||||||
install_base
|
install_base
|
||||||
elif ! venv_is_healthy "$INSTALL_DIR/venv"; then
|
elif ! venv_is_healthy "$INSTALL_DIR/venv"; then
|
||||||
log_warn "Basis-Installation vorhanden, aber die venv passt nicht mehr zum System-Python."
|
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"
|
log_step "Basis-Installation wird zur Reparatur erneut ausgefuehrt"
|
||||||
install_base
|
install_base
|
||||||
else
|
else
|
||||||
|
|||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# PDF OCR Hotfolder — gemeinsame Shell-Funktionen
|
||||||
|
#
|
||||||
|
# Wird von install.sh und update.sh gesourct:
|
||||||
|
# source "$SCRIPT_DIR/lib/common.sh"
|
||||||
|
#
|
||||||
|
# Diese Datei fuehrt beim Sourcen NICHTS aus, was das System anfasst — sie
|
||||||
|
# enthaelt nur Definitionen und Vorgabewerte. Alle Pfade sind ueber die
|
||||||
|
# Umgebung ueberschreibbar, damit sich die Funktionen gegen eine Fake-Umgebung
|
||||||
|
# testen lassen (siehe PDF_OCR_UPDATE_LIB_ONLY in update.sh).
|
||||||
|
#
|
||||||
|
# Die Datei wird bei Installation und Update nach $INSTALL_DIR/lib/ kopiert,
|
||||||
|
# damit auch ein Lauf ausserhalb des Repos sie findet.
|
||||||
|
#
|
||||||
|
# shellcheck shell=bash
|
||||||
|
|
||||||
|
# Mehrfaches Sourcen ist harmlos, aber unnoetig.
|
||||||
|
if [ -n "${PDF_OCR_COMMON_LOADED:-}" ]; then
|
||||||
|
# shellcheck disable=SC2317 # 'exit' greift nur, wenn die Datei nicht gesourct wurde
|
||||||
|
return 0 2>/dev/null || exit 0
|
||||||
|
fi
|
||||||
|
PDF_OCR_COMMON_LOADED=1
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Ausgabe
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
|
||||||
|
log_info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
||||||
|
log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||||
|
log_error() { echo -e "${RED}[ERROR]${NC} $*"; }
|
||||||
|
log_step() { echo -e "\n${BLUE}==>${NC} $*"; }
|
||||||
|
|
||||||
|
# Bricht ab, wenn nicht root. $1 = der Aufruf, der gemeint ist.
|
||||||
|
require_root() {
|
||||||
|
if [ "${EUID:-$(id -u)}" -ne 0 ]; then
|
||||||
|
log_error "Bitte als root ausfuehren: ${1:-sudo $0}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Installations-Layout — einzige Quelle der Wahrheit
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
: "${INSTALL_DIR:=/opt/pdf-ocr-hotfolder}"
|
||||||
|
: "${CONFIG_DIR:=/etc/pdf-ocr-hotfolder}"
|
||||||
|
: "${DATA_ROOT:=/var/lib/pdf-ocr-hotfolder}"
|
||||||
|
: "${SYSTEMD_DIR:=/etc/systemd/system}"
|
||||||
|
: "${DEFAULT_USER:=pdfocr}"
|
||||||
|
|
||||||
|
SERVICE_TEMPLATE="pdf-ocr-hotfolder@.service"
|
||||||
|
LXC_DROPIN_DIR="$SYSTEMD_DIR/${SERVICE_TEMPLATE}.d"
|
||||||
|
# shellcheck disable=SC2034 # wird von install.sh und update.sh genutzt
|
||||||
|
LXC_DROPIN="$LXC_DROPIN_DIR/lxc-compat.conf"
|
||||||
|
|
||||||
|
# Pfad dieser Datei relativ zum Repo- bzw. Installationsverzeichnis. update.sh
|
||||||
|
# schneidet damit die Paketliste aus der Repo-Fassung heraus (siehe unten).
|
||||||
|
# shellcheck disable=SC2034 # wird von update.sh genutzt
|
||||||
|
COMMON_LIB_REL="lib/common.sh"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# System-Pakete
|
||||||
|
# ============================================================
|
||||||
|
# Der folgende Block wird von update.sh aus DIESER Datei herausgeschnitten
|
||||||
|
# (sed auf die BEGIN/END-Marken) und dort ausgewertet: beim Update soll die
|
||||||
|
# Liste aus dem Repo gelten, nicht die vielleicht aeltere, bereits gesourcte
|
||||||
|
# aus dem Installationsverzeichnis. Die Marken und der Funktionsname duerfen
|
||||||
|
# sich deshalb nicht aendern, ohne update.sh anzupassen.
|
||||||
|
# --- BEGIN apt-packages (wird von update.sh extrahiert) ---
|
||||||
|
pdf_ocr_apt_packages() {
|
||||||
|
cat <<'PKGLIST'
|
||||||
|
python3
|
||||||
|
python3-venv
|
||||||
|
python3-pip
|
||||||
|
tesseract-ocr
|
||||||
|
tesseract-ocr-deu
|
||||||
|
tesseract-ocr-eng
|
||||||
|
ghostscript
|
||||||
|
qpdf
|
||||||
|
unpaper
|
||||||
|
pngquant
|
||||||
|
icc-profiles-free
|
||||||
|
ca-certificates
|
||||||
|
curl
|
||||||
|
PKGLIST
|
||||||
|
}
|
||||||
|
# --- END apt-packages ---
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Python / venv
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# major.minor des uebergebenen Interpreters; leer, wenn er nicht laeuft.
|
||||||
|
py_mm() {
|
||||||
|
local py="$1"
|
||||||
|
"$py" -c 'import sys; print("%d.%d" % sys.version_info[:2])' 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# major.minor aus pyvenv.cfg (version = / version_info =); leer, wenn unlesbar.
|
||||||
|
pyvenv_cfg_mm() {
|
||||||
|
local cfg="$1/pyvenv.cfg"
|
||||||
|
[ -f "$cfg" ] || return 0
|
||||||
|
sed -n 's/^[[:space:]]*version\(_info\)\?[[:space:]]*=[[:space:]]*\([0-9]\+\.[0-9]\+\).*/\2/p' "$cfg" | head -n1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prueft die venv gegen das aktuelle System-Python.
|
||||||
|
# Setzt VENV_ISSUES (Array) und gibt 0 zurueck, wenn alles passt.
|
||||||
|
#
|
||||||
|
# Drei Faelle fuehren zum Neubau: (1) der Interpreter der venv laeuft gar nicht
|
||||||
|
# mehr (toter Symlink nach einem Distributions-Upgrade, systemd: 203/EXEC),
|
||||||
|
# (2) er laeuft noch, ist aber eine andere Version als das System-Python
|
||||||
|
# (Debian 12 -> 13), (3) pyvenv.cfg und Interpreter widersprechen sich.
|
||||||
|
# Keine Versionsnummer ist hier hartcodiert.
|
||||||
|
VENV_ISSUES=()
|
||||||
|
venv_is_healthy() {
|
||||||
|
local venv="$1"
|
||||||
|
local sys_mm venv_mm cfg_mm
|
||||||
|
VENV_ISSUES=()
|
||||||
|
|
||||||
|
if [ ! -d "$venv" ]; then
|
||||||
|
VENV_ISSUES+=("venv-Verzeichnis fehlt: $venv")
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [ ! -x "$venv/bin/python" ]; then
|
||||||
|
VENV_ISSUES+=("$venv/bin/python fehlt oder ist nicht ausfuehrbar")
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
venv_mm="$(py_mm "$venv/bin/python")"
|
||||||
|
if [ -z "$venv_mm" ]; then
|
||||||
|
VENV_ISSUES+=("$venv/bin/python laeuft nicht (toter Symlink nach einem Distributions-Upgrade?)")
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
sys_mm="$(py_mm "$(command -v python3 || echo /usr/bin/python3)")"
|
||||||
|
if [ -z "$sys_mm" ]; then
|
||||||
|
VENV_ISSUES+=("System-python3 laeuft nicht — venv-Pruefung nicht moeglich")
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$venv_mm" != "$sys_mm" ]; then
|
||||||
|
VENV_ISSUES+=("venv haengt an Python $venv_mm, das System liefert Python $sys_mm")
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cfg_mm="$(pyvenv_cfg_mm "$venv")"
|
||||||
|
if [ -n "$cfg_mm" ] && [ "$cfg_mm" != "$venv_mm" ]; then
|
||||||
|
VENV_ISSUES+=("pyvenv.cfg nennt Python $cfg_mm, der Interpreter meldet $venv_mm")
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Gibt die von venv_is_healthy gesammelten Befunde als Warnungen aus.
|
||||||
|
report_venv_issues() {
|
||||||
|
local issue
|
||||||
|
for issue in "${VENV_ISSUES[@]:-}"; do
|
||||||
|
[ -n "$issue" ] || continue
|
||||||
|
log_warn " - $issue"
|
||||||
|
done
|
||||||
|
}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
"""PDF OCR Hotfolder — Scanner-PDFs automatisch durchsuchbar machen."""
|
"""PDF OCR Hotfolder — Scanner-PDFs automatisch durchsuchbar machen."""
|
||||||
|
|
||||||
__version__ = "0.6.3"
|
__version__ = "0.7.0"
|
||||||
|
|||||||
@@ -27,13 +27,32 @@ CHECK_ERROR = 2
|
|||||||
|
|
||||||
|
|
||||||
def _setup_logging(level: str) -> None:
|
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(
|
logging.basicConfig(
|
||||||
level=getattr(logging, level.upper(), logging.INFO),
|
level=getattr(logging, level.upper(), logging.INFO),
|
||||||
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
||||||
datefmt="%Y-%m-%d %H:%M:%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:
|
def _log_config_warnings(cfg: Config) -> None:
|
||||||
"""Schreibt Legacy- und Unbekannt-Warnungen beim Dienststart ins Log."""
|
"""Schreibt Legacy- und Unbekannt-Warnungen beim Dienststart ins Log."""
|
||||||
for warning in config_warnings(cfg):
|
for warning in config_warnings(cfg):
|
||||||
@@ -55,7 +74,7 @@ def check_config(cfg_path: Path) -> int:
|
|||||||
print(f"FEHLER: {e}", file=sys.stderr)
|
print(f"FEHLER: {e}", file=sys.stderr)
|
||||||
return CHECK_ERROR
|
return CHECK_ERROR
|
||||||
except tomllib.TOMLDecodeError as e:
|
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
|
return CHECK_ERROR
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
print(f"FEHLER: {cfg_path} nicht lesbar: {e}", file=sys.stderr)
|
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" ocrmypdf = {detect_ocrmypdf_version() or '(nicht installiert)'}")
|
||||||
print(f" Ghostscript = {detect_ghostscript_version() or '(nicht gefunden)'}")
|
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] = []
|
errors: list[str] = []
|
||||||
try:
|
try:
|
||||||
check_preflight(cfg.ocr.pdfa_level, cfg.ocr.skip_text)
|
check_preflight(cfg.ocr.pdfa_level, cfg.ocr.skip_text,
|
||||||
print(" Preflight ok (tesseract, gs vorhanden, Ghostscript-Version "
|
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).")
|
"passt zu ocrmypdf + [ocr]-Einstellungen).")
|
||||||
except PreflightError as e:
|
except PreflightError as e:
|
||||||
errors.append(str(e))
|
errors.append(str(e))
|
||||||
@@ -142,6 +170,18 @@ def main() -> int:
|
|||||||
except ConfigError as e:
|
except ConfigError as e:
|
||||||
print(f"FEHLER: {e}", file=sys.stderr)
|
print(f"FEHLER: {e}", file=sys.stderr)
|
||||||
return 2
|
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)
|
_setup_logging(cfg.log_level)
|
||||||
_log_config_warnings(cfg)
|
_log_config_warnings(cfg)
|
||||||
|
|
||||||
@@ -156,12 +196,14 @@ def main() -> int:
|
|||||||
return 1 if errors > 0 else 0
|
return 1 if errors > 0 else 0
|
||||||
|
|
||||||
try:
|
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:
|
except PreflightError as e:
|
||||||
print(f"FEHLER: {e}", file=sys.stderr)
|
print(f"FEHLER: {e}", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
pass
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,29 @@ def _section(data: dict[str, Any], *keys: str) -> dict[str, Any]:
|
|||||||
return cur if isinstance(cur, dict) else {}
|
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:
|
def _require_path(p: dict[str, Any], key: str, cfg_path: Path) -> Path:
|
||||||
"""Holt einen Pflicht-Pfad aus der [paths]-Sektion.
|
"""Holt einen Pflicht-Pfad aus der [paths]-Sektion.
|
||||||
|
|
||||||
@@ -144,7 +167,10 @@ def _require_path(p: dict[str, Any], key: str, cfg_path: Path) -> Path:
|
|||||||
f'{key} = "/var/lib/pdf-ocr-hotfolder/{key}" '
|
f'{key} = "/var/lib/pdf-ocr-hotfolder/{key}" '
|
||||||
f"— siehe config.example.toml."
|
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, ...],
|
def _unknown_in(data: dict[str, Any], keys: tuple[str, ...],
|
||||||
@@ -218,6 +244,13 @@ def load_config(path: str | Path) -> Config:
|
|||||||
email = EmailNotify(**{k: v for k, v in _section(data, "notify", "email").items()
|
email = EmailNotify(**{k: v for k, v in _section(data, "notify", "email").items()
|
||||||
if k in EmailNotify.__annotations__})
|
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")
|
log_level = _section(data, "logging").get("level", "INFO")
|
||||||
|
|
||||||
return Config(
|
return Config(
|
||||||
|
|||||||
+192
-29
@@ -2,9 +2,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .config import OcrConfig, OutputConfig, VeraPdfConfig
|
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}")
|
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
|
@dataclass
|
||||||
class ProcessResult:
|
class ProcessResult:
|
||||||
source: Path
|
source: Path
|
||||||
@@ -52,6 +75,9 @@ class ProcessResult:
|
|||||||
success: bool
|
success: bool
|
||||||
error: str = ""
|
error: str = ""
|
||||||
verapdf_passed: bool | None = None
|
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:
|
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)
|
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:
|
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:
|
if not cfg.enabled:
|
||||||
return True
|
return True
|
||||||
if not Path(cfg.binary).exists():
|
binary = resolve_verapdf_binary(cfg.binary)
|
||||||
log.warning("veraPDF binary nicht gefunden: %s", cfg.binary)
|
if binary is None:
|
||||||
return False
|
raise VeraPdfUnavailable(
|
||||||
|
f"[verapdf].binary = {cfg.binary!r} existiert nicht oder ist nicht "
|
||||||
|
"ausführbar"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[cfg.binary, "--flavour", cfg.flavour, "--format", "text", str(pdf)],
|
[binary, "--flavour", cfg.flavour, "--format", "text", str(pdf)],
|
||||||
capture_output=True, text=True, timeout=300,
|
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
|
ok = result.returncode == 0 and "PASS" in result.stdout
|
||||||
log.info("veraPDF %s: %s", "PASS" if ok else "FAIL", pdf.name)
|
log.info("veraPDF %s: %s", "PASS" if ok else "FAIL", pdf.name)
|
||||||
return ok
|
return ok
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
log.error("veraPDF Timeout: %s", pdf.name)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def process_pdf(
|
def process_pdf(
|
||||||
@@ -151,7 +218,24 @@ def process_pdf(
|
|||||||
|
|
||||||
vera_ok: bool | None = None
|
vera_ok: bool | None = None
|
||||||
if vera_cfg.enabled:
|
if vera_cfg.enabled:
|
||||||
|
try:
|
||||||
vera_ok = run_verapdf(work_out, vera_cfg)
|
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:
|
if not vera_ok:
|
||||||
# Das OCR-Ergebnis ist unbrauchbar und wandert nach error/. Das
|
# Das OCR-Ergebnis ist unbrauchbar und wandert nach error/. Das
|
||||||
# Original wird aber NICHT bedingungslos gelöscht: es folgt derselben
|
# Original wird aber NICHT bedingungslos gelöscht: es folgt derselben
|
||||||
@@ -168,9 +252,26 @@ def process_pdf(
|
|||||||
"verapdf validation failed", verapdf_passed=False)
|
"verapdf validation failed", verapdf_passed=False)
|
||||||
|
|
||||||
outgoing_dir.mkdir(parents=True, exist_ok=True)
|
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))
|
shutil.move(str(work_out), str(final_out))
|
||||||
_dispose_original(work_src, src.name, output_cfg)
|
# Scheitert die Entsorgung des Originals (Platte voll, read-only), ist der
|
||||||
return ProcessResult(src, final_out, True, verapdf_passed=vera_ok)
|
# 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:
|
def _is_same_file(a: Path, b: Path) -> bool:
|
||||||
@@ -181,41 +282,103 @@ def _is_same_file(a: Path, b: Path) -> bool:
|
|||||||
return False
|
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.
|
"""Entsorgt das Original laut [output].original_on_success — löschen oder archivieren.
|
||||||
|
|
||||||
Wird nach erfolgreichem OCR aufgerufen und ebenso, wenn veraPDF die
|
Wird nach erfolgreichem OCR aufgerufen und ebenso, wenn veraPDF die
|
||||||
Validierung ablehnt: auch dann soll `archive` das Original erhalten.
|
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():
|
if not work_src.exists():
|
||||||
return
|
return ""
|
||||||
mode = cfg.original_on_success
|
mode = cfg.original_on_success
|
||||||
if mode == "delete":
|
if mode == "archive" and cfg.archive_dir:
|
||||||
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
|
|
||||||
archive = Path(cfg.archive_dir)
|
archive = Path(cfg.archive_dir)
|
||||||
|
try:
|
||||||
archive.mkdir(parents=True, exist_ok=True)
|
archive.mkdir(parents=True, exist_ok=True)
|
||||||
dest = archive / original_name
|
# Bei Namens-Kollision mit Timestamp umbenennen (gleicher Weg wie
|
||||||
# Bei Namens-Kollision mit Timestamp umbenennen
|
# für das Ergebnis in outgoing/)
|
||||||
if dest.exists():
|
dest = _collision_free_path(archive / original_name)
|
||||||
from datetime import datetime
|
|
||||||
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
||||||
dest = archive / f"{dest.stem}_{ts}{dest.suffix}"
|
|
||||||
shutil.move(str(work_src), str(dest))
|
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)
|
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)
|
log.warning("Unbekannter original_on_success=%r — lösche stattdessen", mode)
|
||||||
|
try:
|
||||||
work_src.unlink(missing_ok=True)
|
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:
|
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)
|
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:
|
try:
|
||||||
shutil.move(str(p), str(error_dir / p.name))
|
shutil.move(str(p), str(dest))
|
||||||
except OSError:
|
except OSError:
|
||||||
log.exception("Konnte %s nicht in error-Verzeichnis verschieben", p)
|
log.exception("Konnte %s nicht in error-Verzeichnis verschieben", p)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from .processor import (
|
|||||||
ProcessResult,
|
ProcessResult,
|
||||||
_move_to_error,
|
_move_to_error,
|
||||||
process_pdf,
|
process_pdf,
|
||||||
|
resolve_verapdf_binary,
|
||||||
)
|
)
|
||||||
from .uploaders import notify_email, upload_folder, upload_nextcloud, upload_sftp
|
from .uploaders import notify_email, upload_folder, upload_nextcloud, upload_sftp
|
||||||
|
|
||||||
@@ -32,6 +33,13 @@ class PreflightError(RuntimeError):
|
|||||||
"""Erforderliche externe Binaries fehlen."""
|
"""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
|
# Pflicht-Binaries für ocrmypdf
|
||||||
_REQUIRED_BINARIES = ("tesseract", "gs")
|
_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.
|
"""Prüft externe Abhängigkeiten.
|
||||||
|
|
||||||
- Tesseract und Ghostscript müssen im PATH sein
|
- Tesseract und Ghostscript müssen im PATH sein
|
||||||
- Die Ghostscript-Version wird gegen den bekannten 10.0.0–10.02.0 Bug
|
- 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
|
geprüft, und zwar genau unter der Bedingung, unter der ocrmypdf selbst
|
||||||
abbricht (siehe `_gs_block_reason`).
|
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.
|
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:
|
if reason:
|
||||||
raise PreflightError(reason)
|
raise PreflightError(reason)
|
||||||
|
|
||||||
|
check_verapdf_binary(verapdf_enabled, verapdf_binary)
|
||||||
|
|
||||||
|
|
||||||
def _gs_block_reason(pdfa_level: str, skip_text: bool) -> str | None:
|
def _gs_block_reason(pdfa_level: str, skip_text: bool) -> str | None:
|
||||||
"""Liefert die Fehlermeldung, wenn ocrmypdf mit diesem Ghostscript abbricht.
|
"""Liefert die Fehlermeldung, wenn ocrmypdf mit diesem Ghostscript abbricht.
|
||||||
@@ -298,11 +342,20 @@ class HotfolderService:
|
|||||||
|
|
||||||
# ---- Lifecycle ----
|
# ---- Lifecycle ----
|
||||||
|
|
||||||
def run(self) -> None:
|
def _preflight(self) -> None:
|
||||||
check_preflight(self.cfg.ocr.pdfa_level, self.cfg.ocr.skip_text)
|
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,
|
check_output_config(self.cfg.output.original_on_success,
|
||||||
self.cfg.output.archive_dir,
|
self.cfg.output.archive_dir,
|
||||||
self.cfg.output.name_mode)
|
self.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.ensure_dirs()
|
||||||
self._scan_existing()
|
self._scan_existing()
|
||||||
|
|
||||||
@@ -315,21 +368,49 @@ class HotfolderService:
|
|||||||
signal.signal(signal.SIGINT, lambda *_: self._stop.set())
|
signal.signal(signal.SIGINT, lambda *_: self._stop.set())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while not self._stop.is_set():
|
return self._wait_loop()
|
||||||
self._stop.wait(1.0)
|
|
||||||
finally:
|
finally:
|
||||||
self.shutdown()
|
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:
|
def run_once(self) -> int:
|
||||||
"""Verarbeitet alle bereits liegenden PDFs (incoming/ + working/) und beendet sich.
|
"""Verarbeitet alle bereits liegenden PDFs (incoming/ + working/) und beendet sich.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Anzahl fehlgeschlagener PDFs (0 = alles ok).
|
Anzahl fehlgeschlagener PDFs (0 = alles ok).
|
||||||
"""
|
"""
|
||||||
check_preflight(self.cfg.ocr.pdfa_level, self.cfg.ocr.skip_text)
|
self._preflight()
|
||||||
check_output_config(self.cfg.output.original_on_success,
|
|
||||||
self.cfg.output.archive_dir,
|
|
||||||
self.cfg.output.name_mode)
|
|
||||||
self.ensure_dirs()
|
self.ensure_dirs()
|
||||||
self._scan_existing()
|
self._scan_existing()
|
||||||
self._executor.shutdown(wait=True)
|
self._executor.shutdown(wait=True)
|
||||||
@@ -356,9 +437,32 @@ class HotfolderService:
|
|||||||
incoming-Datei nach working/ will.
|
incoming-Datei nach working/ will.
|
||||||
"""
|
"""
|
||||||
self._scan_working()
|
self._scan_working()
|
||||||
|
fremd: list[str] = []
|
||||||
for p in sorted(self.cfg.paths.incoming.iterdir()):
|
for p in sorted(self.cfg.paths.incoming.iterdir()):
|
||||||
if _is_pdf(p):
|
if _is_pdf(p):
|
||||||
self.enqueue(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:
|
def _scan_working(self) -> None:
|
||||||
"""Greift Dateien auf, die ein harter Stopp in working/ liegen ließ.
|
"""Greift Dateien auf, die ein harter Stopp in working/ liegen ließ.
|
||||||
@@ -563,6 +667,19 @@ class HotfolderService:
|
|||||||
notify_email(self.cfg.email, subject, body, False)
|
notify_email(self.cfg.email, subject, body, False)
|
||||||
|
|
||||||
def _notify(self, result: ProcessResult) -> None:
|
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:
|
if result.success:
|
||||||
subject = f"[pdf-ocr] OK: {result.source.name}"
|
subject = f"[pdf-ocr] OK: {result.source.name}"
|
||||||
body = f"Datei verarbeitet: {result.output}\n"
|
body = f"Datei verarbeitet: {result.output}\n"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import paramiko
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
from .config import EmailNotify, FolderUpload, NextcloudUpload, SftpUpload
|
from .config import EmailNotify, FolderUpload, NextcloudUpload, SftpUpload
|
||||||
|
from .processor import _collision_free_path
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -26,6 +27,15 @@ def upload_folder(pdf: Path, cfg: FolderUpload, default_target: Path) -> bool:
|
|||||||
try:
|
try:
|
||||||
if pdf.resolve() == dest.resolve():
|
if pdf.resolve() == dest.resolve():
|
||||||
return True
|
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
|
# copyfile statt read_bytes/write_bytes: große PDFs nicht komplett
|
||||||
# in den Speicher laden
|
# in den Speicher laden
|
||||||
shutil.copyfile(pdf, dest)
|
shutil.copyfile(pdf, dest)
|
||||||
|
|||||||
@@ -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
|
ExecStart=/opt/pdf-ocr-hotfolder/venv/bin/python -m pdf_ocr_hotfolder --config /etc/pdf-ocr-hotfolder/%i.toml
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
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
|
KillMode=mixed
|
||||||
# Ein laufendes OCR soll beim Stoppen zu Ende laufen duerfen. Bei SIGKILL
|
# Ein laufendes OCR soll beim Stoppen zu Ende laufen duerfen. Bei SIGKILL
|
||||||
# bliebe das Original in working/ liegen (wird beim naechsten Start zwar
|
# bliebe das Original in working/ liegen (wird beim naechsten Start zwar
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 == ""
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -64,3 +64,71 @@ def test_upload_folder_reports_failure(tmp_path: Path) -> None:
|
|||||||
assert upload_folder(src, FolderUpload(enabled=True,
|
assert upload_folder(src, FolderUpload(enabled=True,
|
||||||
target=str(tmp_path / "ziel")),
|
target=str(tmp_path / "ziel")),
|
||||||
tmp_path / "out") is False
|
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]
|
||||||
|
|||||||
@@ -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()) == []
|
||||||
@@ -18,17 +18,40 @@
|
|||||||
#
|
#
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
|
|
||||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
log_info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
|
||||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
# ============================================================
|
||||||
log_error() { echo -e "${RED}[ERROR]${NC} $*"; }
|
# Gemeinsame Funktionen (Logging, Pfade, venv-Pruefung, Paketliste)
|
||||||
log_step() { echo -e "\n${BLUE}==>${NC} $*"; }
|
# ============================================================
|
||||||
|
# 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
|
# Pfade sind ueberschreibbar, damit die Funktionen dieses Skripts isoliert
|
||||||
# gegen eine Fake-Umgebung getestet werden koennen (siehe LIB_ONLY unten).
|
# gegen eine Fake-Umgebung getestet werden koennen (siehe LIB_ONLY unten).
|
||||||
: "${INSTALL_DIR:=/opt/pdf-ocr-hotfolder}"
|
# INSTALL_DIR/CONFIG_DIR/SYSTEMD_DIR und die Unit-Namen kommen aus lib/common.sh.
|
||||||
: "${CONFIG_DIR:=/etc/pdf-ocr-hotfolder}"
|
|
||||||
: "${SYSTEMD_DIR:=/etc/systemd/system}"
|
|
||||||
: "${BACKUP_DIR:=/var/backups/pdf-ocr-hotfolder}"
|
: "${BACKUP_DIR:=/var/backups/pdf-ocr-hotfolder}"
|
||||||
: "${TAR_ROOT:=/}"
|
: "${TAR_ROOT:=/}"
|
||||||
: "${VERIFY_WAIT:=6}"
|
: "${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
|
# Mini-PDF braucht ein paar Sekunden (Datei-Stabilisierung + OCR), unter Last
|
||||||
# auch mal laenger.
|
# auch mal laenger.
|
||||||
: "${SMOKE_TIMEOUT:=90}"
|
: "${SMOKE_TIMEOUT:=90}"
|
||||||
SERVICE_TEMPLATE="pdf-ocr-hotfolder@.service"
|
|
||||||
UNIT_GLOB='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
|
REBUILD_VENV=0 # per --rebuild-venv erzwungen
|
||||||
VENV_REBUILT=0 # wurde tatsaechlich neu gebaut?
|
VENV_REBUILT=0 # wurde tatsaechlich neu gebaut?
|
||||||
APT_WARN=0 # apt-Sync hat gemeckert
|
APT_WARN=0 # apt-Sync hat gemeckert
|
||||||
LXC_SYNCED=0
|
LXC_SYNCED=0
|
||||||
BACKUP_FILE=""
|
BACKUP_FILE=""
|
||||||
PRIMARY_USER="pdfocr"
|
PRIMARY_USER="$DEFAULT_USER"
|
||||||
TOUCHED=0 # 1, sobald auf der Platte etwas getauscht wurde
|
TOUCHED=0 # 1, sobald auf der Platte etwas getauscht wurde
|
||||||
SMOKE_TEST=1 # per --no-smoke-test abschaltbar
|
SMOKE_TEST=1 # per --no-smoke-test abschaltbar
|
||||||
|
|
||||||
@@ -156,61 +176,9 @@ die() { log_error "$*"; abort_handler 1 "${BASH_LINENO[0]:-?}"; }
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
# Python / venv
|
# Python / venv
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
#
|
||||||
# major.minor des uebergebenen Interpreters; leer, wenn er nicht laeuft.
|
# py_mm(), pyvenv_cfg_mm() und venv_is_healthy() stehen in lib/common.sh —
|
||||||
py_mm() {
|
# install.sh braucht dieselbe Pruefung.
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
# Installiert die Requirements und uebersetzt pip-Fehler in eine Ansage, mit
|
# Installiert die Requirements und uebersetzt pip-Fehler in eine Ansage, mit
|
||||||
# der man etwas anfangen kann (typisch: Pin passt nicht mehr zum Python).
|
# der man etwas anfangen kann (typisch: Pin passt nicht mehr zum Python).
|
||||||
@@ -388,21 +356,26 @@ rebuild_venv() {
|
|||||||
# System-Pakete
|
# 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.
|
# apt-get install ist idempotent; bereits vorhandene Pakete bleiben unberuehrt.
|
||||||
# Nachinstallierte Tesseract-Sprachpakete werden NICHT angefasst (kein purge,
|
# Nachinstallierte Tesseract-Sprachpakete werden NICHT angefasst (kein purge,
|
||||||
# kein autoremove).
|
# 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() {
|
sync_system_packages() {
|
||||||
local block pkgs
|
local block pkgs repo_lib
|
||||||
log_step "System-Pakete abgleichen"
|
log_step "System-Pakete abgleichen"
|
||||||
|
|
||||||
block="$(sed -n '/^# --- BEGIN apt-packages/,/^# --- END apt-packages/p' "$REPO_DIR/install.sh" 2>/dev/null || true)"
|
repo_lib="$REPO_DIR/$COMMON_LIB_REL"
|
||||||
if [ -z "$block" ] || ! printf '%s' "$block" | grep -q 'pdf_ocr_apt_packages()'; then
|
block="$(sed -n '/^# --- BEGIN apt-packages/,/^# --- END apt-packages/p' "$repo_lib" 2>/dev/null || true)"
|
||||||
log_warn "Paketliste in install.sh nicht gefunden — System-Pakete werden nicht abgeglichen."
|
if [ -n "$block" ] && printf '%s' "$block" | grep -q 'pdf_ocr_apt_packages()'; then
|
||||||
APT_WARN=1
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
eval "$block"
|
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
|
if ! declare -F pdf_ocr_apt_packages >/dev/null; then
|
||||||
log_warn "pdf_ocr_apt_packages() liess sich nicht laden — uebersprungen."
|
log_warn "pdf_ocr_apt_packages() liess sich nicht laden — uebersprungen."
|
||||||
APT_WARN=1
|
APT_WARN=1
|
||||||
@@ -984,7 +957,7 @@ create_backup() {
|
|||||||
BACKUP_FILE="$tmp"
|
BACKUP_FILE="$tmp"
|
||||||
log_info "Backup: $tmp ($(du -h "$tmp" 2>/dev/null | awk '{print $1}'))"
|
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 " 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 " NICHT enthalten: venv und die Datenverzeichnisse ($DATA_ROOT)"
|
||||||
log_warn " Achtung: die Instanz-Configs enthalten Klartext-Passwoerter"
|
log_warn " Achtung: die Instanz-Configs enthalten Klartext-Passwoerter"
|
||||||
log_warn " (SMTP/Nextcloud/SFTP) — Archiv ist deshalb root-only (0600)."
|
log_warn " (SMTP/Nextcloud/SFTP) — Archiv ist deshalb root-only (0600)."
|
||||||
rotate_backups
|
rotate_backups
|
||||||
@@ -1057,12 +1030,9 @@ while [ "$#" -gt 0 ]; do
|
|||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
|
|
||||||
if [ "${EUID}" -ne 0 ]; then
|
require_root "sudo ./update.sh"
|
||||||
log_error "Bitte als root ausfuehren: sudo ./update.sh"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
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
|
if [ -f "$SCRIPT_DIR/pdf_ocr_hotfolder/__init__.py" ]; then
|
||||||
REPO_DIR="$SCRIPT_DIR"
|
REPO_DIR="$SCRIPT_DIR"
|
||||||
elif [ -f "$INSTALL_DIR/.repo_path" ]; then
|
elif [ -f "$INSTALL_DIR/.repo_path" ]; then
|
||||||
@@ -1099,11 +1069,11 @@ report_instances
|
|||||||
|
|
||||||
# --- Eigentuemer des Codes merken (vor dem venv-Neubau) ---
|
# --- Eigentuemer des Codes merken (vor dem venv-Neubau) ---
|
||||||
if [ -d "$INSTALL_DIR/venv" ]; then
|
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
|
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
|
fi
|
||||||
[ "$PRIMARY_USER" = "root" ] && PRIMARY_USER="pdfocr"
|
[ "$PRIMARY_USER" = "root" ] && PRIMARY_USER="$DEFAULT_USER"
|
||||||
|
|
||||||
# --- System-Pakete (auch beim Update, nicht nur bei install.sh) ---
|
# --- System-Pakete (auch beim Update, nicht nur bei install.sh) ---
|
||||||
sync_system_packages
|
sync_system_packages
|
||||||
@@ -1118,9 +1088,7 @@ elif venv_is_healthy "$INSTALL_DIR/venv"; then
|
|||||||
log_info "venv ok ✓ (Python $(py_mm "$INSTALL_DIR/venv/bin/python"))"
|
log_info "venv ok ✓ (Python $(py_mm "$INSTALL_DIR/venv/bin/python"))"
|
||||||
else
|
else
|
||||||
log_warn "venv passt nicht mehr:"
|
log_warn "venv passt nicht mehr:"
|
||||||
for issue in "${VENV_ISSUES[@]}"; do
|
report_venv_issues
|
||||||
log_warn " - $issue"
|
|
||||||
done
|
|
||||||
log_warn "Typische Ursache: Debian-Major-Upgrade (z.B. 12 -> 13). Die venv haengt"
|
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 "am alten Interpreter, systemd quittiert das mit 203/EXEC."
|
||||||
log_warn "-> venv wird neu gebaut."
|
log_warn "-> venv wird neu gebaut."
|
||||||
@@ -1145,6 +1113,10 @@ log_step "Code aktualisieren"
|
|||||||
TOUCHED=1
|
TOUCHED=1
|
||||||
rm -rf "$INSTALL_DIR/pdf_ocr_hotfolder"
|
rm -rf "$INSTALL_DIR/pdf_ocr_hotfolder"
|
||||||
cp -r "$REPO_DIR/pdf_ocr_hotfolder" "$INSTALL_DIR/"
|
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/requirements.txt" "$INSTALL_DIR/"
|
||||||
cp "$REPO_DIR/VERSION" "$INSTALL_DIR/"
|
cp "$REPO_DIR/VERSION" "$INSTALL_DIR/"
|
||||||
cp "$REPO_DIR/config.example.toml" "$INSTALL_DIR/"
|
cp "$REPO_DIR/config.example.toml" "$INSTALL_DIR/"
|
||||||
@@ -1168,7 +1140,7 @@ rm -f "$DEP_BEFORE" "$DEP_AFTER"
|
|||||||
install_units
|
install_units
|
||||||
|
|
||||||
log_step "Berechtigungen setzen"
|
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.
|
# ggf. als anderer User, lesen aber nur den Code.
|
||||||
chown -R "$PRIMARY_USER":"$PRIMARY_USER" "$INSTALL_DIR"
|
chown -R "$PRIMARY_USER":"$PRIMARY_USER" "$INSTALL_DIR"
|
||||||
log_info "Eigentuemer: $PRIMARY_USER"
|
log_info "Eigentuemer: $PRIMARY_USER"
|
||||||
|
|||||||
Reference in New Issue
Block a user