From 5251e21f7e9d0f4089115b178a01884c05159bd5 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sun, 7 Dec 2025 14:14:35 -0800 Subject: [PATCH] refactor: migrate OCROptions validators to Pydantic V2 Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) --- src/ocrmypdf/_options.py | 65 ++++++++++++++++++++++++++-------------- 1 file changed, 43 insertions(+), 22 deletions(-) diff --git a/src/ocrmypdf/_options.py b/src/ocrmypdf/_options.py index 33565b6d..2688abdd 100644 --- a/src/ocrmypdf/_options.py +++ b/src/ocrmypdf/_options.py @@ -11,12 +11,13 @@ from collections.abc import Iterable, Sequence from copy import copy from pathlib import Path from typing import Any, BinaryIO, Union +from io import IOBase -from pydantic import BaseModel, Field, validator +from pydantic import BaseModel, Field, field_validator, ConfigDict, model_validator from ocrmypdf._defaults import DEFAULT_LANGUAGE, DEFAULT_ROTATE_PAGES_THRESHOLD -PathOrIO = Union[BinaryIO, Path, str, bytes] +PathOrIO = Union[BinaryIO, IOBase, Path, str, bytes] class OCROptions(BaseModel): @@ -109,7 +110,7 @@ class OCROptions(BaseModel): def __setattr__(self, name: str, value: Any) -> None: """Allow attribute setting like argparse.Namespace.""" - if name.startswith('_') or name in self.__fields__: + if name.startswith('_') or name in self.model_fields: super().__setattr__(name, value) else: if not hasattr(self, 'extra_attrs'): @@ -118,7 +119,7 @@ class OCROptions(BaseModel): def __delattr__(self, name: str) -> None: """Allow attribute deletion like argparse.Namespace.""" - if name in self.__fields__: + if name in self.model_fields: super().__delattr__(name) elif name in self.extra_attrs: del self.extra_attrs[name] @@ -133,7 +134,7 @@ class OCROptions(BaseModel): extra_attrs = {} for key, value in vars(ns).items(): - if key in cls.__fields__: + if key in cls.model_fields: known_fields[key] = value else: extra_attrs[key] = value @@ -147,7 +148,7 @@ class OCROptions(BaseModel): ns = Namespace() # Add pydantic fields - for field_name in self.__fields__: + for field_name in self.model_fields: field_value = getattr(self, field_name) setattr(ns, field_name, field_value) @@ -157,14 +158,16 @@ class OCROptions(BaseModel): return ns - @validator('languages') + @field_validator('languages') + @classmethod def validate_languages(cls, v): """Ensure languages list is not empty.""" if not v: return [DEFAULT_LANGUAGE] return v - @validator('output_type') + @field_validator('output_type') + @classmethod 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'} @@ -172,7 +175,8 @@ class OCROptions(BaseModel): raise ValueError(f"output_type must be one of {valid_types}") return v - @validator('pdf_renderer') + @field_validator('pdf_renderer') + @classmethod def validate_pdf_renderer(cls, v): """Validate PDF renderer is one of the allowed values.""" valid_renderers = {'auto', 'hocr', 'sandwich', 'hocrdebug'} @@ -180,49 +184,66 @@ class OCROptions(BaseModel): raise ValueError(f"pdf_renderer must be one of {valid_renderers}") return v - @validator('clean_final') - def validate_clean_final(cls, v, values): + @field_validator('clean_final') + @classmethod + def validate_clean_final(cls, v, info): """If clean_final is True, also set clean to True.""" - if v and 'clean' in values: - values['clean'] = True + if v and hasattr(info, 'data') and 'clean' in info.data: + info.data['clean'] = True return v - @validator('jobs') + @field_validator('jobs') + @classmethod 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') + @field_validator('verbose') + @classmethod 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') + @field_validator('oversample') + @classmethod 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') + @field_validator('max_image_mpixels') + @classmethod 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') + @field_validator('rotate_pages_threshold') + @classmethod 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 + @model_validator(mode='before') + @classmethod + def handle_special_cases(cls, data): + """Handle special cases for API compatibility.""" + if isinstance(data, dict): + # For hOCR API, output_file might not be present + if 'output_folder' in data and 'output_file' not in data: + data['output_file'] = '/dev/null' # Placeholder + return data + + model_config = ConfigDict( + 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 + )