Use plugin namespace access pattern throughout codebase

Migrate all code from flat accessor pattern (options.tesseract_timeout)
to the plugin namespace pattern (options.tesseract.timeout).

Key changes:
- Fix _get_plugin_options to raise AttributeError for unregistered
  namespaces instead of silently returning None
- Add _convert_value helper to convert PathLike to str for plugin
  model field compatibility
- Filter out _plugin_cache_* entries from JSON serialization to fix
  worker process serialization (test_simulate_oom_killer)
- Update tesseract_ocr.py, ghostscript.py, _validation_coordinator.py,
  and _pipelines/ocr.py to use options.tesseract.* and
  options.ghostscript.* accessors
- Update tests to use setup_plugin_infrastructure() for plugin
  model registration
This commit is contained in:
James R. Barlow
2025-12-23 02:02:21 -08:00
parent aec995aced
commit 9ebba91466
6 changed files with 91 additions and 73 deletions
+25 -13
View File
@@ -387,9 +387,15 @@ class OCROptions(BaseModel):
if serialized_value is not None: # Skip None values from properties
serializable_data[key] = serialized_value
# Add extra_attrs
# Add extra_attrs, excluding plugin cache entries (they'll be recreated lazily)
if self.extra_attrs:
serializable_data['_extra_attrs'] = _serialize_value(self.extra_attrs)
filtered_extra = {
k: v
for k, v in self.extra_attrs.items()
if not k.startswith('_plugin_cache_')
}
if filtered_extra:
serializable_data['_extra_attrs'] = _serialize_value(filtered_extra)
return json.dumps(serializable_data)
@@ -464,10 +470,19 @@ class OCROptions(BaseModel):
return self.extra_attrs[cache_key]
if namespace not in _plugin_option_models:
return None
raise AttributeError(
f"Plugin namespace '{namespace}' is not registered. "
f"Ensure setup_plugin_infrastructure() was called."
)
model_class = _plugin_option_models[namespace]
def _convert_value(value):
"""Convert value to be compatible with plugin model fields."""
if isinstance(value, os.PathLike):
return os.fspath(value)
return value
# Build kwargs from flat fields
kwargs = {}
for field_name in model_class.model_fields:
@@ -476,33 +491,30 @@ class OCROptions(BaseModel):
if flat_name in OCROptions.model_fields:
value = getattr(self, flat_name)
if value is not None:
kwargs[field_name] = value
kwargs[field_name] = _convert_value(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
kwargs[field_name] = _convert_value(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
kwargs[field_name] = _convert_value(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
kwargs[field_name] = _convert_value(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
instance = model_class(**kwargs)
self.extra_attrs[cache_key] = instance
return instance
def __getattr__(self, name: str) -> Any:
"""Support dynamic access to plugin option namespaces.
+1 -1
View File
@@ -126,7 +126,7 @@ def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]:
max_workers=max_workers,
progress_kwargs=dict(
total=len(context.pdfinfo),
desc='OCR' if options.tesseract_timeout > 0 else 'Image processing',
desc='OCR' if options.tesseract.timeout > 0 else 'Image processing',
unit='page',
disable=not options.progress_bar,
),
+26 -23
View File
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING:
import pluggy
from ocrmypdf._options import OCROptions
log = logging.getLogger(__name__)
@@ -18,11 +19,11 @@ log = logging.getLogger(__name__)
class ValidationCoordinator:
"""Coordinates validation across plugin models and core options."""
def __init__(self, plugin_manager: pluggy.PluginManager):
self.plugin_manager = plugin_manager
self.registry = getattr(plugin_manager, '_option_registry', None)
def validate_all_options(self, options: OCROptions) -> None:
"""Run comprehensive validation on all options.
@@ -36,41 +37,41 @@ class ValidationCoordinator:
"""
# Step 1: Plugin context validation
self._validate_plugin_contexts(options)
# Step 2: Cross-cutting validation
self._validate_cross_cutting_concerns(options)
def _validate_plugin_contexts(self, options: OCROptions) -> None:
"""Validate plugin options that require external context."""
# For now, we'll run the plugin validation directly since the models
# are still being integrated. This ensures the validation warnings
# and checks still work as expected.
# Run Tesseract validation
self._validate_tesseract_options(options)
# Run Optimize validation
# Run Optimize validation
self._validate_optimize_options(options)
def _validate_tesseract_options(self, options: OCROptions) -> None:
"""Validate Tesseract options."""
# Check pagesegmode warning
if options.tesseract_pagesegmode in (0, 2):
if options.tesseract.pagesegmode in (0, 2):
log.warning(
"The tesseract-pagesegmode you selected will disable OCR. "
"This may cause processing to fail."
)
# Check downsample consistency
if (
options.tesseract_downsample_above != 32767
and not options.tesseract_downsample_large_images
options.tesseract.downsample_above != 32767
and not options.tesseract.downsample_large_images
):
log.warning(
"The --tesseract-downsample-above argument will have no effect unless "
"--tesseract-downsample-large-images is also given."
)
# Check for blocked languages
from ocrmypdf.exceptions import BadArgsError
DENIED_LANGUAGES = {'equ', 'osd'}
@@ -81,20 +82,20 @@ class ValidationCoordinator:
f"{', '.join(DENIED_LANGUAGES & set(options.languages))}\n"
"Remove them from the -l/--language argument."
)
def _validate_optimize_options(self, options: OCROptions) -> None:
"""Validate optimization options."""
# Check optimization consistency
if options.optimize == 0 and any([
options.jbig2_lossy,
options.png_quality and options.png_quality > 0,
options.jbig2_lossy,
options.png_quality and options.png_quality > 0,
options.jpeg_quality and options.jpeg_quality > 0
]):
log.warning(
"The arguments --jbig2-lossy, --png-quality, and --jpeg-quality "
"will be ignored because --optimize=0."
)
def _validate_cross_cutting_concerns(self, options: OCROptions) -> None:
"""Validate cross-cutting concerns that span multiple plugins."""
# Validate mutually exclusive OCR options
@@ -103,7 +104,7 @@ class ValidationCoordinator:
)
if exclusive_options >= 2:
raise ValueError("Choose only one of --force-ocr, --skip-text, --redo-ocr.")
# Validate redo_ocr compatibility
if options.redo_ocr:
if options.deskew or options.clean_final or options.remove_background:
@@ -111,7 +112,7 @@ class ValidationCoordinator:
"--redo-ocr is not currently compatible with --deskew, "
"--clean-final, and --remove-background"
)
# Validate output type compatibility
if options.output_type == 'none' and str(options.output_file) not in (
os.devnull, '-'
@@ -121,11 +122,13 @@ class ValidationCoordinator:
f"{options.output_file} cannot be produced. Set the output file to "
"`-` to suppress this message."
)
# Validate PDF/A image compression compatibility
if (options.pdfa_image_compression and
options.pdfa_image_compression != 'auto' and
not options.output_type.startswith('pdfa')):
if (
options.ghostscript.pdfa_image_compression
and options.ghostscript.pdfa_image_compression != 'auto'
and not options.output_type.startswith('pdfa')
):
log.warning(
"--pdfa-image-compression argument only applies when "
"--output-type is one of 'pdfa', 'pdfa-1', or 'pdfa-2'"
+11 -6
View File
@@ -104,12 +104,17 @@ def check_options(options):
"use --force-ocr to discard existing text."
)
if options.color_conversion_strategy not in ghostscript.COLOR_CONVERSION_STRATEGIES:
if (
options.ghostscript.color_conversion_strategy
not in ghostscript.COLOR_CONVERSION_STRATEGIES
):
raise ValueError(
f"Invalid color conversion strategy: {options.color_conversion_strategy}"
f"Invalid color conversion strategy: "
f"{options.ghostscript.color_conversion_strategy}"
)
if options.pdfa_image_compression != 'auto' and not options.output_type.startswith(
'pdfa'
if (
options.ghostscript.pdfa_image_compression != 'auto'
and not options.output_type.startswith('pdfa')
):
log.warning(
"--pdfa-image-compression argument only applies when "
@@ -171,8 +176,8 @@ def generate_pdfa(
ghostscript.generate_pdfa(
pdf_pages=[pdfmark, *pdf_pages],
output_file=output_file,
compression=context.options.pdfa_image_compression,
color_conversion_strategy=context.options.color_conversion_strategy,
compression=context.options.ghostscript.pdfa_image_compression,
color_conversion_strategy=context.options.ghostscript.color_conversion_strategy,
pdf_version=pdf_version,
pdfa_part=pdfa_part,
progressbar_class=progressbar_class,
+25 -26
View File
@@ -10,7 +10,7 @@ import os
from typing import Annotated
from PIL import Image
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator, model_validator
from ocrmypdf import hookimpl
from ocrmypdf._exec import tesseract
@@ -21,7 +21,6 @@ from ocrmypdf.helpers import available_cpu_count, clamp
from ocrmypdf.imageops import calculate_downsample, downsample_image
from ocrmypdf.pluginspec import OcrEngine
from ocrmypdf.subprocess import check_external_program
from pydantic import field_validator, model_validator
log = logging.getLogger(__name__)
@@ -284,7 +283,7 @@ def check_options(options):
)
# Check version-specific feature compatibility
if not tesseract.has_thresholding() and options.tesseract_thresholding != 0:
if not tesseract.has_thresholding() and options.tesseract.thresholding != 0:
log.warning(
"The installed version of Tesseract does not support changes to its "
"thresholding method. The --tesseract-threshold argument will be "
@@ -311,8 +310,8 @@ def validate(pdfinfo, options):
log.debug("Using Tesseract OpenMP thread limit %d", tess_threads)
if (
options.tesseract_downsample_above != 32767
and not options.tesseract_downsample_large_images
options.tesseract.downsample_above != 32767
and not options.tesseract.downsample_large_images
):
log.warning(
"The --tesseract-downsample-above argument will have no effect unless "
@@ -328,10 +327,10 @@ def filter_ocr_image(page: PageContext, image: Image.Image) -> Image.Image:
or more than 2**31 bytes. This function resizes the image to fit within
those limits.
"""
threshold = min(page.options.tesseract_downsample_above, 32767)
options = page.options
if options.tesseract_downsample_large_images:
threshold = min(options.tesseract.downsample_above, 32767)
if options.tesseract.downsample_large_images:
size = calculate_downsample(
image, max_size=(threshold, threshold), max_bytes=(2**31) - 1
)
@@ -374,8 +373,8 @@ class TesseractOcrEngine(OcrEngine):
def get_orientation(input_file, options):
return tesseract.get_orientation(
input_file,
engine_mode=options.tesseract_oem,
timeout=options.tesseract_non_ocr_timeout,
engine_mode=options.tesseract.oem,
timeout=options.tesseract.non_ocr_timeout,
)
@staticmethod
@@ -383,8 +382,8 @@ class TesseractOcrEngine(OcrEngine):
return tesseract.get_deskew(
input_file,
languages=options.languages,
engine_mode=options.tesseract_oem,
timeout=options.tesseract_non_ocr_timeout,
engine_mode=options.tesseract.oem,
timeout=options.tesseract.non_ocr_timeout,
)
@staticmethod
@@ -394,13 +393,13 @@ class TesseractOcrEngine(OcrEngine):
output_hocr=output_hocr,
output_text=output_text,
languages=options.languages,
engine_mode=options.tesseract_oem,
tessconfig=options.tesseract_config,
timeout=options.tesseract_timeout,
pagesegmode=options.tesseract_pagesegmode,
thresholding=options.tesseract_thresholding,
user_words=options.user_words,
user_patterns=options.user_patterns,
engine_mode=options.tesseract.oem,
tessconfig=options.tesseract.config,
timeout=options.tesseract.timeout,
pagesegmode=options.tesseract.pagesegmode,
thresholding=options.tesseract.thresholding,
user_words=options.tesseract.user_words,
user_patterns=options.tesseract.user_patterns,
)
@staticmethod
@@ -410,13 +409,13 @@ class TesseractOcrEngine(OcrEngine):
output_pdf=output_pdf,
output_text=output_text,
languages=options.languages,
engine_mode=options.tesseract_oem,
tessconfig=options.tesseract_config,
timeout=options.tesseract_timeout,
pagesegmode=options.tesseract_pagesegmode,
thresholding=options.tesseract_thresholding,
user_words=options.user_words,
user_patterns=options.user_patterns,
engine_mode=options.tesseract.oem,
tessconfig=options.tesseract.config,
timeout=options.tesseract.timeout,
pagesegmode=options.tesseract.pagesegmode,
thresholding=options.tesseract.thresholding,
user_words=options.tesseract.user_words,
user_patterns=options.tesseract.user_patterns,
)
+3 -4
View File
@@ -14,8 +14,7 @@ from ocrmypdf import _validation as vd
from ocrmypdf._concurrent import NullProgressBar, SerialExecutor
from ocrmypdf._exec.tesseract import TesseractVersion
from ocrmypdf._options import OCROptions
from ocrmypdf._plugin_manager import get_plugin_manager
from ocrmypdf.api import create_options
from ocrmypdf.api import create_options, setup_plugin_infrastructure
from ocrmypdf.cli import get_parser
from ocrmypdf.exceptions import BadArgsError, MissingDependencyError
from ocrmypdf.pdfinfo import PdfInfo
@@ -27,7 +26,7 @@ def make_opts_pm(input_file='a.pdf', output_file='b.pdf', language='eng', **kwar
if language is not None:
kwargs['language'] = language
parser = get_parser()
pm = get_plugin_manager(kwargs.get('plugins', []))
pm = setup_plugin_infrastructure(plugins=kwargs.get('plugins', []))
pm.hook.add_options(parser=parser) # pylint: disable=no-member
return (
create_options(
@@ -268,7 +267,7 @@ def test_optional_program_recommended(caplog):
def test_pagesegmode_warning(caplog):
opts = make_opts(tesseract_pagesegmode='0')
plugin_manager = get_plugin_manager(opts.plugins)
plugin_manager = setup_plugin_infrastructure(plugins=opts.plugins or [])
vd.check_options(opts, plugin_manager)
assert 'disable OCR' in caplog.text