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:
+25
-13
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user