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.
- {python_multipart-0.0.11 → python_multipart-0.0.12}/.gitignore +1 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/CHANGELOG.md +6 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/PKG-INFO +1 -1
- {python_multipart-0.0.11 → python_multipart-0.0.12}/multipart/__init__.py +1 -1
- {python_multipart-0.0.11 → python_multipart-0.0.12}/multipart/decoders.py +19 -6
- {python_multipart-0.0.11 → python_multipart-0.0.12}/multipart/multipart.py +107 -79
- {python_multipart-0.0.11 → python_multipart-0.0.12}/pyproject.toml +6 -1
- python_multipart-0.0.12/tests/__init__.py +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/compat.py +17 -9
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_multipart.py +190 -168
- {python_multipart-0.0.11 → python_multipart-0.0.12}/LICENSE.txt +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/README.md +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/multipart/exceptions.py +0 -0
- /python_multipart-0.0.11/tests/__init__.py → /python_multipart-0.0.12/multipart/py.typed +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/CRLF_in_header.http +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/CRLF_in_header.yaml +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/CR_in_header.http +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/CR_in_header.yaml +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/CR_in_header_value.http +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/CR_in_header_value.yaml +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/almost_match_boundary.http +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/almost_match_boundary.yaml +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/almost_match_boundary_without_CR.http +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/almost_match_boundary_without_CR.yaml +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/almost_match_boundary_without_LF.http +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/almost_match_boundary_without_LF.yaml +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/almost_match_boundary_without_final_hyphen.http +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/almost_match_boundary_without_final_hyphen.yaml +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/bad_end_of_headers.http +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/bad_end_of_headers.yaml +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/bad_header_char.http +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/bad_header_char.yaml +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/bad_initial_boundary.http +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/bad_initial_boundary.yaml +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/base64_encoding.http +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/base64_encoding.yaml +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/empty_header.http +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/empty_header.yaml +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/header_with_number.http +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/header_with_number.yaml +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/mixed_plain_and_base64_encoding.http +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/mixed_plain_and_base64_encoding.yaml +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/multiple_fields.http +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/multiple_fields.yaml +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/multiple_files.http +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/multiple_files.yaml +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/quoted_printable_encoding.http +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/quoted_printable_encoding.yaml +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/single_field.http +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/single_field.yaml +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/single_field_blocks.http +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/single_field_blocks.yaml +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/single_field_longer.http +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/single_field_longer.yaml +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/single_field_single_file.http +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/single_field_single_file.yaml +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/single_field_with_leading_newlines.http +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/single_field_with_leading_newlines.yaml +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/single_file.http +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/single_file.yaml +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/utf8_filename.http +0 -0
- {python_multipart-0.0.11 → python_multipart-0.0.12}/tests/test_data/http/utf8_filename.yaml +0 -0
|
@@ -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.
|
|
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/
|
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
import base64
|
|
2
2
|
import binascii
|
|
3
|
-
from
|
|
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:
|
|
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:
|
|
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,
|
|
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
|
|
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) ->
|
|
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
|
-
|
|
489
|
-
|
|
507
|
+
assert tmp_file is not None
|
|
490
508
|
# Encode filename as bytes.
|
|
491
|
-
if isinstance(
|
|
492
|
-
fname =
|
|
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(
|
|
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
|
-
|
|
595
|
-
func = self.callbacks.get(
|
|
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]",
|
|
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",
|
|
627
|
+
self.logger.debug("Calling %s with no data", on_name)
|
|
609
628
|
func()
|
|
610
629
|
|
|
611
|
-
def set_callback(self, name:
|
|
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:
|
|
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 = "
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1616
|
-
|
|
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
|
|
1642
|
+
if on_file:
|
|
1643
|
+
on_file(f_multi)
|
|
1619
1644
|
else:
|
|
1620
|
-
on_field
|
|
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,
|
|
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
|
-
|
|
1676
|
+
f_multi = FieldClass(field_name)
|
|
1651
1677
|
else:
|
|
1652
|
-
|
|
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 =
|
|
1687
|
+
writer = f_multi
|
|
1662
1688
|
|
|
1663
1689
|
elif transfer_encoding == b"base64":
|
|
1664
|
-
writer = Base64Decoder(
|
|
1690
|
+
writer = Base64Decoder(f_multi)
|
|
1665
1691
|
|
|
1666
1692
|
elif transfer_encoding == b"quoted-printable":
|
|
1667
|
-
writer = QuotedPrintableDecoder(
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
95
|
+
fail_under = 100
|
|
91
96
|
skip_covered = true
|
|
92
97
|
show_missing = true
|
|
93
98
|
exclude_lines = [
|
|
File without changes
|