From 0ad7f5fc13dfa503f1cecb6c581fd0c1bb1145f7 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sat, 20 Dec 2025 16:27:16 -0800 Subject: [PATCH] feat: add dynamic nested access to plugin options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/ocrmypdf/_options.py | 105 +++++++++++++++++++++++++++++++++++++++ src/ocrmypdf/api.py | 5 ++ tests/test_api.py | 39 +++++++++++++++ 3 files changed, 149 insertions(+) diff --git a/src/ocrmypdf/_options.py b/src/ocrmypdf/_options.py index 2bf95e30..1ccafc02 100644 --- a/src/ocrmypdf/_options.py +++ b/src/ocrmypdf/_options.py @@ -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}'" + ) diff --git a/src/ocrmypdf/api.py b/src/ocrmypdf/api.py index d704be0e..67a8c612 100644 --- a/src/ocrmypdf/api.py +++ b/src/ocrmypdf/api.py @@ -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 diff --git a/tests/test_api.py b/tests/test_api.py index d6f34fcd..234244bf 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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