Files
2026-07-13 12:59:42 +08:00

448 lines
17 KiB
Python

"""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