python-multipart 0.0.11__tar.gz → 0.0.12__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. {python_multipart-0.0.11 → python_multipart-0.0.12}/.gitignore +1 -0
  2. {python_multipart-0.0.11 → python_multipart-0.0.12}/CHANGELOG.md +6 -0
  3. {python_multipart-0.0.11 → python_multipart-0.0.12}/PKG-INFO +1 -1
  4. {python_multipart-0.0.11 → python_multipart-0.0.12}/multipart/__init__.py +1 -1
  5. {python_multipart-0.0.11 → python_multipart-0.0.12}/multipart/decoders.py +19 -6
  6. {python_multipart-0.0.11 → python_multipart-0.0.12}/multipart/multipart.py +107 -79
  7. {python_multipart-0.0.11 → python_multipart-0.0.12}/pyproject.toml +6 -1
  8. python_multipart-0.0.12/tests/__init__.py +0 -0
  9. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/compat.py +17 -9
  10. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_multipart.py +190 -168
  11. {python_multipart-0.0.11 → python_multipart-0.0.12}/LICENSE.txt +0 -0
  12. {python_multipart-0.0.11 → python_multipart-0.0.12}/README.md +0 -0
  13. {python_multipart-0.0.11 → python_multipart-0.0.12}/multipart/exceptions.py +0 -0
  14. /python_multipart-0.0.11/tests/__init__.py → /python_multipart-0.0.12/multipart/py.typed +0 -0
  15. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/CRLF_in_header.http +0 -0
  16. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/CRLF_in_header.yaml +0 -0
  17. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/CR_in_header.http +0 -0
  18. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/CR_in_header.yaml +0 -0
  19. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/CR_in_header_value.http +0 -0
  20. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/CR_in_header_value.yaml +0 -0
  21. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/almost_match_boundary.http +0 -0
  22. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/almost_match_boundary.yaml +0 -0
  23. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/almost_match_boundary_without_CR.http +0 -0
  24. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/almost_match_boundary_without_CR.yaml +0 -0
  25. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/almost_match_boundary_without_LF.http +0 -0
  26. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/almost_match_boundary_without_LF.yaml +0 -0
  27. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/almost_match_boundary_without_final_hyphen.http +0 -0
  28. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/almost_match_boundary_without_final_hyphen.yaml +0 -0
  29. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/bad_end_of_headers.http +0 -0
  30. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/bad_end_of_headers.yaml +0 -0
  31. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/bad_header_char.http +0 -0
  32. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/bad_header_char.yaml +0 -0
  33. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/bad_initial_boundary.http +0 -0
  34. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/bad_initial_boundary.yaml +0 -0
  35. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/base64_encoding.http +0 -0
  36. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/base64_encoding.yaml +0 -0
  37. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/empty_header.http +0 -0
  38. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/empty_header.yaml +0 -0
  39. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/header_with_number.http +0 -0
  40. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/header_with_number.yaml +0 -0
  41. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/mixed_plain_and_base64_encoding.http +0 -0
  42. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/mixed_plain_and_base64_encoding.yaml +0 -0
  43. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/multiple_fields.http +0 -0
  44. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/multiple_fields.yaml +0 -0
  45. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/multiple_files.http +0 -0
  46. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/multiple_files.yaml +0 -0
  47. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/quoted_printable_encoding.http +0 -0
  48. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/quoted_printable_encoding.yaml +0 -0
  49. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/single_field.http +0 -0
  50. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/single_field.yaml +0 -0
  51. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/single_field_blocks.http +0 -0
  52. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/single_field_blocks.yaml +0 -0
  53. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/single_field_longer.http +0 -0
  54. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/single_field_longer.yaml +0 -0
  55. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/single_field_single_file.http +0 -0
  56. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/single_field_single_file.yaml +0 -0
  57. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/single_field_with_leading_newlines.http +0 -0
  58. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/single_field_with_leading_newlines.yaml +0 -0
  59. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/single_file.http +0 -0
  60. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/single_file.yaml +0 -0
  61. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/utf8_filename.http +0 -0
  62. {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/utf8_filename.yaml +0 -0
@@ -89,6 +89,7 @@ coverage.xml
89
89
  *.py,cover
90
90
  .hypothesis/
91
91
  .pytest_cache/
92
+ .ruff_cache/
92
93
  cover/
93
94
 
94
95
  # Translations
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.12 (2024-09-29)
4
+
5
+ * Improve error message when boundary character does not match [#124](https://github.com/Kludex/python-multipart/pull/124).
6
+ * Add mypy strict typing [#140](https://github.com/Kludex/python-multipart/pull/140).
7
+ * Enforce 100% coverage [#159](https://github.com/Kludex/python-multipart/pull/159).
8
+
3
9
  ## 0.0.11 (2024-09-28)
4
10
 
5
11
  * Improve performance, especially in data with many CR-LF [#137](https://github.com/Kludex/python-multipart/pull/137).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: python-multipart
3
- Version: 0.0.11
3
+ Version: 0.0.12
4
4
  Summary: A streaming multipart parser for Python
5
5
  Project-URL: Homepage, https://github.com/Kludex/python-multipart
6
6
  Project-URL: Documentation, https://kludex.github.io/python-multipart/
@@ -2,7 +2,7 @@
2
2
  __author__ = "Andrew Dunham"
3
3
  __license__ = "Apache"
4
4
  __copyright__ = "Copyright (c) 2012-2013, Andrew Dunham"
5
- __version__ = "0.0.11"
5
+ __version__ = "0.0.12"
6
6
 
7
7
  from .multipart import (
8
8
  BaseParser,
@@ -1,9 +1,22 @@
1
1
  import base64
2
2
  import binascii
3
- from io import BufferedWriter
3
+ from typing import TYPE_CHECKING
4
4
 
5
5
  from .exceptions import DecodeError
6
6
 
7
+ if TYPE_CHECKING: # pragma: no cover
8
+ from typing import Protocol, TypeVar
9
+
10
+ _T_contra = TypeVar("_T_contra", contravariant=True)
11
+
12
+ class SupportsWrite(Protocol[_T_contra]):
13
+ def write(self, __b: _T_contra) -> object: ...
14
+
15
+ # No way to specify optional methods. See
16
+ # https://github.com/python/typing/issues/601
17
+ # close() [Optional]
18
+ # finalize() [Optional]
19
+
7
20
 
8
21
  class Base64Decoder:
9
22
  """This object provides an interface to decode a stream of Base64 data. It
@@ -34,7 +47,7 @@ class Base64Decoder:
34
47
  :param underlying: the underlying object to pass writes to
35
48
  """
36
49
 
37
- def __init__(self, underlying: BufferedWriter):
50
+ def __init__(self, underlying: "SupportsWrite[bytes]") -> None:
38
51
  self.cache = bytearray()
39
52
  self.underlying = underlying
40
53
 
@@ -67,9 +80,9 @@ class Base64Decoder:
67
80
  # Get the remaining bytes and save in our cache.
68
81
  remaining_len = len(data) % 4
69
82
  if remaining_len > 0:
70
- self.cache = data[-remaining_len:]
83
+ self.cache[:] = data[-remaining_len:]
71
84
  else:
72
- self.cache = b""
85
+ self.cache[:] = b""
73
86
 
74
87
  # Return the length of the data to indicate no error.
75
88
  return len(data)
@@ -112,7 +125,7 @@ class QuotedPrintableDecoder:
112
125
  :param underlying: the underlying object to pass writes to
113
126
  """
114
127
 
115
- def __init__(self, underlying: BufferedWriter) -> None:
128
+ def __init__(self, underlying: "SupportsWrite[bytes]") -> None:
116
129
  self.cache = b""
117
130
  self.underlying = underlying
118
131
 
@@ -160,7 +173,7 @@ class QuotedPrintableDecoder:
160
173
  call it.
161
174
  """
162
175
  # If we have a cache, write and then remove it.
163
- if len(self.cache) > 0:
176
+ if len(self.cache) > 0: # pragma: no cover
164
177
  self.underlying.write(binascii.a2b_qp(self.cache))
165
178
  self.cache = b""
166
179
 
@@ -1,6 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- import io
4
3
  import logging
5
4
  import os
6
5
  import shutil
@@ -8,15 +7,20 @@ import sys
8
7
  import tempfile
9
8
  from email.message import Message
10
9
  from enum import IntEnum
11
- from io import BytesIO
10
+ from io import BufferedRandom, BytesIO
12
11
  from numbers import Number
13
- from typing import TYPE_CHECKING, Any
12
+ from typing import TYPE_CHECKING, cast
14
13
 
15
14
  from .decoders import Base64Decoder, QuotedPrintableDecoder
16
15
  from .exceptions import FileError, FormParserError, MultipartParseError, QuerystringParseError
17
16
 
18
17
  if TYPE_CHECKING: # pragma: no cover
19
- from typing import Callable, Protocol, TypedDict
18
+ from typing import Any, Callable, Literal, Protocol, TypedDict
19
+
20
+ from typing_extensions import TypeAlias
21
+
22
+ class SupportsRead(Protocol):
23
+ def read(self, __n: int) -> bytes: ...
20
24
 
21
25
  class QuerystringCallbacks(TypedDict, total=False):
22
26
  on_field_start: Callable[[], None]
@@ -64,7 +68,7 @@ if TYPE_CHECKING: # pragma: no cover
64
68
  def close(self) -> None: ...
65
69
 
66
70
  class FieldProtocol(_FormProtocol, Protocol):
67
- def __init__(self, name: bytes) -> None: ...
71
+ def __init__(self, name: bytes | None) -> None: ...
68
72
 
69
73
  def set_none(self) -> None: ...
70
74
 
@@ -74,6 +78,23 @@ if TYPE_CHECKING: # pragma: no cover
74
78
  OnFieldCallback = Callable[[FieldProtocol], None]
75
79
  OnFileCallback = Callable[[FileProtocol], None]
76
80
 
81
+ CallbackName: TypeAlias = Literal[
82
+ "start",
83
+ "data",
84
+ "end",
85
+ "field_start",
86
+ "field_name",
87
+ "field_data",
88
+ "field_end",
89
+ "part_begin",
90
+ "part_data",
91
+ "part_end",
92
+ "header_begin",
93
+ "header_field",
94
+ "header_value",
95
+ "header_end",
96
+ "headers_finished",
97
+ ]
77
98
 
78
99
  # Unique missing object.
79
100
  _missing = object()
@@ -142,11 +163,7 @@ TOKEN_CHARS_SET = frozenset(
142
163
  # fmt: on
143
164
 
144
165
 
145
- def ord_char(c: int) -> int:
146
- return c
147
-
148
-
149
- def parse_options_header(value: str | bytes) -> tuple[bytes, dict[bytes, bytes]]:
166
+ def parse_options_header(value: str | bytes | None) -> tuple[bytes, dict[bytes, bytes]]:
150
167
  """Parses a Content-Type header into a value in the following format: (content_type, {parameters})."""
151
168
  # Uses email.message.Message to parse the header as described in PEP 594.
152
169
  # Ref: https://peps.python.org/pep-0594/#cgi
@@ -206,7 +223,7 @@ class Field:
206
223
  name: The name of the form field.
207
224
  """
208
225
 
209
- def __init__(self, name: bytes) -> None:
226
+ def __init__(self, name: bytes | None) -> None:
210
227
  self._name = name
211
228
  self._value: list[bytes] = []
212
229
 
@@ -287,7 +304,7 @@ class Field:
287
304
  self._cache = None
288
305
 
289
306
  @property
290
- def field_name(self) -> bytes:
307
+ def field_name(self) -> bytes | None:
291
308
  """This property returns the name of the field."""
292
309
  return self._name
293
310
 
@@ -297,6 +314,7 @@ class Field:
297
314
  if self._cache is _missing:
298
315
  self._cache = b"".join(self._value)
299
316
 
317
+ assert isinstance(self._cache, bytes) or self._cache is None
300
318
  return self._cache
301
319
 
302
320
  def __eq__(self, other: object) -> bool:
@@ -345,7 +363,7 @@ class File:
345
363
  self._config = config
346
364
  self._in_memory = True
347
365
  self._bytes_written = 0
348
- self._fileobj = BytesIO()
366
+ self._fileobj: BytesIO | BufferedRandom = BytesIO()
349
367
 
350
368
  # Save the provided field/file name.
351
369
  self._field_name = field_name
@@ -353,7 +371,7 @@ class File:
353
371
 
354
372
  # Our actual file name is None by default, since, depending on our
355
373
  # config, we may not actually use the provided name.
356
- self._actual_file_name = None
374
+ self._actual_file_name: bytes | None = None
357
375
 
358
376
  # Split the extension from the filename.
359
377
  if file_name is not None:
@@ -374,14 +392,14 @@ class File:
374
392
  return self._file_name
375
393
 
376
394
  @property
377
- def actual_file_name(self):
395
+ def actual_file_name(self) -> bytes | None:
378
396
  """The file name that this file is saved as. Will be None if it's not
379
397
  currently saved on disk.
380
398
  """
381
399
  return self._actual_file_name
382
400
 
383
401
  @property
384
- def file_object(self):
402
+ def file_object(self) -> BytesIO | BufferedRandom:
385
403
  """The file object that we're currently writing to. Note that this
386
404
  will either be an instance of a :class:`io.BytesIO`, or a regular file
387
405
  object.
@@ -436,7 +454,7 @@ class File:
436
454
  # Close the old file object.
437
455
  old_fileobj.close()
438
456
 
439
- def _get_disk_file(self) -> io.BufferedRandom | tempfile._TemporaryFileWrapper[bytes]: # type: ignore[reportPrivateUsage]
457
+ def _get_disk_file(self) -> BufferedRandom:
440
458
  """This function is responsible for getting a file object on-disk for us."""
441
459
  self.logger.info("Opening a file on disk")
442
460
 
@@ -444,6 +462,7 @@ class File:
444
462
  keep_filename = self._config.get("UPLOAD_KEEP_FILENAME", False)
445
463
  keep_extensions = self._config.get("UPLOAD_KEEP_EXTENSIONS", False)
446
464
  delete_tmp = self._config.get("UPLOAD_DELETE_TMP", True)
465
+ tmp_file: None | BufferedRandom = None
447
466
 
448
467
  # If we have a directory and are to keep the filename...
449
468
  if file_dir is not None and keep_filename:
@@ -453,7 +472,7 @@ class File:
453
472
  # TODO: what happens if we don't have a filename?
454
473
  fname = self._file_base + self._ext if keep_extensions else self._file_base
455
474
 
456
- path = os.path.join(file_dir, fname)
475
+ path = os.path.join(file_dir, fname) # type: ignore[arg-type]
457
476
  try:
458
477
  self.logger.info("Opening file: %r", path)
459
478
  tmp_file = open(path, "w+b")
@@ -473,23 +492,24 @@ class File:
473
492
  elif isinstance(file_dir, bytes):
474
493
  dir = file_dir.decode(sys.getfilesystemencoding())
475
494
  else:
476
- dir = file_dir
495
+ dir = file_dir # pragma: no cover
477
496
 
478
497
  # Create a temporary (named) file with the appropriate settings.
479
498
  self.logger.info(
480
499
  "Creating a temporary file with options: %r", {"suffix": suffix, "delete": delete_tmp, "dir": dir}
481
500
  )
482
501
  try:
483
- tmp_file = tempfile.NamedTemporaryFile(suffix=suffix, delete=delete_tmp, dir=dir)
502
+ tmp_file = cast(BufferedRandom, tempfile.NamedTemporaryFile(suffix=suffix, delete=delete_tmp, dir=dir))
484
503
  except OSError:
485
504
  self.logger.exception("Error creating named temporary file")
486
505
  raise FileError("Error creating named temporary file")
487
506
 
488
- fname = tmp_file.name
489
-
507
+ assert tmp_file is not None
490
508
  # Encode filename as bytes.
491
- if isinstance(fname, str):
492
- fname = fname.encode(sys.getfilesystemencoding())
509
+ if isinstance(tmp_file.name, str):
510
+ fname = tmp_file.name.encode(sys.getfilesystemencoding())
511
+ else:
512
+ fname = cast(bytes, tmp_file.name) # pragma: no cover
493
513
 
494
514
  self._actual_file_name = fname
495
515
  return tmp_file
@@ -511,11 +531,7 @@ class File:
511
531
  Returns:
512
532
  The number of bytes written.
513
533
  """
514
- pos = self._fileobj.tell()
515
534
  bwritten = self._fileobj.write(data)
516
- # true file objects write returns None
517
- if bwritten is None:
518
- bwritten = self._fileobj.tell() - pos
519
535
 
520
536
  # If the bytes written isn't the same as the length, just return.
521
537
  if bwritten != len(data):
@@ -579,8 +595,11 @@ class BaseParser:
579
595
 
580
596
  def __init__(self) -> None:
581
597
  self.logger = logging.getLogger(__name__)
598
+ self.callbacks: QuerystringCallbacks | OctetStreamCallbacks | MultipartCallbacks = {}
582
599
 
583
- def callback(self, name: str, data: bytes | None = None, start: int | None = None, end: int | None = None):
600
+ def callback(
601
+ self, name: CallbackName, data: bytes | None = None, start: int | None = None, end: int | None = None
602
+ ) -> None:
584
603
  """This function calls a provided callback with some data. If the
585
604
  callback is not set, will do nothing.
586
605
 
@@ -591,24 +610,24 @@ class BaseParser:
591
610
  end: An integer that is passed to the data callback.
592
611
  start: An integer that is passed to the data callback.
593
612
  """
594
- name = "on_" + name
595
- func = self.callbacks.get(name)
613
+ on_name = "on_" + name
614
+ func = self.callbacks.get(on_name)
596
615
  if func is None:
597
616
  return
598
-
617
+ func = cast("Callable[..., Any]", func)
599
618
  # Depending on whether we're given a buffer...
600
619
  if data is not None:
601
620
  # Don't do anything if we have start == end.
602
621
  if start is not None and start == end:
603
622
  return
604
623
 
605
- self.logger.debug("Calling %s with data[%d:%d]", name, start, end)
624
+ self.logger.debug("Calling %s with data[%d:%d]", on_name, start, end)
606
625
  func(data, start, end)
607
626
  else:
608
- self.logger.debug("Calling %s with no data", name)
627
+ self.logger.debug("Calling %s with no data", on_name)
609
628
  func()
610
629
 
611
- def set_callback(self, name: str, new_func: Callable[..., Any] | None) -> None:
630
+ def set_callback(self, name: CallbackName, new_func: Callable[..., Any] | None) -> None:
612
631
  """Update the function for a callback. Removes from the callbacks dict
613
632
  if new_func is None.
614
633
 
@@ -619,17 +638,17 @@ class BaseParser:
619
638
  exist).
620
639
  """
621
640
  if new_func is None:
622
- self.callbacks.pop("on_" + name, None)
641
+ self.callbacks.pop("on_" + name, None) # type: ignore[misc]
623
642
  else:
624
- self.callbacks["on_" + name] = new_func
643
+ self.callbacks["on_" + name] = new_func # type: ignore[literal-required]
625
644
 
626
- def close(self):
645
+ def close(self) -> None:
627
646
  pass # pragma: no cover
628
647
 
629
- def finalize(self):
648
+ def finalize(self) -> None:
630
649
  pass # pragma: no cover
631
650
 
632
- def __repr__(self):
651
+ def __repr__(self) -> str:
633
652
  return "%s()" % self.__class__.__name__
634
653
 
635
654
 
@@ -655,7 +674,7 @@ class OctetStreamParser(BaseParser):
655
674
 
656
675
  if not isinstance(max_size, Number) or max_size < 1:
657
676
  raise ValueError("max_size must be a positive number, not %r" % max_size)
658
- self.max_size = max_size
677
+ self.max_size: int | float = max_size
659
678
  self._current_size = 0
660
679
 
661
680
  def write(self, data: bytes) -> int:
@@ -737,7 +756,7 @@ class QuerystringParser(BaseParser):
737
756
  # Max-size stuff
738
757
  if not isinstance(max_size, Number) or max_size < 1:
739
758
  raise ValueError("max_size must be a positive number, not %r" % max_size)
740
- self.max_size = max_size
759
+ self.max_size: int | float = max_size
741
760
  self._current_size = 0
742
761
 
743
762
  # Should parsing be strict?
@@ -1027,7 +1046,7 @@ class MultipartParser(BaseParser):
1027
1046
  i = 0
1028
1047
 
1029
1048
  # Set a mark.
1030
- def set_mark(name: str):
1049
+ def set_mark(name: str) -> None:
1031
1050
  self.marks[name] = i
1032
1051
 
1033
1052
  # Remove a mark.
@@ -1039,7 +1058,7 @@ class MultipartParser(BaseParser):
1039
1058
  # end of the buffer, and reset the mark, instead of deleting it. This
1040
1059
  # is used at the end of the function to call our callbacks with any
1041
1060
  # remaining data in this chunk.
1042
- def data_callback(name: str, end_i: int, remaining: bool = False) -> None:
1061
+ def data_callback(name: CallbackName, end_i: int, remaining: bool = False) -> None:
1043
1062
  marked_index = self.marks.get(name)
1044
1063
  if marked_index is None:
1045
1064
  return
@@ -1131,7 +1150,7 @@ class MultipartParser(BaseParser):
1131
1150
  else:
1132
1151
  # Check to ensure our boundary matches
1133
1152
  if c != boundary[index + 2]:
1134
- msg = "Did not find boundary character %r at index " "%d" % (c, index + 2)
1153
+ msg = "Expected boundary character %r, got %r at index %d" % (boundary[index + 2], c, index + 2)
1135
1154
  self.logger.warning(msg)
1136
1155
  e = MultipartParseError(msg)
1137
1156
  e.offset = i
@@ -1381,7 +1400,7 @@ class MultipartParser(BaseParser):
1381
1400
  elif state == MultipartState.END:
1382
1401
  # Do nothing and just consume a byte in the end state.
1383
1402
  if c not in (CR, LF):
1384
- self.logger.warning("Consuming a byte '0x%x' in the end state", c)
1403
+ self.logger.warning("Consuming a byte '0x%x' in the end state", c) # pragma: no cover
1385
1404
 
1386
1405
  else: # pragma: no cover (error case)
1387
1406
  # We got into a strange state somehow! Just stop processing.
@@ -1479,8 +1498,8 @@ class FormParser:
1479
1498
  def __init__(
1480
1499
  self,
1481
1500
  content_type: str,
1482
- on_field: OnFieldCallback,
1483
- on_file: OnFileCallback,
1501
+ on_field: OnFieldCallback | None,
1502
+ on_file: OnFileCallback | None,
1484
1503
  on_end: Callable[[], None] | None = None,
1485
1504
  boundary: bytes | str | None = None,
1486
1505
  file_name: bytes | None = None,
@@ -1506,8 +1525,10 @@ class FormParser:
1506
1525
  self.FieldClass = Field
1507
1526
 
1508
1527
  # Set configuration options.
1509
- self.config = self.DEFAULT_CONFIG.copy()
1510
- self.config.update(config)
1528
+ self.config: FormParserConfig = self.DEFAULT_CONFIG.copy()
1529
+ self.config.update(config) # type: ignore[typeddict-item]
1530
+
1531
+ parser: OctetStreamParser | MultipartParser | QuerystringParser | None = None
1511
1532
 
1512
1533
  # Depending on the Content-Type, we instantiate the correct parser.
1513
1534
  if content_type == "application/octet-stream":
@@ -1515,7 +1536,7 @@ class FormParser:
1515
1536
 
1516
1537
  def on_start() -> None:
1517
1538
  nonlocal file
1518
- file = FileClass(file_name, None, config=self.config)
1539
+ file = FileClass(file_name, None, config=cast("FileConfig", self.config))
1519
1540
 
1520
1541
  def on_data(data: bytes, start: int, end: int) -> None:
1521
1542
  nonlocal file
@@ -1527,7 +1548,8 @@ class FormParser:
1527
1548
  file.finalize()
1528
1549
 
1529
1550
  # Call our callback.
1530
- on_file(file)
1551
+ if on_file:
1552
+ on_file(file)
1531
1553
 
1532
1554
  # Call the on-end callback.
1533
1555
  if self.on_end is not None:
@@ -1542,7 +1564,7 @@ class FormParser:
1542
1564
  elif content_type == "application/x-www-form-urlencoded" or content_type == "application/x-url-encoded":
1543
1565
  name_buffer: list[bytes] = []
1544
1566
 
1545
- f: FieldProtocol = None # type: ignore
1567
+ f: FieldProtocol | None = None
1546
1568
 
1547
1569
  def on_field_start() -> None:
1548
1570
  pass
@@ -1568,7 +1590,8 @@ class FormParser:
1568
1590
  f.set_none()
1569
1591
 
1570
1592
  f.finalize()
1571
- on_field(f)
1593
+ if on_field:
1594
+ on_field(f)
1572
1595
  f = None
1573
1596
 
1574
1597
  def _on_end() -> None:
@@ -1594,30 +1617,33 @@ class FormParser:
1594
1617
 
1595
1618
  header_name: list[bytes] = []
1596
1619
  header_value: list[bytes] = []
1597
- headers = {}
1620
+ headers: dict[bytes, bytes] = {}
1598
1621
 
1599
- f: FileProtocol | FieldProtocol | None = None
1622
+ f_multi: FileProtocol | FieldProtocol | None = None
1600
1623
  writer = None
1601
1624
  is_file = False
1602
1625
 
1603
- def on_part_begin():
1626
+ def on_part_begin() -> None:
1604
1627
  # Reset headers in case this isn't the first part.
1605
1628
  nonlocal headers
1606
1629
  headers = {}
1607
1630
 
1608
1631
  def on_part_data(data: bytes, start: int, end: int) -> None:
1609
1632
  nonlocal writer
1610
- bytes_processed = writer.write(data[start:end])
1633
+ assert writer is not None
1634
+ writer.write(data[start:end])
1611
1635
  # TODO: check for error here.
1612
- return bytes_processed
1613
1636
 
1614
1637
  def on_part_end() -> None:
1615
- nonlocal f, is_file
1616
- f.finalize()
1638
+ nonlocal f_multi, is_file
1639
+ assert f_multi is not None
1640
+ f_multi.finalize()
1617
1641
  if is_file:
1618
- on_file(f)
1642
+ if on_file:
1643
+ on_file(f_multi)
1619
1644
  else:
1620
- on_field(f)
1645
+ if on_field:
1646
+ on_field(cast("FieldProtocol", f_multi))
1621
1647
 
1622
1648
  def on_header_field(data: bytes, start: int, end: int) -> None:
1623
1649
  header_name.append(data[start:end])
@@ -1631,7 +1657,7 @@ class FormParser:
1631
1657
  del header_value[:]
1632
1658
 
1633
1659
  def on_headers_finished() -> None:
1634
- nonlocal is_file, f, writer
1660
+ nonlocal is_file, f_multi, writer
1635
1661
  # Reset the 'is file' flag.
1636
1662
  is_file = False
1637
1663
 
@@ -1647,9 +1673,9 @@ class FormParser:
1647
1673
 
1648
1674
  # Create the proper class.
1649
1675
  if file_name is None:
1650
- f = FieldClass(field_name)
1676
+ f_multi = FieldClass(field_name)
1651
1677
  else:
1652
- f = FileClass(file_name, field_name, config=self.config)
1678
+ f_multi = FileClass(file_name, field_name, config=cast("FileConfig", self.config))
1653
1679
  is_file = True
1654
1680
 
1655
1681
  # Parse the given Content-Transfer-Encoding to determine what
@@ -1658,25 +1684,26 @@ class FormParser:
1658
1684
  transfer_encoding = headers.get(b"Content-Transfer-Encoding", b"7bit")
1659
1685
 
1660
1686
  if transfer_encoding in (b"binary", b"8bit", b"7bit"):
1661
- writer = f
1687
+ writer = f_multi
1662
1688
 
1663
1689
  elif transfer_encoding == b"base64":
1664
- writer = Base64Decoder(f)
1690
+ writer = Base64Decoder(f_multi)
1665
1691
 
1666
1692
  elif transfer_encoding == b"quoted-printable":
1667
- writer = QuotedPrintableDecoder(f)
1693
+ writer = QuotedPrintableDecoder(f_multi)
1668
1694
 
1669
1695
  else:
1670
1696
  self.logger.warning("Unknown Content-Transfer-Encoding: %r", transfer_encoding)
1671
1697
  if self.config["UPLOAD_ERROR_ON_BAD_CTE"]:
1672
- raise FormParserError('Unknown Content-Transfer-Encoding "{}"'.format(transfer_encoding))
1698
+ raise FormParserError('Unknown Content-Transfer-Encoding "{!r}"'.format(transfer_encoding))
1673
1699
  else:
1674
1700
  # If we aren't erroring, then we just treat this as an
1675
1701
  # unencoded Content-Transfer-Encoding.
1676
- writer = f
1702
+ writer = f_multi
1677
1703
 
1678
1704
  def _on_end() -> None:
1679
1705
  nonlocal writer
1706
+ assert writer is not None
1680
1707
  writer.finalize()
1681
1708
  if self.on_end is not None:
1682
1709
  self.on_end()
@@ -1715,6 +1742,7 @@ class FormParser:
1715
1742
  """
1716
1743
  self.bytes_received += len(data)
1717
1744
  # TODO: check the parser's return value for errors?
1745
+ assert self.parser is not None
1718
1746
  return self.parser.write(data)
1719
1747
 
1720
1748
  def finalize(self) -> None:
@@ -1733,8 +1761,8 @@ class FormParser:
1733
1761
 
1734
1762
  def create_form_parser(
1735
1763
  headers: dict[str, bytes],
1736
- on_field: OnFieldCallback,
1737
- on_file: OnFileCallback,
1764
+ on_field: OnFieldCallback | None,
1765
+ on_file: OnFileCallback | None,
1738
1766
  trust_x_headers: bool = False,
1739
1767
  config: dict[Any, Any] = {},
1740
1768
  ) -> FormParser:
@@ -1752,7 +1780,7 @@ def create_form_parser(
1752
1780
  name from X-File-Name.
1753
1781
  config: Configuration variables to pass to the FormParser.
1754
1782
  """
1755
- content_type = headers.get("Content-Type")
1783
+ content_type: str | bytes | None = headers.get("Content-Type")
1756
1784
  if content_type is None:
1757
1785
  logging.getLogger(__name__).warning("No Content-Type header given")
1758
1786
  raise ValueError("No Content-Type header given!")
@@ -1777,9 +1805,9 @@ def create_form_parser(
1777
1805
 
1778
1806
  def parse_form(
1779
1807
  headers: dict[str, bytes],
1780
- input_stream: io.FileIO,
1781
- on_field: OnFieldCallback,
1782
- on_file: OnFileCallback,
1808
+ input_stream: SupportsRead,
1809
+ on_field: OnFieldCallback | None,
1810
+ on_file: OnFileCallback | None,
1783
1811
  chunk_size: int = 1048576,
1784
1812
  ) -> None:
1785
1813
  """This function is useful if you just want to parse a request body,
@@ -1800,7 +1828,7 @@ def parse_form(
1800
1828
 
1801
1829
  # Read chunks of 1MiB and write to the parser, but never read more than
1802
1830
  # the given Content-Length, if any.
1803
- content_length = headers.get("Content-Length")
1831
+ content_length: int | float | bytes | None = headers.get("Content-Length")
1804
1832
  if content_length is not None:
1805
1833
  content_length = int(content_length)
1806
1834
  else:
@@ -1809,7 +1837,7 @@ def parse_form(
1809
1837
 
1810
1838
  while True:
1811
1839
  # Read only up to the Content-Length given.
1812
- max_readable = min(content_length - bytes_read, chunk_size)
1840
+ max_readable = int(min(content_length - bytes_read, chunk_size))
1813
1841
  buff = input_stream.read(max_readable)
1814
1842
 
1815
1843
  # Write to the parser and update our length.
@@ -45,6 +45,8 @@ dev-dependencies = [
45
45
  "invoke==2.2.0",
46
46
  "pytest-timeout==2.3.1",
47
47
  "ruff==0.3.4",
48
+ "mypy",
49
+ "types-PyYAML",
48
50
  "atheris==2.3.0; python_version != '3.12'",
49
51
  # Documentation
50
52
  "mkdocs",
@@ -68,6 +70,9 @@ packages = ["multipart"]
68
70
  [tool.hatch.build.targets.sdist]
69
71
  include = ["/multipart", "/tests", "CHANGELOG.md", "LICENSE.txt"]
70
72
 
73
+ [tool.mypy]
74
+ strict = true
75
+
71
76
  [tool.ruff]
72
77
  line-length = 120
73
78
 
@@ -87,7 +92,7 @@ branch = false
87
92
  omit = ["tests/*"]
88
93
 
89
94
  [tool.coverage.report]
90
- # fail_under = 100
95
+ fail_under = 100
91
96
  skip_covered = true
92
97
  show_missing = true
93
98
  exclude_lines = [
File without changes