diff --git a/docling/cli/main.py b/docling/cli/main.py index 35708925ed..1f2e826a2d 100644 --- a/docling/cli/main.py +++ b/docling/cli/main.py @@ -148,9 +148,14 @@ from docling.models.factories import ( get_layout_factory, get_ocr_factory, + get_picture_description_factory, get_table_structure_factory, ) from docling.models.factories.base_factory import BaseFactory +from docling.models.factories.plugin_registry import ( + is_internal_plugin_distribution, + load_plugin_modules, +) from docling.utils.profiling import ProfilingItem warnings.filterwarnings(action="ignore", category=UserWarning, module="pydantic|torch") @@ -239,9 +244,6 @@ def _expand_from_formats(from_formats: list[str] | None) -> list[InputFormat]: return list(dict.fromkeys(expanded_formats)) -ocr_factory_internal = get_ocr_factory(allow_external_plugins=False) -ocr_engines_enum_internal = ocr_factory_internal.get_enum() - # Get available VLM presets from the registry vlm_preset_ids = VlmConvertOptions.list_preset_ids() @@ -405,6 +407,16 @@ def show_external_plugins_callback(value: bool): 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) + picture_factory_all = get_picture_description_factory( + allow_external_plugins=True + ) + plugin_packages = { + plugin.name: plugin.distribution_name + for plugin in load_plugin_modules( + BaseFactory.default_plugin_name, + allow_external_plugins=True, + ) + } def print_external_plugins(factory: BaseFactory, factory_name: str): table = rich.table.Table(title=f"Available {factory_name} engines") @@ -412,17 +424,19 @@ def print_external_plugins(factory: BaseFactory, factory_name: str): table.add_column("Plugin") table.add_column("Package") for meta in factory.registered_meta.values(): - if not meta.module.startswith("docling."): + package = plugin_packages.get(meta.plugin_name) + if package is not None and not is_internal_plugin_distribution(package): table.add_row( f"[bold]{meta.kind}[/bold]", meta.plugin_name, - meta.module.split(".")[0], + package, ) rich.print(table) print_external_plugins(ocr_factory_all, "OCR") print_external_plugins(layout_factory_all, "layout") print_external_plugins(table_factory_all, "table") + print_external_plugins(picture_factory_all, "picture description") raise typer.Exit() @@ -845,9 +859,8 @@ def convert( # noqa: C901 typer.Option( ..., help=( - f"The OCR engine to use. When --allow-external-plugins is *not* set, the available values are: " - f"{', '.join(o.value for o in ocr_engines_enum_internal)}. " - f"Use the option --show-external-plugins to see the options allowed with external plugins." + "The registered OCR engine kind to use. Use " + "--show-external-plugins to list third-party options." ), ), ] = OcrAutoOptions.kind, diff --git a/docling/models/factories/base_factory.py b/docling/models/factories/base_factory.py index 208f0cab39..5b292a282c 100644 --- a/docling/models/factories/base_factory.py +++ b/docling/models/factories/base_factory.py @@ -1,20 +1,35 @@ import enum import logging from abc import ABCMeta -from typing import Generic, Optional, Type, TypeVar +from collections.abc import Mapping, Sequence +from typing import Generic, Literal, TypeVar, cast -from pluggy import PluginManager from pydantic import BaseModel from docling.datamodel.pipeline_options import BaseOptions from docling.models.base_model import BaseModelWithOptions +from docling.models.factories.plugin_registry import load_plugin_modules A = TypeVar("A", bound=BaseModelWithOptions) +PluginCapability = Literal[ + "layout_engines", + "ocr_engines", + "picture_description", + "table_structure_engines", +] logger = logging.getLogger(__name__) +class PluginConfigurationError(RuntimeError): + """A plugin hook returned data outside Docling's plugin contract.""" + + +class PluginHookError(RuntimeError): + """A plugin hook failed while declaring its models.""" + + class FactoryMeta(BaseModel): kind: str plugin_name: str @@ -23,19 +38,26 @@ class FactoryMeta(BaseModel): class BaseFactory(Generic[A], metaclass=ABCMeta): default_plugin_name = "docling" + model_type: type[BaseModelWithOptions] | None = None - def __init__(self, plugin_attr_name: str, plugin_name=default_plugin_name): + def __init__( + self, + plugin_attr_name: PluginCapability, + plugin_name: str = default_plugin_name, + ) -> None: self.plugin_name = plugin_name self.plugin_attr_name = plugin_attr_name - self._classes: dict[Type[BaseOptions], Type[A]] = {} - self._meta: dict[Type[BaseOptions], FactoryMeta] = {} + self._classes: dict[type[BaseOptions], type[A]] = {} + self._options_by_kind: dict[str, type[BaseOptions]] = {} + self._meta: dict[type[BaseOptions], FactoryMeta] = {} + self._model_contracts: dict[type[A], tuple[type[BaseOptions], str]] = {} @property def registered_kind(self) -> list[str]: - return [opt.kind for opt in self._classes.keys()] + return list(self._options_by_kind) - def get_enum(self) -> enum.Enum: + def get_enum(self) -> type[enum.Enum]: return enum.Enum( self.plugin_attr_name + "_enum", names={kind: kind for kind in self.registered_kind}, @@ -44,25 +66,26 @@ def get_enum(self) -> enum.Enum: ) @property - def classes(self): + def classes(self) -> Mapping[type[BaseOptions], type[A]]: return self._classes @property - def registered_meta(self): + def registered_meta(self) -> Mapping[type[BaseOptions], FactoryMeta]: return self._meta def create_instance(self, options: BaseOptions, **kwargs) -> A: try: - _cls = self._classes[type(options)] - return _cls(options=options, **kwargs) + model_class = self._classes[type(options)] except KeyError: - raise RuntimeError(self._err_msg_on_class_not_found(options.kind)) + raise RuntimeError(self._err_msg_on_class_not_found(options.kind)) from None + return model_class(options=options, **kwargs) def create_options(self, kind: str, *args, **kwargs) -> BaseOptions: - for opt_cls, _ in self._classes.items(): - if opt_cls.kind == kind: - return opt_cls(*args, **kwargs) - raise RuntimeError(self._err_msg_on_class_not_found(kind)) + try: + options_type = self._options_by_kind[kind] + except KeyError: + raise RuntimeError(self._err_msg_on_class_not_found(kind)) from None + return options_type(*args, **kwargs) def _err_msg_on_class_not_found(self, kind: str): msg = [] @@ -74,49 +97,178 @@ def _err_msg_on_class_not_found(self, kind: str): return f"No class found with the name {kind!r}, known classes are:\n{msg_str}" - def register(self, cls: Type[A], plugin_name: str, plugin_module_name: str): - opt_type = cls.get_options_type() - - if opt_type in self._classes: - raise ValueError( - f"{opt_type.kind!r} already registered to class {self._classes[opt_type]!r}" - ) - - self._classes[opt_type] = cls - self._meta[opt_type] = FactoryMeta( - kind=opt_type.kind, plugin_name=plugin_name, module=plugin_module_name + def register( + self, + cls: type[A], + plugin_name: str, + plugin_module_name: str, + ) -> None: + self._register_models( + (cls,), + plugin_name=plugin_name, + plugin_module_name=plugin_module_name, ) def load_from_plugins( - self, plugin_name: Optional[str] = None, allow_external_plugins: bool = False - ): + self, plugin_name: str | None = None, allow_external_plugins: bool = False + ) -> None: plugin_name = plugin_name or self.plugin_name - plugin_manager = PluginManager(plugin_name) - plugin_manager.load_setuptools_entrypoints(plugin_name) + for plugin in load_plugin_modules( + plugin_name, + allow_external_plugins=allow_external_plugins, + ): + # Plugin hook names are the documented third-party interface. + hook = getattr(plugin.module, self.plugin_attr_name, None) + + if hook is None: + continue + if not callable(hook): + raise self._configuration_error( + plugin.name, + f"the {self.plugin_attr_name!r} hook must be callable", + ) + + logger.info("Loading plugin %r", plugin.name) + try: + config = hook() + except Exception as exc: + raise PluginHookError( + f"Plugin {plugin.name!r} failed while running its " + f"{self.plugin_attr_name!r} hook: {exc}" + ) from exc + self.process_plugin( + config, + plugin.name, + plugin.module_name, + ) + + def process_plugin( + self, + config: object, + plugin_name: str, + plugin_module_name: str, + ) -> None: + if not isinstance(config, Mapping): + raise self._configuration_error( + plugin_name, "the hook must return a mapping" + ) - for plugin_name, plugin_module in plugin_manager.list_name_plugin(): - plugin_module_name = str(plugin_module.__name__) # type: ignore + plugin_config = cast(Mapping[object, object], config) + models = plugin_config.get(self.plugin_attr_name) + if not isinstance(models, list): + raise self._configuration_error( + plugin_name, + f"the {self.plugin_attr_name!r} value must be a list of model classes", + ) - if not allow_external_plugins and not plugin_module_name.startswith( - "docling." + validated_models: list[type[A]] = [] + for index, model in enumerate(models): + if not isinstance(model, type) or ( + self.model_type is not None and not issubclass(model, self.model_type) ): - logger.warning( - f"The plugin {plugin_name} will not be loaded because Docling is being executed with allow_external_plugins=false." + expected_model = ( + self.model_type.__name__ + if self.model_type is not None + else "BaseModelWithOptions" ) - continue + raise self._configuration_error( + plugin_name, + f"{self.plugin_attr_name!r} item {index} must be a " + f"{expected_model} model class", + ) + validated_models.append(cast(type[A], model)) + + self._validate_registrations(validated_models, plugin_name) + for model in validated_models: + self.register(model, plugin_name, plugin_module_name) + + def _register_models( + self, + models: Sequence[type[A]], + *, + plugin_name: str, + plugin_module_name: str, + ) -> None: + self._validate_registrations(models, plugin_name) + for model in models: + options_type, kind = self._validate_options_type(model, plugin_name) + self._classes[options_type] = model + self._options_by_kind[kind] = options_type + self._meta[options_type] = FactoryMeta( + kind=kind, + plugin_name=plugin_name, + module=plugin_module_name, + ) - attr = getattr(plugin_module, self.plugin_attr_name, None) + def _validate_registrations( + self, models: Sequence[type[A]], plugin_name: str + ) -> None: + classes = self._classes.copy() + options_by_kind = self._options_by_kind.copy() + + for model in models: + options_type, kind = self._validate_options_type(model, plugin_name) + registered_model = classes.get(options_type) + if registered_model is not None: + raise self._configuration_error( + plugin_name, + f"{options_type.__name__} is already registered to " + f"{registered_model.__name__}, so it cannot also register " + f"{model.__name__}", + ) - if callable(attr): - logger.info("Loading plugin %r", plugin_name) + registered_options = options_by_kind.get(kind) + if registered_options is not None: + registered_model = classes[registered_options] + raise self._configuration_error( + plugin_name, + f"model kind {kind!r} is already registered to " + f"{registered_model.__name__}, so it cannot also register " + f"{model.__name__}", + ) - config = attr() - self.process_plugin(config, plugin_name, plugin_module_name) + classes[options_type] = model + options_by_kind[kind] = options_type - def process_plugin(self, config, plugin_name: str, plugin_module_name: str): - for item in config[self.plugin_attr_name]: - try: - self.register(item, plugin_name, plugin_module_name) - except ValueError: - logger.warning("%r already registered", item) + def _validate_options_type( + self, model: type[A], plugin_name: str + ) -> tuple[type[BaseOptions], str]: + cached_contract = self._model_contracts.get(model) + if cached_contract is not None: + return cached_contract + + try: + options_type = model.get_options_type() + except Exception as exc: + raise self._configuration_error( + plugin_name, + f"{model.__name__}.get_options_type() failed: {exc}", + ) from exc + + if not isinstance(options_type, type) or not issubclass( + options_type, BaseOptions + ): + raise self._configuration_error( + plugin_name, + f"{model.__name__}.get_options_type() must return a " + "BaseOptions subclass", + ) + + kind = vars(options_type).get("kind") + if not isinstance(kind, str) or not kind: + raise self._configuration_error( + plugin_name, + f"{options_type.__name__} must declare a non-empty string kind", + ) + contract = (options_type, kind) + self._model_contracts[model] = contract + return contract + + def _configuration_error( + self, plugin_name: str, problem: str + ) -> PluginConfigurationError: + return PluginConfigurationError( + f"Plugin {plugin_name!r} has an invalid {self.plugin_attr_name!r} " + f"contract: {problem}." + ) diff --git a/docling/models/factories/layout_factory.py b/docling/models/factories/layout_factory.py index 7390c07792..cfbc911017 100644 --- a/docling/models/factories/layout_factory.py +++ b/docling/models/factories/layout_factory.py @@ -3,5 +3,7 @@ class LayoutFactory(BaseFactory[BaseLayoutModel]): - def __init__(self, *args, **kwargs): - super().__init__("layout_engines", *args, **kwargs) + model_type = BaseLayoutModel + + def __init__(self, plugin_name: str = BaseFactory.default_plugin_name) -> None: + super().__init__("layout_engines", plugin_name) diff --git a/docling/models/factories/ocr_factory.py b/docling/models/factories/ocr_factory.py index 34fc7c434c..963e4077c9 100644 --- a/docling/models/factories/ocr_factory.py +++ b/docling/models/factories/ocr_factory.py @@ -7,5 +7,7 @@ class OcrFactory(BaseFactory[BaseOcrModel]): - def __init__(self, *args, **kwargs): - super().__init__("ocr_engines", *args, **kwargs) + model_type = BaseOcrModel + + def __init__(self, plugin_name: str = BaseFactory.default_plugin_name) -> None: + super().__init__("ocr_engines", plugin_name) diff --git a/docling/models/factories/picture_description_factory.py b/docling/models/factories/picture_description_factory.py index f66d132f67..79d5471423 100644 --- a/docling/models/factories/picture_description_factory.py +++ b/docling/models/factories/picture_description_factory.py @@ -7,5 +7,7 @@ class PictureDescriptionFactory(BaseFactory[PictureDescriptionBaseModel]): - def __init__(self, *args, **kwargs): - super().__init__("picture_description", *args, **kwargs) + model_type = PictureDescriptionBaseModel + + def __init__(self, plugin_name: str = BaseFactory.default_plugin_name) -> None: + super().__init__("picture_description", plugin_name) diff --git a/docling/models/factories/plugin_registry.py b/docling/models/factories/plugin_registry.py new file mode 100644 index 0000000000..6748d3e121 --- /dev/null +++ b/docling/models/factories/plugin_registry.py @@ -0,0 +1,130 @@ +from collections.abc import Callable +from dataclasses import dataclass +from functools import cache +from importlib import metadata + +_INTERNAL_DISTRIBUTIONS = frozenset({"docling-slim"}) + + +class PluginDiscoveryError(RuntimeError): + """Installed plugin metadata does not define a deterministic registry.""" + + +class PluginLoadError(RuntimeError): + """An allowed plugin entry point could not be imported.""" + + +@dataclass(frozen=True, slots=True) +class _PluginEntryPoint: + name: str + module_name: str + value: str + distribution_name: str + load: Callable[[], object] + + +@dataclass(frozen=True, slots=True) +class PluginModule: + name: str + module_name: str + distribution_name: str + module: object + + +@cache +def load_plugin_modules( + group: str, + *, + allow_external_plugins: bool, +) -> tuple[PluginModule, ...]: + """Load each allowed plugin entry point once, preserving discovery order.""" + return tuple( + _load_plugin(entry_point) + for entry_point in _discover_entry_points( + group, + allow_external_plugins=allow_external_plugins, + ) + ) + + +def _discover_entry_points( + group: str, + *, + allow_external_plugins: bool, +) -> tuple[_PluginEntryPoint, ...]: + discovered_entry_points: list[_PluginEntryPoint] = [] + + for distribution in metadata.distributions(): + for entry_point in distribution.entry_points: + if entry_point.group != group: + continue + try: + distribution_name = distribution.metadata["Name"] + except KeyError: + raise PluginDiscoveryError( + f"Plugin entry point {entry_point.name!r} has no provider " + "distribution name." + ) from None + if not distribution_name: + raise PluginDiscoveryError( + f"Plugin entry point {entry_point.name!r} has an empty provider " + "distribution name." + ) + if not allow_external_plugins and not is_internal_plugin_distribution( + distribution_name + ): + continue + + discovered_entry_points.append( + _PluginEntryPoint( + name=entry_point.name, + module_name=entry_point.module, + value=entry_point.value, + distribution_name=distribution_name, + load=entry_point.load, + ) + ) + + discovered_entry_points.sort( + key=lambda entry_point: ( + entry_point.name, + entry_point.distribution_name, + entry_point.value, + ) + ) + entry_points_by_name: dict[str, _PluginEntryPoint] = {} + for discovered in discovered_entry_points: + registered = entry_points_by_name.get(discovered.name) + if registered is not None: + raise PluginDiscoveryError( + f"Plugin entry point {discovered.name!r} is provided by both " + f"{registered.distribution_name!r} ({registered.value!r}) and " + f"{discovered.distribution_name!r} ({discovered.value!r}). " + "Entry point names must be unique." + ) + entry_points_by_name[discovered.name] = discovered + + return tuple(entry_points_by_name.values()) + + +def _load_plugin(entry_point: _PluginEntryPoint) -> PluginModule: + try: + plugin_module = entry_point.load() + except Exception as exc: + raise PluginLoadError( + f"Could not load plugin entry point {entry_point.name!r} " + f"from {entry_point.module_name!r}: {exc}" + ) from exc + + return PluginModule( + name=entry_point.name, + module_name=entry_point.module_name, + distribution_name=entry_point.distribution_name, + module=plugin_module, + ) + + +def is_internal_plugin_distribution(distribution_name: str) -> bool: + """Return whether a plugin entry point comes from Docling's own package.""" + normalized_name = distribution_name.casefold().replace("_", "-") + return normalized_name in _INTERNAL_DISTRIBUTIONS diff --git a/docling/models/factories/table_factory.py b/docling/models/factories/table_factory.py index ccb2a07e84..94c1db0dde 100644 --- a/docling/models/factories/table_factory.py +++ b/docling/models/factories/table_factory.py @@ -3,5 +3,7 @@ class TableStructureFactory(BaseFactory[BaseTableStructureModel]): - def __init__(self, *args, **kwargs): - super().__init__("table_structure_engines", *args, **kwargs) + model_type = BaseTableStructureModel + + def __init__(self, plugin_name: str = BaseFactory.default_plugin_name) -> None: + super().__init__("table_structure_engines", plugin_name) diff --git a/docs/concepts/plugins.md b/docs/concepts/plugins.md index 5b14fb7670..cba7da248c 100644 --- a/docs/concepts/plugins.md +++ b/docs/concepts/plugins.md @@ -1,6 +1,6 @@ -Docling allows to be extended with third-party plugins which extend the choice of options provided in several steps of the pipeline. +Docling can be extended with third-party plugins that add model implementations to several pipeline stages. -Plugins are loaded via the [pluggy](https://github.com/pytest-dev/pluggy/) system which allows third-party developers to register the new capabilities using the [setuptools entrypoint](https://setuptools.pypa.io/en/latest/userguide/entry_point.html#entry-points-for-plugins). +Plugins are discovered through Python package [entry points](https://packaging.python.org/en/latest/specifications/entry-points/) in the `docling` group. Entry-point names must be unique across the environment. The actual entrypoint definition might vary, depending on the packaging system you are using. Here are a few examples: @@ -46,6 +46,17 @@ The actual entrypoint definition might vary, depending on the packaging system y ## Plugin factories +A plugin module can define one or more of these hooks: + +| Hook | Model contract | Options contract | +| --- | --- | --- | +| `ocr_engines` | `BaseOcrModel` | `OcrOptions` | +| `layout_engines` | `BaseLayoutModel` | `BaseLayoutOptions` | +| `table_structure_engines` | `BaseTableStructureModel` | `BaseTableStructureOptions` | +| `picture_description` | `PictureDescriptionBaseModel` | `PictureDescriptionBaseOptions` | + +Each hook must be callable and return a mapping whose matching key contains a list of model classes. Every model class must implement the model contract for that hook and return its options class from `get_options_type()`. Option `kind` values must be unique within a factory; Docling rejects conflicting registrations instead of selecting one based on package discovery order. + ### OCR factory The OCR factory allows to provide more OCR engines to the Docling users. @@ -62,13 +73,13 @@ def ocr_engines(): } ``` -where `YourOcrModel` must implement the [`BaseOcrModel`](https://github.com/docling-project/docling/blob/main/docling/models/base_ocr_model.py#L23) and provide an options class derived from [`OcrOptions`](https://github.com/docling-project/docling/blob/main/docling/datamodel/pipeline_options.py#L105). +where `YourOcrModel` must implement [`BaseOcrModel`](https://github.com/docling-project/docling/blob/main/docling/models/base_ocr_model.py) and provide an options class derived from [`OcrOptions`](https://github.com/docling-project/docling/blob/main/docling/datamodel/pipeline_options.py). If you look for an example, the [default Docling plugins](https://github.com/docling-project/docling/blob/main/docling/models/plugins/defaults.py) is a good starting point. ## Third-party plugins -When the plugin is not provided by the main `docling` package but by a third-party package this have to be enabled explicitly via the `allow_external_plugins` option. +Plugins outside the main `docling` package must be enabled explicitly through the `allow_external_plugins` option. When external plugins are disabled, Docling filters their entry points before importing any third-party plugin code. ```py from docling.datamodel.base_models import InputFormat @@ -76,7 +87,7 @@ from docling.datamodel.pipeline_options import PdfPipelineOptions from docling.document_converter import DocumentConverter, PdfFormatOption pipeline_options = PdfPipelineOptions() -pipeline_options.allow_external_plugins = True # <-- enabled the external plugins +pipeline_options.allow_external_plugins = True # Enable external plugins. pipeline_options.ocr_options = YourOptions # <-- your options here doc_converter = DocumentConverter( @@ -90,7 +101,7 @@ doc_converter = DocumentConverter( ### Using the `docling` CLI -Similarly, when using the `docling` users have to enable external plugins before selecting the new one. +The CLI can list external models from all four supported plugin factories. External plugins still have to be enabled before selecting one for conversion. ```sh # Show the external plugins diff --git a/pyproject.toml b/pyproject.toml index ccbf972ab0..106882e0b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,6 @@ dependencies = [ 'filetype>=1.2.0,<2.0.0', 'requests>=2.32.2,<3.0.0', 'certifi>=2024.7.4', - 'pluggy>=1.0.0,<2.0.0', 'tqdm>=4.65.0,<5.0.0', ] diff --git a/tests/test_cli.py b/tests/test_cli.py index 783cb6ca8c..df16530fe6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,7 @@ import base64 import re +import subprocess +import sys import zipfile from io import BytesIO from pathlib import Path @@ -77,6 +79,24 @@ def test_cli_convert_help(): assert "input_sources" not in result.output +def test_cli_import_does_not_discover_plugins() -> None: + completed = subprocess.run( + [ + sys.executable, + "-c", + ( + "import sys; import docling.cli.main; " + "assert 'docling.models.plugins.defaults' not in sys.modules" + ), + ], + capture_output=True, + check=False, + text=True, + ) + + assert completed.returncode == 0, completed.stderr + + def test_cli_version(): result = runner.invoke(app, ["--version"]) assert result.exit_code == 0 diff --git a/tests/test_plugin_registry.py b/tests/test_plugin_registry.py new file mode 100644 index 0000000000..24621e9a2d --- /dev/null +++ b/tests/test_plugin_registry.py @@ -0,0 +1,555 @@ +from collections.abc import Iterator +from dataclasses import dataclass +from importlib import metadata +from types import ModuleType +from typing import Callable, ClassVar + +import pytest + +from docling.datamodel.pipeline_options import BaseOptions +from docling.models.factories import ( + get_layout_factory, + get_ocr_factory, + get_picture_description_factory, + get_table_structure_factory, +) +from docling.models.factories.base_factory import ( + BaseFactory, + PluginConfigurationError, + PluginHookError, +) +from docling.models.factories.plugin_registry import ( + PluginDiscoveryError, + PluginLoadError, + load_plugin_modules, +) + + +@dataclass(frozen=True) +class _FakeEntryPoint: + name: str + module: str + loader: Callable[[], ModuleType] + group: str = "docling" + + @property + def value(self) -> str: + return self.module + + def load(self) -> ModuleType: + return self.loader() + + +@dataclass(frozen=True) +class _FakeDistribution: + entry_points: tuple[_FakeEntryPoint, ...] + name: str = "third-party-package" + + @property + def metadata(self) -> dict[str, str]: + return {"Name": self.name} + + +class _PluginModelBase: + def __init__(self, *, options: BaseOptions, **kwargs: object) -> None: + self.options = options + + @classmethod + def get_options_type(cls) -> type[BaseOptions]: + raise NotImplementedError + + +class _FirstOptions(BaseOptions): + kind: ClassVar[str] = "shared-kind" + + +class _SecondOptions(BaseOptions): + kind: ClassVar[str] = "shared-kind" + + +class _MissingKindOptions(BaseOptions): + pass + + +class _FirstModel(_PluginModelBase): + @classmethod + def get_options_type(cls) -> type[BaseOptions]: + return _FirstOptions + + +class _SecondModel(_PluginModelBase): + @classmethod + def get_options_type(cls) -> type[BaseOptions]: + return _SecondOptions + + +class _ConstructorKeyErrorModel(_PluginModelBase): + def __init__(self, *, options: BaseOptions, **kwargs: object) -> None: + raise KeyError("raised inside model constructor") + + @classmethod + def get_options_type(cls) -> type[BaseOptions]: + return _FirstOptions + + +class _MissingKindModel(_PluginModelBase): + @classmethod + def get_options_type(cls) -> type[BaseOptions]: + return _MissingKindOptions + + +@pytest.fixture(autouse=True) +def _clear_factory_cache() -> Iterator[None]: + factory_getters = ( + get_layout_factory, + get_ocr_factory, + get_picture_description_factory, + get_table_structure_factory, + ) + for get_factory in factory_getters: + get_factory.cache_clear() + load_plugin_modules.cache_clear() + yield + for get_factory in factory_getters: + get_factory.cache_clear() + load_plugin_modules.cache_clear() + + +def test_disabled_external_plugins_are_not_imported( + monkeypatch: pytest.MonkeyPatch, +) -> None: + imported = False + + def load_external_plugin() -> ModuleType: + nonlocal imported + imported = True + return ModuleType("third_party_docling_plugin") + + distribution = _FakeDistribution( + entry_points=( + _FakeEntryPoint( + name="third-party", + module="third_party_docling_plugin", + loader=load_external_plugin, + ), + ) + ) + monkeypatch.setattr(metadata, "distributions", lambda: [distribution]) + + get_ocr_factory(allow_external_plugins=False) + + assert imported is False + + +def test_external_distribution_cannot_spoof_docling_module_ownership( + monkeypatch: pytest.MonkeyPatch, +) -> None: + imported = False + + def load_spoofed_plugin() -> ModuleType: + nonlocal imported + imported = True + return ModuleType("docling.spoofed_plugin") + + distribution = _FakeDistribution( + entry_points=( + _FakeEntryPoint( + name="spoofed-plugin", + module="docling.spoofed_plugin", + loader=load_spoofed_plugin, + ), + ), + name="malicious-package", + ) + monkeypatch.setattr(metadata, "distributions", lambda: [distribution]) + + get_ocr_factory(allow_external_plugins=False) + + assert imported is False + + +def test_plugin_entry_points_are_loaded_once_across_factories( + monkeypatch: pytest.MonkeyPatch, +) -> None: + load_count = 0 + + def load_external_plugin() -> ModuleType: + nonlocal load_count + load_count += 1 + return ModuleType("third_party_docling_plugin") + + distribution = _FakeDistribution( + entry_points=( + _FakeEntryPoint( + name="third-party", + module="third_party_docling_plugin", + loader=load_external_plugin, + ), + ) + ) + monkeypatch.setattr(metadata, "distributions", lambda: [distribution]) + + get_ocr_factory(allow_external_plugins=True) + get_layout_factory(allow_external_plugins=True) + get_table_structure_factory(allow_external_plugins=True) + get_picture_description_factory(allow_external_plugins=True) + + assert load_count == 1 + + +def test_conflicting_entry_point_names_fail_before_import( + monkeypatch: pytest.MonkeyPatch, +) -> None: + imported_modules: list[str] = [] + + def load_plugin(module_name: str) -> ModuleType: + imported_modules.append(module_name) + return ModuleType(module_name) + + distributions = ( + _FakeDistribution( + entry_points=( + _FakeEntryPoint( + name="duplicate-name", + module="first_docling_plugin", + loader=lambda: load_plugin("first_docling_plugin"), + ), + ) + ), + _FakeDistribution( + entry_points=( + _FakeEntryPoint( + name="duplicate-name", + module="second_docling_plugin", + loader=lambda: load_plugin("second_docling_plugin"), + ), + ) + ), + ) + monkeypatch.setattr(metadata, "distributions", lambda: distributions) + + with pytest.raises( + PluginDiscoveryError, + match=(r"duplicate-name.*first_docling_plugin.*second_docling_plugin"), + ): + get_ocr_factory(allow_external_plugins=True) + + assert imported_modules == [] + + +def test_duplicate_entry_point_declarations_are_always_rejected( + monkeypatch: pytest.MonkeyPatch, +) -> None: + imported = False + + def load_plugin() -> ModuleType: + nonlocal imported + imported = True + return ModuleType("shared_docling_plugin") + + distributions = ( + _FakeDistribution( + entry_points=( + _FakeEntryPoint( + name="duplicate-name", + module="shared_docling_plugin", + loader=load_plugin, + ), + ), + name="z-provider", + ), + _FakeDistribution( + entry_points=( + _FakeEntryPoint( + name="duplicate-name", + module="shared_docling_plugin", + loader=load_plugin, + ), + ), + name="a-provider", + ), + ) + monkeypatch.setattr(metadata, "distributions", lambda: distributions) + + with pytest.raises( + PluginDiscoveryError, + match=r"duplicate-name.*a-provider.*z-provider", + ): + get_ocr_factory(allow_external_plugins=True) + + assert imported is False + + +def test_plugin_import_failure_identifies_the_entry_point( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import_error = ImportError("missing optional runtime") + + def fail_to_load() -> ModuleType: + raise import_error + + distribution = _FakeDistribution( + entry_points=( + _FakeEntryPoint( + name="broken-plugin", + module="broken_docling_plugin", + loader=fail_to_load, + ), + ) + ) + monkeypatch.setattr(metadata, "distributions", lambda: [distribution]) + + with pytest.raises( + PluginLoadError, + match=r"broken-plugin.*broken_docling_plugin", + ) as exc_info: + get_ocr_factory(allow_external_plugins=True) + + assert exc_info.value.__cause__ is import_error + + +def test_malformed_plugin_configuration_identifies_the_contract( + monkeypatch: pytest.MonkeyPatch, +) -> None: + plugin_module = ModuleType("malformed_docling_plugin") + + def ocr_engines() -> object: + return {"ocr_engines": ["not-a-model-class"]} + + setattr(plugin_module, "ocr_engines", ocr_engines) + distribution = _FakeDistribution( + entry_points=( + _FakeEntryPoint( + name="malformed-plugin", + module=plugin_module.__name__, + loader=lambda: plugin_module, + ), + ) + ) + monkeypatch.setattr(metadata, "distributions", lambda: [distribution]) + + with pytest.raises( + PluginConfigurationError, + match=r"malformed-plugin.*ocr_engines.*model class", + ): + get_ocr_factory(allow_external_plugins=True) + + +def test_plugin_model_kinds_must_be_unique( + monkeypatch: pytest.MonkeyPatch, +) -> None: + plugin_module = ModuleType("duplicate_kind_docling_plugin") + + def ocr_engines() -> object: + return {"ocr_engines": [_FirstModel, _SecondModel]} + + setattr(plugin_module, "ocr_engines", ocr_engines) + distribution = _FakeDistribution( + entry_points=( + _FakeEntryPoint( + name="duplicate-kind-plugin", + module=plugin_module.__name__, + loader=lambda: plugin_module, + ), + ) + ) + monkeypatch.setattr(metadata, "distributions", lambda: [distribution]) + factory = BaseFactory[_PluginModelBase]("ocr_engines") + + with pytest.raises( + PluginConfigurationError, + match=r"duplicate-kind-plugin.*shared-kind.*_FirstModel.*_SecondModel", + ): + factory.load_from_plugins(allow_external_plugins=True) + + assert factory.registered_kind == [] + + +def test_model_constructor_key_errors_are_not_reported_as_missing_models() -> None: + factory = BaseFactory[_PluginModelBase]("ocr_engines") + factory.register( + _ConstructorKeyErrorModel, + plugin_name="test-plugin", + plugin_module_name="test_plugin", + ) + + with pytest.raises(KeyError, match="raised inside model constructor"): + factory.create_instance(_FirstOptions()) + + +def test_base_factory_registration_contract_is_preserved() -> None: + factory = BaseFactory[_PluginModelBase]("ocr_engines") + factory.register(_FirstModel, "test-plugin", "test_plugin") + + assert factory.registered_kind == ["shared-kind"] + + hook_factory = BaseFactory[_PluginModelBase]("ocr_engines") + hook_factory.process_plugin( + {"ocr_engines": [_FirstModel]}, + "test-plugin", + "test_plugin", + ) + + assert hook_factory.registered_kind == ["shared-kind"] + + +def test_model_contract_is_evaluated_once_per_factory() -> None: + evaluation_count = 0 + + class _SingleEvaluationModel(_PluginModelBase): + @classmethod + def get_options_type(cls) -> type[BaseOptions]: + nonlocal evaluation_count + evaluation_count += 1 + return _FirstOptions + + factory = BaseFactory[_PluginModelBase]("ocr_engines") + + factory.process_plugin( + {"ocr_engines": [_SingleEvaluationModel]}, + "test-plugin", + "test_plugin", + ) + + assert evaluation_count == 1 + + +def test_plugin_discovery_preserves_factory_override_dispatch( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _TrackingFactory(BaseFactory[_PluginModelBase]): + def __init__(self) -> None: + super().__init__("ocr_engines") + self.process_calls = 0 + self.register_calls = 0 + + def process_plugin( + self, + config: object, + plugin_name: str, + plugin_module_name: str, + ) -> None: + self.process_calls += 1 + super().process_plugin(config, plugin_name, plugin_module_name) + + def register( + self, + cls: type[_PluginModelBase], + plugin_name: str, + plugin_module_name: str, + ) -> None: + self.register_calls += 1 + super().register(cls, plugin_name, plugin_module_name) + + plugin_module = ModuleType("overridden_factory_plugin") + + def ocr_engines() -> object: + return {"ocr_engines": [_FirstModel]} + + setattr(plugin_module, "ocr_engines", ocr_engines) + distribution = _FakeDistribution( + entry_points=( + _FakeEntryPoint( + name="override-plugin", + module=plugin_module.__name__, + loader=lambda: plugin_module, + ), + ) + ) + monkeypatch.setattr(metadata, "distributions", lambda: [distribution]) + factory = _TrackingFactory() + + factory.load_from_plugins(allow_external_plugins=True) + + assert factory.process_calls == 1 + assert factory.register_calls == 1 + assert factory.registered_kind == ["shared-kind"] + + +def test_plugin_options_must_declare_a_nonempty_kind() -> None: + factory = BaseFactory[_PluginModelBase]("ocr_engines") + + with pytest.raises( + PluginConfigurationError, + match=r"test-plugin.*_MissingKindOptions.*non-empty.*kind", + ): + factory.register( + _MissingKindModel, + plugin_name="test-plugin", + plugin_module_name="test_plugin", + ) + + assert factory.registered_kind == [] + + +def test_plugin_hook_failures_identify_the_capability( + monkeypatch: pytest.MonkeyPatch, +) -> None: + hook_error = RuntimeError("plugin setup failed") + plugin_module = ModuleType("failing_hook_docling_plugin") + + def ocr_engines() -> object: + raise hook_error + + setattr(plugin_module, "ocr_engines", ocr_engines) + distribution = _FakeDistribution( + entry_points=( + _FakeEntryPoint( + name="failing-hook-plugin", + module=plugin_module.__name__, + loader=lambda: plugin_module, + ), + ) + ) + monkeypatch.setattr(metadata, "distributions", lambda: [distribution]) + + with pytest.raises( + PluginHookError, + match=r"failing-hook-plugin.*ocr_engines.*plugin setup failed", + ) as exc_info: + get_ocr_factory(allow_external_plugins=True) + + assert exc_info.value.__cause__ is hook_error + + +def test_cli_inventory_discovers_external_picture_plugin( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from typer.testing import CliRunner + + from docling.cli.main import app + from docling.models.stages.picture_description.picture_description_api_model import ( + PictureDescriptionApiModel, + ) + + load_count = 0 + plugin_module = ModuleType("external_picture_plugin") + + def picture_description() -> object: + return {"picture_description": [PictureDescriptionApiModel]} + + def load_plugin() -> ModuleType: + nonlocal load_count + load_count += 1 + return plugin_module + + setattr(plugin_module, "picture_description", picture_description) + distribution = _FakeDistribution( + entry_points=( + _FakeEntryPoint( + name="external-picture-plugin", + module=plugin_module.__name__, + loader=load_plugin, + ), + ), + name="external-picture-package", + ) + monkeypatch.setattr(metadata, "distributions", lambda: [distribution]) + + result = CliRunner().invoke(app, ["convert", "--show-external-plugins"]) + + assert result.exit_code == 0 + assert "Available picture description engines" in result.output + assert "external-picture-plugin" in result.output + assert "external-picture-package" in result.output + assert load_count == 1 diff --git a/uv.lock b/uv.lock index b719201867..6e41b23daf 100644 --- a/uv.lock +++ b/uv.lock @@ -1655,7 +1655,6 @@ dependencies = [ { name = "certifi" }, { name = "docling-core" }, { name = "filetype" }, - { name = "pluggy" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "requests" }, @@ -2037,7 +2036,6 @@ requires-dist = [ { name = "peft", marker = "extra == 'models-vlm-inline'", specifier = ">=0.18.1" }, { name = "pillow", marker = "extra == 'convert-core'", specifier = ">=10.0.0,<13.0.0" }, { name = "playwright", marker = "extra == 'format-html-render'", specifier = ">=1.58.0" }, - { name = "pluggy", specifier = ">=1.0.0,<2.0.0" }, { name = "polyfactory", marker = "extra == 'extract-core'", specifier = ">=2.22.2" }, { name = "pydantic", specifier = ">=2.0.0,<3.0.0" }, { name = "pydantic-settings", specifier = ">=2.3.0,<3.0.0" },