Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 39 additions & 23 deletions docling/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,20 +146,33 @@
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,
get_ocr_factory,
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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
74 changes: 51 additions & 23 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import json
import re
import shutil
import subprocess
import sys
import zipfile
from io import BytesIO
from pathlib import Path
Expand Down Expand Up @@ -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):
Expand Down
Loading