feat: add PluginOptionRegistry for dynamic option models

Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
This commit is contained in:
James R. Barlow
2025-12-21 12:21:48 -08:00
co-authored by aider
parent 40f01d85ae
commit 6913ec7cb8
6 changed files with 129 additions and 3 deletions
+73
View File
@@ -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
+15 -3
View File
@@ -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
@@ -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")
+6
View File
@@ -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(
@@ -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")
+23
View File
@@ -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.