From bbfc7f2635aed8d9c2888ca6c98f31e7a7df5f75 Mon Sep 17 00:00:00 2001 From: Hassan Raza Date: Sun, 26 Jul 2026 02:27:39 +0500 Subject: [PATCH 01/20] fix: filter disabled plugins before import Signed-off-by: Hassan Raza --- docling/models/factories/base_factory.py | 29 ++++------- docling/models/factories/plugin_registry.py | 45 ++++++++++++++++ tests/test_plugin_registry.py | 58 +++++++++++++++++++++ 3 files changed, 113 insertions(+), 19 deletions(-) create mode 100644 docling/models/factories/plugin_registry.py create mode 100644 tests/test_plugin_registry.py diff --git a/docling/models/factories/base_factory.py b/docling/models/factories/base_factory.py index 208f0cab39..b3b2ed04cd 100644 --- a/docling/models/factories/base_factory.py +++ b/docling/models/factories/base_factory.py @@ -3,11 +3,11 @@ from abc import ABCMeta from typing import Generic, Optional, Type, TypeVar -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) @@ -35,7 +35,7 @@ def __init__(self, plugin_attr_name: str, plugin_name=default_plugin_name): def registered_kind(self) -> list[str]: return [opt.kind for opt in self._classes.keys()] - 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}, @@ -92,27 +92,18 @@ def load_from_plugins( ): plugin_name = plugin_name or self.plugin_name - plugin_manager = PluginManager(plugin_name) - plugin_manager.load_setuptools_entrypoints(plugin_name) - - for plugin_name, plugin_module in plugin_manager.list_name_plugin(): - plugin_module_name = str(plugin_module.__name__) # type: ignore - - if not allow_external_plugins and not plugin_module_name.startswith( - "docling." - ): - logger.warning( - f"The plugin {plugin_name} will not be loaded because Docling is being executed with allow_external_plugins=false." - ) - continue - - attr = getattr(plugin_module, self.plugin_attr_name, None) + for plugin in load_plugin_modules( + plugin_name, + allow_external_plugins=allow_external_plugins, + ): + # Plugin hook names are the documented third-party interface. + attr = getattr(plugin.module, self.plugin_attr_name, None) if callable(attr): - logger.info("Loading plugin %r", plugin_name) + logger.info("Loading plugin %r", plugin.name) config = attr() - self.process_plugin(config, plugin_name, plugin_module_name) + self.process_plugin(config, plugin.name, plugin.module_name) def process_plugin(self, config, plugin_name: str, plugin_module_name: str): for item in config[self.plugin_attr_name]: diff --git a/docling/models/factories/plugin_registry.py b/docling/models/factories/plugin_registry.py new file mode 100644 index 0000000000..540d970bd1 --- /dev/null +++ b/docling/models/factories/plugin_registry.py @@ -0,0 +1,45 @@ +from dataclasses import dataclass +from importlib import metadata + +_INTERNAL_MODULE = "docling" + + +@dataclass(frozen=True, slots=True) +class PluginModule: + name: str + module_name: str + module: object + + +def load_plugin_modules( + group: str, + *, + allow_external_plugins: bool, +) -> tuple[PluginModule, ...]: + """Load each allowed plugin entry point once, preserving discovery order.""" + loaded_names: set[str] = set() + plugins: list[PluginModule] = [] + + for distribution in metadata.distributions(): + for entry_point in distribution.entry_points: + if entry_point.group != group or entry_point.name in loaded_names: + continue + if not allow_external_plugins and not _is_internal(entry_point.module): + continue + + loaded_names.add(entry_point.name) + plugins.append( + PluginModule( + name=entry_point.name, + module_name=entry_point.module, + module=entry_point.load(), + ) + ) + + return tuple(plugins) + + +def _is_internal(module_name: str) -> bool: + return module_name == _INTERNAL_MODULE or module_name.startswith( + f"{_INTERNAL_MODULE}." + ) diff --git a/tests/test_plugin_registry.py b/tests/test_plugin_registry.py new file mode 100644 index 0000000000..6570bd9e5f --- /dev/null +++ b/tests/test_plugin_registry.py @@ -0,0 +1,58 @@ +from collections.abc import Iterator +from dataclasses import dataclass +from importlib import metadata +from types import ModuleType +from typing import Callable + +import pytest + +from docling.models.factories import get_ocr_factory + + +@dataclass(frozen=True) +class _FakeEntryPoint: + name: str + module: str + loader: Callable[[], ModuleType] + group: str = "docling" + + def load(self) -> ModuleType: + return self.loader() + + +@dataclass(frozen=True) +class _FakeDistribution: + entry_points: tuple[_FakeEntryPoint, ...] + + +@pytest.fixture(autouse=True) +def _clear_factory_cache() -> Iterator[None]: + get_ocr_factory.cache_clear() + yield + get_ocr_factory.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 From 8f920135d60b0593472077429efe1fcac71b8a36 Mon Sep 17 00:00:00 2001 From: Hassan Raza Date: Sun, 26 Jul 2026 02:28:47 +0500 Subject: [PATCH 02/20] refactor: share plugin discovery across factories Signed-off-by: Hassan Raza --- docling/models/factories/plugin_registry.py | 2 + tests/test_plugin_registry.py | 51 +++++++++++++++++++-- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/docling/models/factories/plugin_registry.py b/docling/models/factories/plugin_registry.py index 540d970bd1..f2181f17e5 100644 --- a/docling/models/factories/plugin_registry.py +++ b/docling/models/factories/plugin_registry.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from functools import cache from importlib import metadata _INTERNAL_MODULE = "docling" @@ -11,6 +12,7 @@ class PluginModule: module: object +@cache def load_plugin_modules( group: str, *, diff --git a/tests/test_plugin_registry.py b/tests/test_plugin_registry.py index 6570bd9e5f..c1ac16b8d4 100644 --- a/tests/test_plugin_registry.py +++ b/tests/test_plugin_registry.py @@ -6,7 +6,13 @@ import pytest -from docling.models.factories import get_ocr_factory +from docling.models.factories import ( + get_layout_factory, + get_ocr_factory, + get_picture_description_factory, + get_table_structure_factory, +) +from docling.models.factories.plugin_registry import load_plugin_modules @dataclass(frozen=True) @@ -27,9 +33,19 @@ class _FakeDistribution: @pytest.fixture(autouse=True) def _clear_factory_cache() -> Iterator[None]: - get_ocr_factory.cache_clear() + 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 - get_ocr_factory.cache_clear() + for get_factory in factory_getters: + get_factory.cache_clear() + load_plugin_modules.cache_clear() def test_disabled_external_plugins_are_not_imported( @@ -56,3 +72,32 @@ def load_external_plugin() -> ModuleType: 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 From 5dc15eeb1356f22208c8b1ad0efc3a61ef9023aa Mon Sep 17 00:00:00 2001 From: Hassan Raza Date: Sun, 26 Jul 2026 02:30:13 +0500 Subject: [PATCH 03/20] fix: reject conflicting plugin entry points Signed-off-by: Hassan Raza --- docling/models/factories/plugin_registry.py | 57 +++++++++++++++++---- tests/test_plugin_registry.py | 45 +++++++++++++++- 2 files changed, 90 insertions(+), 12 deletions(-) diff --git a/docling/models/factories/plugin_registry.py b/docling/models/factories/plugin_registry.py index f2181f17e5..01884305c2 100644 --- a/docling/models/factories/plugin_registry.py +++ b/docling/models/factories/plugin_registry.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from dataclasses import dataclass from functools import cache from importlib import metadata @@ -5,6 +6,17 @@ _INTERNAL_MODULE = "docling" +class PluginDiscoveryError(RuntimeError): + """Installed plugin metadata does not define a deterministic registry.""" + + +@dataclass(frozen=True, slots=True) +class _PluginEntryPoint: + name: str + module_name: str + load: Callable[[], object] + + @dataclass(frozen=True, slots=True) class PluginModule: name: str @@ -19,26 +31,49 @@ def load_plugin_modules( allow_external_plugins: bool, ) -> tuple[PluginModule, ...]: """Load each allowed plugin entry point once, preserving discovery order.""" - loaded_names: set[str] = set() - plugins: list[PluginModule] = [] + return tuple( + PluginModule( + name=entry_point.name, + module_name=entry_point.module_name, + module=entry_point.load(), + ) + 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, ...]: + entry_points_by_name: dict[str, _PluginEntryPoint] = {} for distribution in metadata.distributions(): for entry_point in distribution.entry_points: - if entry_point.group != group or entry_point.name in loaded_names: + if entry_point.group != group: continue if not allow_external_plugins and not _is_internal(entry_point.module): continue - loaded_names.add(entry_point.name) - plugins.append( - PluginModule( - name=entry_point.name, - module_name=entry_point.module, - module=entry_point.load(), - ) + discovered = _PluginEntryPoint( + name=entry_point.name, + module_name=entry_point.module, + load=entry_point.load, ) + registered = entry_points_by_name.get(discovered.name) + if registered is None: + entry_points_by_name[discovered.name] = discovered + elif registered.module_name != discovered.module_name: + raise PluginDiscoveryError( + f"Plugin entry point {discovered.name!r} is provided by both " + f"{registered.module_name!r} and {discovered.module_name!r}. " + "Entry point names must be unique." + ) - return tuple(plugins) + return tuple(entry_points_by_name.values()) def _is_internal(module_name: str) -> bool: diff --git a/tests/test_plugin_registry.py b/tests/test_plugin_registry.py index c1ac16b8d4..c4068cdba2 100644 --- a/tests/test_plugin_registry.py +++ b/tests/test_plugin_registry.py @@ -12,7 +12,10 @@ get_picture_description_factory, get_table_structure_factory, ) -from docling.models.factories.plugin_registry import load_plugin_modules +from docling.models.factories.plugin_registry import ( + PluginDiscoveryError, + load_plugin_modules, +) @dataclass(frozen=True) @@ -101,3 +104,43 @@ def load_external_plugin() -> ModuleType: 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 == [] From f04e96f78815fd7e400cba01fd2bcb7fd531c6f4 Mon Sep 17 00:00:00 2001 From: Hassan Raza Date: Sun, 26 Jul 2026 02:33:18 +0500 Subject: [PATCH 04/20] fix: explain plugin import failures Signed-off-by: Hassan Raza --- docling/models/factories/plugin_registry.py | 26 ++++++++++++++---- tests/test_plugin_registry.py | 29 +++++++++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/docling/models/factories/plugin_registry.py b/docling/models/factories/plugin_registry.py index 01884305c2..dbb8d869cd 100644 --- a/docling/models/factories/plugin_registry.py +++ b/docling/models/factories/plugin_registry.py @@ -10,6 +10,10 @@ 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 @@ -32,11 +36,7 @@ def load_plugin_modules( ) -> tuple[PluginModule, ...]: """Load each allowed plugin entry point once, preserving discovery order.""" return tuple( - PluginModule( - name=entry_point.name, - module_name=entry_point.module_name, - module=entry_point.load(), - ) + _load_plugin(entry_point) for entry_point in _discover_entry_points( group, allow_external_plugins=allow_external_plugins, @@ -76,6 +76,22 @@ def _discover_entry_points( 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, + module=plugin_module, + ) + + def _is_internal(module_name: str) -> bool: return module_name == _INTERNAL_MODULE or module_name.startswith( f"{_INTERNAL_MODULE}." diff --git a/tests/test_plugin_registry.py b/tests/test_plugin_registry.py index c4068cdba2..70da4fc9e2 100644 --- a/tests/test_plugin_registry.py +++ b/tests/test_plugin_registry.py @@ -14,6 +14,7 @@ ) from docling.models.factories.plugin_registry import ( PluginDiscoveryError, + PluginLoadError, load_plugin_modules, ) @@ -144,3 +145,31 @@ def load_plugin(module_name: str) -> ModuleType: get_ocr_factory(allow_external_plugins=True) assert imported_modules == [] + + +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 From a7f72ef65493e839e4361f88f667a8fd027bf5b1 Mon Sep 17 00:00:00 2001 From: Hassan Raza Date: Sun, 26 Jul 2026 02:36:15 +0500 Subject: [PATCH 05/20] refactor: define typed plugin hook contracts Signed-off-by: Hassan Raza --- docling/models/factories/base_factory.py | 95 +++++++++++++++---- docling/models/factories/layout_factory.py | 4 +- docling/models/factories/ocr_factory.py | 4 +- .../factories/picture_description_factory.py | 6 +- docling/models/factories/table_factory.py | 6 +- tests/test_plugin_registry.py | 28 ++++++ 6 files changed, 114 insertions(+), 29 deletions(-) diff --git a/docling/models/factories/base_factory.py b/docling/models/factories/base_factory.py index b3b2ed04cd..ecd8e571de 100644 --- a/docling/models/factories/base_factory.py +++ b/docling/models/factories/base_factory.py @@ -1,7 +1,8 @@ import enum import logging from abc import ABCMeta -from typing import Generic, Optional, Type, TypeVar +from collections.abc import Mapping +from typing import Generic, Literal, TypeVar, cast from pydantic import BaseModel @@ -10,11 +11,21 @@ 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 FactoryMeta(BaseModel): kind: str plugin_name: str @@ -24,12 +35,18 @@ class FactoryMeta(BaseModel): class BaseFactory(Generic[A], metaclass=ABCMeta): default_plugin_name = "docling" - def __init__(self, plugin_attr_name: str, plugin_name=default_plugin_name): + def __init__( + self, + plugin_attr_name: PluginCapability, + model_type: type[A], + plugin_name: str = default_plugin_name, + ) -> None: self.plugin_name = plugin_name self.plugin_attr_name = plugin_attr_name + self.model_type = model_type - self._classes: dict[Type[BaseOptions], Type[A]] = {} - self._meta: dict[Type[BaseOptions], FactoryMeta] = {} + self._classes: dict[type[BaseOptions], type[A]] = {} + self._meta: dict[type[BaseOptions], FactoryMeta] = {} @property def registered_kind(self) -> list[str]: @@ -44,11 +61,11 @@ def get_enum(self) -> type[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: @@ -74,7 +91,7 @@ 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): + def register(self, cls: type[A], plugin_name: str, plugin_module_name: str) -> None: opt_type = cls.get_options_type() if opt_type in self._classes: @@ -88,8 +105,8 @@ def register(self, cls: Type[A], plugin_name: str, plugin_module_name: str): ) 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 for plugin in load_plugin_modules( @@ -97,17 +114,53 @@ def load_from_plugins( allow_external_plugins=allow_external_plugins, ): # Plugin hook names are the documented third-party interface. - attr = getattr(plugin.module, self.plugin_attr_name, None) - - if callable(attr): - logger.info("Loading plugin %r", plugin.name) + 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) + config = hook() + 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" + ) - config = attr() - self.process_plugin(config, plugin.name, plugin.module_name) + 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", + ) - 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) + validated_models: list[type[A]] = [] + for index, model in enumerate(models): + if not isinstance(model, type) or not issubclass(model, self.model_type): + raise self._configuration_error( + plugin_name, + f"{self.plugin_attr_name!r} item {index} must be a " + f"{self.model_type.__name__} model class", + ) + validated_models.append(cast(type[A], model)) + + for model in validated_models: + self.register(model, plugin_name, plugin_module_name) + + 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..406abef83f 100644 --- a/docling/models/factories/layout_factory.py +++ b/docling/models/factories/layout_factory.py @@ -3,5 +3,5 @@ class LayoutFactory(BaseFactory[BaseLayoutModel]): - def __init__(self, *args, **kwargs): - super().__init__("layout_engines", *args, **kwargs) + def __init__(self, plugin_name: str = BaseFactory.default_plugin_name) -> None: + super().__init__("layout_engines", BaseLayoutModel, plugin_name) diff --git a/docling/models/factories/ocr_factory.py b/docling/models/factories/ocr_factory.py index 34fc7c434c..7193665eda 100644 --- a/docling/models/factories/ocr_factory.py +++ b/docling/models/factories/ocr_factory.py @@ -7,5 +7,5 @@ class OcrFactory(BaseFactory[BaseOcrModel]): - def __init__(self, *args, **kwargs): - super().__init__("ocr_engines", *args, **kwargs) + def __init__(self, plugin_name: str = BaseFactory.default_plugin_name) -> None: + super().__init__("ocr_engines", BaseOcrModel, plugin_name) diff --git a/docling/models/factories/picture_description_factory.py b/docling/models/factories/picture_description_factory.py index f66d132f67..d232db4390 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) + def __init__(self, plugin_name: str = BaseFactory.default_plugin_name) -> None: + super().__init__( + "picture_description", PictureDescriptionBaseModel, plugin_name + ) diff --git a/docling/models/factories/table_factory.py b/docling/models/factories/table_factory.py index ccb2a07e84..619f271be3 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) + def __init__(self, plugin_name: str = BaseFactory.default_plugin_name) -> None: + super().__init__( + "table_structure_engines", BaseTableStructureModel, plugin_name + ) diff --git a/tests/test_plugin_registry.py b/tests/test_plugin_registry.py index 70da4fc9e2..378a03cf19 100644 --- a/tests/test_plugin_registry.py +++ b/tests/test_plugin_registry.py @@ -12,6 +12,7 @@ get_picture_description_factory, get_table_structure_factory, ) +from docling.models.factories.base_factory import PluginConfigurationError from docling.models.factories.plugin_registry import ( PluginDiscoveryError, PluginLoadError, @@ -173,3 +174,30 @@ def fail_to_load() -> ModuleType: 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) From efac2e3e7dff2ece4a596efd9cf83b82e95183de Mon Sep 17 00:00:00 2001 From: Hassan Raza Date: Sun, 26 Jul 2026 02:38:46 +0500 Subject: [PATCH 06/20] fix: reject duplicate plugin model kinds Signed-off-by: Hassan Raza --- docling/models/factories/base_factory.py | 80 ++++++++++++++++++------ tests/test_plugin_registry.py | 64 ++++++++++++++++++- 2 files changed, 124 insertions(+), 20 deletions(-) diff --git a/docling/models/factories/base_factory.py b/docling/models/factories/base_factory.py index ecd8e571de..1516122633 100644 --- a/docling/models/factories/base_factory.py +++ b/docling/models/factories/base_factory.py @@ -1,7 +1,7 @@ import enum import logging from abc import ABCMeta -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from typing import Generic, Literal, TypeVar, cast from pydantic import BaseModel @@ -46,11 +46,12 @@ def __init__( self.model_type = model_type self._classes: dict[type[BaseOptions], type[A]] = {} + self._options_by_kind: dict[str, type[BaseOptions]] = {} self._meta: dict[type[BaseOptions], FactoryMeta] = {} @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) -> type[enum.Enum]: return enum.Enum( @@ -76,10 +77,11 @@ def create_instance(self, options: BaseOptions, **kwargs) -> A: raise RuntimeError(self._err_msg_on_class_not_found(options.kind)) 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 = [] @@ -92,16 +94,10 @@ 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) -> None: - 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 + self._register_models( + (cls,), + plugin_name=plugin_name, + plugin_module_name=plugin_module_name, ) def load_from_plugins( @@ -154,8 +150,56 @@ def process_plugin( ) validated_models.append(cast(type[A], model)) - for model in validated_models: - self.register(model, plugin_name, plugin_module_name) + self._register_models( + validated_models, + plugin_name=plugin_name, + plugin_module_name=plugin_module_name, + ) + + def _register_models( + self, + models: Sequence[type[A]], + *, + plugin_name: str, + plugin_module_name: str, + ) -> None: + classes = self._classes.copy() + options_by_kind = self._options_by_kind.copy() + registrations: list[tuple[type[BaseOptions], type[A]]] = [] + + for model in models: + options_type = model.get_options_type() + 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__}", + ) + + registered_options = options_by_kind.get(options_type.kind) + if registered_options is not None: + registered_model = classes[registered_options] + raise self._configuration_error( + plugin_name, + f"model kind {options_type.kind!r} is already registered to " + f"{registered_model.__name__}, so it cannot also register " + f"{model.__name__}", + ) + + classes[options_type] = model + options_by_kind[options_type.kind] = options_type + registrations.append((options_type, model)) + + for options_type, model in registrations: + self._classes[options_type] = model + self._options_by_kind[options_type.kind] = options_type + self._meta[options_type] = FactoryMeta( + kind=options_type.kind, + plugin_name=plugin_name, + module=plugin_module_name, + ) def _configuration_error( self, plugin_name: str, problem: str diff --git a/tests/test_plugin_registry.py b/tests/test_plugin_registry.py index 378a03cf19..0ba28eb19d 100644 --- a/tests/test_plugin_registry.py +++ b/tests/test_plugin_registry.py @@ -2,17 +2,18 @@ from dataclasses import dataclass from importlib import metadata from types import ModuleType -from typing import Callable +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 PluginConfigurationError +from docling.models.factories.base_factory import BaseFactory, PluginConfigurationError from docling.models.factories.plugin_registry import ( PluginDiscoveryError, PluginLoadError, @@ -36,6 +37,35 @@ class _FakeDistribution: entry_points: tuple[_FakeEntryPoint, ...] +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 _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 + + @pytest.fixture(autouse=True) def _clear_factory_cache() -> Iterator[None]: factory_getters = ( @@ -201,3 +231,33 @@ def ocr_engines() -> object: 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("ocr_engines", _PluginModelBase) + + 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 == [] From 4dae0edd174dae90cc00c19a3289cf2c5128746a Mon Sep 17 00:00:00 2001 From: Hassan Raza Date: Sun, 26 Jul 2026 02:39:27 +0500 Subject: [PATCH 07/20] fix: preserve model constructor errors Signed-off-by: Hassan Raza --- docling/models/factories/base_factory.py | 6 +++--- tests/test_plugin_registry.py | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/docling/models/factories/base_factory.py b/docling/models/factories/base_factory.py index 1516122633..414a5fc86e 100644 --- a/docling/models/factories/base_factory.py +++ b/docling/models/factories/base_factory.py @@ -71,10 +71,10 @@ def registered_meta(self) -> Mapping[type[BaseOptions], FactoryMeta]: 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: try: diff --git a/tests/test_plugin_registry.py b/tests/test_plugin_registry.py index 0ba28eb19d..65578cdb10 100644 --- a/tests/test_plugin_registry.py +++ b/tests/test_plugin_registry.py @@ -66,6 +66,15 @@ 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 + + @pytest.fixture(autouse=True) def _clear_factory_cache() -> Iterator[None]: factory_getters = ( @@ -261,3 +270,15 @@ def ocr_engines() -> object: 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("ocr_engines", _PluginModelBase) + 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()) From ee9031a2502945e7cfa53176e283368f049f0e23 Mon Sep 17 00:00:00 2001 From: Hassan Raza Date: Sun, 26 Jul 2026 02:40:08 +0500 Subject: [PATCH 08/20] fix: explain plugin hook failures Signed-off-by: Hassan Raza --- docling/models/factories/base_factory.py | 12 +++++++- tests/test_plugin_registry.py | 36 +++++++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/docling/models/factories/base_factory.py b/docling/models/factories/base_factory.py index 414a5fc86e..d013abe1ef 100644 --- a/docling/models/factories/base_factory.py +++ b/docling/models/factories/base_factory.py @@ -26,6 +26,10 @@ 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 @@ -121,7 +125,13 @@ def load_from_plugins( ) logger.info("Loading plugin %r", plugin.name) - config = hook() + 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( diff --git a/tests/test_plugin_registry.py b/tests/test_plugin_registry.py index 65578cdb10..c4a8dbe575 100644 --- a/tests/test_plugin_registry.py +++ b/tests/test_plugin_registry.py @@ -13,7 +13,11 @@ get_picture_description_factory, get_table_structure_factory, ) -from docling.models.factories.base_factory import BaseFactory, PluginConfigurationError +from docling.models.factories.base_factory import ( + BaseFactory, + PluginConfigurationError, + PluginHookError, +) from docling.models.factories.plugin_registry import ( PluginDiscoveryError, PluginLoadError, @@ -282,3 +286,33 @@ def test_model_constructor_key_errors_are_not_reported_as_missing_models() -> No with pytest.raises(KeyError, match="raised inside model constructor"): factory.create_instance(_FirstOptions()) + + +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 From a25e8a54c90590261149c972eaab7e10fc4ecb2f Mon Sep 17 00:00:00 2001 From: Hassan Raza Date: Sun, 26 Jul 2026 02:41:42 +0500 Subject: [PATCH 09/20] perf: avoid plugin discovery during cli import Signed-off-by: Hassan Raza --- docling/cli/main.py | 8 ++------ tests/test_cli.py | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/docling/cli/main.py b/docling/cli/main.py index 35708925ed..0d70d99726 100644 --- a/docling/cli/main.py +++ b/docling/cli/main.py @@ -239,9 +239,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() @@ -845,9 +842,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/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 From b9c7802402e6709c8c4fa98e297353703357df0f Mon Sep 17 00:00:00 2001 From: Hassan Raza Date: Sun, 26 Jul 2026 02:43:14 +0500 Subject: [PATCH 10/20] fix: include picture plugins in cli inventory Signed-off-by: Hassan Raza --- docling/cli/main.py | 8 +++- docling/models/factories/plugin_registry.py | 7 +++- tests/test_cli.py | 41 ++++++++++++++++++++- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/docling/cli/main.py b/docling/cli/main.py index 0d70d99726..97e4e226e6 100644 --- a/docling/cli/main.py +++ b/docling/cli/main.py @@ -148,9 +148,11 @@ 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_module from docling.utils.profiling import ProfilingItem warnings.filterwarnings(action="ignore", category=UserWarning, module="pydantic|torch") @@ -402,6 +404,9 @@ 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 + ) def print_external_plugins(factory: BaseFactory, factory_name: str): table = rich.table.Table(title=f"Available {factory_name} engines") @@ -409,7 +414,7 @@ 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."): + if not is_internal_plugin_module(meta.module): table.add_row( f"[bold]{meta.kind}[/bold]", meta.plugin_name, @@ -420,6 +425,7 @@ def print_external_plugins(factory: BaseFactory, factory_name: str): 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() diff --git a/docling/models/factories/plugin_registry.py b/docling/models/factories/plugin_registry.py index dbb8d869cd..841304d148 100644 --- a/docling/models/factories/plugin_registry.py +++ b/docling/models/factories/plugin_registry.py @@ -55,7 +55,9 @@ def _discover_entry_points( for entry_point in distribution.entry_points: if entry_point.group != group: continue - if not allow_external_plugins and not _is_internal(entry_point.module): + if not allow_external_plugins and not is_internal_plugin_module( + entry_point.module + ): continue discovered = _PluginEntryPoint( @@ -92,7 +94,8 @@ def _load_plugin(entry_point: _PluginEntryPoint) -> PluginModule: ) -def _is_internal(module_name: str) -> bool: +def is_internal_plugin_module(module_name: str) -> bool: + """Return whether a plugin module is owned by the Docling package.""" return module_name == _INTERNAL_MODULE or module_name.startswith( f"{_INTERNAL_MODULE}." ) diff --git a/tests/test_cli.py b/tests/test_cli.py index df16530fe6..b0ea77ecd3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,14 +12,16 @@ from PIL import Image from typer.testing import CliRunner +from docling.cli import main as cli_main from docling.cli.export_utils import _should_generate_export_images, _split_list -from docling.cli.main import app from docling.datamodel.accelerator_options import AcceleratorDevice from docling.datamodel.backend_options import ThreadedDoclingParseBackendOptions from docling.datamodel.base_models import InputFormat, OutputFormat from docling.datamodel.pipeline_options import OcrMode, PdfBackend, VlmPipelineOptions from docling.document_converter import PdfFormatOption +from docling.models.factories.base_factory import FactoryMeta +app = cli_main.app runner = CliRunner() PNG_BYTES = base64.b64decode( @@ -97,6 +99,43 @@ def test_cli_import_does_not_discover_plugins() -> None: assert completed.returncode == 0, completed.stderr +def test_cli_plugin_inventory_includes_picture_description( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _InventoryFactory: + def __init__(self, metadata: tuple[FactoryMeta, ...]) -> None: + self.registered_meta = dict(enumerate(metadata)) + + empty_factory = _InventoryFactory(()) + picture_factory = _InventoryFactory( + ( + FactoryMeta( + kind="external-picture", + plugin_name="picture-plugin", + module="third_party_docling.picture", + ), + ) + ) + monkeypatch.setattr(cli_main, "get_ocr_factory", lambda **_: empty_factory) + monkeypatch.setattr(cli_main, "get_layout_factory", lambda **_: empty_factory) + monkeypatch.setattr( + cli_main, "get_table_structure_factory", lambda **_: empty_factory + ) + monkeypatch.setattr( + cli_main, + "get_picture_description_factory", + lambda **_: picture_factory, + raising=False, + ) + + result = runner.invoke(app, ["convert", "--show-external-plugins"]) + + assert result.exit_code == 0 + assert "Available picture description engines" in result.output + assert "external-picture" in result.output + assert "picture-plugin" in result.output + + def test_cli_version(): result = runner.invoke(app, ["--version"]) assert result.exit_code == 0 From 69078090942cbd33cab3068a656f9165d11e9ba4 Mon Sep 17 00:00:00 2001 From: Hassan Raza Date: Sun, 26 Jul 2026 02:44:10 +0500 Subject: [PATCH 11/20] docs: define the plugin factory contract Signed-off-by: Hassan Raza --- docs/concepts/plugins.md | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) 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 From 7c348d3219ee1d052b505a7c61e55d6855767ace Mon Sep 17 00:00:00 2001 From: Hassan Raza Date: Sun, 26 Jul 2026 02:49:37 +0500 Subject: [PATCH 12/20] fix: verify plugin ownership by distribution Signed-off-by: Hassan Raza --- docling/cli/main.py | 6 +- docling/models/factories/base_factory.py | 27 ++++++- docling/models/factories/plugin_registry.py | 62 ++++++++++------ tests/test_plugin_registry.py | 80 +++++++++++++++++++++ 4 files changed, 147 insertions(+), 28 deletions(-) diff --git a/docling/cli/main.py b/docling/cli/main.py index 97e4e226e6..b878b71338 100644 --- a/docling/cli/main.py +++ b/docling/cli/main.py @@ -152,7 +152,7 @@ get_table_structure_factory, ) from docling.models.factories.base_factory import BaseFactory -from docling.models.factories.plugin_registry import is_internal_plugin_module +from docling.models.factories.plugin_registry import is_internal_plugin_distribution from docling.utils.profiling import ProfilingItem warnings.filterwarnings(action="ignore", category=UserWarning, module="pydantic|torch") @@ -414,11 +414,11 @@ 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 is_internal_plugin_module(meta.module): + if not is_internal_plugin_distribution(meta.package): table.add_row( f"[bold]{meta.kind}[/bold]", meta.plugin_name, - meta.module.split(".")[0], + meta.package, ) rich.print(table) diff --git a/docling/models/factories/base_factory.py b/docling/models/factories/base_factory.py index d013abe1ef..83ca5649c6 100644 --- a/docling/models/factories/base_factory.py +++ b/docling/models/factories/base_factory.py @@ -33,6 +33,7 @@ class PluginHookError(RuntimeError): class FactoryMeta(BaseModel): kind: str plugin_name: str + package: str module: str @@ -97,10 +98,18 @@ 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) -> None: + def register( + self, + cls: type[A], + *, + plugin_name: str, + plugin_package_name: str, + plugin_module_name: str, + ) -> None: self._register_models( (cls,), plugin_name=plugin_name, + plugin_package_name=plugin_package_name, plugin_module_name=plugin_module_name, ) @@ -132,10 +141,19 @@ def load_from_plugins( 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) + self.process_plugin( + config, + plugin.name, + plugin.distribution_name, + plugin.module_name, + ) def process_plugin( - self, config: object, plugin_name: str, plugin_module_name: str + self, + config: object, + plugin_name: str, + plugin_package_name: str, + plugin_module_name: str, ) -> None: if not isinstance(config, Mapping): raise self._configuration_error( @@ -163,6 +181,7 @@ def process_plugin( self._register_models( validated_models, plugin_name=plugin_name, + plugin_package_name=plugin_package_name, plugin_module_name=plugin_module_name, ) @@ -171,6 +190,7 @@ def _register_models( models: Sequence[type[A]], *, plugin_name: str, + plugin_package_name: str, plugin_module_name: str, ) -> None: classes = self._classes.copy() @@ -208,6 +228,7 @@ def _register_models( self._meta[options_type] = FactoryMeta( kind=options_type.kind, plugin_name=plugin_name, + package=plugin_package_name, module=plugin_module_name, ) diff --git a/docling/models/factories/plugin_registry.py b/docling/models/factories/plugin_registry.py index 841304d148..c1aefd5984 100644 --- a/docling/models/factories/plugin_registry.py +++ b/docling/models/factories/plugin_registry.py @@ -3,7 +3,7 @@ from functools import cache from importlib import metadata -_INTERNAL_MODULE = "docling" +_INTERNAL_DISTRIBUTIONS = frozenset({"docling-slim"}) class PluginDiscoveryError(RuntimeError): @@ -18,6 +18,8 @@ class PluginLoadError(RuntimeError): class _PluginEntryPoint: name: str module_name: str + value: str + distribution_name: str load: Callable[[], object] @@ -25,6 +27,7 @@ class _PluginEntryPoint: class PluginModule: name: str module_name: str + distribution_name: str module: object @@ -49,31 +52,46 @@ def _discover_entry_points( *, allow_external_plugins: bool, ) -> tuple[_PluginEntryPoint, ...]: - entry_points_by_name: dict[str, _PluginEntryPoint] = {} + discovered_entry_points: list[_PluginEntryPoint] = [] for distribution in metadata.distributions(): + distribution_name = distribution.metadata["Name"] for entry_point in distribution.entry_points: if entry_point.group != group: continue - if not allow_external_plugins and not is_internal_plugin_module( - entry_point.module + if not allow_external_plugins and not is_internal_plugin_distribution( + distribution_name ): continue - discovered = _PluginEntryPoint( - name=entry_point.name, - module_name=entry_point.module, - load=entry_point.load, - ) - registered = entry_points_by_name.get(discovered.name) - if registered is None: - entry_points_by_name[discovered.name] = discovered - elif registered.module_name != discovered.module_name: - raise PluginDiscoveryError( - f"Plugin entry point {discovered.name!r} is provided by both " - f"{registered.module_name!r} and {discovered.module_name!r}. " - "Entry point names must be unique." + 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()) @@ -90,12 +108,12 @@ def _load_plugin(entry_point: _PluginEntryPoint) -> PluginModule: 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_module(module_name: str) -> bool: - """Return whether a plugin module is owned by the Docling package.""" - return module_name == _INTERNAL_MODULE or module_name.startswith( - f"{_INTERNAL_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/tests/test_plugin_registry.py b/tests/test_plugin_registry.py index c4a8dbe575..ec0048940d 100644 --- a/tests/test_plugin_registry.py +++ b/tests/test_plugin_registry.py @@ -32,6 +32,10 @@ class _FakeEntryPoint: loader: Callable[[], ModuleType] group: str = "docling" + @property + def value(self) -> str: + return self.module + def load(self) -> ModuleType: return self.loader() @@ -39,6 +43,11 @@ def load(self) -> ModuleType: @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: @@ -122,6 +131,33 @@ def load_external_plugin() -> ModuleType: 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: @@ -191,6 +227,49 @@ def load_plugin(module_name: str) -> ModuleType: 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: @@ -281,6 +360,7 @@ def test_model_constructor_key_errors_are_not_reported_as_missing_models() -> No factory.register( _ConstructorKeyErrorModel, plugin_name="test-plugin", + plugin_package_name="test-package", plugin_module_name="test_plugin", ) From eddfb136b80deeac25845109ee547af78af87cff Mon Sep 17 00:00:00 2001 From: Hassan Raza Date: Sun, 26 Jul 2026 02:51:01 +0500 Subject: [PATCH 13/20] fix: validate plugin option contracts Signed-off-by: Hassan Raza --- docling/models/factories/base_factory.py | 46 +++++++++++++++++++----- tests/test_plugin_registry.py | 27 ++++++++++++++ 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/docling/models/factories/base_factory.py b/docling/models/factories/base_factory.py index 83ca5649c6..96bdb1a493 100644 --- a/docling/models/factories/base_factory.py +++ b/docling/models/factories/base_factory.py @@ -195,10 +195,10 @@ def _register_models( ) -> None: classes = self._classes.copy() options_by_kind = self._options_by_kind.copy() - registrations: list[tuple[type[BaseOptions], type[A]]] = [] + registrations: list[tuple[type[BaseOptions], str, type[A]]] = [] for model in models: - options_type = model.get_options_type() + 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( @@ -208,30 +208,58 @@ def _register_models( f"{model.__name__}", ) - registered_options = options_by_kind.get(options_type.kind) + 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 {options_type.kind!r} is already registered to " + f"model kind {kind!r} is already registered to " f"{registered_model.__name__}, so it cannot also register " f"{model.__name__}", ) classes[options_type] = model - options_by_kind[options_type.kind] = options_type - registrations.append((options_type, model)) + options_by_kind[kind] = options_type + registrations.append((options_type, kind, model)) - for options_type, model in registrations: + for options_type, kind, model in registrations: self._classes[options_type] = model - self._options_by_kind[options_type.kind] = options_type + self._options_by_kind[kind] = options_type self._meta[options_type] = FactoryMeta( - kind=options_type.kind, + kind=kind, plugin_name=plugin_name, package=plugin_package_name, module=plugin_module_name, ) + def _validate_options_type( + self, model: type[A], plugin_name: str + ) -> tuple[type[BaseOptions], str]: + 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", + ) + return options_type, kind + def _configuration_error( self, plugin_name: str, problem: str ) -> PluginConfigurationError: diff --git a/tests/test_plugin_registry.py b/tests/test_plugin_registry.py index ec0048940d..043a2ebff6 100644 --- a/tests/test_plugin_registry.py +++ b/tests/test_plugin_registry.py @@ -67,6 +67,10 @@ class _SecondOptions(BaseOptions): kind: ClassVar[str] = "shared-kind" +class _MissingKindOptions(BaseOptions): + pass + + class _FirstModel(_PluginModelBase): @classmethod def get_options_type(cls) -> type[BaseOptions]: @@ -88,6 +92,12 @@ 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 = ( @@ -368,6 +378,23 @@ def test_model_constructor_key_errors_are_not_reported_as_missing_models() -> No factory.create_instance(_FirstOptions()) +def test_plugin_options_must_declare_a_nonempty_kind() -> None: + factory = BaseFactory("ocr_engines", _PluginModelBase) + + with pytest.raises( + PluginConfigurationError, + match=r"test-plugin.*_MissingKindOptions.*non-empty.*kind", + ): + factory.register( + _MissingKindModel, + plugin_name="test-plugin", + plugin_package_name="test-package", + plugin_module_name="test_plugin", + ) + + assert factory.registered_kind == [] + + def test_plugin_hook_failures_identify_the_capability( monkeypatch: pytest.MonkeyPatch, ) -> None: From 5db248f67895d7d19267a15765885d4bc2bbfa8b Mon Sep 17 00:00:00 2001 From: Hassan Raza Date: Sun, 26 Jul 2026 02:51:41 +0500 Subject: [PATCH 14/20] test: exercise cli plugin discovery end to end Signed-off-by: Hassan Raza --- tests/test_cli.py | 41 +-------------------------------- tests/test_plugin_registry.py | 43 +++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 40 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index b0ea77ecd3..df16530fe6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,16 +12,14 @@ from PIL import Image from typer.testing import CliRunner -from docling.cli import main as cli_main from docling.cli.export_utils import _should_generate_export_images, _split_list +from docling.cli.main import app from docling.datamodel.accelerator_options import AcceleratorDevice from docling.datamodel.backend_options import ThreadedDoclingParseBackendOptions from docling.datamodel.base_models import InputFormat, OutputFormat from docling.datamodel.pipeline_options import OcrMode, PdfBackend, VlmPipelineOptions from docling.document_converter import PdfFormatOption -from docling.models.factories.base_factory import FactoryMeta -app = cli_main.app runner = CliRunner() PNG_BYTES = base64.b64decode( @@ -99,43 +97,6 @@ def test_cli_import_does_not_discover_plugins() -> None: assert completed.returncode == 0, completed.stderr -def test_cli_plugin_inventory_includes_picture_description( - monkeypatch: pytest.MonkeyPatch, -) -> None: - class _InventoryFactory: - def __init__(self, metadata: tuple[FactoryMeta, ...]) -> None: - self.registered_meta = dict(enumerate(metadata)) - - empty_factory = _InventoryFactory(()) - picture_factory = _InventoryFactory( - ( - FactoryMeta( - kind="external-picture", - plugin_name="picture-plugin", - module="third_party_docling.picture", - ), - ) - ) - monkeypatch.setattr(cli_main, "get_ocr_factory", lambda **_: empty_factory) - monkeypatch.setattr(cli_main, "get_layout_factory", lambda **_: empty_factory) - monkeypatch.setattr( - cli_main, "get_table_structure_factory", lambda **_: empty_factory - ) - monkeypatch.setattr( - cli_main, - "get_picture_description_factory", - lambda **_: picture_factory, - raising=False, - ) - - result = runner.invoke(app, ["convert", "--show-external-plugins"]) - - assert result.exit_code == 0 - assert "Available picture description engines" in result.output - assert "external-picture" in result.output - assert "picture-plugin" in result.output - - 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 index 043a2ebff6..4b7cf72a23 100644 --- a/tests/test_plugin_registry.py +++ b/tests/test_plugin_registry.py @@ -423,3 +423,46 @@ def ocr_engines() -> object: 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 From 09a1e7293e8f901109e3ffa96d22269be9b03dcb Mon Sep 17 00:00:00 2001 From: Hassan Raza Date: Sun, 26 Jul 2026 02:53:26 +0500 Subject: [PATCH 15/20] chore: drop the unused pluggy dependency Signed-off-by: Hassan Raza --- pyproject.toml | 1 - uv.lock | 2 -- 2 files changed, 3 deletions(-) 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/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" }, From 926b4c4a3006a090b602ffca0f51acc183696701 Mon Sep 17 00:00:00 2001 From: Hassan Raza Date: Sun, 26 Jul 2026 02:55:43 +0500 Subject: [PATCH 16/20] fix: reject nameless plugin providers Signed-off-by: Hassan Raza --- docling/models/factories/plugin_registry.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docling/models/factories/plugin_registry.py b/docling/models/factories/plugin_registry.py index c1aefd5984..6748d3e121 100644 --- a/docling/models/factories/plugin_registry.py +++ b/docling/models/factories/plugin_registry.py @@ -55,10 +55,21 @@ def _discover_entry_points( discovered_entry_points: list[_PluginEntryPoint] = [] for distribution in metadata.distributions(): - distribution_name = distribution.metadata["Name"] 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 ): From 1054997b34416e2db05824f3da1fd1105691bd8e Mon Sep 17 00:00:00 2001 From: Hassan Raza Date: Sun, 26 Jul 2026 03:00:23 +0500 Subject: [PATCH 17/20] refactor: preserve factory extension contracts Signed-off-by: Hassan Raza --- docling/models/factories/base_factory.py | 48 +++++++++++++------ docling/models/factories/layout_factory.py | 4 +- docling/models/factories/ocr_factory.py | 4 +- .../factories/picture_description_factory.py | 6 +-- docling/models/factories/table_factory.py | 6 +-- tests/test_plugin_registry.py | 24 ++++++++-- 6 files changed, 64 insertions(+), 28 deletions(-) diff --git a/docling/models/factories/base_factory.py b/docling/models/factories/base_factory.py index 96bdb1a493..eb04bd7068 100644 --- a/docling/models/factories/base_factory.py +++ b/docling/models/factories/base_factory.py @@ -33,22 +33,21 @@ class PluginHookError(RuntimeError): class FactoryMeta(BaseModel): kind: str plugin_name: str - package: str module: str + package: str | None = None class BaseFactory(Generic[A], metaclass=ABCMeta): default_plugin_name = "docling" + model_type: type[BaseModelWithOptions] | None = None def __init__( self, plugin_attr_name: PluginCapability, - model_type: type[A], plugin_name: str = default_plugin_name, ) -> None: self.plugin_name = plugin_name self.plugin_attr_name = plugin_attr_name - self.model_type = model_type self._classes: dict[type[BaseOptions], type[A]] = {} self._options_by_kind: dict[str, type[BaseOptions]] = {} @@ -101,15 +100,13 @@ def _err_msg_on_class_not_found(self, kind: str): def register( self, cls: type[A], - *, plugin_name: str, - plugin_package_name: str, plugin_module_name: str, ) -> None: self._register_models( (cls,), plugin_name=plugin_name, - plugin_package_name=plugin_package_name, + plugin_package_name=None, plugin_module_name=plugin_module_name, ) @@ -141,18 +138,32 @@ def load_from_plugins( 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.distribution_name, - plugin.module_name, + self._process_plugin( + config=config, + plugin_name=plugin.name, + plugin_package_name=plugin.distribution_name, + plugin_module_name=plugin.module_name, ) def process_plugin( self, config: object, plugin_name: str, - plugin_package_name: str, + plugin_module_name: str, + ) -> None: + self._process_plugin( + config=config, + plugin_name=plugin_name, + plugin_package_name=None, + plugin_module_name=plugin_module_name, + ) + + def _process_plugin( + self, + *, + config: object, + plugin_name: str, + plugin_package_name: str | None, plugin_module_name: str, ) -> None: if not isinstance(config, Mapping): @@ -170,11 +181,18 @@ def process_plugin( validated_models: list[type[A]] = [] for index, model in enumerate(models): - if not isinstance(model, type) or not issubclass(model, self.model_type): + if not isinstance(model, type) or ( + self.model_type is not None and not issubclass(model, self.model_type) + ): + expected_model = ( + self.model_type.__name__ + if self.model_type is not None + else "BaseModelWithOptions" + ) raise self._configuration_error( plugin_name, f"{self.plugin_attr_name!r} item {index} must be a " - f"{self.model_type.__name__} model class", + f"{expected_model} model class", ) validated_models.append(cast(type[A], model)) @@ -190,7 +208,7 @@ def _register_models( models: Sequence[type[A]], *, plugin_name: str, - plugin_package_name: str, + plugin_package_name: str | None, plugin_module_name: str, ) -> None: classes = self._classes.copy() diff --git a/docling/models/factories/layout_factory.py b/docling/models/factories/layout_factory.py index 406abef83f..cfbc911017 100644 --- a/docling/models/factories/layout_factory.py +++ b/docling/models/factories/layout_factory.py @@ -3,5 +3,7 @@ class LayoutFactory(BaseFactory[BaseLayoutModel]): + model_type = BaseLayoutModel + def __init__(self, plugin_name: str = BaseFactory.default_plugin_name) -> None: - super().__init__("layout_engines", BaseLayoutModel, plugin_name) + super().__init__("layout_engines", plugin_name) diff --git a/docling/models/factories/ocr_factory.py b/docling/models/factories/ocr_factory.py index 7193665eda..963e4077c9 100644 --- a/docling/models/factories/ocr_factory.py +++ b/docling/models/factories/ocr_factory.py @@ -7,5 +7,7 @@ class OcrFactory(BaseFactory[BaseOcrModel]): + model_type = BaseOcrModel + def __init__(self, plugin_name: str = BaseFactory.default_plugin_name) -> None: - super().__init__("ocr_engines", BaseOcrModel, plugin_name) + 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 d232db4390..79d5471423 100644 --- a/docling/models/factories/picture_description_factory.py +++ b/docling/models/factories/picture_description_factory.py @@ -7,7 +7,7 @@ class PictureDescriptionFactory(BaseFactory[PictureDescriptionBaseModel]): + model_type = PictureDescriptionBaseModel + def __init__(self, plugin_name: str = BaseFactory.default_plugin_name) -> None: - super().__init__( - "picture_description", PictureDescriptionBaseModel, plugin_name - ) + super().__init__("picture_description", plugin_name) diff --git a/docling/models/factories/table_factory.py b/docling/models/factories/table_factory.py index 619f271be3..94c1db0dde 100644 --- a/docling/models/factories/table_factory.py +++ b/docling/models/factories/table_factory.py @@ -3,7 +3,7 @@ class TableStructureFactory(BaseFactory[BaseTableStructureModel]): + model_type = BaseTableStructureModel + def __init__(self, plugin_name: str = BaseFactory.default_plugin_name) -> None: - super().__init__( - "table_structure_engines", BaseTableStructureModel, plugin_name - ) + super().__init__("table_structure_engines", plugin_name) diff --git a/tests/test_plugin_registry.py b/tests/test_plugin_registry.py index 4b7cf72a23..67734c3d5c 100644 --- a/tests/test_plugin_registry.py +++ b/tests/test_plugin_registry.py @@ -354,7 +354,7 @@ def ocr_engines() -> object: ) ) monkeypatch.setattr(metadata, "distributions", lambda: [distribution]) - factory = BaseFactory("ocr_engines", _PluginModelBase) + factory = BaseFactory[_PluginModelBase]("ocr_engines") with pytest.raises( PluginConfigurationError, @@ -366,11 +366,10 @@ def ocr_engines() -> object: def test_model_constructor_key_errors_are_not_reported_as_missing_models() -> None: - factory = BaseFactory("ocr_engines", _PluginModelBase) + factory = BaseFactory[_PluginModelBase]("ocr_engines") factory.register( _ConstructorKeyErrorModel, plugin_name="test-plugin", - plugin_package_name="test-package", plugin_module_name="test_plugin", ) @@ -378,8 +377,24 @@ def test_model_constructor_key_errors_are_not_reported_as_missing_models() -> No 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_plugin_options_must_declare_a_nonempty_kind() -> None: - factory = BaseFactory("ocr_engines", _PluginModelBase) + factory = BaseFactory[_PluginModelBase]("ocr_engines") with pytest.raises( PluginConfigurationError, @@ -388,7 +403,6 @@ def test_plugin_options_must_declare_a_nonempty_kind() -> None: factory.register( _MissingKindModel, plugin_name="test-plugin", - plugin_package_name="test-package", plugin_module_name="test_plugin", ) From e8cd7cc0c2a52f9f0aef78951b47230aa4692ed1 Mon Sep 17 00:00:00 2001 From: Hassan Raza Date: Sun, 26 Jul 2026 03:00:54 +0500 Subject: [PATCH 18/20] fix: skip unowned manual registrations in inventory Signed-off-by: Hassan Raza --- docling/cli/main.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docling/cli/main.py b/docling/cli/main.py index b878b71338..84bba9db2d 100644 --- a/docling/cli/main.py +++ b/docling/cli/main.py @@ -414,7 +414,9 @@ 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 is_internal_plugin_distribution(meta.package): + if meta.package is not None and not is_internal_plugin_distribution( + meta.package + ): table.add_row( f"[bold]{meta.kind}[/bold]", meta.plugin_name, From a645dc9e6af08ab0d62df4ae446f758ad6f5f5a9 Mon Sep 17 00:00:00 2001 From: Hassan Raza Date: Sun, 26 Jul 2026 03:03:33 +0500 Subject: [PATCH 19/20] fix: keep factory override dispatch Signed-off-by: Hassan Raza --- docling/cli/main.py | 19 ++++++-- docling/models/factories/base_factory.py | 62 ++++++++---------------- tests/test_plugin_registry.py | 52 ++++++++++++++++++++ 3 files changed, 87 insertions(+), 46 deletions(-) diff --git a/docling/cli/main.py b/docling/cli/main.py index 84bba9db2d..1f2e826a2d 100644 --- a/docling/cli/main.py +++ b/docling/cli/main.py @@ -152,7 +152,10 @@ get_table_structure_factory, ) from docling.models.factories.base_factory import BaseFactory -from docling.models.factories.plugin_registry import is_internal_plugin_distribution +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") @@ -407,6 +410,13 @@ def show_external_plugins_callback(value: bool): 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") @@ -414,13 +424,12 @@ 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 meta.package is not None and not is_internal_plugin_distribution( - meta.package - ): + 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.package, + package, ) rich.print(table) diff --git a/docling/models/factories/base_factory.py b/docling/models/factories/base_factory.py index eb04bd7068..80e07a929a 100644 --- a/docling/models/factories/base_factory.py +++ b/docling/models/factories/base_factory.py @@ -34,7 +34,6 @@ class FactoryMeta(BaseModel): kind: str plugin_name: str module: str - package: str | None = None class BaseFactory(Generic[A], metaclass=ABCMeta): @@ -106,7 +105,6 @@ def register( self._register_models( (cls,), plugin_name=plugin_name, - plugin_package_name=None, plugin_module_name=plugin_module_name, ) @@ -138,11 +136,10 @@ def load_from_plugins( f"Plugin {plugin.name!r} failed while running its " f"{self.plugin_attr_name!r} hook: {exc}" ) from exc - self._process_plugin( - config=config, - plugin_name=plugin.name, - plugin_package_name=plugin.distribution_name, - plugin_module_name=plugin.module_name, + self.process_plugin( + config, + plugin.name, + plugin.module_name, ) def process_plugin( @@ -150,21 +147,6 @@ def process_plugin( config: object, plugin_name: str, plugin_module_name: str, - ) -> None: - self._process_plugin( - config=config, - plugin_name=plugin_name, - plugin_package_name=None, - plugin_module_name=plugin_module_name, - ) - - def _process_plugin( - self, - *, - config: object, - plugin_name: str, - plugin_package_name: str | None, - plugin_module_name: str, ) -> None: if not isinstance(config, Mapping): raise self._configuration_error( @@ -196,24 +178,33 @@ def _process_plugin( ) validated_models.append(cast(type[A], model)) - self._register_models( - validated_models, - plugin_name=plugin_name, - plugin_package_name=plugin_package_name, - plugin_module_name=plugin_module_name, - ) + 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_package_name: str | None, 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, + ) + + def _validate_registrations( + self, models: Sequence[type[A]], plugin_name: str ) -> None: classes = self._classes.copy() options_by_kind = self._options_by_kind.copy() - registrations: list[tuple[type[BaseOptions], str, type[A]]] = [] for model in models: options_type, kind = self._validate_options_type(model, plugin_name) @@ -238,17 +229,6 @@ def _register_models( classes[options_type] = model options_by_kind[kind] = options_type - registrations.append((options_type, kind, model)) - - for options_type, kind, model in registrations: - self._classes[options_type] = model - self._options_by_kind[kind] = options_type - self._meta[options_type] = FactoryMeta( - kind=kind, - plugin_name=plugin_name, - package=plugin_package_name, - module=plugin_module_name, - ) def _validate_options_type( self, model: type[A], plugin_name: str diff --git a/tests/test_plugin_registry.py b/tests/test_plugin_registry.py index 67734c3d5c..5da5745df0 100644 --- a/tests/test_plugin_registry.py +++ b/tests/test_plugin_registry.py @@ -393,6 +393,58 @@ def test_base_factory_registration_contract_is_preserved() -> None: assert hook_factory.registered_kind == ["shared-kind"] +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") From 261cb6b6fe03ef1e6bee15202f2fb46330e2df46 Mon Sep 17 00:00:00 2001 From: Hassan Raza Date: Sun, 26 Jul 2026 13:12:13 +0500 Subject: [PATCH 20/20] perf: avoid repeated plugin contract checks Signed-off-by: Hassan Raza --- docling/models/factories/base_factory.py | 9 ++++++++- tests/test_plugin_registry.py | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/docling/models/factories/base_factory.py b/docling/models/factories/base_factory.py index 80e07a929a..5b292a282c 100644 --- a/docling/models/factories/base_factory.py +++ b/docling/models/factories/base_factory.py @@ -51,6 +51,7 @@ def __init__( 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]: @@ -233,6 +234,10 @@ def _validate_registrations( 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: @@ -256,7 +261,9 @@ def _validate_options_type( plugin_name, f"{options_type.__name__} must declare a non-empty string kind", ) - return options_type, kind + contract = (options_type, kind) + self._model_contracts[model] = contract + return contract def _configuration_error( self, plugin_name: str, problem: str diff --git a/tests/test_plugin_registry.py b/tests/test_plugin_registry.py index 5da5745df0..24621e9a2d 100644 --- a/tests/test_plugin_registry.py +++ b/tests/test_plugin_registry.py @@ -393,6 +393,27 @@ def test_base_factory_registration_contract_is_preserved() -> None: 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: