Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/pypdf/_utils.py: 49%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# Copyright (c) 2006, Mathieu Fenniak
2# All rights reserved.
3#
4# Redistribution and use in source and binary forms, with or without
5# modification, are permitted provided that the following conditions are
6# met:
7#
8# * Redistributions of source code must retain the above copyright notice,
9# this list of conditions and the following disclaimer.
10# * Redistributions in binary form must reproduce the above copyright notice,
11# this list of conditions and the following disclaimer in the documentation
12# and/or other materials provided with the distribution.
13# * The name of the author may not be used to endorse or promote products
14# derived from this software without specific prior written permission.
15#
16# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
17# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
18# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
19# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
20# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
21# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
22# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
23# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
24# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
25# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
26# POSSIBILITY OF SUCH DAMAGE.
28"""Utility functions for PDF library."""
29__author__ = "Mathieu Fenniak"
30__author_email__ = "biziqe@mathieu.fenniak.net"
32import functools
33import logging
34import re
35import sys
36import warnings
37from dataclasses import dataclass
38from datetime import datetime, timezone
39from io import DEFAULT_BUFFER_SIZE
40from os import SEEK_CUR
41from re import Pattern
42from typing import (
43 IO,
44 Any,
45 NoReturn,
46 Optional,
47 Union,
48)
50if sys.version_info[:2] >= (3, 10):
51 # Python 3.10+: https://www.python.org/dev/peps/pep-0484/
52 from typing import TypeAlias
53else:
54 from typing_extensions import TypeAlias
56if sys.version_info >= (3, 11):
57 from typing import Self
58else:
59 from typing_extensions import Self
61from .errors import (
62 STREAM_TRUNCATED_PREMATURELY,
63 DeprecationError,
64 LimitReachedError,
65 PdfStreamError,
66)
68TransformationMatrixType: TypeAlias = tuple[
69 tuple[float, float, float], tuple[float, float, float], tuple[float, float, float]
70]
71CompressedTransformationMatrix: TypeAlias = tuple[
72 float, float, float, float, float, float
73]
75StreamType = IO[Any]
76BinaryStreamType = IO[bytes]
77StrByteType = Union[str, StreamType]
80def parse_iso8824_date(text: Optional[str]) -> Optional[datetime]:
81 orgtext = text
82 if not text:
83 return None
84 if text[0].isdigit():
85 text = "D:" + text
86 if text.endswith(("Z", "z")):
87 text += "0000"
88 text = text.replace("z", "+").replace("Z", "+").replace("'", "")
89 i = max(text.find("+"), text.find("-"))
90 if i > 0 and i != len(text) - 5:
91 text += "00"
92 for f in (
93 "D:%Y",
94 "D:%Y%m",
95 "D:%Y%m%d",
96 "D:%Y%m%d%H",
97 "D:%Y%m%d%H%M",
98 "D:%Y%m%d%H%M%S",
99 "D:%Y%m%d%H%M%S%z",
100 ):
101 try:
102 d = datetime.strptime(text, f) # noqa: DTZ007
103 except ValueError:
104 continue
105 else:
106 if text.endswith("+0000"):
107 d = d.replace(tzinfo=timezone.utc)
108 return d
109 raise ValueError(f"Can not convert date: {orgtext}")
112def format_iso8824_date(dt: datetime) -> str:
113 """
114 Convert a datetime object to PDF date string format.
116 Converts datetime to the PDF date format D:YYYYMMDDHHmmSSOHH'mm
117 as specified in the PDF Reference.
119 Args:
120 dt: A datetime object to convert.
122 Returns:
123 A date string in PDF format.
124 """
125 date_str = dt.strftime("D:%Y%m%d%H%M%S")
126 if dt.tzinfo is not None:
127 offset = dt.utcoffset()
128 assert offset is not None
129 total_seconds = int(offset.total_seconds())
130 hours, remainder = divmod(abs(total_seconds), 3600)
131 minutes = remainder // 60
132 sign = "+" if total_seconds >= 0 else "-"
133 date_str += f"{sign}{hours:02d}'{minutes:02d}'"
134 return date_str
137def _get_max_pdf_version_header(header1: str, header2: str) -> str:
138 versions = (
139 "%PDF-1.3",
140 "%PDF-1.4",
141 "%PDF-1.5",
142 "%PDF-1.6",
143 "%PDF-1.7",
144 "%PDF-2.0",
145 )
146 pdf_header_indices = []
147 if header1 in versions:
148 pdf_header_indices.append(versions.index(header1))
149 if header2 in versions:
150 pdf_header_indices.append(versions.index(header2))
151 if len(pdf_header_indices) == 0:
152 raise ValueError(f"Neither {header1!r} nor {header2!r} are proper headers")
153 return versions[max(pdf_header_indices)]
156WHITESPACES = (b"\x00", b"\t", b"\n", b"\f", b"\r", b" ")
157WHITESPACES_AS_BYTES = b"".join(WHITESPACES)
158WHITESPACES_AS_REGEXP = b"[" + WHITESPACES_AS_BYTES + b"]"
161def read_until_whitespace(stream: StreamType, max_bytes: Optional[int] = None, strict: bool = False) -> bytes:
162 """
163 Read non-whitespace characters and return them.
165 Stops upon encountering whitespace or when max_bytes is reached.
167 Args:
168 stream: The data stream from which was read.
169 max_bytes: The maximum number of bytes which are considered valid. One more byte
170 will always be read from the stream.
171 strict: Whether to raise an exception if the token exceeds max_bytes.
172 If False, a warning is logged and only the first max_bytes are returned.
174 Returns:
175 The data which was read.
177 """
178 txt = bytearray()
179 while True:
180 tok = stream.read(1)
181 if tok.isspace() or tok in WHITESPACES or not tok:
182 break
183 if max_bytes is not None and len(txt) >= max_bytes:
184 if strict:
185 raise LimitReachedError(f"Token exceeds maximum length of {max_bytes} bytes.")
186 logger_warning(
187 "Token exceeds maximum length of %(max_bytes)d bytes.", source=__name__, max_bytes=max_bytes
188 )
189 break
190 txt += tok
191 return bytes(txt)
194def read_non_whitespace(stream: BinaryStreamType) -> bytes:
195 """
196 Find and read the next non-whitespace character (ignores whitespace).
198 Args:
199 stream: The data stream from which was read.
201 Returns:
202 The data which was read.
204 """
205 tok = stream.read(1)
206 while tok in WHITESPACES:
207 tok = stream.read(1)
208 return tok
211def skip_over_whitespace(stream: StreamType) -> bool:
212 """
213 Similar to read_non_whitespace, but return a boolean if at least one
214 whitespace character was read.
216 Args:
217 stream: The data stream from which was read.
219 Returns:
220 True if one or more whitespace was skipped, otherwise return False.
222 """
223 tok = stream.read(1)
224 cnt = 0
225 while tok in WHITESPACES:
226 cnt += 1
227 tok = stream.read(1)
228 return cnt > 0
231def check_if_whitespace_only(value: bytes) -> bool:
232 """
233 Check if the given value consists of whitespace characters only.
235 Args:
236 value: The bytes to check.
238 Returns:
239 True if the value only has whitespace characters, otherwise return False.
241 """
242 return all(b in WHITESPACES_AS_BYTES for b in value)
245NEUTRAL_CHARACTER_RANGES = (
246 ("\x00", "\x2F"), # ASCII control codes, space, and early punctuation (!"#$%)
247 ("\x3A", "\x40"), # ASCII operators and punctuation between digits & A (:;<=>?@)
248 ("\u0660", "\u066D"), # Arabic-Indic digits and Arabic numeric separators
249 ("\u06F0", "\u06F9"), # Extended Arabic-Indic (Persian) digits
250 ("\u2000", "\u206F"), # General punctuation
251 ("\u20A0", "\u21FF"), # Currency symbols, diacritical marks, letter-like symbols, number forms, arrows
252)
255def is_char_neutral(char: str, custom_special_characters: str = "") -> bool:
256 """Check if a character is part of neutral character ranges"""
257 if any(start <= char <= end for start, end in NEUTRAL_CHARACTER_RANGES):
258 return True
260 return bool(custom_special_characters and char in custom_special_characters)
263RTL_CHARACTER_RANGES = (
264 ("\u0590", "\u08FF"), # Hebrew, Arabic, Syriac, Thaana, N'Ko, etc.
265 ("\uFB1D", "\uFDFF"), # Hebrew & Arabic Presentation Forms-A
266 ("\uFE70", "\uFEFF"), # Arabic Presentation Forms-B
267)
270def is_char_rtl(char: str, custom_rtl_min: str = "", custom_rtl_max: str = "") -> bool:
271 """Check if a character is part of RTL character ranges"""
272 if any(start <= char <= end for start, end in RTL_CHARACTER_RANGES):
273 return True
275 return bool(custom_rtl_min and custom_rtl_max and (custom_rtl_min <= char <= custom_rtl_max))
278def skip_over_comment(stream: StreamType) -> None:
279 tok = stream.read(1)
280 stream.seek(-1, 1)
281 if tok == b"%":
282 while tok not in (b"\n", b"\r"):
283 tok = stream.read(1)
284 if tok == b"":
285 raise PdfStreamError("File ended unexpectedly.")
288def skip_over_comments(stream: StreamType, *, limit: Optional[int] = None) -> None:
289 """
290 Skip consecutive leading comments and the whitespace following each one.
292 The stream must be positioned at the first comment, if any. If the next
293 character is not ``%``, the stream position is unchanged. If only whitespace
294 follows the last comment before EOF, leave the stream at the last whitespace
295 byte, preserving the existing read_object behaviour for a missing object.
297 Args:
298 stream: The stream to read from.
299 limit: The maximum number of comments to skip, or ``None`` for no limit.
301 Raises:
302 LimitReachedError: If another comment follows after ``limit`` comments;
303 the stream remains at its ``%`` without reading its body.
304 PdfStreamError: If a comment ends before a line ending.
306 """
307 tok = stream.read(1)
308 stream.seek(-len(tok), 1)
309 comment_count = 0
310 while tok == b"%":
311 if limit is not None and comment_count >= limit:
312 raise LimitReachedError(f"Maximum number of leading comments reached: {limit}.")
313 skip_over_comment(stream)
314 tok = read_non_whitespace(stream)
315 stream.seek(-1, 1)
316 comment_count += 1
319def read_until_regex(*, stream: StreamType, regex: Pattern[bytes], length: int = sys.maxsize) -> bytes:
320 """
321 Read until the regular expression pattern matched (ignore the match).
322 Treats EOF on the underlying stream as the end of the token to be matched.
324 Args:
325 stream: The stream to read from.
326 regex: The pattern to search for.
327 length: The (approximated) maximum number of bytes to read before raising an exception.
329 Returns:
330 The read bytes.
332 """
333 parts: list[bytes] = []
334 total_length = 0
335 tail = b""
336 chunk_size = 16
337 while True:
338 token = stream.read(chunk_size)
339 if not token:
340 return b"".join(parts)
341 token_length = len(token)
342 if (current_length := total_length + token_length) >= length:
343 raise LimitReachedError(
344 f"Read stream length of {current_length} exceeds maximum allowed length of {length}."
345 )
347 # Search overlap of previous tail + new chunk to catch
348 # multi-byte regex matches spanning chunk boundaries.
349 current_buffer = tail + token
350 search_match = regex.search(current_buffer)
351 parts.append(token)
352 if search_match is not None:
353 overlap = len(tail)
354 actual_start = total_length - overlap + search_match.start()
355 stream.seek(actual_start - total_length - token_length, 1)
356 return b"".join(parts)[:actual_start]
357 total_length += token_length
359 # Fixed overlap: 16 bytes is sufficient for the short
360 # delimiter patterns used in PDF parsing.
361 tail = token[-16:]
362 if chunk_size < 8192:
363 chunk_size <<= 1
366def read_block_backwards(stream: BinaryStreamType, to_read: int) -> bytes:
367 """
368 Given a stream at position X, read a block of size to_read ending at position X.
370 This changes the stream's position to the beginning of where the block was
371 read.
373 Args:
374 stream:
375 to_read:
377 Returns:
378 The data which was read.
380 """
381 if stream.tell() < to_read:
382 raise PdfStreamError("Could not read malformed PDF file")
383 # Seek to the start of the block we want to read.
384 stream.seek(-to_read, SEEK_CUR)
385 read = stream.read(to_read)
386 # Seek to the start of the block we read after reading it.
387 stream.seek(-to_read, SEEK_CUR)
388 return read
391def read_previous_line(stream: StreamType) -> bytes:
392 """
393 Given a byte stream with current position X, return the previous line.
395 All characters between the first CR/LF byte found before X
396 (or, the start of the file, if no such byte is found) and position X
397 After this call, the stream will be positioned one byte after the
398 first non-CRLF character found beyond the first CR/LF byte before X,
399 or, if no such byte is found, at the beginning of the stream.
401 Args:
402 stream: StreamType:
404 Returns:
405 The data which was read.
407 """
408 line_content = []
409 found_crlf = False
410 if stream.tell() == 0:
411 raise PdfStreamError(STREAM_TRUNCATED_PREMATURELY)
412 while True:
413 to_read = min(DEFAULT_BUFFER_SIZE, stream.tell())
414 if to_read == 0:
415 break
416 # Read the block. After this, our stream will be one
417 # beyond the initial position.
418 block = read_block_backwards(stream, to_read)
419 idx = len(block) - 1
420 if not found_crlf:
421 # We haven't found our first CR/LF yet.
422 # Read off characters until we hit one.
423 while idx >= 0 and block[idx] not in b"\r\n":
424 idx -= 1
425 if idx >= 0:
426 found_crlf = True
427 if found_crlf:
428 # We found our first CR/LF already (on this block or
429 # a previous one).
430 # Our combined line is the remainder of the block
431 # plus any previously read blocks.
432 line_content.append(block[idx + 1 :])
433 # Continue to read off any more CRLF characters.
434 while idx >= 0 and block[idx] in b"\r\n":
435 idx -= 1
436 else:
437 # Didn't find CR/LF yet - add this block to our
438 # previously read blocks and continue.
439 line_content.append(block)
440 if idx >= 0:
441 # We found the next non-CRLF character.
442 # Set the stream position correctly, then break
443 stream.seek(idx + 1, SEEK_CUR)
444 break
445 # Join all the blocks in the line (which are in reverse order)
446 return b"".join(line_content[::-1])
449def matrix_multiply(
450 a: TransformationMatrixType, b: TransformationMatrixType
451) -> TransformationMatrixType:
452 return tuple( # type: ignore[return-value]
453 tuple(sum(float(i) * float(j) for i, j in zip(row, col)) for col in zip(*b))
454 for row in a
455 )
458def mark_location(stream: StreamType) -> None:
459 """Create text file showing current location in context."""
460 # Mainly for debugging
461 radius = 5000
462 stream.seek(-radius, 1)
463 with open("pypdf_pdfLocation.txt", "wb") as output_fh:
464 output_fh.write(stream.read(radius))
465 output_fh.write(b"HERE")
466 output_fh.write(stream.read(radius))
467 stream.seek(-radius, 1)
470def deprecate(msg: str, stacklevel: int = 3) -> None:
471 warnings.warn(msg, DeprecationWarning, stacklevel=stacklevel)
474def deprecation(msg: str) -> NoReturn:
475 raise DeprecationError(msg)
478def deprecate_with_replacement(old_name: str, new_name: str, removed_in: str) -> None:
479 """Issue a warning that a feature will be removed, but has a replacement."""
480 deprecate(
481 f"{old_name} is deprecated and will be removed in pypdf {removed_in}. Use {new_name} instead.",
482 4,
483 )
486def deprecation_with_replacement(old_name: str, new_name: str, removed_in: str) -> NoReturn:
487 """Raise an exception that a feature was already removed, but has a replacement."""
488 deprecation(
489 f"{old_name} is deprecated and was removed in pypdf {removed_in}. Use {new_name} instead."
490 )
493def deprecate_no_replacement(name: str, removed_in: str) -> None:
494 """Issue a warning that a feature will be removed without replacement."""
495 deprecate(f"{name} is deprecated and will be removed in pypdf {removed_in}.", 4)
498def deprecation_no_replacement(name: str, removed_in: str) -> NoReturn:
499 """Raise an exception that a feature was already removed without replacement."""
500 deprecation(f"{name} is deprecated and was removed in pypdf {removed_in}.")
503def logger_error(message: str, *, source: str, **values: Any) -> None:
504 """
505 Use this instead of logger.error directly.
507 That allows people to overwrite it more easily.
509 See the docs on when to use which:
510 https://pypdf.readthedocs.io/en/latest/user/suppress-warnings.html
511 """
512 if values:
513 logging.getLogger(source).error(message, values)
514 else:
515 logging.getLogger(source).error(message)
518def logger_warning(message: str, *, source: str, **values: Any) -> None:
519 """
520 Use this instead of logger.warning directly.
522 That allows people to overwrite it more easily.
524 ## Exception, warnings.warn, logger_warning
525 - Exceptions should be used if the user should write code that deals with
526 an error case, e.g. the PDF being completely broken.
527 - warnings.warn should be used if the user needs to fix their code, e.g.
528 DeprecationWarnings
529 - logger_warning should be used if the user needs to know that an issue was
530 handled by pypdf, e.g. a non-compliant PDF being read in a way that
531 pypdf could apply a robustness fix to still read it. This applies mainly
532 to strict=False mode.
533 """
534 if values:
535 logging.getLogger(source).warning(message, values)
536 else:
537 # Keep parity with logger_error and support plain warning messages.
538 # Passing an empty dict to logging is not equivalent to passing no args:
539 # plain messages would fail while being formatted.
540 logging.getLogger(source).warning(message)
543def rename_kwargs(
544 func_name: str, kwargs: dict[str, Any], aliases: dict[str, str], fail: bool = False
545) -> None:
546 """
547 Helper function to deprecate arguments.
549 Args:
550 func_name: Name of the function to be deprecated
551 kwargs:
552 aliases:
553 fail:
555 """
556 for old_term, new_term in aliases.items():
557 if old_term in kwargs:
558 if fail:
559 raise DeprecationError(
560 f"{old_term} is deprecated as an argument. Use {new_term} instead"
561 )
562 if new_term in kwargs:
563 raise TypeError(
564 f"{func_name} received both {old_term} and {new_term} as "
565 f"an argument. {old_term} is deprecated. "
566 f"Use {new_term} instead."
567 )
568 kwargs[new_term] = kwargs.pop(old_term)
569 warnings.warn(
570 message=(
571 f"{old_term} is deprecated as an argument. Use {new_term} instead"
572 ),
573 category=DeprecationWarning,
574 stacklevel=3,
575 )
578def _human_readable_bytes(bytes: int) -> str:
579 if bytes < 10**3:
580 return f"{bytes} Byte"
581 if bytes < 10**6:
582 return f"{bytes / 10**3:.1f} kB"
583 if bytes < 10**9:
584 return f"{bytes / 10**6:.1f} MB"
585 return f"{bytes / 10**9:.1f} GB"
588# The following class has been copied from Django:
589# https://github.com/django/django/blob/adae619426b6f50046b3daaa744db52989c9d6db/django/utils/functional.py#L51-L65
590# It received some modifications to comply with our own coding standards.
591#
592# Original license:
593#
594# ---------------------------------------------------------------------------------
595# Copyright (c) Django Software Foundation and individual contributors.
596# All rights reserved.
597#
598# Redistribution and use in source and binary forms, with or without modification,
599# are permitted provided that the following conditions are met:
600#
601# 1. Redistributions of source code must retain the above copyright notice,
602# this list of conditions and the following disclaimer.
603#
604# 2. Redistributions in binary form must reproduce the above copyright
605# notice, this list of conditions and the following disclaimer in the
606# documentation and/or other materials provided with the distribution.
607#
608# 3. Neither the name of Django nor the names of its contributors may be used
609# to endorse or promote products derived from this software without
610# specific prior written permission.
611#
612# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
613# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
614# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
615# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
616# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
617# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
618# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
619# ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
620# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
621# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
622# ---------------------------------------------------------------------------------
623class classproperty: # noqa: N801
624 """
625 Decorator that converts a method with a single cls argument into a property
626 that can be accessed directly from the class.
627 """
629 def __init__(self, method=None) -> None: # type: ignore # noqa: ANN001
630 self.fget = method
632 def __get__(self, instance, cls=None) -> Any: # type: ignore # noqa: ANN001
633 return self.fget(cls)
635 def getter(self, method) -> Self: # type: ignore # noqa: ANN001
636 self.fget = method
637 return self
640@dataclass
641class File:
642 from .generic import IndirectObject # noqa: PLC0415
644 name: str = ""
645 """
646 Filename as identified within the PDF file.
647 """
648 data: bytes = b""
649 """
650 Data as bytes.
651 """
652 indirect_reference: Optional[IndirectObject] = None
653 """
654 Reference to the object storing the stream.
655 """
657 def __str__(self) -> str:
658 return f"{self.__class__.__name__}(name={self.name}, data: {_human_readable_bytes(len(self.data))})"
660 def __repr__(self) -> str:
661 return self.__str__()[:-1] + f", hash: {hash(self.data)})"
664@functools.total_ordering
665class Version:
666 COMPONENT_PATTERN = re.compile(r"^(\d+)(.*)$")
668 def __init__(self, version_str: str) -> None:
669 self.version_str = version_str
670 self.components = self._parse_version(version_str)
672 def _parse_version(self, version_str: str) -> list[tuple[int, str]]:
673 components = version_str.split(".")
674 parsed_components = []
675 for component in components:
676 match = Version.COMPONENT_PATTERN.match(component)
677 if not match:
678 parsed_components.append((0, component))
679 continue
680 integer_prefix = match.group(1)
681 suffix = match.group(2)
682 if integer_prefix is None:
683 integer_prefix = 0
684 parsed_components.append((int(integer_prefix), suffix))
685 return parsed_components
687 def __eq__(self, other: object) -> bool:
688 if not isinstance(other, Version):
689 return False
690 return self.components == other.components
692 def __hash__(self) -> int:
693 # Convert to tuple as lists cannot be hashed.
694 return hash((self.__class__, tuple(self.components)))
696 def __lt__(self, other: Any) -> bool:
697 if not isinstance(other, Version):
698 raise ValueError(f"Version cannot be compared against {type(other)}")
700 for self_component, other_component in zip(self.components, other.components):
701 self_value, self_suffix = self_component
702 other_value, other_suffix = other_component
704 if self_value < other_value:
705 return True
706 if self_value > other_value:
707 return False
709 if self_suffix < other_suffix:
710 return True
711 if self_suffix > other_suffix:
712 return False
714 return len(self.components) < len(other.components)
717@dataclass
718class _TraversalState:
719 """Sometimes we need mutable objects which just count something."""
720 entry_count: int = 0
721 has_logged: bool = False