From a9addd5e18fd559555a8689735d99044ea6a2de1 Mon Sep 17 00:00:00 2001 From: John Costa Date: Wed, 8 Apr 2026 21:01:20 -0700 Subject: [PATCH 1/8] DEV: Align mypy Makefile target with strict mode Replace individual mypy flags with `strict = true` in pyproject.toml and fix all resulting type errors across the codebase. Changes: - pyproject.toml: use `strict = true`, keep test overrides from #3660 - .pre-commit-config.yaml: add cryptography and pycryptodome as additional_dependencies for mypy hook - Makefile: simplify mypy target (config now in pyproject.toml) - Fix type annotations in ~20 source files - Fix latent bug: `data[-1] != b"\n"` compared int to bytes (always True), changed to `data[-1:] != b"\n"` for correct bytes comparison - Add tests for AnnotationDictionary.flags, Destination defaults, ArrayObject._to_lst --- .pre-commit-config.yaml | 3 ++ Makefile | 2 +- pypdf/_doc_common.py | 4 +- pypdf/_page.py | 14 +++--- pypdf/_page_labels.py | 2 +- pypdf/_reader.py | 4 +- .../_layout_mode/_text_state_manager.py | 4 +- pypdf/_utils.py | 5 +- pypdf/annotations/_base.py | 2 +- pypdf/filters.py | 8 +-- pypdf/generic/_base.py | 21 +++++--- pypdf/generic/_data_structures.py | 50 ++++++++++--------- pypdf/generic/_files.py | 4 +- pypdf/generic/_image_inline.py | 3 +- pypdf/generic/_link.py | 2 +- pypdf/generic/_rectangle.py | 14 ++++-- pypdf/generic/_viewerpref.py | 3 +- pypdf/xmp.py | 7 +-- pyproject.toml | 10 +--- tests/generic/test_data_structures.py | 21 +++++++- tests/test_annotations.py | 16 +++++- tests/test_generic.py | 10 +++- 22 files changed, 133 insertions(+), 76 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c38e2e817f..6c14baf8bc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -35,3 +35,6 @@ repos: hooks: - id: mypy files: ^pypdf/.* + additional_dependencies: + - cryptography + - pycryptodome diff --git a/Makefile b/Makefile index e49444a11c..85d2371ef3 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,7 @@ benchmark: pytest tests/bench.py mypy: - mypy pypdf --ignore-missing-imports --check-untyped --strict + mypy pypdf ruff: ruff check pypdf tests make_release.py diff --git a/pypdf/_doc_common.py b/pypdf/_doc_common.py index bfc58c76c2..aef7b41502 100644 --- a/pypdf/_doc_common.py +++ b/pypdf/_doc_common.py @@ -92,7 +92,7 @@ def convert_to_int(d: bytes, size: int) -> Union[int, tuple[Any, ...]]: raise PdfReadError("Invalid size in convert_to_int") d = b"\x00\x00\x00\x00\x00\x00\x00\x00" + d d = d[-8:] - return struct.unpack(">q", d)[0] + return cast(int, struct.unpack(">q", d)[0]) class DocumentInformation(DictionaryObject): @@ -382,7 +382,7 @@ def recursive_call( node: DictionaryObject, mi: int ) -> tuple[Optional[PdfObject], int]: ma = cast(int, node.get("/Count", 1)) # default 1 for /Page types - if node["/Type"] == "/Page": + if node["/Type"] == "/Page": # type: ignore[comparison-overlap] if page_number == mi: return node, -1 return None, mi + 1 diff --git a/pypdf/_page.py b/pypdf/_page.py index 383be98f4d..a0dd49ed16 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -540,7 +540,7 @@ def user_unit(self) -> float: space unit is 1/72 inch, and a value of 3 means that a user space unit is 3/72 inch. """ - return self.get(PG.USER_UNIT, 1) + return cast(float, self.get(PG.USER_UNIT, 1)) @staticmethod def create_blank_page( @@ -947,15 +947,15 @@ def _add_transformation_matrix( ctm: CompressedTransformationMatrix, ) -> ContentStream: """Add transformation matrix at the beginning of the given contents stream.""" - contents = ContentStream(contents, pdf) - contents.operations.insert( + content_stream = ContentStream(contents, pdf) + content_stream.operations.insert( 0, - [ + ( [FloatObject(x) for x in ctm], b"cm", - ], + ), ) - return contents + return content_stream def _get_contents_as_bytes(self) -> Optional[bytes]: """ @@ -1627,7 +1627,7 @@ def page_number(self) -> Optional[int]: return None try: lst = self.indirect_reference.pdf.pages - return lst.index(self) + return int(lst.index(self)) except ValueError: return None diff --git a/pypdf/_page_labels.py b/pypdf/_page_labels.py index 7a43582260..d47313f42d 100644 --- a/pypdf/_page_labels.py +++ b/pypdf/_page_labels.py @@ -156,7 +156,7 @@ def get_label_from_nums(dictionary_object: DictionaryObject, index: int) -> str: if not isinstance(value, dict): return str(index + 1) # Fallback start = value.get("/St", 1) - prefix = value.get("/P", "") + prefix = cast(str, value.get("/P", "")) mapping_function = m[value.get("/S")] return prefix + mapping_function(index - start_index + start) diff --git a/pypdf/_reader.py b/pypdf/_reader.py index cc6c7fff73..db9279f405 100644 --- a/pypdf/_reader.py +++ b/pypdf/_reader.py @@ -730,7 +730,7 @@ def _basic_validation(self, stream: StreamType) -> None: f"PDF starts with '{header_byte.decode('utf8')}', " "but '%PDF-' expected" ) - logger_warning(f"invalid pdf header: {header_byte}", __name__) + logger_warning(f"invalid pdf header: {header_byte!r}", __name__) stream.seek(0, os.SEEK_END) def _find_eof_marker(self, stream: StreamType) -> None: @@ -1005,7 +1005,7 @@ def _read_xref(self, stream: StreamType) -> Optional[int]: ) stream.seek(p, 0) if "/Prev" in new_trailer: - return new_trailer["/Prev"] + return cast(int, new_trailer["/Prev"]) return None def _read_xref_other_error( diff --git a/pypdf/_text_extraction/_layout_mode/_text_state_manager.py b/pypdf/_text_extraction/_layout_mode/_text_state_manager.py index 947cdd4165..03f1c139b7 100644 --- a/pypdf/_text_extraction/_layout_mode/_text_state_manager.py +++ b/pypdf/_text_extraction/_layout_mode/_text_state_manager.py @@ -132,7 +132,7 @@ def raw_transform( _d: float = 1.0, _e: float = 0.0, _f: float = 0.0, - ) -> dict[int, float]: + ) -> TextStateManagerDictType: """Only a/b/c/d/e/f matrix params""" return dict(zip(range(6), map(float, (_a, _b, _c, _d, _e, _f)))) @@ -148,7 +148,7 @@ def new_transform( is_render: bool = False, ) -> TextStateManagerDictType: """Standard a/b/c/d/e/f matrix params + 'is_text' and 'is_render' keys""" - result: Any = TextStateManager.raw_transform(_a, _b, _c, _d, _e, _f) + result = TextStateManager.raw_transform(_a, _b, _c, _d, _e, _f) result.update({"is_text": is_text, "is_render": is_render}) return result diff --git a/pypdf/_utils.py b/pypdf/_utils.py index 0396ca6f32..4834186fb7 100644 --- a/pypdf/_utils.py +++ b/pypdf/_utils.py @@ -72,6 +72,7 @@ ] StreamType = IO[Any] +BinaryStreamType = IO[bytes] StrByteType = Union[str, StreamType] @@ -181,7 +182,7 @@ def read_until_whitespace(stream: StreamType, maxchars: Optional[int] = None) -> return txt -def read_non_whitespace(stream: StreamType) -> bytes: +def read_non_whitespace(stream: BinaryStreamType) -> bytes: """ Find and read the next non-whitespace character (ignores whitespace). @@ -282,7 +283,7 @@ def read_until_regex(stream: StreamType, regex: Pattern[bytes]) -> bytes: return b"".join(parts) -def read_block_backwards(stream: StreamType, to_read: int) -> bytes: +def read_block_backwards(stream: BinaryStreamType, to_read: int) -> bytes: """ Given a stream at position X, read a block of size to_read ending at position X. diff --git a/pypdf/annotations/_base.py b/pypdf/annotations/_base.py index dc065e1595..7731b18682 100644 --- a/pypdf/annotations/_base.py +++ b/pypdf/annotations/_base.py @@ -19,7 +19,7 @@ def __init__(self) -> None: @property def flags(self) -> AnnotationFlag: - return self.get(NameObject("/F"), AnnotationFlag(0)) + return AnnotationFlag(self.get(NameObject("/F"), 0)) @flags.setter def flags(self, value: AnnotationFlag) -> None: diff --git a/pypdf/filters.py b/pypdf/filters.py index b6dc515c5e..3d6f45cf33 100644 --- a/pypdf/filters.py +++ b/pypdf/filters.py @@ -216,11 +216,11 @@ def decode( if predictor == 2: row_length -= 1 # remove the predictor byte bpp = row_length // columns - str_data = bytearray(str_data) - for i in range(len(str_data)): + str_data_mut = bytearray(str_data) + for i in range(len(str_data_mut)): if i % row_length >= bpp: - str_data[i] = (str_data[i] + str_data[i - bpp]) % 256 - str_data = bytes(str_data) + str_data_mut[i] = (str_data_mut[i] + str_data_mut[i - bpp]) % 256 + str_data = bytes(str_data_mut) # PNG prediction: elif 10 <= predictor <= 15: str_data = FlateDecode._decode_png_prediction( diff --git a/pypdf/generic/_base.py b/pypdf/generic/_base.py index bf6f66c53b..975e2e12b9 100644 --- a/pypdf/generic/_base.py +++ b/pypdf/generic/_base.py @@ -136,8 +136,8 @@ def clone( ) def _reference_clone( - self, clone: Any, pdf_dest: PdfWriterProtocol, force_duplicate: bool = False - ) -> PdfObjectProtocol: + self, clone: "PdfObject", pdf_dest: PdfWriterProtocol, force_duplicate: bool = False + ) -> "PdfObject": """ Reference the object within the _objects of pdf_dest only if indirect_reference attribute exists (which means the objects was @@ -153,7 +153,11 @@ def _reference_clone( """ try: - if not force_duplicate and clone.indirect_reference.pdf == pdf_dest: + if ( + not force_duplicate + and clone.indirect_reference is not None + and clone.indirect_reference.pdf == pdf_dest + ): return clone except Exception: pass @@ -182,7 +186,7 @@ def _reference_clone( obj = pdf_dest.get_object( pdf_dest._id_translated[id(ind.pdf)][ind.idnum] ) - assert obj is not None + assert isinstance(obj, PdfObject), "mypy" return obj pdf_dest._id_translated[id(ind.pdf)][ind.idnum] = i try: @@ -252,6 +256,8 @@ def __hash__(self) -> int: class BooleanObject(PdfObject): + value: bool + def __init__(self, value: Any) -> None: self.value = value @@ -370,7 +376,7 @@ def clone( dup = pdf_dest._add_object( obj.clone(pdf_dest, force_duplicate, ignore_fields) ) - assert dup is not None, "mypy" + assert isinstance(dup, PdfObject), "mypy" assert dup.indirect_reference is not None, "mypy" return dup.indirect_reference @@ -379,7 +385,8 @@ def indirect_reference(self) -> "IndirectObject": # type: ignore[override] return self def get_object(self) -> Optional["PdfObject"]: - return self.pdf.get_object(self) + obj: Optional[PdfObject] = self.pdf.get_object(self) + return obj def __deepcopy__(self, memo: Any) -> "IndirectObject": return IndirectObject(self.idnum, self.generation, self.pdf) @@ -517,7 +524,7 @@ def hash_bin(self) -> int: return hash((self.__class__, self.as_numeric)) def myrepr(self) -> str: - if self == 0: + if self == 0: # type: ignore[comparison-overlap] return "0.0" nb = FLOAT_WRITE_PRECISION - int(log10(abs(self))) return f"{self:.{max(1, nb)}f}".rstrip("0").rstrip(".") diff --git a/pypdf/generic/_data_structures.py b/pypdf/generic/_data_structures.py index dc03fabbf1..3f1b7cb2e1 100644 --- a/pypdf/generic/_data_structures.py +++ b/pypdf/generic/_data_structures.py @@ -46,6 +46,7 @@ from .._protocols import PdfReaderProtocol, PdfWriterProtocol, XmpInformationProtocol from .._utils import ( WHITESPACES, + BinaryStreamType, StreamType, deprecation_no_replacement, logger_warning, @@ -165,20 +166,21 @@ def items(self) -> Iterable[Any]: def _to_lst(self, lst: Any) -> list[Any]: # Convert to list, internal + result: list[Any] if isinstance(lst, (list, tuple, set)): - pass + result = list(lst) elif isinstance(lst, PdfObject): - lst = [lst] + result = [lst] elif isinstance(lst, str): if lst[0] == "/": - lst = [NameObject(lst)] + result = [NameObject(lst)] else: - lst = [TextStringObject(lst)] + result = [TextStringObject(lst)] elif isinstance(lst, bytes): - lst = [ByteStringObject(lst)] + result = [ByteStringObject(lst)] else: # for numbers,... - lst = [lst] - return lst + result = [lst] + return result def __add__(self, lst: Any) -> "ArrayObject": """ @@ -476,7 +478,7 @@ def setdefault(self, key: Any, value: Optional[Any] = None) -> Any: return dict.setdefault(self, key, value) def __getitem__(self, key: Any) -> PdfObject: - return dict.__getitem__(self, key).get_object() + return cast(PdfObject, dict.__getitem__(self, key).get_object()) @property def xmp_metadata(self) -> Optional[XmpInformationProtocol]: @@ -532,7 +534,7 @@ def _get_next_object_position( @classmethod def _read_unsized_from_stream( - cls, stream: StreamType, pdf: PdfReaderProtocol + cls, stream: BinaryStreamType, pdf: PdfReaderProtocol ) -> bytes: object_position = cls._get_next_object_position( position_before=stream.tell(), position_end=2 ** 32, generations=list(pdf.xref), pdf=pdf @@ -768,45 +770,46 @@ def insert_child( if inc_parent_counter is None: inc_parent_counter = self.inc_parent_counter_default child_obj = child.get_object() - child = child.indirect_reference # get_reference(child_obj) + assert child.indirect_reference is not None + child_reference: IndirectObject = child.indirect_reference prev: Optional[DictionaryObject] if "/First" not in self: # no child yet - self[NameObject("/First")] = child + self[NameObject("/First")] = child_reference self[NameObject("/Count")] = NumberObject(0) - self[NameObject("/Last")] = child + self[NameObject("/Last")] = child_reference child_obj[NameObject("/Parent")] = self.indirect_reference inc_parent_counter(self, child_obj.get("/Count", 1)) if "/Next" in child_obj: del child_obj["/Next"] if "/Prev" in child_obj: del child_obj["/Prev"] - return child + return child_reference prev = cast("DictionaryObject", self["/Last"]) while prev.indirect_reference != before: if "/Next" in prev: prev = cast("TreeObject", prev["/Next"]) else: # append at the end - prev[NameObject("/Next")] = cast("TreeObject", child) + prev[NameObject("/Next")] = cast("TreeObject", child_reference) child_obj[NameObject("/Prev")] = prev.indirect_reference child_obj[NameObject("/Parent")] = self.indirect_reference if "/Next" in child_obj: del child_obj["/Next"] - self[NameObject("/Last")] = child + self[NameObject("/Last")] = child_reference inc_parent_counter(self, child_obj.get("/Count", 1)) - return child + return child_reference try: # insert as first or in the middle assert isinstance(prev["/Prev"], DictionaryObject) - prev["/Prev"][NameObject("/Next")] = child + prev["/Prev"][NameObject("/Next")] = child_reference child_obj[NameObject("/Prev")] = prev["/Prev"] except Exception: # it means we are inserting in first position del child_obj["/Next"] child_obj[NameObject("/Next")] = prev - prev[NameObject("/Prev")] = child + prev[NameObject("/Prev")] = child_reference child_obj[NameObject("/Parent")] = self.indirect_reference inc_parent_counter(self, child_obj.get("/Count", 1)) - return child + return child_reference def _remove_node_from_tree( self, prev: Any, prev_ref: Any, cur: Any, last: Any @@ -1211,7 +1214,7 @@ def __init__( f"{MAX_ARRAY_BASED_STREAM_OUTPUT_LENGTH} output bytes." ) data += new_data - if len(data) == 0 or data[-1] != b"\n": + if len(data) == 0 or data[-1:] != b"\n": # There should be no direct need to check for a change of one byte. length += 1 data += b"\n" @@ -1775,8 +1778,9 @@ def bottom(self) -> Optional[FloatObject]: @property def color(self) -> Optional["ArrayObject"]: """Read-only property accessing the color in (R, G, B) with values 0.0-1.0.""" - return self.get( - "/C", ArrayObject([FloatObject(0), FloatObject(0), FloatObject(0)]) + return cast( + "ArrayObject", + self.get("/C", ArrayObject([FloatObject(0), FloatObject(0), FloatObject(0)])), ) @property @@ -1786,7 +1790,7 @@ def font_format(self) -> Optional[OutlineFontFlag]: 1=italic, 2=bold, 3=both """ - return self.get("/F", 0) + return OutlineFontFlag(self.get("/F", 0)) @property def outline_count(self) -> Optional[int]: diff --git a/pypdf/generic/_files.py b/pypdf/generic/_files.py index f29fa770f6..ac7d51cf7c 100644 --- a/pypdf/generic/_files.py +++ b/pypdf/generic/_files.py @@ -206,7 +206,7 @@ def description(self, value: TextStringObject | None) -> None: @property def associated_file_relationship(self) -> str: """Retrieve the relationship of the referring document to this embedded file.""" - return self.pdf_object.get("/AFRelationship", "/Unspecified") + return cast(str, self.pdf_object.get("/AFRelationship", "/Unspecified")) @associated_file_relationship.setter def associated_file_relationship(self, value: NameObject) -> None: @@ -227,7 +227,7 @@ def _embedded_file(self) -> StreamObject: @property def _params(self) -> DictionaryObject: """Retrieve the file-specific parameters.""" - return self._embedded_file.get("/Params", DictionaryObject()).get_object() + return cast(DictionaryObject, self._embedded_file.get("/Params", DictionaryObject()).get_object()) @cached_property def _ensure_params(self) -> DictionaryObject: diff --git a/pypdf/generic/_image_inline.py b/pypdf/generic/_image_inline.py index bd11970497..82e423a180 100644 --- a/pypdf/generic/_image_inline.py +++ b/pypdf/generic/_image_inline.py @@ -32,6 +32,7 @@ from .._utils import ( WHITESPACES, WHITESPACES_AS_BYTES, + BinaryStreamType, StreamType, logger_warning, read_non_whitespace, @@ -158,7 +159,7 @@ def extract_inline__run_length_decode(stream: StreamType) -> bytes: return bytes(data_out) -def extract_inline__dct_decode(stream: StreamType) -> bytes: +def extract_inline__dct_decode(stream: BinaryStreamType) -> bytes: """ Extract DCT (JPEG) stream from inline image. The stream will be moved onto the EI. diff --git a/pypdf/generic/_link.py b/pypdf/generic/_link.py index f92c75c7ca..f3e7566549 100644 --- a/pypdf/generic/_link.py +++ b/pypdf/generic/_link.py @@ -66,7 +66,7 @@ def __init__(self, reference: ArrayObject) -> None: self._reference = reference def find_referenced_page(self) -> IndirectObject: - return self._reference[0] + return cast(IndirectObject, self._reference[0]) def patch_reference(self, target_pdf: "PdfWriter", new_page: IndirectObject) -> None: """target_pdf: PdfWriter which the new link went into""" diff --git a/pypdf/generic/_rectangle.py b/pypdf/generic/_rectangle.py index ba7865ae58..c07d6bb1f4 100644 --- a/pypdf/generic/_rectangle.py +++ b/pypdf/generic/_rectangle.py @@ -27,7 +27,7 @@ def __init__( def _ensure_is_number(self, value: Any) -> Union[FloatObject, NumberObject]: if not isinstance(value, (FloatObject, NumberObject)): - value = FloatObject(value) + return FloatObject(value) return value def scale(self, sx: float, sy: float) -> "RectangleObject": @@ -45,7 +45,8 @@ def __repr__(self) -> str: @property def left(self) -> FloatObject: - return self[0] + value: FloatObject = self[0] + return value @left.setter def left(self, f: float) -> None: @@ -53,7 +54,8 @@ def left(self, f: float) -> None: @property def bottom(self) -> FloatObject: - return self[1] + value: FloatObject = self[1] + return value @bottom.setter def bottom(self, f: float) -> None: @@ -61,7 +63,8 @@ def bottom(self, f: float) -> None: @property def right(self) -> FloatObject: - return self[2] + value: FloatObject = self[2] + return value @right.setter def right(self, f: float) -> None: @@ -69,7 +72,8 @@ def right(self, f: float) -> None: @property def top(self) -> FloatObject: - return self[3] + value: FloatObject = self[3] + return value @top.setter def top(self, f: float) -> None: diff --git a/pypdf/generic/_viewerpref.py b/pypdf/generic/_viewerpref.py index 04f95858be..84fce613b1 100644 --- a/pypdf/generic/_viewerpref.py +++ b/pypdf/generic/_viewerpref.py @@ -29,6 +29,7 @@ from typing import ( Any, Optional, + cast, ) from ._base import BooleanObject, NameObject, NumberObject, is_null_or_none @@ -160,4 +161,4 @@ def _add_prop_int(key: str, default: Optional[int]) -> property: cls.enforce = _add_prop_arr("/Enforce", ArrayObject()) - return DictionaryObject.__new__(cls) + return cast("ViewerPreferences", DictionaryObject.__new__(cls)) diff --git a/pypdf/xmp.py b/pypdf/xmp.py index e6c066fb5d..8e399a5a99 100644 --- a/pypdf/xmp.py +++ b/pypdf/xmp.py @@ -14,6 +14,7 @@ Optional, TypeVar, Union, + cast, ) from xml.dom.expatbuilder import ExpatBuilderNS from xml.dom.minidom import Document @@ -289,7 +290,7 @@ def _get_single_value( def _getter_bag(self, namespace: str, name: str) -> Optional[list[str]]: cached = self.cache.get(namespace, {}).get(name) if cached: - return cached + return cast(list[str], cached) retval: list[str] = [] for element in self.get_element("", namespace, name): if (bags := _generic_get(element, self, list_type="Bag")) is not None: @@ -309,7 +310,7 @@ def _get_seq_values( ) -> Optional[list[Any]]: cached = self.cache.get(namespace, {}).get(name) if cached: - return cached + return cast(list[Any], cached) retval: list[Any] = [] for element in self.get_element("", namespace, name): if (seqs := _generic_get(element, self, list_type="Seq", converter=converter)) is not None: @@ -333,7 +334,7 @@ def _get_seq_values( def _get_langalt_values(self, namespace: str, name: str) -> Optional[dict[Any, Any]]: cached = self.cache.get(namespace, {}).get(name) if cached: - return cached + return cast(dict[Any, Any], cached) retval: dict[Any, Any] = {} for element in self.get_element("", namespace, name): alts = element.getElementsByTagNameNS(RDF_NAMESPACE, "Alt") diff --git a/pyproject.toml b/pyproject.toml index f0dce80515..e8da061817 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -241,15 +241,9 @@ wrap-summaries = 0 wrap-descriptions = 0 [tool.mypy] -show_error_codes = true +strict = true ignore_missing_imports = true -check_untyped_defs = true -disallow_any_generics = true -disallow_untyped_defs = true -disallow_incomplete_defs = true -warn_redundant_casts = true -warn_unused_ignores = true -warn_unused_configs = true +show_error_codes = true exclude = ['venv', '.venv'] diff --git a/tests/generic/test_data_structures.py b/tests/generic/test_data_structures.py index 694444be13..16ac8e8751 100644 --- a/tests/generic/test_data_structures.py +++ b/tests/generic/test_data_structures.py @@ -12,12 +12,14 @@ from pypdf.errors import LimitReachedError, PdfReadError from pypdf.generic import ( ArrayObject, + ByteStringObject, ContentStream, DictionaryObject, NameObject, NullObject, RectangleObject, StreamObject, + TextStringObject, TreeObject, ) from tests import RESOURCE_ROOT, get_data_from_url @@ -122,6 +124,23 @@ def test_array_object__clone_same_stream_multiple_times() -> None: ) +def test_array_object__to_lst_conversion(): + arr = ArrayObject() + + # str not starting with "/" -> TextStringObject + arr += "hello" + assert isinstance(arr[0], TextStringObject) + + # bytes -> ByteStringObject + arr += b"data" + assert isinstance(arr[1], ByteStringObject) + + # number (else branch) - should pass through unwrapped + arr += 42 + assert arr[2] == 42 + assert type(arr[2]) is int + + @pytest.mark.enable_socket def test_dictionary_object__read_from_stream__limit() -> None: name = "read_from_stream__length_2gb.pdf" @@ -265,7 +284,7 @@ def test_content_stream__array_based__output_length() -> None: reader = PdfReader(buffer) with pytest.raises( expected_exception=LimitReachedError, - match=r"^Array\-based stream has at least 75003501 > 75000000 output bytes\.$" + match=r"^Array\-based stream has at least 75002550 > 75000000 output bytes\.$" ): _ = reader.pages[0].get_contents() diff --git a/tests/test_annotations.py b/tests/test_annotations.py index 17cd9de069..6caf5ee931 100644 --- a/tests/test_annotations.py +++ b/tests/test_annotations.py @@ -19,12 +19,26 @@ Rectangle, Text, ) +from pypdf.constants import AnnotationFlag from pypdf.errors import PdfReadError -from pypdf.generic import ArrayObject, FloatObject, NumberObject +from pypdf.generic import ArrayObject, FloatObject, NameObject, NumberObject from . import RESOURCE_ROOT, get_data_from_url +def test_annotation_flags_returns_annotation_flag_type(): + annot = Text(rect=(0, 0, 100, 100), text="test") + + # Without /F key, should return AnnotationFlag(0) + assert isinstance(annot.flags, AnnotationFlag) + assert annot.flags == 0 + + # With /F key set as a NumberObject (as stored in PDFs) + annot[NameObject("/F")] = NumberObject(4) + assert isinstance(annot.flags, AnnotationFlag) + assert annot.flags == AnnotationFlag.PRINT + + def test_ellipse(pdf_file_path): # Arrange pdf_path = RESOURCE_ROOT / "crazyones.pdf" diff --git a/tests/test_generic.py b/tests/test_generic.py index 3d872e1e9c..560c22125b 100644 --- a/tests/test_generic.py +++ b/tests/test_generic.py @@ -10,7 +10,7 @@ import pytest from pypdf import PdfReader, PdfWriter -from pypdf.constants import CheckboxRadioButtonAttributes +from pypdf.constants import CheckboxRadioButtonAttributes, OutlineFontFlag from pypdf.errors import DeprecationError, PdfReadError, PdfStreamError from pypdf.generic import ( ArrayObject, @@ -284,6 +284,14 @@ def test_destination_fit_r(): d.empty_tree() +def test_destination_color_and_font_format_defaults(): + d = Destination(NameObject("title"), NullObject(), Fit.fit_rectangle(0, 0, 0, 0)) + assert isinstance(d.color, ArrayObject) + assert d.color == [FloatObject(0), FloatObject(0), FloatObject(0)] + assert isinstance(d.font_format, OutlineFontFlag) + assert d.font_format == 0 + + def test_destination_fit_v(): d = Destination(NameObject("title"), NullObject(), Fit.fit_vertically(left=0)) From 6f1789dca834da8c8995464b24225052e6ddcf3c Mon Sep 17 00:00:00 2001 From: John Costa Date: Wed, 8 Apr 2026 21:05:51 -0700 Subject: [PATCH 2/8] Add return type annotation to test function --- tests/generic/test_data_structures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/generic/test_data_structures.py b/tests/generic/test_data_structures.py index 16ac8e8751..09ea63ca13 100644 --- a/tests/generic/test_data_structures.py +++ b/tests/generic/test_data_structures.py @@ -124,7 +124,7 @@ def test_array_object__clone_same_stream_multiple_times() -> None: ) -def test_array_object__to_lst_conversion(): +def test_array_object__to_lst_conversion() -> None: arr = ArrayObject() # str not starting with "/" -> TextStringObject From adc6b94e0ac8d9e7e7b9bfb0e47de223688a157b Mon Sep 17 00:00:00 2001 From: John Costa Date: Wed, 8 Apr 2026 21:10:51 -0700 Subject: [PATCH 3/8] Fix strict mypy errors in untouched files - Add mypy override for pycryptodome (no type stubs available) - Add type: ignore for comparison-overlap in _writer.py and test_files.py - Add type: ignore for attr-defined in test_image_xobject.py (PIL) - Remove pycryptodome from pre-commit additional_dependencies (no stubs) --- .pre-commit-config.yaml | 1 - pypdf/_writer.py | 4 ++-- pyproject.toml | 4 ++++ tests/generic/test_files.py | 4 ++-- tests/generic/test_image_xobject.py | 2 +- 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6c14baf8bc..11f301b29e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -37,4 +37,3 @@ repos: files: ^pypdf/.* additional_dependencies: - cryptography - - pycryptodome diff --git a/pypdf/_writer.py b/pypdf/_writer.py index 5b8ae049e4..42c672711c 100644 --- a/pypdf/_writer.py +++ b/pypdf/_writer.py @@ -3007,9 +3007,9 @@ def _insert_filtered_annotations( for an in annots: ano = cast("DictionaryObject", an.get_object()) if ( - ano["/Subtype"] != "/Link" + ano["/Subtype"] != "/Link" # type: ignore[comparison-overlap] or "/A" not in ano - or cast("DictionaryObject", ano["/A"])["/S"] != "/GoTo" + or cast("DictionaryObject", ano["/A"])["/S"] != "/GoTo" # type: ignore[comparison-overlap] or "/Dest" in ano ): if "/Dest" not in ano: diff --git a/pyproject.toml b/pyproject.toml index e8da061817..0eaffbe654 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -247,6 +247,10 @@ show_error_codes = true exclude = ['venv', '.venv'] +[[tool.mypy.overrides]] +module = "pypdf._crypt_providers._pycryptodome" +warn_return_any = false + [[tool.mypy.overrides]] module = "tests.*" ignore_errors = true diff --git a/tests/generic/test_files.py b/tests/generic/test_files.py index 5230cda49f..df2dd2cbcd 100644 --- a/tests/generic/test_files.py +++ b/tests/generic/test_files.py @@ -501,7 +501,7 @@ def test_embedded_file__create__kids_based_name_tree() -> None: assert isinstance(embedded, DictionaryObject) result = embedded["/Names"] - assert result == [ + assert result == [ # type: ignore[comparison-overlap] "factur-x.xml", attachments[0].pdf_object.indirect_reference, "test.pdf", @@ -605,7 +605,7 @@ def test_embedded_file__order() -> None: assert isinstance(names, DictionaryObject) files = names["/EmbeddedFiles"] assert isinstance(files, DictionaryObject) - assert files["/Names"] == [ + assert files["/Names"] == [ # type: ignore[comparison-overlap] "abc.txt", attachment2.pdf_object.indirect_reference, "test.txt", attachment1.pdf_object.indirect_reference, "test.txt", attachment4.pdf_object.indirect_reference, diff --git a/tests/generic/test_image_xobject.py b/tests/generic/test_image_xobject.py index cdebcc497c..7a4d9fd7fb 100644 --- a/tests/generic/test_image_xobject.py +++ b/tests/generic/test_image_xobject.py @@ -242,7 +242,7 @@ def test_handle_flate__icc_based__image_mode_1() -> None: @pytest.mark.skipif( - condition=Version(Image.__version__) < Version("12.1.0"), + condition=Version(Image.__version__) < Version("12.1.0"), # type: ignore[attr-defined] reason="Unsuitable Pillow version." ) def test_handle_jpx__explicit_decode() -> None: From 6bb4ebe6fb9ab9705beb08b0148967871195912f Mon Sep 17 00:00:00 2001 From: John Costa Date: Fri, 10 Apr 2026 17:47:40 -0700 Subject: [PATCH 4/8] TST: Add coverage for TreeObject.insert_child insert-before-last path --- tests/test_generic.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/test_generic.py b/tests/test_generic.py index 560c22125b..49ebab1605 100644 --- a/tests/test_generic.py +++ b/tests/test_generic.py @@ -660,6 +660,37 @@ def test_remove_child_in_tree(): tree.empty_tree() +def test_insert_child_before_last_with_multiple_existing(): + """Cover TreeObject.insert_child try-success path. + + Inserts a child before the existing /Last node when the tree already + has multiple children, so the node being inserted before has a /Prev. + """ + writer = PdfWriter() + tree = TreeObject() + writer._add_object(tree) + + child1 = TreeObject() + child1[NameObject("/Foo")] = TextStringObject("1") + child1_ref = writer._add_object(child1) + tree.add_child(child1_ref, writer) + + child2 = TreeObject() + child2[NameObject("/Foo")] = TextStringObject("2") + child2_ref = writer._add_object(child2) + tree.add_child(child2_ref, writer) + + # /Last is now child2, /First is child1, child2 has /Prev pointing at child1. + # Inserting before child2_ref hits the try block successfully. + new_child = TreeObject() + new_child[NameObject("/Foo")] = TextStringObject("new") + new_child_ref = writer._add_object(new_child) + tree.insert_child(new_child_ref, child2_ref, writer) + + assert tree[NameObject("/Count")] == 3 + assert len(list(tree.children())) == 3 + + @pytest.mark.enable_socket @pytest.mark.parametrize( ("url", "name", "caplog_content"), From 0e1322bedc32112857971d3f894cf60603e52842 Mon Sep 17 00:00:00 2001 From: John Costa Date: Mon, 13 Apr 2026 19:17:51 -0700 Subject: [PATCH 5/8] DEV: Add pycryptodome to mypy deps, remove unneeded override pycryptodome ships .pyi stubs and a py.typed marker, so adding it to additional_dependencies lets mypy resolve its types directly. The warn_return_any override for the pycryptodome provider is no longer needed. --- .pre-commit-config.yaml | 1 + pyproject.toml | 4 ---- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 11f301b29e..6c14baf8bc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -37,3 +37,4 @@ repos: files: ^pypdf/.* additional_dependencies: - cryptography + - pycryptodome diff --git a/pyproject.toml b/pyproject.toml index 0eaffbe654..e8da061817 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -247,10 +247,6 @@ show_error_codes = true exclude = ['venv', '.venv'] -[[tool.mypy.overrides]] -module = "pypdf._crypt_providers._pycryptodome" -warn_return_any = false - [[tool.mypy.overrides]] module = "tests.*" ignore_errors = true From 721558e1e97b645d3a9c914ec59b36ea668b590c Mon Sep 17 00:00:00 2001 From: John Costa Date: Tue, 14 Apr 2026 14:18:58 -0700 Subject: [PATCH 6/8] DEV: Add pycryptodome to CI requirements for mypy The direct `mypy .` step in CI installs from requirements/ci-3.11.txt which was missing pycryptodome. Without it installed, mypy treats Crypto module types as Any, causing no-any-return errors in _pycryptodome.py. pycryptodome 3.23.0 ships proper .pyi stubs and a py.typed marker, so installing it lets mypy resolve types directly. --- requirements/ci-3.11.txt | 2 ++ requirements/ci.in | 1 + requirements/ci.txt | 2 ++ 3 files changed, 5 insertions(+) diff --git a/requirements/ci-3.11.txt b/requirements/ci-3.11.txt index f9afed0012..69916e671d 100644 --- a/requirements/ci-3.11.txt +++ b/requirements/ci-3.11.txt @@ -42,6 +42,8 @@ py-cpuinfo==9.0.0 # via pytest-benchmark pycparser==2.22 # via cffi +pycryptodome==3.23.0 + # via -r requirements/ci.in pytest==9.0.3 # via # -r requirements/ci.in diff --git a/requirements/ci.in b/requirements/ci.in index 126c722281..9b9888297d 100644 --- a/requirements/ci.in +++ b/requirements/ci.in @@ -4,6 +4,7 @@ fpdf2 mypy pillow cryptography +pycryptodome pytest pytest-benchmark pytest-socket diff --git a/requirements/ci.txt b/requirements/ci.txt index 40659d3c08..27eaee3b94 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -38,6 +38,8 @@ py-cpuinfo==9.0.0 # via pytest-benchmark pycparser==2.22 # via cffi +pycryptodome==3.23.0 + # via -r requirements/ci.in pytest==8.3.3 # via # -r requirements/ci.in From b01f488a9671227c2810217094cb12829b680249 Mon Sep 17 00:00:00 2001 From: John Costa Date: Sat, 18 Apr 2026 12:36:58 -0700 Subject: [PATCH 7/8] Address review feedback: NameObject cast, mypy assert message, PIL version --- pypdf/generic/_data_structures.py | 2 +- pypdf/generic/_files.py | 5 ++++- tests/generic/test_image_xobject.py | 3 ++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/pypdf/generic/_data_structures.py b/pypdf/generic/_data_structures.py index 3f1b7cb2e1..3a75f15b9e 100644 --- a/pypdf/generic/_data_structures.py +++ b/pypdf/generic/_data_structures.py @@ -770,7 +770,7 @@ def insert_child( if inc_parent_counter is None: inc_parent_counter = self.inc_parent_counter_default child_obj = child.get_object() - assert child.indirect_reference is not None + assert child.indirect_reference is not None, "mypy" child_reference: IndirectObject = child.indirect_reference prev: Optional[DictionaryObject] diff --git a/pypdf/generic/_files.py b/pypdf/generic/_files.py index ac7d51cf7c..d40009f9ab 100644 --- a/pypdf/generic/_files.py +++ b/pypdf/generic/_files.py @@ -206,7 +206,10 @@ def description(self, value: TextStringObject | None) -> None: @property def associated_file_relationship(self) -> str: """Retrieve the relationship of the referring document to this embedded file.""" - return cast(str, self.pdf_object.get("/AFRelationship", "/Unspecified")) + return cast( + NameObject, + self.pdf_object.get("/AFRelationship", NameObject("/Unspecified")), + ) @associated_file_relationship.setter def associated_file_relationship(self, value: NameObject) -> None: diff --git a/tests/generic/test_image_xobject.py b/tests/generic/test_image_xobject.py index 7a4d9fd7fb..6f47a8adda 100644 --- a/tests/generic/test_image_xobject.py +++ b/tests/generic/test_image_xobject.py @@ -2,6 +2,7 @@ from io import BytesIO import pytest +import PIL from PIL import Image from pypdf import PdfReader @@ -242,7 +243,7 @@ def test_handle_flate__icc_based__image_mode_1() -> None: @pytest.mark.skipif( - condition=Version(Image.__version__) < Version("12.1.0"), # type: ignore[attr-defined] + condition=Version(PIL.__version__) < Version("12.1.0"), reason="Unsuitable Pillow version." ) def test_handle_jpx__explicit_decode() -> None: From 309a37ee9bb08d107562e0be1bc4c06048a4ffc9 Mon Sep 17 00:00:00 2001 From: John Costa Date: Sat, 18 Apr 2026 13:06:34 -0700 Subject: [PATCH 8/8] style: sort imports in test_image_xobject.py --- tests/generic/test_image_xobject.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/generic/test_image_xobject.py b/tests/generic/test_image_xobject.py index 6f47a8adda..b2050fc36c 100644 --- a/tests/generic/test_image_xobject.py +++ b/tests/generic/test_image_xobject.py @@ -1,8 +1,8 @@ """Test the pypdf.generic._image_xobject module.""" from io import BytesIO -import pytest import PIL +import pytest from PIL import Image from pypdf import PdfReader