diff --git a/src/ocrmypdf/_plugin_registry.py b/src/ocrmypdf/_plugin_registry.py new file mode 100644 index 00000000..506efc75 --- /dev/null +++ b/src/ocrmypdf/_plugin_registry.py @@ -0,0 +1,73 @@ +# SPDX-FileCopyrightText: 2024 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 + +"""Plugin option registry for dynamic model composition.""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, Type + +from pydantic import BaseModel, create_model + +log = logging.getLogger(__name__) + + +class PluginOptionRegistry: + """Registry for plugin option models.""" + + def __init__(self): + self._option_models: Dict[str, Type[BaseModel]] = {} + self._extended_model_cache: Type[BaseModel] | None = None + + def register_option_model(self, namespace: str, model_class: Type[BaseModel]) -> None: + """Register a plugin's option model. + + Args: + namespace: The namespace for the plugin options (e.g., 'tesseract') + model_class: The Pydantic model class for the plugin options + """ + if namespace in self._option_models: + log.warning(f"Plugin option namespace '{namespace}' already registered, overriding") + + self._option_models[namespace] = model_class + # Clear cache when new models are registered + self._extended_model_cache = None + + log.debug(f"Registered plugin option model for namespace '{namespace}': {model_class.__name__}") + + def get_registered_models(self) -> Dict[str, Type[BaseModel]]: + """Get all registered plugin option models.""" + return self._option_models.copy() + + def get_extended_options_model(self, base_model: Type[BaseModel]) -> Type[BaseModel]: + """Create an extended options model that includes all registered plugin options. + + Args: + base_model: The base OCROptions model to extend + + Returns: + A new model class that includes the base model fields plus all plugin option fields + """ + if self._extended_model_cache is not None: + return self._extended_model_cache + + # Start with base model fields + model_fields = {} + + # Add plugin option models as nested fields + for namespace, model_class in self._option_models.items(): + model_fields[namespace] = (model_class, model_class()) + + # Create the extended model + self._extended_model_cache = create_model( + 'ExtendedOCROptions', + __base__=base_model, + **model_fields + ) + + return self._extended_model_cache + + def clear_cache(self) -> None: + """Clear the extended model cache.""" + self._extended_model_cache = None diff --git a/src/ocrmypdf/api.py b/src/ocrmypdf/api.py index 16978899..d8f92b00 100644 --- a/src/ocrmypdf/api.py +++ b/src/ocrmypdf/api.py @@ -79,7 +79,7 @@ def setup_plugin_infrastructure( This function handles: 1. Creating or validating the plugin manager 2. Calling plugin initialization hooks - 3. Setting up any future plugin registries (Phase 2) + 3. Setting up plugin option registry Args: plugins: List of plugin paths/names to load @@ -105,10 +105,22 @@ def setup_plugin_infrastructure( if not plugin_manager: plugin_manager = get_plugin_manager(plugins) - # Initialize plugins (this was missing in the API path) + # Initialize plugins plugin_manager.hook.initialize(plugin_manager=plugin_manager) # pylint: disable=no-member - # Future: Initialize plugin option registry here (Phase 2) + # Initialize plugin option registry + from ocrmypdf._plugin_registry import PluginOptionRegistry + registry = PluginOptionRegistry() + + # Let plugins register their option models + option_models = plugin_manager.hook.register_options() # pylint: disable=no-member + for plugin_options in option_models: + if plugin_options: # Skip None returns + for namespace, model_class in plugin_options.items(): + registry.register_option_model(namespace, model_class) + + # Store registry in plugin manager for later access + plugin_manager._option_registry = registry return plugin_manager diff --git a/src/ocrmypdf/builtin_plugins/ghostscript.py b/src/ocrmypdf/builtin_plugins/ghostscript.py index 550ac599..b7a5e63b 100644 --- a/src/ocrmypdf/builtin_plugins/ghostscript.py +++ b/src/ocrmypdf/builtin_plugins/ghostscript.py @@ -29,6 +29,12 @@ class GhostscriptOptions(BaseModel): pdfa_image_compression: Annotated[str, Field(description="PDF/A image compression method")] = "auto" +@hookimpl +def register_options(): + """Register Ghostscript option model.""" + return {'ghostscript': GhostscriptOptions} + + @hookimpl def add_options(parser): gs = parser.add_argument_group("Ghostscript", "Advanced control of Ghostscript") diff --git a/src/ocrmypdf/builtin_plugins/optimize.py b/src/ocrmypdf/builtin_plugins/optimize.py index e5e3ffc6..23a25fb3 100644 --- a/src/ocrmypdf/builtin_plugins/optimize.py +++ b/src/ocrmypdf/builtin_plugins/optimize.py @@ -33,6 +33,12 @@ class OptimizeOptions(BaseModel): jbig2_threshold: Annotated[float, Field(ge=0.4, le=0.9, description="JBIG2 symbol classification threshold")] = 0.85 +@hookimpl +def register_options(): + """Register optimization option model.""" + return {'optimize': OptimizeOptions} + + @hookimpl def add_options(parser): optimizing = parser.add_argument_group( diff --git a/src/ocrmypdf/builtin_plugins/tesseract_ocr.py b/src/ocrmypdf/builtin_plugins/tesseract_ocr.py index 1f8c94e4..dd10cbe7 100644 --- a/src/ocrmypdf/builtin_plugins/tesseract_ocr.py +++ b/src/ocrmypdf/builtin_plugins/tesseract_ocr.py @@ -38,6 +38,12 @@ class TesseractOptions(BaseModel): downsample_above: Annotated[int, Field(ge=100, le=32767, description="Downsample images larger than this pixel size")] = 32767 +@hookimpl +def register_options(): + """Register Tesseract option model.""" + return {'tesseract': TesseractOptions} + + @hookimpl def add_options(parser): tess = parser.add_argument_group("Tesseract", "Advanced control of Tesseract OCR") diff --git a/src/ocrmypdf/pluginspec.py b/src/ocrmypdf/pluginspec.py index e456e957..01b26c20 100644 --- a/src/ocrmypdf/pluginspec.py +++ b/src/ocrmypdf/pluginspec.py @@ -13,6 +13,7 @@ from pathlib import Path from typing import TYPE_CHECKING, NamedTuple import pluggy +from pydantic import BaseModel from ocrmypdf import Executor, PdfContext from ocrmypdf._options import OCROptions @@ -87,6 +88,28 @@ def add_options(parser: ArgumentParser) -> None: """ +@hookspec +def register_options() -> dict[str, type[BaseModel]]: + """Return plugin's option models keyed by namespace. + + This hook allows plugins to register their option models with the + plugin option registry. The returned dictionary should map namespace + strings to Pydantic model classes. + + Returns: + Dictionary mapping namespace strings to BaseModel classes + + Example: + @hookimpl + def register_options(): + return {'tesseract': TesseractOptions} + + Note: + This hook will be called from the main process during plugin + infrastructure setup, before child worker processes are forked. + """ + + @hookspec def check_options(options: OCROptions) -> None: """Called to ask the plugin to check all of the options.