refactor: Create OCROptions model with Namespace compatibility

This commit introduces a new `OCROptions` class in `_options.py` that provides:
- Proper typing for OCRmyPDF options
- Pydantic validation
- Backward compatibility with `argparse.Namespace`
- Gradual migration support for the options system

Key changes:
- Added comprehensive option fields with type hints
- Implemented custom attribute access methods
- Created conversion methods between Namespace and OCROptions
- Updated type hints in multiple files to support both types
- Maintained existing validation logic

The new model allows for a step-by-step refactoring of the options handling throughout the project.

Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
This commit is contained in:
James R. Barlow
2025-12-13 11:40:57 -08:00
co-authored by aider
parent 8d715c4157
commit 1579337ebe
4 changed files with 290 additions and 34 deletions
+26 -8
View File
@@ -10,9 +10,11 @@ from argparse import Namespace
from collections.abc import Iterator
from copy import copy
from pathlib import Path
from typing import Union
from pluggy import PluginManager
from ocrmypdf._options import OCROptions
from ocrmypdf.pdfinfo import PdfInfo
from ocrmypdf.pdfinfo.info import PageInfo
@@ -20,25 +22,38 @@ from ocrmypdf.pdfinfo.info import PageInfo
class PdfContext:
"""Holds the context for a particular run of the pipeline."""
options: Namespace #: The specified options for processing this PDF.
options: Union[Namespace, OCROptions] #: The specified options for processing this PDF.
origin: Path #: The filename of the original input file.
pdfinfo: PdfInfo #: Detailed data for this PDF.
plugin_manager: PluginManager #: PluginManager for processing the current PDF.
def __init__(
self,
options: Namespace,
options: Union[Namespace, OCROptions],
work_folder: Path,
origin: Path,
pdfinfo: PdfInfo,
plugin_manager,
):
self.options = options
# Accept both types during transition
if isinstance(options, Namespace):
self.options = OCROptions.from_namespace(options)
self._legacy_options = options
else:
self.options = options
self._legacy_options = None
self.work_folder = work_folder
self.origin = origin
self.pdfinfo = pdfinfo
self.plugin_manager = plugin_manager
@property
def legacy_options(self) -> Namespace:
"""Provide Namespace for plugin compatibility."""
if self._legacy_options is None:
self._legacy_options = self.options.to_namespace()
return self._legacy_options
def get_path(self, name: str) -> Path:
"""Generate a ``Path`` for an intermediate file involved in processing.
@@ -67,7 +82,7 @@ class PageContext:
capable of their serializing themselves via ``__getstate__``.
"""
options: Namespace #: The specified options for processing this PDF.
options: Union[Namespace, OCROptions] #: The specified options for processing this PDF.
origin: Path #: The filename of the original input file.
pageno: int #: This page number (zero-based).
pageinfo: PageInfo #: Information on this page.
@@ -93,8 +108,11 @@ class PageContext:
state = self.__dict__.copy()
state['options'] = copy(self.options)
if not isinstance(state['options'].input_file, str | bytes | os.PathLike):
state['options'].input_file = 'stream'
if not isinstance(state['options'].output_file, str | bytes | os.PathLike):
state['options'].output_file = 'stream'
# Handle both OCROptions and Namespace
if hasattr(state['options'], 'input_file'):
if not isinstance(state['options'].input_file, str | bytes | os.PathLike):
state['options'].input_file = 'stream'
if hasattr(state['options'], 'output_file'):
if not isinstance(state['options'].output_file, str | bytes | os.PathLike):
state['options'].output_file = 'stream'
return state
+228
View File
@@ -0,0 +1,228 @@
# SPDX-FileCopyrightText: 2024 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Internal options model for OCRmyPDF."""
from __future__ import annotations
import os
from argparse import Namespace
from collections.abc import Iterable, Sequence
from copy import copy
from pathlib import Path
from typing import Any, BinaryIO, Union
from pydantic import BaseModel, Field, validator
from ocrmypdf._defaults import DEFAULT_LANGUAGE, DEFAULT_ROTATE_PAGES_THRESHOLD
PathOrIO = Union[BinaryIO, Path, str, bytes]
class OCROptions(BaseModel):
"""Internal options model that can masquerade as argparse.Namespace.
This model provides proper typing and validation while maintaining
compatibility with existing code that expects argparse.Namespace behavior.
"""
# I/O options
input_file: PathOrIO
output_file: PathOrIO
sidecar: PathOrIO | None = None
# Core OCR options
languages: list[str] = Field(default_factory=lambda: [DEFAULT_LANGUAGE])
output_type: str = 'pdfa'
force_ocr: bool = False
skip_text: bool = False
redo_ocr: bool = False
# Job control
jobs: int | None = None
use_threads: bool = True
progress_bar: bool = True
quiet: bool = False
verbose: int = 0
keep_temporary_files: bool = False
# Image processing
image_dpi: int | None = None
deskew: bool = False
clean: bool = False
clean_final: bool = False
rotate_pages: bool = False
remove_background: bool = False
remove_vectors: bool = False
oversample: int = 0
unpaper_args: str | None = None
# OCR behavior
skip_big: float | None = None
pages: str | None = None
invalidate_digital_signatures: bool = False
# Metadata
title: str | None = None
author: str | None = None
subject: str | None = None
keywords: str | None = None
# Optimization
optimize: int | None = None
jpg_quality: int | None = None
png_quality: int | None = None
jbig2_lossy: bool | None = None
jbig2_page_group_size: int | None = None
jbig2_threshold: float | None = None
# Advanced options
max_image_mpixels: float = 250.0
pdf_renderer: str = 'auto'
tesseract_config: Iterable[str] | None = None
tesseract_pagesegmode: int | None = None
tesseract_oem: int | None = None
tesseract_thresholding: int | None = None
tesseract_timeout: float | None = None
tesseract_non_ocr_timeout: float | None = None
tesseract_downsample_above: int | None = None
tesseract_downsample_large_images: bool | None = None
rotate_pages_threshold: float = DEFAULT_ROTATE_PAGES_THRESHOLD
pdfa_image_compression: str | None = None
color_conversion_strategy: str | None = None
user_words: os.PathLike | None = None
user_patterns: os.PathLike | None = None
fast_web_view: float | None = None
continue_on_soft_render_error: bool | None = None
# Plugin system
plugins: Sequence[Path | str] | None = None
# Store any extra attributes (for plugins and dynamic options)
_extra_attrs: dict[str, Any] = Field(default_factory=dict, exclude=True)
def __getattr__(self, name: str) -> Any:
"""Allow attribute access like argparse.Namespace."""
if name in self._extra_attrs:
return self._extra_attrs[name]
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
def __setattr__(self, name: str, value: Any) -> None:
"""Allow attribute setting like argparse.Namespace."""
if name.startswith('_') or name in self.__fields__:
super().__setattr__(name, value)
else:
if not hasattr(self, '_extra_attrs'):
super().__setattr__('_extra_attrs', {})
self._extra_attrs[name] = value
def __delattr__(self, name: str) -> None:
"""Allow attribute deletion like argparse.Namespace."""
if name in self.__fields__:
super().__delattr__(name)
elif name in self._extra_attrs:
del self._extra_attrs[name]
else:
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
@classmethod
def from_namespace(cls, ns: Namespace) -> OCROptions:
"""Convert argparse.Namespace to OCROptions."""
# Extract known fields
known_fields = {}
extra_attrs = {}
for key, value in vars(ns).items():
if key in cls.__fields__:
known_fields[key] = value
else:
extra_attrs[key] = value
instance = cls(**known_fields)
instance._extra_attrs = extra_attrs
return instance
def to_namespace(self) -> Namespace:
"""Convert back to argparse.Namespace for compatibility."""
ns = Namespace()
# Add pydantic fields
for field_name in self.__fields__:
field_value = getattr(self, field_name)
setattr(ns, field_name, field_value)
# Add extra attributes
for key, value in self._extra_attrs.items():
setattr(ns, key, value)
return ns
@validator('languages')
def validate_languages(cls, v):
"""Ensure languages list is not empty."""
if not v:
return [DEFAULT_LANGUAGE]
return v
@validator('output_type')
def validate_output_type(cls, v):
"""Validate output type is one of the allowed values."""
valid_types = {'pdfa', 'pdf', 'pdfa-1', 'pdfa-2', 'pdfa-3', 'none'}
if v not in valid_types:
raise ValueError(f"output_type must be one of {valid_types}")
return v
@validator('pdf_renderer')
def validate_pdf_renderer(cls, v):
"""Validate PDF renderer is one of the allowed values."""
valid_renderers = {'auto', 'hocr', 'sandwich', 'hocrdebug'}
if v not in valid_renderers:
raise ValueError(f"pdf_renderer must be one of {valid_renderers}")
return v
@validator('clean_final')
def validate_clean_final(cls, v, values):
"""If clean_final is True, also set clean to True."""
if v and 'clean' in values:
values['clean'] = True
return v
@validator('jobs')
def validate_jobs(cls, v):
"""Validate jobs is a reasonable number."""
if v is not None and (v < 0 or v > 256):
raise ValueError("jobs must be between 0 and 256")
return v
@validator('verbose')
def validate_verbose(cls, v):
"""Validate verbose level."""
if v < 0 or v > 2:
raise ValueError("verbose must be between 0 and 2")
return v
@validator('oversample')
def validate_oversample(cls, v):
"""Validate oversample DPI."""
if v < 0 or v > 5000:
raise ValueError("oversample must be between 0 and 5000")
return v
@validator('max_image_mpixels')
def validate_max_image_mpixels(cls, v):
"""Validate max image megapixels."""
if v < 0:
raise ValueError("max_image_mpixels must be non-negative")
return v
@validator('rotate_pages_threshold')
def validate_rotate_pages_threshold(cls, v):
"""Validate rotate pages threshold."""
if v < 0 or v > 1000:
raise ValueError("rotate_pages_threshold must be between 0 and 1000")
return v
class Config:
extra = "forbid" # Force use of _extra_attrs for unknown fields
arbitrary_types_allowed = True # Allow BinaryIO, Path, etc.
validate_assignment = True # Validate on attribute assignment
+23 -16
View File
@@ -15,6 +15,7 @@ from argparse import Namespace
from collections.abc import Sequence
from pathlib import Path
from shutil import copyfileobj
from typing import Union
import pikepdf
import PIL
@@ -22,6 +23,7 @@ from pluggy import PluginManager
from ocrmypdf._defaults import DEFAULT_LANGUAGE, DEFAULT_ROTATE_PAGES_THRESHOLD
from ocrmypdf._exec import unpaper
from ocrmypdf._options import OCROptions
from ocrmypdf.exceptions import (
BadArgsError,
InputFileError,
@@ -51,7 +53,7 @@ def check_platform() -> None:
def check_options_languages(
options: Namespace, ocr_engine_languages: list[str]
options: Union[Namespace, OCROptions], ocr_engine_languages: list[str]
) -> None:
if not options.languages:
options.languages = [DEFAULT_LANGUAGE]
@@ -81,7 +83,7 @@ def check_options_languages(
raise MissingDependencyError(msg)
def check_options_output(options: Namespace) -> None:
def check_options_output(options: Union[Namespace, OCROptions]) -> None:
if options.output_type == 'none' and options.output_file not in (os.devnull, '-'):
raise BadArgsError(
"Since you specified `--output-type none`, the output file "
@@ -90,7 +92,7 @@ def check_options_output(options: Namespace) -> None:
)
def set_lossless_reconstruction(options: Namespace) -> None:
def set_lossless_reconstruction(options: Union[Namespace, OCROptions]) -> None:
lossless_reconstruction = False
if not any(
(
@@ -110,7 +112,7 @@ def set_lossless_reconstruction(options: Namespace) -> None:
)
def check_options_sidecar(options: Namespace) -> None:
def check_options_sidecar(options: Union[Namespace, OCROptions]) -> None:
if options.sidecar == '\0':
if options.output_file == '-':
raise BadArgsError("--sidecar filename needed when output file is stdout.")
@@ -125,7 +127,7 @@ def check_options_sidecar(options: Namespace) -> None:
)
def check_options_preprocessing(options: Namespace) -> None:
def check_options_preprocessing(options: Union[Namespace, OCROptions]) -> None:
if options.clean_final:
options.clean = True
if options.unpaper_args and not options.clean:
@@ -191,7 +193,7 @@ def _pages_from_ranges(ranges: str) -> set[int]:
return set(pages)
def check_options_ocr_behavior(options: Namespace) -> None:
def check_options_ocr_behavior(options: Union[Namespace, OCROptions]) -> None:
exclusive_options = sum(
(1 if opt else 0)
for opt in (options.force_ocr, options.skip_text, options.redo_ocr)
@@ -202,7 +204,7 @@ def check_options_ocr_behavior(options: Namespace) -> None:
options.pages = _pages_from_ranges(options.pages)
def check_options_metadata(options: Namespace) -> None:
def check_options_metadata(options: Union[Namespace, OCROptions]) -> None:
docinfo = [options.title, options.author, options.keywords, options.subject]
for s in (m for m in docinfo if m):
for char in s:
@@ -215,13 +217,13 @@ def check_options_metadata(options: Namespace) -> None:
)
def check_options_pillow(options: Namespace) -> None:
def check_options_pillow(options: Union[Namespace, OCROptions]) -> None:
PIL.Image.MAX_IMAGE_PIXELS = int(options.max_image_mpixels * 1_000_000)
if PIL.Image.MAX_IMAGE_PIXELS == 0:
PIL.Image.MAX_IMAGE_PIXELS = None # type: ignore
def _check_plugin_invariant_options(options: Namespace) -> None:
def _check_plugin_invariant_options(options: Union[Namespace, OCROptions]) -> None:
check_platform()
check_options_metadata(options)
check_options_output(options)
@@ -232,18 +234,23 @@ def _check_plugin_invariant_options(options: Namespace) -> None:
check_options_pillow(options)
def _check_plugin_options(options: Namespace, plugin_manager: PluginManager) -> None:
plugin_manager.hook.check_options(options=options)
ocr_engine_languages = plugin_manager.hook.get_ocr_engine().languages(options)
def _check_plugin_options(options: Union[Namespace, OCROptions], plugin_manager: PluginManager) -> None:
# Convert to Namespace for plugin compatibility during transition
if isinstance(options, OCROptions):
legacy_options = options.to_namespace()
else:
legacy_options = options
plugin_manager.hook.check_options(options=legacy_options)
ocr_engine_languages = plugin_manager.hook.get_ocr_engine().languages(legacy_options)
check_options_languages(options, ocr_engine_languages)
def check_options(options: Namespace, plugin_manager: PluginManager) -> None:
def check_options(options: Union[Namespace, OCROptions], plugin_manager: PluginManager) -> None:
_check_plugin_invariant_options(options)
_check_plugin_options(options, plugin_manager)
def create_input_file(options: Namespace, work_folder: Path) -> tuple[Path, str]:
def create_input_file(options: Union[Namespace, OCROptions], work_folder: Path) -> tuple[Path, str]:
if options.input_file == '-':
# stdin
log.info('reading file from standard input')
@@ -288,7 +295,7 @@ def create_input_file(options: Namespace, work_folder: Path) -> tuple[Path, str]
raise InputFileError(msg) from e
def check_requested_output_file(options: Namespace) -> None:
def check_requested_output_file(options: Union[Namespace, OCROptions]) -> None:
if options.output_file == '-':
if sys.stdout.isatty():
raise BadArgsError(
@@ -306,7 +313,7 @@ def check_requested_output_file(options: Namespace) -> None:
def report_output_file_size(
options: Namespace,
options: Union[Namespace, OCROptions],
input_file: Path,
output_file: Path,
optimize_messages: Sequence[str] | None = None,
+13 -10
View File
@@ -54,6 +54,7 @@ from warnings import warn
import pluggy
from ocrmypdf._logging import PageNumberFilter
from ocrmypdf._options import OCROptions
from ocrmypdf._pipelines.hocr_to_ocr_pdf import run_hocr_to_ocr_pdf_pipeline
from ocrmypdf._pipelines.ocr import run_pipeline, run_pipeline_cli
from ocrmypdf._pipelines.pdf_to_hocr import run_hocr_pipeline
@@ -216,7 +217,7 @@ def _kwargs_to_cmdline(
def create_options(
*, input_file: PathOrIO, output_file: PathOrIO, parser: ArgumentParser, **kwargs
) -> Namespace:
) -> OCROptions:
"""Construct an options object from the input/output files and keyword arguments.
Args:
@@ -226,7 +227,7 @@ def create_options(
**kwargs: Keyword arguments.
Returns:
argparse.Namespace: A Namespace object containing the parsed arguments.
OCROptions: An options object containing the parsed arguments.
Raises:
TypeError: If the type of a keyword argument is not supported.
@@ -248,17 +249,19 @@ def create_options(
cmdline.append('stream://sidecar')
parser.enable_api_mode()
options = parser.parse_args(cmdline)
namespace_options = parser.parse_args(cmdline)
for keyword, val in deferred.items():
setattr(options, keyword, val)
setattr(namespace_options, keyword, val)
if options.input_file == 'stream://input_file':
options.input_file = input_file
if options.output_file == 'stream://output_file':
options.output_file = output_file
if options.sidecar == 'stream://sidecar':
options.sidecar = kwargs['sidecar']
if namespace_options.input_file == 'stream://input_file':
namespace_options.input_file = input_file
if namespace_options.output_file == 'stream://output_file':
namespace_options.output_file = output_file
if namespace_options.sidecar == 'stream://sidecar':
namespace_options.sidecar = kwargs['sidecar']
# Convert to OCROptions
options = OCROptions.from_namespace(namespace_options)
return options