diff --git a/docling/cli/main.py b/docling/cli/main.py index 08eed6291e..ec6d123ccb 100644 --- a/docling/cli/main.py +++ b/docling/cli/main.py @@ -146,10 +146,12 @@ from docling.utils.profiling import ProfilingItem from docling.utils.progress import ProgressPrinter -# The local model stack (scipy, torch, …) is absent on lightweight installs -# (docling-slim[service-client] / docling-client). Guard these imports so the -# CLI module — and `convert-remote` — remain importable without them. -_local_model_stack_available: bool +# The local model stack (scipy, torch, packaging, …) is absent on lightweight +# installs (docling-slim[service-client] / docling-client). Importing the +# factories is not enough to detect this: building them loads the built-in +# plugins, which import the engine modules. Guard both steps so the CLI module +# — and `convert-remote` — remain importable without the local model stack. +_local_model_stack_error: ImportError | None = None try: from docling.models.factories import ( get_layout_factory, @@ -157,9 +159,20 @@ get_table_structure_factory, ) - _local_model_stack_available = True -except ImportError: - _local_model_stack_available = False + ocr_engines_enum_internal = list( + get_ocr_factory(allow_external_plugins=False).get_enum() + ) + layout_engines_enum_internal = list( + get_layout_factory(allow_external_plugins=False).get_enum() + ) + table_structure_engines_enum_internal = list( + get_table_structure_factory(allow_external_plugins=False).get_enum() + ) +except ImportError as exc: + _local_model_stack_error = exc + ocr_engines_enum_internal = [] + layout_engines_enum_internal = [] + table_structure_engines_enum_internal = [] if TYPE_CHECKING: from docling.models.factories.base_factory import BaseFactory @@ -287,22 +300,6 @@ def _expand_from_formats(from_formats: list[str] | None) -> list[InputFormat]: return list(dict.fromkeys(expanded_formats)) -if _local_model_stack_available: - ocr_factory_internal = get_ocr_factory(allow_external_plugins=False) - ocr_engines_enum_internal = ocr_factory_internal.get_enum() - - layout_factory_internal = get_layout_factory(allow_external_plugins=False) - layout_engines_enum_internal = layout_factory_internal.get_enum() - - table_structure_factory_internal = get_table_structure_factory( - allow_external_plugins=False - ) - table_structure_engines_enum_internal = table_structure_factory_internal.get_enum() -else: - ocr_engines_enum_internal = [] - layout_engines_enum_internal = [] - table_structure_engines_enum_internal = [] - # Get available VLM presets from the registry vlm_preset_ids = VlmConvertOptions.list_preset_ids() chart_extraction_preset_ids = ChartExtractionVlmEngineOptions.list_preset_ids() @@ -440,6 +437,22 @@ def _resolve_asr_options(asr_model: AsrModelType) -> InlineAsrOptions: ) +def _require_local_model_stack(command: str) -> None: + """Exit with an install hint when the local model stack is missing.""" + if _local_model_stack_error is None: + return + err_console.print( + f"[red]Error: `{command}` needs the local conversion stack, " + f"which is not installed ({_local_model_stack_error}).[/red]\n\n" + "Install the full docling package for local conversion:\n" + " pip install docling\n\n" + "To convert with a Docling Serve endpoint instead, use:\n" + " docling convert-remote --help", + highlight=False, + ) + raise typer.Exit(1) + + def logo_callback(value: bool): if value: print(DOCLING_ASCII_ART) @@ -465,6 +478,7 @@ def version_callback(value: bool): def show_external_plugins_callback(value: bool): if value: + _require_local_model_stack("--show-external-plugins") ocr_factory_all = get_ocr_factory(allow_external_plugins=True) layout_factory_all = get_layout_factory(allow_external_plugins=True) table_factory_all = get_table_structure_factory(allow_external_plugins=True) @@ -1253,6 +1267,8 @@ def convert( # noqa: C901 ), ] = None, ): + _require_local_model_stack("docling convert") + # Heavy backend/converter/pipeline imports are deferred to here so the CLI # (and `convert-remote`) stay importable without the local PDF stack # (pypdfium2 / docling_parse). Only local `convert` needs them. diff --git a/tests/test_cli.py b/tests/test_cli.py index 3b801af188..8f3fbe6f36 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,6 +5,8 @@ import json import re import shutil +import subprocess +import sys import zipfile from io import BytesIO from pathlib import Path @@ -1656,32 +1658,58 @@ def test_cli_invalid_table_structure_engine_is_rejected(tmp_path): assert result.exit_code != 0 -def test_cli_main_importable_without_local_model_stack( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Importing docling.cli.main must not require pypdfium2 or scipy. +_SLIM_CLI_SCRIPT = """ +import sys + +# Simulate a docling-client / docling-slim[service-client] install. +for blocked in ("pypdfium2", "scipy", "scipy.ndimage", "torch", "packaging"): + sys.modules[blocked] = None + +from typer.testing import CliRunner + +from docling.cli.main import app + +result = CliRunner().invoke(app, sys.argv[1:]) +sys.stdout.write(result.output) +# A traceback (any exception other than a clean exit) maps to exit code 99. +crashed = result.exception is not None and not isinstance(result.exception, SystemExit) +sys.exit(99 if crashed else result.exit_code) +""" + - Ensure that CLI does not crash in `docling convert-remote` - on lightweight `docling-client` / `docling-slim[service-client]` installs. +def _run_slim_cli(*args: str) -> subprocess.CompletedProcess[str]: + # A subprocess keeps the blocked modules and the re-imported CLI out of + # the other tests in this session. + return subprocess.run( + [sys.executable, "-c", _SLIM_CLI_SCRIPT, *args], + capture_output=True, + text=True, + check=False, + ) + + +def test_cli_convert_remote_works_without_local_model_stack() -> None: + """`docling convert-remote` must work on `docling-client` installs. + + `docling-client` / `docling-slim[service-client]` has no local model stack + (pypdfium2, scipy, torch, packaging, ...). Building the engine factories + loads the built-in plugins, which import the engine modules, so the CLI + must guard that step too and not only the factory import. """ - import importlib - import sys - - monkeypatch.setitem(sys.modules, "pypdfium2", None) # type: ignore[arg-type] - monkeypatch.setitem(sys.modules, "scipy", None) # type: ignore[arg-type] - monkeypatch.setitem(sys.modules, "scipy.ndimage", None) # type: ignore[arg-type] - - for mod in list(sys.modules): - if mod.startswith( - ( - "docling.cli.main", - "docling.document_converter", - "docling.models.factories", - ) - ): - monkeypatch.delitem(sys.modules, mod) + result = _run_slim_cli("convert-remote", "--help") + assert result.returncode == 0, result.stdout + result.stderr + assert "docling-serve" in strip_ansi(result.stdout) + - importlib.import_module("docling.cli.main") +def test_cli_convert_without_local_model_stack_shows_install_hint( + tmp_path: Path, +) -> None: + """Local `docling convert` must exit with an install hint, not a traceback.""" + source = tmp_path / "doc.pdf" + source.write_bytes(b"%PDF-1.4") + result = _run_slim_cli("convert", str(source)) + assert result.returncode == 1, result.stdout + result.stderr + assert "pip install docling" in strip_ansi(result.stdout) def test_cli_invalid_ocr_engine_is_rejected(tmp_path):