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:
co-authored by
Claude Opus 4.5
parent
47cea37487
commit
0ad7f5fc13
@@ -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}'"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user