feat: add dynamic nested access to plugin options

Completes Phase 5 of the CLI refactoring plan by enabling nested
plugin option access (e.g., options.tesseract.timeout) alongside
the legacy flat access (options.tesseract_timeout).

Changes:
- Add module-level plugin option model registry in _options.py
- Add __getattr__ to OCROptions for dynamic namespace access
- Register plugin models in setup_plugin_infrastructure()
- Add test for nested plugin option access

Plugin option instances are lazily created from flat field values
and cached for subsequent access.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
James R. Barlow
2025-12-21 12:21:48 -08:00
co-authored by Claude Opus 4.5
parent 47cea37487
commit 0ad7f5fc13
3 changed files with 149 additions and 0 deletions
+105
View File
@@ -25,6 +25,10 @@ from ocrmypdf.helpers import monotonic
log = logging.getLogger(__name__)
# Module-level registry for plugin option models
# This is populated by setup_plugin_infrastructure() after plugins are loaded
_plugin_option_models: dict[str, type] = {}
PathOrIO = BinaryIO | IOBase | Path | str | bytes
@@ -179,6 +183,7 @@ class OCROptions(BaseModel):
default_factory=dict, exclude=True, alias='_extra_attrs'
)
@field_validator('languages')
@classmethod
def validate_languages(cls, v):
@@ -424,3 +429,103 @@ class OCROptions(BaseModel):
arbitrary_types_allowed=True, # Allow BinaryIO, Path, etc.
validate_assignment=True, # Validate on attribute assignment
)
@classmethod
def register_plugin_models(cls, models: dict[str, type]) -> None:
"""Register plugin option model classes for nested access.
Args:
models: Dictionary mapping namespace to model class
"""
global _plugin_option_models
_plugin_option_models.update(models)
def _get_plugin_options(self, namespace: str) -> Any:
"""Get or create a plugin options instance for the given namespace.
This method creates plugin option instances lazily from flat field values.
Args:
namespace: The plugin namespace (e.g., 'tesseract', 'optimize')
Returns:
An instance of the plugin's option model, or None if not registered
"""
# Use extra_attrs to cache plugin option instances
cache_key = f'_plugin_cache_{namespace}'
if cache_key in self.extra_attrs:
return self.extra_attrs[cache_key]
if namespace not in _plugin_option_models:
return None
model_class = _plugin_option_models[namespace]
# Build kwargs from flat fields
kwargs = {}
for field_name in model_class.model_fields:
# Try namespace_field pattern first (e.g., tesseract_timeout)
flat_name = f"{namespace}_{field_name}"
if flat_name in OCROptions.model_fields:
value = getattr(self, flat_name)
if value is not None:
kwargs[field_name] = value
# Also check direct field name (for fields like jbig2_lossy)
elif field_name in OCROptions.model_fields:
value = getattr(self, field_name)
if value is not None:
kwargs[field_name] = value
# Check for special mappings
elif namespace == 'optimize' and field_name == 'level':
# 'optimize' field maps to 'level' in OptimizeOptions
if 'optimize' in OCROptions.model_fields:
value = getattr(self, 'optimize')
if value is not None:
kwargs[field_name] = value
elif namespace == 'optimize' and field_name == 'jpeg_quality':
# jpg_quality maps to jpeg_quality
if 'jpg_quality' in OCROptions.model_fields:
value = getattr(self, 'jpg_quality')
if value is not None:
kwargs[field_name] = value
# Create and cache the plugin options instance
try:
instance = model_class(**kwargs)
self.extra_attrs[cache_key] = instance
return instance
except Exception:
return None
def __getattr__(self, name: str) -> Any:
"""Support dynamic access to plugin option namespaces.
This allows accessing plugin options like:
options.tesseract.timeout
options.optimize.level
Args:
name: Attribute name
Returns:
Plugin options instance if name is a registered namespace,
otherwise raises AttributeError
"""
# Check if this is a plugin namespace
if name.startswith('_'):
# Private attributes should not trigger plugin lookup
raise AttributeError(
f"'{type(self).__name__}' object has no attribute '{name}'"
)
# Try to get plugin options for this namespace
if name in _plugin_option_models:
return self._get_plugin_options(name)
# Check extra_attrs
if 'extra_attrs' in self.__dict__ and name in self.extra_attrs:
return self.extra_attrs[name]
raise AttributeError(
f"'{type(self).__name__}' object has no attribute '{name}'"
)
+5
View File
@@ -115,10 +115,15 @@ def setup_plugin_infrastructure(
# Let plugins register their option models
option_models = plugin_manager.hook.register_options() # pylint: disable=no-member
all_plugin_models: dict[str, type] = {}
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)
all_plugin_models[namespace] = model_class
# Register plugin models with OCROptions for dynamic nested access
OCROptions.register_plugin_models(all_plugin_models)
# Store registry in plugin manager for later access
plugin_manager._option_registry = registry
+39
View File
@@ -103,3 +103,42 @@ def test_hocr_result_pickle():
orientation_correction=180,
)
assert result == pickle.loads(pickle.dumps(result))
def test_nested_plugin_option_access():
"""Test that plugin options can be accessed via nested namespaces."""
from ocrmypdf._options import OCROptions
from ocrmypdf.api import setup_plugin_infrastructure
# Set up plugin infrastructure to register plugin models
setup_plugin_infrastructure()
# Create options with tesseract settings
options = OCROptions(
input_file='test.pdf',
output_file='output.pdf',
tesseract_timeout=120.0,
tesseract_oem=1,
optimize=2,
jbig2_lossy=True,
)
# Test flat access still works
assert options.tesseract_timeout == 120.0
assert options.tesseract_oem == 1
assert options.optimize == 2
assert options.jbig2_lossy is True
# Test nested access for tesseract
tesseract = options.tesseract
assert tesseract is not None
assert tesseract.timeout == 120.0
assert tesseract.oem == 1
# Test nested access for ghostscript
ghostscript = options.ghostscript
assert ghostscript is not None
assert ghostscript.color_conversion_strategy == "LeaveColorUnchanged"
# Test that cached instances are returned
assert options.tesseract is tesseract