refactor: solidify startup UX and engine-aware preprocessing

- Slim core deps: move ML stack to optional extras (paddle/tables/figures/scientific/full)
- Lazy settings proxy with config search paths (env var, cwd, user dir)
- New commands: init, demo, setup [basic|full], first-run guard on run/watch
- Engine-aware preprocessing: Paddle gets original image (fixes dark mode 0.83->0.95)
- Results table shows Skipped count; lazy run-dir creation
- kg_ocr marked experimental with extra, Docker defaults with OCR_PIPELINE_CONFIG
- 25/25 tests, ruff clean
This commit is contained in:
2026-07-19 22:14:54 +02:00
parent deab02610b
commit a0211155fa
59 changed files with 2426 additions and 629 deletions

116
tests/test_startup.py Normal file
View File

@@ -0,0 +1,116 @@
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
from typer.testing import CliRunner
from ocr_pipeline import config as config_module
from ocr_pipeline.cli import app
runner = CliRunner()
SRC_DIR = Path(__file__).parent.parent / "src"
def _isolate_config(tmp_path: Path, monkeypatch) -> None:
"""Pretend no config exists anywhere."""
monkeypatch.delenv(config_module.CONFIG_ENV_VAR, raising=False)
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(config_module, "user_config_dir", lambda: tmp_path / "no-such-dir")
def test_find_config_file_prefers_env_var(tmp_path: Path, monkeypatch) -> None:
env_cfg = tmp_path / "env.yaml"
env_cfg.write_text("ocr:\n engine: tesseract\n", encoding="utf-8")
cwd_dir = tmp_path / "cwd"
cwd_dir.mkdir()
(cwd_dir / "config.yaml").write_text("{}", encoding="utf-8")
monkeypatch.setenv(config_module.CONFIG_ENV_VAR, str(env_cfg))
monkeypatch.chdir(cwd_dir)
assert config_module.find_config_file() == env_cfg
def test_find_config_file_uses_cwd(tmp_path: Path, monkeypatch) -> None:
monkeypatch.delenv(config_module.CONFIG_ENV_VAR, raising=False)
(tmp_path / "config.yaml").write_text("{}", encoding="utf-8")
monkeypatch.chdir(tmp_path)
assert config_module.find_config_file() == tmp_path / "config.yaml"
def test_find_config_file_none_when_unconfigured(tmp_path: Path, monkeypatch) -> None:
_isolate_config(tmp_path, monkeypatch)
assert config_module.find_config_file() is None
def test_import_does_not_read_cwd_config(tmp_path: Path) -> None:
(tmp_path / "config.yaml").write_text("{invalid yaml: [", encoding="utf-8")
result = subprocess.run(
[sys.executable, "-c", "import ocr_pipeline.config; print('ok')"],
cwd=tmp_path,
capture_output=True,
text=True,
env={"PYTHONPATH": str(SRC_DIR), "PATH": "/usr/bin:/bin"},
)
assert result.returncode == 0
assert "ok" in result.stdout
def test_settings_proxy_delegates() -> None:
from ocr_pipeline.config import settings
assert settings.ocr.engine in {"tesseract", "paddleocr", "auto"}
assert repr(settings)
def test_init_writes_local_config(tmp_path: Path, monkeypatch) -> None:
_isolate_config(tmp_path, monkeypatch)
result = runner.invoke(app, ["init", "--local"])
assert result.exit_code == 0
content = (tmp_path / "config.yaml").read_text(encoding="utf-8")
assert "paths:" in content
assert "tesseract" in content
def test_init_refuses_to_overwrite(tmp_path: Path, monkeypatch) -> None:
_isolate_config(tmp_path, monkeypatch)
(tmp_path / "config.yaml").write_text("existing: true\n", encoding="utf-8")
result = runner.invoke(app, ["init", "--local"])
assert result.exit_code == 1
assert (tmp_path / "config.yaml").read_text(encoding="utf-8") == "existing: true\n"
def test_run_guard_without_config_or_input_dir(tmp_path: Path, monkeypatch) -> None:
_isolate_config(tmp_path, monkeypatch)
result = runner.invoke(app, ["run"])
assert result.exit_code == 2
assert "ocr-pipeline init" in result.output
assert "ocr-pipeline demo" in result.output
def test_watch_guard_without_config(tmp_path: Path, monkeypatch) -> None:
_isolate_config(tmp_path, monkeypatch)
result = runner.invoke(app, ["watch"])
assert result.exit_code == 2
def test_setup_dry_run_prints_steps_without_executing() -> None:
result = runner.invoke(app, ["setup", "full", "--dry-run"])
assert result.exit_code == 0
assert "uv sync" in result.output or "pip install" in result.output
def test_setup_rejects_unknown_target() -> None:
result = runner.invoke(app, ["setup", "everything", "--dry-run"])
assert result.exit_code == 2
def test_demo_image_bundled_and_valid() -> None:
from PIL import Image
from ocr_pipeline.demo import demo_image_path
with Image.open(demo_image_path()) as img:
assert img.size[0] >= 800
assert img.format == "PNG"