"""Tests for the OCR option surface added to the hybrid server. Covers: * `create_converter` delegation to docling's `get_ocr_factory` * Defaults preserve prior behavior (do_ocr=True, engine=easyocr) * `disable_ocr=True` -> do_ocr=False (#387) * Engine selection produces the matching OcrOptions subclass (#436, #439) * `psm` is applied only for Tesseract engines (silently ignored otherwise) * Unknown / denylisted engines exit with a clear message * argparse `--no-ocr` and `--force-ocr` are mutually exclusive * argparse `--ocr-engine` choices exclude `kserve_v2_ocr` """ import argparse import io from contextlib import redirect_stderr from unittest.mock import patch import pytest from opendataloader_pdf import hybrid_server # ---------- create_converter: factory delegation ---------- def _capture_pipeline_options(**kwargs): """Call create_converter while mocking the docling document_converter so we can introspect the PdfPipelineOptions instance that would be passed in. """ captured = {} def fake_pdf_format_option(*, pipeline_options): captured["pipeline_options"] = pipeline_options return object() with patch( "docling.document_converter.DocumentConverter" ) as mock_dc, patch( "docling.document_converter.PdfFormatOption", side_effect=fake_pdf_format_option ): mock_dc.return_value = object() hybrid_server.create_converter(**kwargs) return captured["pipeline_options"] def test_defaults_preserve_prior_behavior(): """No flags -> do_ocr=True, EasyOcrOptions (matches pre-change behavior).""" from docling.datamodel.pipeline_options import EasyOcrOptions opts = _capture_pipeline_options() assert opts.do_ocr is True assert isinstance(opts.ocr_options, EasyOcrOptions) def test_disable_ocr_sets_do_ocr_false(): """`disable_ocr=True` -> do_ocr=False (#387).""" opts = _capture_pipeline_options(disable_ocr=True) assert opts.do_ocr is False def test_ocr_engine_tesseract_yields_tesseract_options(): """`ocr_engine='tesseract'` -> TesseractCliOcrOptions (#439 path).""" from docling.datamodel.pipeline_options import TesseractCliOcrOptions opts = _capture_pipeline_options(ocr_engine="tesseract") assert isinstance(opts.ocr_options, TesseractCliOcrOptions) def test_ocr_engine_rapidocr_yields_rapidocr_options(): """`ocr_engine='rapidocr'` -> RapidOcrOptions (#436 path).""" from docling.datamodel.pipeline_options import RapidOcrOptions opts = _capture_pipeline_options(ocr_engine="rapidocr") assert isinstance(opts.ocr_options, RapidOcrOptions) def test_force_full_page_ocr_propagates_to_engine_options(): """`force_full_page_ocr=True` flows to the engine's options instance.""" opts = _capture_pipeline_options( ocr_engine="rapidocr", force_full_page_ocr=True ) assert opts.ocr_options.force_full_page_ocr is True def test_ocr_lang_overrides_engine_default(): """A non-empty ocr_lang list replaces the engine's default lang.""" opts = _capture_pipeline_options( ocr_engine="tesseract", ocr_lang=["mal", "eng"] ) assert opts.ocr_options.lang == ["mal", "eng"] def test_psm_is_applied_to_tesseract(): """`psm` is applied when the engine is Tesseract.""" opts = _capture_pipeline_options(ocr_engine="tesseract", psm=6) assert opts.ocr_options.psm == 6 def test_psm_is_ignored_for_non_tesseract_engines(): """`psm` is silently ignored for engines that do not expose it.""" # EasyOcrOptions has no `psm` field; passing psm should not raise. opts = _capture_pipeline_options(ocr_engine="easyocr", psm=6) assert not hasattr(opts.ocr_options, "psm") def test_ocr_engine_auto_yields_auto_options(): """`ocr_engine='auto'` -> OcrAutoOptions; engine choice is deferred to docling.""" from docling.datamodel.pipeline_options import OcrAutoOptions opts = _capture_pipeline_options(ocr_engine="auto") assert isinstance(opts.ocr_options, OcrAutoOptions) def test_unknown_engine_raises_value_error_with_clear_message(): """An unknown engine kind raises ValueError listing valid engines. ValueError (not SystemExit) keeps the function library-friendly: programmatic callers can catch and retry with a different engine. main() relies on argparse `choices` to gate invalid CLI input separately. """ with pytest.raises(ValueError) as excinfo: _capture_pipeline_options(ocr_engine="bogus-engine") msg = str(excinfo.value) assert "bogus-engine" in msg assert "Available engines" in msg def test_create_converter_rejects_denylisted_engine(): """Programmatic callers cannot bypass `_OCR_ENGINE_DENYLIST` via create_converter(). The CLI layer rejects denylisted engines via argparse `choices`. This test locks in the same enforcement at the function-call layer, so importing the module and passing `ocr_engine="kserve_v2_ocr"` directly fails with a clear ValueError instead of building a converter that fails opaquely on the first request. """ with pytest.raises(ValueError) as excinfo: _capture_pipeline_options(ocr_engine="kserve_v2_ocr") msg = str(excinfo.value) assert "kserve_v2_ocr" in msg assert "hybrid local mode" in msg assert "Available engines" in msg # Must also mention the denylist explicitly so a future maintainer searching # for the constant lands here. assert "_OCR_ENGINE_DENYLIST" in msg # ---------- argparse: --no-ocr / --force-ocr / --ocr-engine / --psm ---------- def _build_parser_subset(): """Reconstruct the OCR-relevant argparse subset from main(). Mirrors the layout in hybrid_server.main() so changes to the CLI surface are caught by these tests. Uses the same `_OCR_ENGINE_DENYLIST` module constant as production code so tests stay in sync with main() automatically. """ from docling.models.factories import get_ocr_factory parser = argparse.ArgumentParser() ocr_mode = parser.add_mutually_exclusive_group() ocr_mode.add_argument("--force-ocr", action="store_true") ocr_mode.add_argument("--no-ocr", action="store_true") choices = sorted( set(get_ocr_factory(allow_external_plugins=False).registered_kind) - hybrid_server._OCR_ENGINE_DENYLIST ) parser.add_argument( "--ocr-engine", default="easyocr", choices=choices ) parser.add_argument("--psm", type=int, default=None) parser.add_argument("--ocr-lang", default=None) return parser def test_argparse_defaults(): """No flags -> no_ocr=False, force_ocr=False, engine=easyocr, psm=None.""" args = _build_parser_subset().parse_args([]) assert args.no_ocr is False assert args.force_ocr is False assert args.ocr_engine == "easyocr" assert args.psm is None def test_argparse_no_ocr_and_force_ocr_are_mutually_exclusive(): """Passing both --no-ocr and --force-ocr exits with an argparse error.""" err = io.StringIO() with pytest.raises(SystemExit), redirect_stderr(err): _build_parser_subset().parse_args(["--no-ocr", "--force-ocr"]) assert "not allowed with argument" in err.getvalue() def test_argparse_kserve_engine_is_rejected(): """`--ocr-engine kserve_v2_ocr` is rejected (denylist filter).""" err = io.StringIO() with pytest.raises(SystemExit), redirect_stderr(err): _build_parser_subset().parse_args(["--ocr-engine", "kserve_v2_ocr"]) assert "invalid choice" in err.getvalue() def test_argparse_tesseract_path_for_issue_439(): """The CLI invocation that resolves #439 parses cleanly.""" args = _build_parser_subset().parse_args( ["--ocr-engine", "tesseract", "--ocr-lang", "mal", "--force-ocr"] ) assert args.ocr_engine == "tesseract" assert args.ocr_lang == "mal" assert args.force_ocr is True assert args.no_ocr is False def test_argparse_psm_accepted_as_integer(): """`--psm 6` is parsed as an integer. Range and semantics belong to Tesseract / docling; this server passes the integer through unchanged. Out-of-range values surface from docling / Tesseract at conversion time, not here. """ args = _build_parser_subset().parse_args( ["--ocr-engine", "tesseract", "--psm", "6"] ) assert args.psm == 6 # ---------- _check_ocr_engine_available ---------- def test_engine_check_easyocr_always_ok(): """easyocr ships with the `[hybrid]` extra; check is a no-op.""" ok, msg = hybrid_server._check_ocr_engine_available("easyocr") assert ok is True assert msg == "" def test_engine_check_auto_always_ok(): """`auto` defers engine choice to docling; check is a no-op.""" ok, msg = hybrid_server._check_ocr_engine_available("auto") assert ok is True assert msg == "" def test_engine_check_unknown_kind_returns_false(): """Unknown engine kinds fail closed instead of silently passing. Before this contract was tightened, the helper returned `(True, "")` for any engine name it did not recognize, so a docling release that registered a new engine kind would slip past the probe and fail at first conversion. Locks in the fail-closed behavior. """ ok, msg = hybrid_server._check_ocr_engine_available("hypothetical_new_engine") assert ok is False assert "hypothetical_new_engine" in msg # Maintainer-targeted hint: where to add the probe branch. assert "_check_ocr_engine_available" in msg def test_engine_check_tesseract_missing_binary(): """`tesseract` engine without the binary on PATH fails with an actionable message.""" with patch("shutil.which", return_value=None): ok, msg = hybrid_server._check_ocr_engine_available("tesseract") assert ok is False assert "tesseract" in msg.lower() assert "PATH" in msg def test_engine_check_tesseract_present(): """`tesseract` engine passes when the binary resolves on PATH.""" with patch("shutil.which", return_value="/usr/bin/tesseract"): ok, msg = hybrid_server._check_ocr_engine_available("tesseract") assert ok is True assert msg == "" def test_engine_check_tesserocr_missing_package(): """`tesserocr` engine without the Python package fails with install hint.""" with patch("importlib.util.find_spec", return_value=None): ok, msg = hybrid_server._check_ocr_engine_available("tesserocr") assert ok is False assert "tesserocr" in msg assert "pip install" in msg def test_engine_check_rapidocr_missing_package(): """`rapidocr` engine without the Python package fails with install hint.""" with patch("importlib.util.find_spec", return_value=None): ok, msg = hybrid_server._check_ocr_engine_available("rapidocr") assert ok is False assert "rapidocr" in msg assert "onnxruntime" in msg def test_engine_check_rapidocr_missing_onnxruntime(): """`rapidocr` installed without `onnxruntime` fails with a backend-specific hint.""" # Return a truthy spec for rapidocr, None for onnxruntime, None for anything else. def fake_find_spec(name): return object() if name == "rapidocr" else None with patch("importlib.util.find_spec", side_effect=fake_find_spec): ok, msg = hybrid_server._check_ocr_engine_available("rapidocr") assert ok is False assert "onnxruntime" in msg # The message should not duplicate the "rapidocr is not installed" wording. assert "rapidocr` Python package" not in msg def test_engine_check_ocrmac_off_macos(): """`ocrmac` selected on a non-Darwin platform fails with a clear platform message.""" with patch("sys.platform", "linux"): ok, msg = hybrid_server._check_ocr_engine_available("ocrmac") assert ok is False assert "macOS" in msg def test_engine_check_ocrmac_on_macos_missing_package(): """`ocrmac` on macOS without the `ocrmac` Python package fails with install hint.""" with patch("sys.platform", "darwin"), patch( "importlib.util.find_spec", return_value=None ): ok, msg = hybrid_server._check_ocr_engine_available("ocrmac") assert ok is False assert "ocrmac" in msg assert "pip install" in msg # Should NOT be the platform-mismatch message. assert "macOS only" not in msg and "not macOS" not in msg # ---------- --no-ocr ignored-flag warning ---------- def _run_main_to_warning(argv, monkeypatch, caplog): """Drive `main()` far enough to capture the --no-ocr warning, then short-circuit. `main()` ends in `uvicorn.run(...)`; we patch `create_app` and `uvicorn.run` so the function returns cleanly after argparse + the warning logic without starting a server. """ monkeypatch.setattr("sys.argv", ["opendataloader-pdf-hybrid", *argv]) # Skip dep import checks — pytest already runs inside the [hybrid] env. monkeypatch.setattr(hybrid_server, "_check_dependencies", lambda: None) # Avoid building a real DocumentConverter / FastAPI app. monkeypatch.setattr(hybrid_server, "create_app", lambda **kwargs: object()) # Stub uvicorn so main() returns instead of starting a server. fake_uvicorn = type("FakeUvicorn", (), {"run": staticmethod(lambda *a, **k: None)}) monkeypatch.setitem(__import__("sys").modules, "uvicorn", fake_uvicorn) caplog.set_level("WARNING", logger=hybrid_server.logger.name) hybrid_server.main() def test_no_ocr_warns_when_engine_explicitly_set(monkeypatch, caplog): """`--no-ocr --ocr-engine tesseract` warns that --ocr-engine has no effect.""" _run_main_to_warning( ["--no-ocr", "--ocr-engine", "tesseract"], monkeypatch, caplog ) warnings = [r.message for r in caplog.records if r.levelname == "WARNING"] assert any("--ocr-engine tesseract" in w for w in warnings), warnings def test_no_ocr_warns_when_engine_explicitly_set_to_easyocr(monkeypatch, caplog): """Explicit `--ocr-engine easyocr` under --no-ocr is still inert and must warn. argparse cannot distinguish "user typed easyocr" from "default was used", so main() inspects argv directly. This regression test locks the path in. """ _run_main_to_warning( ["--no-ocr", "--ocr-engine", "easyocr"], monkeypatch, caplog ) warnings = [r.message for r in caplog.records if r.levelname == "WARNING"] assert any("--ocr-engine easyocr" in w for w in warnings), warnings def test_no_ocr_no_warning_when_engine_left_default(monkeypatch, caplog): """`--no-ocr` without --ocr-engine does not falsely report easyocr as inert.""" _run_main_to_warning(["--no-ocr"], monkeypatch, caplog) warnings = [r.message for r in caplog.records if r.levelname == "WARNING"] # No "--ocr-engine" mention because the user did not type it. assert not any("--ocr-engine" in w for w in warnings), warnings def test_no_ocr_warns_when_ocr_lang_set(monkeypatch, caplog): """`--no-ocr --ocr-lang ko` warns that --ocr-lang has no effect.""" _run_main_to_warning(["--no-ocr", "--ocr-lang", "ko"], monkeypatch, caplog) warnings = [r.message for r in caplog.records if r.levelname == "WARNING"] assert any("--ocr-lang" in w for w in warnings), warnings def test_no_ocr_warns_when_psm_set(monkeypatch, caplog): """`--no-ocr --psm 6` warns that --psm has no effect.""" _run_main_to_warning(["--no-ocr", "--psm", "6"], monkeypatch, caplog) warnings = [r.message for r in caplog.records if r.levelname == "WARNING"] assert any("--psm 6" in w for w in warnings), warnings def test_no_ocr_alone_emits_no_warning(monkeypatch, caplog): """`--no-ocr` on its own does not warn — there are no inert flags to call out.""" _run_main_to_warning(["--no-ocr"], monkeypatch, caplog) warnings = [ r.message for r in caplog.records if r.levelname == "WARNING" and "no effect" in r.message ] assert warnings == [] # ---------- main() exits when engine prerequisites are missing ---------- def test_main_exits_when_tesseract_binary_missing(monkeypatch, caplog): """Selecting `--ocr-engine tesseract` without the binary on PATH exits at startup.""" monkeypatch.setattr( "sys.argv", ["opendataloader-pdf-hybrid", "--ocr-engine", "tesseract"] ) monkeypatch.setattr(hybrid_server, "_check_dependencies", lambda: None) monkeypatch.setattr(hybrid_server, "create_app", lambda **kwargs: object()) monkeypatch.setattr("shutil.which", lambda _name: None) fake_uvicorn = type("FakeUvicorn", (), {"run": staticmethod(lambda *a, **k: None)}) monkeypatch.setitem(__import__("sys").modules, "uvicorn", fake_uvicorn) caplog.set_level("ERROR", logger=hybrid_server.logger.name) with pytest.raises(SystemExit) as excinfo: hybrid_server.main() assert excinfo.value.code == 2 errors = [r.message for r in caplog.records if r.levelname == "ERROR"] assert any("tesseract" in e.lower() for e in errors), errors def test_main_skips_engine_check_when_no_ocr(monkeypatch, caplog): """`--no-ocr --ocr-engine tesseract` skips the binary check entirely.""" called = {"which": False} def spy_which(_name): called["which"] = True return None # Would fail the check if it were called. monkeypatch.setattr( "sys.argv", ["opendataloader-pdf-hybrid", "--no-ocr", "--ocr-engine", "tesseract"], ) monkeypatch.setattr(hybrid_server, "_check_dependencies", lambda: None) monkeypatch.setattr(hybrid_server, "create_app", lambda **kwargs: object()) monkeypatch.setattr("shutil.which", spy_which) fake_uvicorn = type("FakeUvicorn", (), {"run": staticmethod(lambda *a, **k: None)}) monkeypatch.setitem(__import__("sys").modules, "uvicorn", fake_uvicorn) hybrid_server.main() # must not raise SystemExit assert called["which"] is False