From dcf0ba14a5dbf23d61a9116b1ec8f60952cf485b Mon Sep 17 00:00:00 2001 From: John Stark Date: Sat, 28 Sep 2024 10:33:28 +0100 Subject: [PATCH 01/31] Handle invalid CRLF in header name. fixes #122 (#141) --- multipart/multipart.py | 2 +- tests/test_data/http/CRLF_in_header.http | 6 ++++++ tests/test_data/http/CRLF_in_header.yaml | 3 +++ tests/test_data/http/CR_in_header.yaml | 2 +- 4 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 tests/test_data/http/CRLF_in_header.http create mode 100644 tests/test_data/http/CRLF_in_header.yaml diff --git a/multipart/multipart.py b/multipart/multipart.py index 0d0ee8c..3275075 100644 --- a/multipart/multipart.py +++ b/multipart/multipart.py @@ -1163,7 +1163,7 @@ def data_callback(name: str, remaining: bool = False) -> None: # If we've reached a CR at the beginning of a header, it means # that we've reached the second of 2 newlines, and so there are # no more headers to parse. - if c == CR: + if c == CR and index == 0: delete_mark("header_field") state = MultipartState.HEADERS_ALMOST_DONE i += 1 diff --git a/tests/test_data/http/CRLF_in_header.http b/tests/test_data/http/CRLF_in_header.http new file mode 100644 index 0000000..41e9e0b --- /dev/null +++ b/tests/test_data/http/CRLF_in_header.http @@ -0,0 +1,6 @@ +------WebKitFormBoundaryTkr3kCBQlBe1nrhc +Content- +isposition: form-data; name="field" + +This is a test. +------WebKitFormBoundaryTkr3kCBQlBe1nrhc-- \ No newline at end of file diff --git a/tests/test_data/http/CRLF_in_header.yaml b/tests/test_data/http/CRLF_in_header.yaml new file mode 100644 index 0000000..9d5f62a --- /dev/null +++ b/tests/test_data/http/CRLF_in_header.yaml @@ -0,0 +1,3 @@ +boundary: ----WebKitFormBoundaryTkr3kCBQlBe1nrhc +expected: + error: 50 diff --git a/tests/test_data/http/CR_in_header.yaml b/tests/test_data/http/CR_in_header.yaml index c9b55f2..9d5f62a 100644 --- a/tests/test_data/http/CR_in_header.yaml +++ b/tests/test_data/http/CR_in_header.yaml @@ -1,3 +1,3 @@ boundary: ----WebKitFormBoundaryTkr3kCBQlBe1nrhc expected: - error: 51 + error: 50 From a790e40477bf7e4be04dd709272660a0ef62984c Mon Sep 17 00:00:00 2001 From: John Stark Date: Sat, 28 Sep 2024 11:19:11 +0100 Subject: [PATCH 02/31] Improve performance, especially in data with many CR-LF (#137) * Improve parsing content with many cr-lf Drops the look-behind buffer since the content is always the boundary. * Improve performance by using built-in bytes.find. The Boyer-Moore-Horspool algorithm was removed and replaced with Python's built-in `find` method. This appears to be faster, sometimes by an order of magnitude. * Delete unused join_bytes --------- Co-authored-by: Marcelo Trylesinski --- multipart/multipart.py | 130 +++++++++++++++++++--------------------- tests/test_multipart.py | 35 ++++++++--- 2 files changed, 89 insertions(+), 76 deletions(-) diff --git a/multipart/multipart.py b/multipart/multipart.py index 3275075..eac3ff8 100644 --- a/multipart/multipart.py +++ b/multipart/multipart.py @@ -146,10 +146,6 @@ def ord_char(c: int) -> int: return c -def join_bytes(b: bytes) -> bytes: - return bytes(list(b)) - - def parse_options_header(value: str | bytes) -> tuple[bytes, dict[bytes, bytes]]: """Parses a Content-Type header into a value in the following format: (content_type, {parameters}).""" # Uses email.message.Message to parse the header as described in PEP 594. @@ -976,29 +972,11 @@ def __init__( # Setup marks. These are used to track the state of data received. self.marks: dict[str, int] = {} - # TODO: Actually use this rather than the dumb version we currently use - # # Precompute the skip table for the Boyer-Moore-Horspool algorithm. - # skip = [len(boundary) for x in range(256)] - # for i in range(len(boundary) - 1): - # skip[ord_char(boundary[i])] = len(boundary) - i - 1 - # - # # We use a tuple since it's a constant, and marginally faster. - # self.skip = tuple(skip) - # Save our boundary. if isinstance(boundary, str): # pragma: no cover boundary = boundary.encode("latin-1") self.boundary = b"\r\n--" + boundary - # Get a set of characters that belong to our boundary. - self.boundary_chars = frozenset(self.boundary) - - # We also create a lookbehind list. - # Note: the +8 is since we can have, at maximum, "\r\n--" + boundary + - # "--\r\n" at the final boundary, and the length of '\r\n--' and - # '--\r\n' is 8 bytes. - self.lookbehind = [NULL for _ in range(len(boundary) + 8)] - def write(self, data: bytes) -> int: """Write some data to the parser, which will perform size verification, and then parse the data into the appropriate location (e.g. header, @@ -1061,21 +1039,43 @@ def delete_mark(name: str, reset: bool = False) -> None: # end of the buffer, and reset the mark, instead of deleting it. This # is used at the end of the function to call our callbacks with any # remaining data in this chunk. - def data_callback(name: str, remaining: bool = False) -> None: + def data_callback(name: str, end_i: int, remaining: bool = False) -> None: marked_index = self.marks.get(name) if marked_index is None: return - # If we're getting remaining data, we ignore the current i value - # and just call with the remaining data. - if remaining: - self.callback(name, data, marked_index, length) - self.marks[name] = 0 - # Otherwise, we call it from the mark to the current byte we're # processing. + if end_i <= marked_index: + # There is no additional data to send. + pass + elif marked_index >= 0: + # We are emitting data from the local buffer. + self.callback(name, data, marked_index, end_i) + else: + # Some of the data comes from a partial boundary match. + # and requires look-behind. + # We need to use self.flags (and not flags) because we care about + # the state when we entered the loop. + lookbehind_len = -marked_index + if lookbehind_len <= len(boundary): + self.callback(name, boundary, 0, lookbehind_len) + elif self.flags & FLAG_PART_BOUNDARY: + lookback = boundary + b"\r\n" + self.callback(name, lookback, 0, lookbehind_len) + elif self.flags & FLAG_LAST_BOUNDARY: + lookback = boundary + b"--\r\n" + self.callback(name, lookback, 0, lookbehind_len) + else: # pragma: no cover (error case) + self.logger.warning("Look-back buffer error") + + if end_i > 0: + self.callback(name, data, 0, end_i) + # If we're getting remaining data, we have got all the data we + # can be certain is not a boundary, leaving only a partial boundary match. + if remaining: + self.marks[name] = end_i - length else: - self.callback(name, data, marked_index, i) self.marks.pop(name, None) # For each byte... @@ -1183,7 +1183,7 @@ def data_callback(name: str, remaining: bool = False) -> None: raise e # Call our callback with the header field. - data_callback("header_field") + data_callback("header_field", i) # Move to parsing the header value. state = MultipartState.HEADER_VALUE_START @@ -1212,7 +1212,7 @@ def data_callback(name: str, remaining: bool = False) -> None: # If we've got a CR, we're nearly done our headers. Otherwise, # we do nothing and just move past this character. if c == CR: - data_callback("header_value") + data_callback("header_value", i) self.callback("header_end") state = MultipartState.HEADER_VALUE_ALMOST_DONE @@ -1256,9 +1256,6 @@ def data_callback(name: str, remaining: bool = False) -> None: # We're processing our part data right now. During this, we # need to efficiently search for our boundary, since any data # on any number of lines can be a part of the current data. - # We use the Boyer-Moore-Horspool algorithm to efficiently - # search through the remainder of the buffer looking for our - # boundary. # Save the current value of our index. We use this in case we # find part of a boundary, but it doesn't match fully. @@ -1266,24 +1263,32 @@ def data_callback(name: str, remaining: bool = False) -> None: # Set up variables. boundary_length = len(boundary) - boundary_end = boundary_length - 1 data_length = length - boundary_chars = self.boundary_chars # If our index is 0, we're starting a new part, so start our # search. if index == 0: - # Search forward until we either hit the end of our buffer, - # or reach a character that's in our boundary. - i += boundary_end - while i < data_length - 1 and data[i] not in boundary_chars: - i += boundary_length - - # Reset i back the length of our boundary, which is the - # earliest possible location that could be our match (i.e. - # if we've just broken out of our loop since we saw the - # last character in our boundary) - i -= boundary_end + # The most common case is likely to be that the whole + # boundary is present in the buffer. + # Calling `find` is much faster than iterating here. + i0 = data.find(boundary, i, data_length) + if i0 >= 0: + # We matched the whole boundary string. + index = boundary_length - 1 + i = i0 + boundary_length - 1 + else: + # No match found for whole string. + # There may be a partial boundary at the end of the + # data, which the find will not match. + # Since the length should to be searched is limited to + # the boundary length, just perform a naive search. + i = max(i, data_length - boundary_length) + + # Search forward until we either hit the end of our buffer, + # or reach a potential start of the boundary. + while i < data_length - 1 and data[i] != boundary[0]: + i += 1 + c = data[i] # Now, we have a couple of cases here. If our index is before @@ -1291,11 +1296,6 @@ def data_callback(name: str, remaining: bool = False) -> None: if index < boundary_length: # If the character matches... if boundary[index] == c: - # If we found a match for our boundary, we send the - # existing data. - if index == 0: - data_callback("part_data") - # The current character matches, so continue! index += 1 else: @@ -1332,6 +1332,8 @@ def data_callback(name: str, remaining: bool = False) -> None: # Unset the part boundary flag. flags &= ~FLAG_PART_BOUNDARY + # We have identified a boundary, callback for any data before it. + data_callback("part_data", i - index) # Callback indicating that we've reached the end of # a part, and are starting a new one. self.callback("part_end") @@ -1353,6 +1355,8 @@ def data_callback(name: str, remaining: bool = False) -> None: elif flags & FLAG_LAST_BOUNDARY: # We need a second hyphen here. if c == HYPHEN: + # We have identified a boundary, callback for any data before it. + data_callback("part_data", i - index) # Callback to end the current part, and then the # message. self.callback("part_end") @@ -1362,26 +1366,14 @@ def data_callback(name: str, remaining: bool = False) -> None: # No match, so reset index. index = 0 - # If we have an index, we need to keep this byte for later, in - # case we can't match the full boundary. - if index > 0: - self.lookbehind[index - 1] = c - # Otherwise, our index is 0. If the previous index is not, it # means we reset something, and we need to take the data we # thought was part of our boundary and send it along as actual # data. - elif prev_index > 0: - # Callback to write the saved data. - lb_data = join_bytes(self.lookbehind) - self.callback("part_data", lb_data, 0, prev_index) - + if index == 0 and prev_index > 0: # Overwrite our previous index. prev_index = 0 - # Re-set our mark for part data. - set_mark("part_data") - # Re-consider the current character, since this could be # the start of the boundary itself. i -= 1 @@ -1410,9 +1402,9 @@ def data_callback(name: str, remaining: bool = False) -> None: # that we haven't yet reached the end of this 'thing'. So, by setting # the mark to 0, we cause any data callbacks that take place in future # calls to this function to start from the beginning of that buffer. - data_callback("header_field", True) - data_callback("header_value", True) - data_callback("part_data", True) + data_callback("header_field", length, True) + data_callback("header_value", length, True) + data_callback("part_data", length - index, True) # Save values to locals. self.state = state diff --git a/tests/test_multipart.py b/tests/test_multipart.py index 3a814fb..2e22812 100644 --- a/tests/test_multipart.py +++ b/tests/test_multipart.py @@ -695,6 +695,14 @@ def test_not_aligned(self): http_tests.append({"name": fname, "test": test_data, "result": yaml_data}) +# Datasets used for single-byte writing test. +single_byte_tests = [ + "almost_match_boundary", + "almost_match_boundary_without_CR", + "almost_match_boundary_without_LF", + "almost_match_boundary_without_final_hyphen", + "single_field_single_file", +] def split_all(val): """ @@ -843,17 +851,19 @@ def test_random_splitting(self): self.assert_field(b"field", b"test1") self.assert_file(b"file", b"file.txt", b"test2") - def test_feed_single_bytes(self): + @parametrize("param", [ t for t in http_tests if t["name"] in single_byte_tests]) + def test_feed_single_bytes(self, param): """ - This test parses a simple multipart body 1 byte at a time. + This test parses multipart bodies 1 byte at a time. """ # Load test data. - test_file = "single_field_single_file.http" + test_file = param["name"] + ".http" + boundary = param["result"]["boundary"] with open(os.path.join(http_tests_dir, test_file), "rb") as f: test_data = f.read() # Create form parser. - self.make("boundary") + self.make(boundary) # Write all bytes. # NOTE: Can't simply do `for b in test_data`, since that gives @@ -868,9 +878,20 @@ def test_feed_single_bytes(self): # Assert we processed everything. self.assertEqual(i, len(test_data)) - # Assert that our file and field are here. - self.assert_field(b"field", b"test1") - self.assert_file(b"file", b"file.txt", b"test2") + # Assert that the parser gave us the appropriate fields/files. + for e in param["result"]["expected"]: + # Get our type and name. + type = e["type"] + name = e["name"].encode("latin-1") + + if type == "field": + self.assert_field(name, e["data"]) + + elif type == "file": + self.assert_file(name, e["file_name"].encode("latin-1"), e["data"]) + + else: + assert False def test_feed_blocks(self): """ From 293ea342c64328819862259877c0d3b7af4f3734 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 28 Sep 2024 12:24:15 +0200 Subject: [PATCH 03/31] Version 0.0.11 (#158) --- CHANGELOG.md | 5 +++++ multipart/__init__.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2be283b..da7ae82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.0.11 (2024-09-28) + +* Improve performance, especially in data with many CR-LF [#137](https://github.com/Kludex/python-multipart/pull/137). +* Handle invalid CRLF in header name [#141](https://github.com/Kludex/python-multipart/pull/141). + ## 0.0.10 (2024-09-21) * Support `on_header_begin` [#103](https://github.com/Kludex/python-multipart/pull/103). diff --git a/multipart/__init__.py b/multipart/__init__.py index a3c7229..a813076 100644 --- a/multipart/__init__.py +++ b/multipart/__init__.py @@ -2,7 +2,7 @@ __author__ = "Andrew Dunham" __license__ = "Apache" __copyright__ = "Copyright (c) 2012-2013, Andrew Dunham" -__version__ = "0.0.10" +__version__ = "0.0.11" from .multipart import ( BaseParser, From 24d5f5749766f0bfec1a097336fed78a4cddf3fa Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 29 Sep 2024 09:16:14 +0200 Subject: [PATCH 04/31] Enforce 100% coverage (#159) --- .github/workflows/main.yml | 6 +++--- multipart/decoders.py | 2 +- multipart/multipart.py | 12 ++---------- pyproject.toml | 2 +- 4 files changed, 7 insertions(+), 15 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c7b7129..9b5ed27 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -27,12 +27,12 @@ jobs: - name: Install dependencies run: uv sync --python ${{ matrix.python-version }} --frozen - - name: Run tests - run: scripts/test - - name: Run linters run: scripts/lint + - name: Run tests + run: scripts/test + # https://github.com/marketplace/actions/alls-green#why used for branch protection checks check: if: always() diff --git a/multipart/decoders.py b/multipart/decoders.py index 218abe4..135c56c 100644 --- a/multipart/decoders.py +++ b/multipart/decoders.py @@ -160,7 +160,7 @@ def finalize(self) -> None: call it. """ # If we have a cache, write and then remove it. - if len(self.cache) > 0: + if len(self.cache) > 0: # pragma: no cover self.underlying.write(binascii.a2b_qp(self.cache)) self.cache = b"" diff --git a/multipart/multipart.py b/multipart/multipart.py index eac3ff8..18d0f1d 100644 --- a/multipart/multipart.py +++ b/multipart/multipart.py @@ -142,10 +142,6 @@ class MultipartState(IntEnum): # fmt: on -def ord_char(c: int) -> int: - return c - - def parse_options_header(value: str | bytes) -> tuple[bytes, dict[bytes, bytes]]: """Parses a Content-Type header into a value in the following format: (content_type, {parameters}).""" # Uses email.message.Message to parse the header as described in PEP 594. @@ -473,7 +469,7 @@ def _get_disk_file(self) -> io.BufferedRandom | tempfile._TemporaryFileWrapper[b elif isinstance(file_dir, bytes): dir = file_dir.decode(sys.getfilesystemencoding()) else: - dir = file_dir + dir = file_dir # pragma: no cover # Create a temporary (named) file with the appropriate settings. self.logger.info( @@ -511,11 +507,7 @@ def on_data(self, data: bytes) -> int: Returns: The number of bytes written. """ - pos = self._fileobj.tell() bwritten = self._fileobj.write(data) - # true file objects write returns None - if bwritten is None: - bwritten = self._fileobj.tell() - pos # If the bytes written isn't the same as the length, just return. if bwritten != len(data): @@ -1381,7 +1373,7 @@ def data_callback(name: str, end_i: int, remaining: bool = False) -> None: elif state == MultipartState.END: # Do nothing and just consume a byte in the end state. if c not in (CR, LF): - self.logger.warning("Consuming a byte '0x%x' in the end state", c) + self.logger.warning("Consuming a byte '0x%x' in the end state", c) # pragma: no cover else: # pragma: no cover (error case) # We got into a strange state somehow! Just stop processing. diff --git a/pyproject.toml b/pyproject.toml index bc29d3e..f672c70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,7 +87,7 @@ branch = false omit = ["tests/*"] [tool.coverage.report] -# fail_under = 100 +fail_under = 100 skip_covered = true show_missing = true exclude_lines = [ From a169d93db50853105cf912653de91f5d4a790db2 Mon Sep 17 00:00:00 2001 From: John Stark Date: Sun, 29 Sep 2024 08:45:57 +0100 Subject: [PATCH 05/31] Add mypy strict typing (#140) * No errors with mypy --strict * Apply ruff formatting * Add py.typed file * Make it more modern * Add strict mode to mypy * Use --with instead of --from --------- Co-authored-by: Marcelo Trylesinski --- .github/workflows/main.yml | 2 +- .gitignore | 1 + multipart/decoders.py | 23 ++- multipart/multipart.py | 172 +++++++++++------- multipart/py.typed | 0 pyproject.toml | 5 + scripts/README.md | 8 + scripts/check | 9 + scripts/setup | 3 + tests/compat.py | 26 ++- tests/test_multipart.py | 351 +++++++++++++++++++------------------ uv.lock | 63 ++++++- 12 files changed, 412 insertions(+), 251 deletions(-) create mode 100644 multipart/py.typed create mode 100644 scripts/README.md create mode 100755 scripts/check create mode 100755 scripts/setup diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9b5ed27..1881a56 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -28,7 +28,7 @@ jobs: run: uv sync --python ${{ matrix.python-version }} --frozen - name: Run linters - run: scripts/lint + run: scripts/check - name: Run tests run: scripts/test diff --git a/.gitignore b/.gitignore index f52a6b1..8c8a694 100644 --- a/.gitignore +++ b/.gitignore @@ -89,6 +89,7 @@ coverage.xml *.py,cover .hypothesis/ .pytest_cache/ +.ruff_cache/ cover/ # Translations diff --git a/multipart/decoders.py b/multipart/decoders.py index 135c56c..07bf742 100644 --- a/multipart/decoders.py +++ b/multipart/decoders.py @@ -1,9 +1,22 @@ import base64 import binascii -from io import BufferedWriter +from typing import TYPE_CHECKING from .exceptions import DecodeError +if TYPE_CHECKING: # pragma: no cover + from typing import Protocol, TypeVar + + _T_contra = TypeVar("_T_contra", contravariant=True) + + class SupportsWrite(Protocol[_T_contra]): + def write(self, __b: _T_contra) -> object: ... + + # No way to specify optional methods. See + # https://github.com/python/typing/issues/601 + # close() [Optional] + # finalize() [Optional] + class Base64Decoder: """This object provides an interface to decode a stream of Base64 data. It @@ -34,7 +47,7 @@ class Base64Decoder: :param underlying: the underlying object to pass writes to """ - def __init__(self, underlying: BufferedWriter): + def __init__(self, underlying: "SupportsWrite[bytes]") -> None: self.cache = bytearray() self.underlying = underlying @@ -67,9 +80,9 @@ def write(self, data: bytes) -> int: # Get the remaining bytes and save in our cache. remaining_len = len(data) % 4 if remaining_len > 0: - self.cache = data[-remaining_len:] + self.cache[:] = data[-remaining_len:] else: - self.cache = b"" + self.cache[:] = b"" # Return the length of the data to indicate no error. return len(data) @@ -112,7 +125,7 @@ class QuotedPrintableDecoder: :param underlying: the underlying object to pass writes to """ - def __init__(self, underlying: BufferedWriter) -> None: + def __init__(self, underlying: "SupportsWrite[bytes]") -> None: self.cache = b"" self.underlying = underlying diff --git a/multipart/multipart.py b/multipart/multipart.py index 18d0f1d..137d6e7 100644 --- a/multipart/multipart.py +++ b/multipart/multipart.py @@ -1,6 +1,5 @@ from __future__ import annotations -import io import logging import os import shutil @@ -8,15 +7,20 @@ import tempfile from email.message import Message from enum import IntEnum -from io import BytesIO +from io import BufferedRandom, BytesIO from numbers import Number -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, cast from .decoders import Base64Decoder, QuotedPrintableDecoder from .exceptions import FileError, FormParserError, MultipartParseError, QuerystringParseError if TYPE_CHECKING: # pragma: no cover - from typing import Callable, Protocol, TypedDict + from typing import Any, Callable, Literal, Protocol, TypedDict + + from typing_extensions import TypeAlias + + class SupportsRead(Protocol): + def read(self, __n: int) -> bytes: ... class QuerystringCallbacks(TypedDict, total=False): on_field_start: Callable[[], None] @@ -64,7 +68,7 @@ def finalize(self) -> None: ... def close(self) -> None: ... class FieldProtocol(_FormProtocol, Protocol): - def __init__(self, name: bytes) -> None: ... + def __init__(self, name: bytes | None) -> None: ... def set_none(self) -> None: ... @@ -74,6 +78,23 @@ def __init__(self, file_name: bytes | None, field_name: bytes | None, config: Fi OnFieldCallback = Callable[[FieldProtocol], None] OnFileCallback = Callable[[FileProtocol], None] + CallbackName: TypeAlias = Literal[ + "start", + "data", + "end", + "field_start", + "field_name", + "field_data", + "field_end", + "part_begin", + "part_data", + "part_end", + "header_begin", + "header_field", + "header_value", + "header_end", + "headers_finished", + ] # Unique missing object. _missing = object() @@ -142,7 +163,7 @@ class MultipartState(IntEnum): # fmt: on -def parse_options_header(value: str | bytes) -> tuple[bytes, dict[bytes, bytes]]: +def parse_options_header(value: str | bytes | None) -> tuple[bytes, dict[bytes, bytes]]: """Parses a Content-Type header into a value in the following format: (content_type, {parameters}).""" # Uses email.message.Message to parse the header as described in PEP 594. # Ref: https://peps.python.org/pep-0594/#cgi @@ -202,7 +223,7 @@ class Field: name: The name of the form field. """ - def __init__(self, name: bytes) -> None: + def __init__(self, name: bytes | None) -> None: self._name = name self._value: list[bytes] = [] @@ -283,7 +304,7 @@ def set_none(self) -> None: self._cache = None @property - def field_name(self) -> bytes: + def field_name(self) -> bytes | None: """This property returns the name of the field.""" return self._name @@ -293,6 +314,7 @@ def value(self) -> bytes | None: if self._cache is _missing: self._cache = b"".join(self._value) + assert isinstance(self._cache, bytes) or self._cache is None return self._cache def __eq__(self, other: object) -> bool: @@ -341,7 +363,7 @@ def __init__(self, file_name: bytes | None, field_name: bytes | None = None, con self._config = config self._in_memory = True self._bytes_written = 0 - self._fileobj = BytesIO() + self._fileobj: BytesIO | BufferedRandom = BytesIO() # Save the provided field/file name. self._field_name = field_name @@ -349,7 +371,7 @@ def __init__(self, file_name: bytes | None, field_name: bytes | None = None, con # Our actual file name is None by default, since, depending on our # config, we may not actually use the provided name. - self._actual_file_name = None + self._actual_file_name: bytes | None = None # Split the extension from the filename. if file_name is not None: @@ -370,14 +392,14 @@ def file_name(self) -> bytes | None: return self._file_name @property - def actual_file_name(self): + def actual_file_name(self) -> bytes | None: """The file name that this file is saved as. Will be None if it's not currently saved on disk. """ return self._actual_file_name @property - def file_object(self): + def file_object(self) -> BytesIO | BufferedRandom: """The file object that we're currently writing to. Note that this will either be an instance of a :class:`io.BytesIO`, or a regular file object. @@ -432,7 +454,7 @@ def flush_to_disk(self) -> None: # Close the old file object. old_fileobj.close() - def _get_disk_file(self) -> io.BufferedRandom | tempfile._TemporaryFileWrapper[bytes]: # type: ignore[reportPrivateUsage] + def _get_disk_file(self) -> BufferedRandom: """This function is responsible for getting a file object on-disk for us.""" self.logger.info("Opening a file on disk") @@ -440,6 +462,7 @@ def _get_disk_file(self) -> io.BufferedRandom | tempfile._TemporaryFileWrapper[b keep_filename = self._config.get("UPLOAD_KEEP_FILENAME", False) keep_extensions = self._config.get("UPLOAD_KEEP_EXTENSIONS", False) delete_tmp = self._config.get("UPLOAD_DELETE_TMP", True) + tmp_file: None | BufferedRandom = None # If we have a directory and are to keep the filename... if file_dir is not None and keep_filename: @@ -449,7 +472,7 @@ def _get_disk_file(self) -> io.BufferedRandom | tempfile._TemporaryFileWrapper[b # TODO: what happens if we don't have a filename? fname = self._file_base + self._ext if keep_extensions else self._file_base - path = os.path.join(file_dir, fname) + path = os.path.join(file_dir, fname) # type: ignore[arg-type] try: self.logger.info("Opening file: %r", path) tmp_file = open(path, "w+b") @@ -476,16 +499,17 @@ def _get_disk_file(self) -> io.BufferedRandom | tempfile._TemporaryFileWrapper[b "Creating a temporary file with options: %r", {"suffix": suffix, "delete": delete_tmp, "dir": dir} ) try: - tmp_file = tempfile.NamedTemporaryFile(suffix=suffix, delete=delete_tmp, dir=dir) + tmp_file = cast(BufferedRandom, tempfile.NamedTemporaryFile(suffix=suffix, delete=delete_tmp, dir=dir)) except OSError: self.logger.exception("Error creating named temporary file") raise FileError("Error creating named temporary file") - fname = tmp_file.name - + assert tmp_file is not None # Encode filename as bytes. - if isinstance(fname, str): - fname = fname.encode(sys.getfilesystemencoding()) + if isinstance(tmp_file.name, str): + fname = tmp_file.name.encode(sys.getfilesystemencoding()) + else: + fname = cast(bytes, tmp_file.name) # pragma: no cover self._actual_file_name = fname return tmp_file @@ -571,8 +595,11 @@ class BaseParser: def __init__(self) -> None: self.logger = logging.getLogger(__name__) + self.callbacks: QuerystringCallbacks | OctetStreamCallbacks | MultipartCallbacks = {} - def callback(self, name: str, data: bytes | None = None, start: int | None = None, end: int | None = None): + def callback( + self, name: CallbackName, data: bytes | None = None, start: int | None = None, end: int | None = None + ) -> None: """This function calls a provided callback with some data. If the callback is not set, will do nothing. @@ -583,24 +610,24 @@ def callback(self, name: str, data: bytes | None = None, start: int | None = Non end: An integer that is passed to the data callback. start: An integer that is passed to the data callback. """ - name = "on_" + name - func = self.callbacks.get(name) + on_name = "on_" + name + func = self.callbacks.get(on_name) if func is None: return - + func = cast("Callable[..., Any]", func) # Depending on whether we're given a buffer... if data is not None: # Don't do anything if we have start == end. if start is not None and start == end: return - self.logger.debug("Calling %s with data[%d:%d]", name, start, end) + self.logger.debug("Calling %s with data[%d:%d]", on_name, start, end) func(data, start, end) else: - self.logger.debug("Calling %s with no data", name) + self.logger.debug("Calling %s with no data", on_name) func() - def set_callback(self, name: str, new_func: Callable[..., Any] | None) -> None: + def set_callback(self, name: CallbackName, new_func: Callable[..., Any] | None) -> None: """Update the function for a callback. Removes from the callbacks dict if new_func is None. @@ -611,17 +638,17 @@ def set_callback(self, name: str, new_func: Callable[..., Any] | None) -> None: exist). """ if new_func is None: - self.callbacks.pop("on_" + name, None) + self.callbacks.pop("on_" + name, None) # type: ignore[misc] else: - self.callbacks["on_" + name] = new_func + self.callbacks["on_" + name] = new_func # type: ignore[literal-required] - def close(self): + def close(self) -> None: pass # pragma: no cover - def finalize(self): + def finalize(self) -> None: pass # pragma: no cover - def __repr__(self): + def __repr__(self) -> str: return "%s()" % self.__class__.__name__ @@ -647,7 +674,7 @@ def __init__(self, callbacks: OctetStreamCallbacks = {}, max_size: float = float if not isinstance(max_size, Number) or max_size < 1: raise ValueError("max_size must be a positive number, not %r" % max_size) - self.max_size = max_size + self.max_size: int | float = max_size self._current_size = 0 def write(self, data: bytes) -> int: @@ -729,7 +756,7 @@ def __init__( # Max-size stuff if not isinstance(max_size, Number) or max_size < 1: raise ValueError("max_size must be a positive number, not %r" % max_size) - self.max_size = max_size + self.max_size: int | float = max_size self._current_size = 0 # Should parsing be strict? @@ -1019,7 +1046,7 @@ def _internal_write(self, data: bytes, length: int) -> int: i = 0 # Set a mark. - def set_mark(name: str): + def set_mark(name: str) -> None: self.marks[name] = i # Remove a mark. @@ -1031,7 +1058,7 @@ def delete_mark(name: str, reset: bool = False) -> None: # end of the buffer, and reset the mark, instead of deleting it. This # is used at the end of the function to call our callbacks with any # remaining data in this chunk. - def data_callback(name: str, end_i: int, remaining: bool = False) -> None: + def data_callback(name: CallbackName, end_i: int, remaining: bool = False) -> None: marked_index = self.marks.get(name) if marked_index is None: return @@ -1471,8 +1498,8 @@ class if you wish to customize behaviour. The class will be instantiated as Fie def __init__( self, content_type: str, - on_field: OnFieldCallback, - on_file: OnFileCallback, + on_field: OnFieldCallback | None, + on_file: OnFileCallback | None, on_end: Callable[[], None] | None = None, boundary: bytes | str | None = None, file_name: bytes | None = None, @@ -1498,8 +1525,10 @@ def __init__( self.FieldClass = Field # Set configuration options. - self.config = self.DEFAULT_CONFIG.copy() - self.config.update(config) + self.config: FormParserConfig = self.DEFAULT_CONFIG.copy() + self.config.update(config) # type: ignore[typeddict-item] + + parser: OctetStreamParser | MultipartParser | QuerystringParser | None = None # Depending on the Content-Type, we instantiate the correct parser. if content_type == "application/octet-stream": @@ -1507,7 +1536,7 @@ def __init__( def on_start() -> None: nonlocal file - file = FileClass(file_name, None, config=self.config) + file = FileClass(file_name, None, config=cast("FileConfig", self.config)) def on_data(data: bytes, start: int, end: int) -> None: nonlocal file @@ -1519,7 +1548,8 @@ def _on_end() -> None: file.finalize() # Call our callback. - on_file(file) + if on_file: + on_file(file) # Call the on-end callback. if self.on_end is not None: @@ -1534,7 +1564,7 @@ def _on_end() -> None: elif content_type == "application/x-www-form-urlencoded" or content_type == "application/x-url-encoded": name_buffer: list[bytes] = [] - f: FieldProtocol = None # type: ignore + f: FieldProtocol | None = None def on_field_start() -> None: pass @@ -1560,7 +1590,8 @@ def on_field_end() -> None: f.set_none() f.finalize() - on_field(f) + if on_field: + on_field(f) f = None def _on_end() -> None: @@ -1586,30 +1617,33 @@ def _on_end() -> None: header_name: list[bytes] = [] header_value: list[bytes] = [] - headers = {} + headers: dict[bytes, bytes] = {} - f: FileProtocol | FieldProtocol | None = None + f_multi: FileProtocol | FieldProtocol | None = None writer = None is_file = False - def on_part_begin(): + def on_part_begin() -> None: # Reset headers in case this isn't the first part. nonlocal headers headers = {} def on_part_data(data: bytes, start: int, end: int) -> None: nonlocal writer - bytes_processed = writer.write(data[start:end]) + assert writer is not None + writer.write(data[start:end]) # TODO: check for error here. - return bytes_processed def on_part_end() -> None: - nonlocal f, is_file - f.finalize() + nonlocal f_multi, is_file + assert f_multi is not None + f_multi.finalize() if is_file: - on_file(f) + if on_file: + on_file(f_multi) else: - on_field(f) + if on_field: + on_field(cast("FieldProtocol", f_multi)) def on_header_field(data: bytes, start: int, end: int) -> None: header_name.append(data[start:end]) @@ -1623,7 +1657,7 @@ def on_header_end() -> None: del header_value[:] def on_headers_finished() -> None: - nonlocal is_file, f, writer + nonlocal is_file, f_multi, writer # Reset the 'is file' flag. is_file = False @@ -1639,9 +1673,9 @@ def on_headers_finished() -> None: # Create the proper class. if file_name is None: - f = FieldClass(field_name) + f_multi = FieldClass(field_name) else: - f = FileClass(file_name, field_name, config=self.config) + f_multi = FileClass(file_name, field_name, config=cast("FileConfig", self.config)) is_file = True # Parse the given Content-Transfer-Encoding to determine what @@ -1650,25 +1684,26 @@ def on_headers_finished() -> None: transfer_encoding = headers.get(b"Content-Transfer-Encoding", b"7bit") if transfer_encoding in (b"binary", b"8bit", b"7bit"): - writer = f + writer = f_multi elif transfer_encoding == b"base64": - writer = Base64Decoder(f) + writer = Base64Decoder(f_multi) elif transfer_encoding == b"quoted-printable": - writer = QuotedPrintableDecoder(f) + writer = QuotedPrintableDecoder(f_multi) else: self.logger.warning("Unknown Content-Transfer-Encoding: %r", transfer_encoding) if self.config["UPLOAD_ERROR_ON_BAD_CTE"]: - raise FormParserError('Unknown Content-Transfer-Encoding "{}"'.format(transfer_encoding)) + raise FormParserError('Unknown Content-Transfer-Encoding "{!r}"'.format(transfer_encoding)) else: # If we aren't erroring, then we just treat this as an # unencoded Content-Transfer-Encoding. - writer = f + writer = f_multi def _on_end() -> None: nonlocal writer + assert writer is not None writer.finalize() if self.on_end is not None: self.on_end() @@ -1707,6 +1742,7 @@ def write(self, data: bytes) -> int: """ self.bytes_received += len(data) # TODO: check the parser's return value for errors? + assert self.parser is not None return self.parser.write(data) def finalize(self) -> None: @@ -1725,8 +1761,8 @@ def __repr__(self) -> str: def create_form_parser( headers: dict[str, bytes], - on_field: OnFieldCallback, - on_file: OnFileCallback, + on_field: OnFieldCallback | None, + on_file: OnFileCallback | None, trust_x_headers: bool = False, config: dict[Any, Any] = {}, ) -> FormParser: @@ -1744,7 +1780,7 @@ def create_form_parser( name from X-File-Name. config: Configuration variables to pass to the FormParser. """ - content_type = headers.get("Content-Type") + content_type: str | bytes | None = headers.get("Content-Type") if content_type is None: logging.getLogger(__name__).warning("No Content-Type header given") raise ValueError("No Content-Type header given!") @@ -1769,9 +1805,9 @@ def create_form_parser( def parse_form( headers: dict[str, bytes], - input_stream: io.FileIO, - on_field: OnFieldCallback, - on_file: OnFileCallback, + input_stream: SupportsRead, + on_field: OnFieldCallback | None, + on_file: OnFileCallback | None, chunk_size: int = 1048576, ) -> None: """This function is useful if you just want to parse a request body, @@ -1792,7 +1828,7 @@ def parse_form( # Read chunks of 1MiB and write to the parser, but never read more than # the given Content-Length, if any. - content_length = headers.get("Content-Length") + content_length: int | float | bytes | None = headers.get("Content-Length") if content_length is not None: content_length = int(content_length) else: @@ -1801,7 +1837,7 @@ def parse_form( while True: # Read only up to the Content-Length given. - max_readable = min(content_length - bytes_read, chunk_size) + max_readable = int(min(content_length - bytes_read, chunk_size)) buff = input_stream.read(max_readable) # Write to the parser and update our length. diff --git a/multipart/py.typed b/multipart/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index f672c70..fb03f83 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,8 @@ dev-dependencies = [ "invoke==2.2.0", "pytest-timeout==2.3.1", "ruff==0.3.4", + "mypy", + "types-PyYAML", "atheris==2.3.0; python_version != '3.12'", # Documentation "mkdocs", @@ -68,6 +70,9 @@ packages = ["multipart"] [tool.hatch.build.targets.sdist] include = ["/multipart", "/tests", "CHANGELOG.md", "LICENSE.txt"] +[tool.mypy] +strict = true + [tool.ruff] line-length = 120 diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..1742ebd --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,8 @@ +# Development Scripts + +* `scripts/setup` - Install dependencies. +* `scripts/test` - Run the test suite. +* `scripts/lint` - Run the code format. +* `scripts/check` - Run the lint in check mode, and the type checker. + +Styled after GitHub's ["Scripts to Rule Them All"](https://github.com/github/scripts-to-rule-them-all). diff --git a/scripts/check b/scripts/check new file mode 100755 index 0000000..0b6a294 --- /dev/null +++ b/scripts/check @@ -0,0 +1,9 @@ +#!/bin/sh -e + +set -x + +SOURCE_FILES="multipart tests" + +uvx ruff format --check --diff $SOURCE_FILES +uvx ruff check $SOURCE_FILES +uvx --with types-PyYAML mypy $SOURCE_FILES diff --git a/scripts/setup b/scripts/setup new file mode 100755 index 0000000..33797fc --- /dev/null +++ b/scripts/setup @@ -0,0 +1,3 @@ +#!/bin/sh -ex + +uv sync --frozen diff --git a/tests/compat.py b/tests/compat.py index 845a926..2253107 100644 --- a/tests/compat.py +++ b/tests/compat.py @@ -1,18 +1,24 @@ +from __future__ import annotations + import functools import os import re import sys import types +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any, Callable -def ensure_in_path(path): +def ensure_in_path(path: str) -> None: """ Ensure that a given path is in the sys.path array """ if not os.path.isdir(path): raise RuntimeError("Tried to add nonexisting path") - def _samefile(x, y): + def _samefile(x: str, y: str) -> bool: try: return os.path.samefile(x, y) except OSError: @@ -34,7 +40,7 @@ def _samefile(x, y): # We don't use the pytest parametrizing function, since it seems to break # with unittest.TestCase subclasses. -def parametrize(field_names, field_values): +def parametrize(field_names: tuple[str] | list[str] | str, field_values: list[Any] | Any) -> Callable[..., Any]: # If we're not given a list of field names, we make it. if not isinstance(field_names, (tuple, list)): field_names = (field_names,) @@ -42,7 +48,7 @@ def parametrize(field_names, field_values): # Create a decorator that saves this list of field names and values on the # function for later parametrizing. - def decorator(func): + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: func.__dict__["param_names"] = field_names func.__dict__["param_values"] = field_values return func @@ -54,7 +60,7 @@ def decorator(func): class ParametrizingMetaclass(type): IDENTIFIER_RE = re.compile("[^A-Za-z0-9]") - def __new__(klass, name, bases, attrs): + def __new__(klass, name: str, bases: tuple[type, ...], attrs: types.MappingProxyType[str, Any]) -> type: new_attrs = attrs.copy() for attr_name, attr in attrs.items(): # We only care about functions @@ -67,7 +73,7 @@ def __new__(klass, name, bases, attrs): continue # Create multiple copies of the function. - for i, values in enumerate(param_values): + for _, values in enumerate(param_values): assert len(param_names) == len(values) # Get a repr of the values, and fix it to be a valid identifier @@ -78,12 +84,14 @@ def __new__(klass, name, bases, attrs): new_name = attr.__name__ + "__" + human # Create a replacement function. - def create_new_func(func, names, values): + def create_new_func( + func: types.FunctionType, names: list[str], values: list[Any] + ) -> Callable[..., Any]: # Create a kwargs dictionary. kwargs = dict(zip(names, values)) @functools.wraps(func) - def new_func(self): + def new_func(self: types.FunctionType) -> Any: return func(self, **kwargs) # Manually set the name and return the new function. @@ -104,5 +112,5 @@ def new_func(self): # This is a class decorator that actually applies the above metaclass. -def parametrize_class(klass): +def parametrize_class(klass: type) -> ParametrizingMetaclass: return ParametrizingMetaclass(klass.__name__, klass.__bases__, klass.__dict__) diff --git a/tests/test_multipart.py b/tests/test_multipart.py index 2e22812..f55e228 100644 --- a/tests/test_multipart.py +++ b/tests/test_multipart.py @@ -6,13 +6,13 @@ import tempfile import unittest from io import BytesIO -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from unittest.mock import Mock import yaml from multipart.decoders import Base64Decoder, QuotedPrintableDecoder -from multipart.exceptions import DecodeError, FileError, FormParserError, MultipartParseError +from multipart.exceptions import DecodeError, FileError, FormParserError, MultipartParseError, QuerystringParseError from multipart.multipart import ( BaseParser, Field, @@ -20,7 +20,6 @@ FormParser, MultipartParser, OctetStreamParser, - QuerystringParseError, QuerystringParser, create_form_parser, parse_form, @@ -30,13 +29,21 @@ from .compat import parametrize, parametrize_class if TYPE_CHECKING: - from multipart.multipart import FileConfig + from typing import Any, Iterator, TypedDict + + from multipart.multipart import FieldProtocol, FileConfig, FileProtocol + + class TestParams(TypedDict): + name: str + test: bytes + result: Any + # Get the current directory for our later test cases. curr_dir = os.path.abspath(os.path.dirname(__file__)) -def force_bytes(val): +def force_bytes(val: str | bytes) -> bytes: if isinstance(val, str): val = val.encode(sys.getfilesystemencoding()) @@ -44,33 +51,33 @@ def force_bytes(val): class TestField(unittest.TestCase): - def setUp(self): - self.f = Field("foo") + def setUp(self) -> None: + self.f = Field(b"foo") - def test_name(self): - self.assertEqual(self.f.field_name, "foo") + def test_name(self) -> None: + self.assertEqual(self.f.field_name, b"foo") - def test_data(self): + def test_data(self) -> None: self.f.write(b"test123") self.assertEqual(self.f.value, b"test123") - def test_cache_expiration(self): + def test_cache_expiration(self) -> None: self.f.write(b"test") self.assertEqual(self.f.value, b"test") self.f.write(b"123") self.assertEqual(self.f.value, b"test123") - def test_finalize(self): + def test_finalize(self) -> None: self.f.write(b"test123") self.f.finalize() self.assertEqual(self.f.value, b"test123") - def test_close(self): + def test_close(self) -> None: self.f.write(b"test123") self.f.close() self.assertEqual(self.f.value, b"test123") - def test_from_value(self): + def test_from_value(self) -> None: f = Field.from_value(b"name", b"value") self.assertEqual(f.field_name, b"name") self.assertEqual(f.value, b"value") @@ -78,18 +85,18 @@ def test_from_value(self): f2 = Field.from_value(b"name", None) self.assertEqual(f2.value, None) - def test_equality(self): + def test_equality(self) -> None: f1 = Field.from_value(b"name", b"value") f2 = Field.from_value(b"name", b"value") self.assertEqual(f1, f2) - def test_equality_with_other(self): + def test_equality_with_other(self) -> None: f = Field.from_value(b"foo", b"bar") self.assertFalse(f == b"foo") self.assertFalse(b"foo" == f) - def test_set_none(self): + def test_set_none(self) -> None: f = Field(b"foo") self.assertEqual(f.value, b"") @@ -98,34 +105,35 @@ def test_set_none(self): class TestFile(unittest.TestCase): - def setUp(self): + def setUp(self) -> None: self.c: FileConfig = {} self.d = force_bytes(tempfile.mkdtemp()) self.f = File(b"foo.txt", config=self.c) - def assert_data(self, data): + def assert_data(self, data: bytes) -> None: f = self.f.file_object f.seek(0) self.assertEqual(f.read(), data) f.seek(0) f.truncate() - def assert_exists(self): + def assert_exists(self) -> None: + assert self.f.actual_file_name is not None full_path = os.path.join(self.d, self.f.actual_file_name) self.assertTrue(os.path.exists(full_path)) - def test_simple(self): + def test_simple(self) -> None: self.f.write(b"foobar") self.assert_data(b"foobar") - def test_invalid_write(self): + def test_invalid_write(self) -> None: m = Mock() m.write.return_value = 5 self.f._fileobj = m v = self.f.write(b"foobar") self.assertEqual(v, 5) - def test_file_fallback(self): + def test_file_fallback(self) -> None: self.c["MAX_MEMORY_FILE_SIZE"] = 1 self.f.write(b"1") @@ -142,7 +150,7 @@ def test_file_fallback(self): self.assertFalse(self.f.in_memory) self.assertIs(self.f.file_object, old_obj) - def test_file_fallback_with_data(self): + def test_file_fallback_with_data(self) -> None: self.c["MAX_MEMORY_FILE_SIZE"] = 10 self.f.write(b"1" * 10) @@ -153,7 +161,7 @@ def test_file_fallback_with_data(self): self.assert_data(b"11111111112222222222") - def test_file_name(self): + def test_file_name(self) -> None: # Write to this dir. self.c["UPLOAD_DIR"] = self.d self.c["MAX_MEMORY_FILE_SIZE"] = 10 @@ -166,7 +174,7 @@ def test_file_name(self): self.assertIsNotNone(self.f.actual_file_name) self.assert_exists() - def test_file_full_name(self): + def test_file_full_name(self) -> None: # Write to this dir. self.c["UPLOAD_DIR"] = self.d self.c["UPLOAD_KEEP_FILENAME"] = True @@ -180,7 +188,7 @@ def test_file_full_name(self): self.assertEqual(self.f.actual_file_name, b"foo") self.assert_exists() - def test_file_full_name_with_ext(self): + def test_file_full_name_with_ext(self) -> None: self.c["UPLOAD_DIR"] = self.d self.c["UPLOAD_KEEP_FILENAME"] = True self.c["UPLOAD_KEEP_EXTENSIONS"] = True @@ -194,7 +202,7 @@ def test_file_full_name_with_ext(self): self.assertEqual(self.f.actual_file_name, b"foo.txt") self.assert_exists() - def test_no_dir_with_extension(self): + def test_no_dir_with_extension(self) -> None: self.c["UPLOAD_KEEP_EXTENSIONS"] = True self.c["MAX_MEMORY_FILE_SIZE"] = 10 @@ -203,11 +211,12 @@ def test_no_dir_with_extension(self): self.assertFalse(self.f.in_memory) # Assert that the file exists + assert self.f.actual_file_name is not None ext = os.path.splitext(self.f.actual_file_name)[1] self.assertEqual(ext, b".txt") self.assert_exists() - def test_invalid_dir_with_name(self): + def test_invalid_dir_with_name(self) -> None: # Write to this dir. self.c["UPLOAD_DIR"] = force_bytes(os.path.join("/", "tmp", "notexisting")) self.c["UPLOAD_KEEP_FILENAME"] = True @@ -217,7 +226,7 @@ def test_invalid_dir_with_name(self): with self.assertRaises(FileError): self.f.write(b"1234567890") - def test_invalid_dir_no_name(self): + def test_invalid_dir_no_name(self) -> None: # Write to this dir. self.c["UPLOAD_DIR"] = force_bytes(os.path.join("/", "tmp", "notexisting")) self.c["UPLOAD_KEEP_FILENAME"] = False @@ -231,50 +240,50 @@ def test_invalid_dir_no_name(self): class TestParseOptionsHeader(unittest.TestCase): - def test_simple(self): + def test_simple(self) -> None: t, p = parse_options_header("application/json") self.assertEqual(t, b"application/json") self.assertEqual(p, {}) - def test_blank(self): + def test_blank(self) -> None: t, p = parse_options_header("") self.assertEqual(t, b"") self.assertEqual(p, {}) - def test_single_param(self): + def test_single_param(self) -> None: t, p = parse_options_header("application/json;par=val") self.assertEqual(t, b"application/json") self.assertEqual(p, {b"par": b"val"}) - def test_single_param_with_spaces(self): + def test_single_param_with_spaces(self) -> None: t, p = parse_options_header(b"application/json; par=val") self.assertEqual(t, b"application/json") self.assertEqual(p, {b"par": b"val"}) - def test_multiple_params(self): + def test_multiple_params(self) -> None: t, p = parse_options_header(b"application/json;par=val;asdf=foo") self.assertEqual(t, b"application/json") self.assertEqual(p, {b"par": b"val", b"asdf": b"foo"}) - def test_quoted_param(self): + def test_quoted_param(self) -> None: t, p = parse_options_header(b'application/json;param="quoted"') self.assertEqual(t, b"application/json") self.assertEqual(p, {b"param": b"quoted"}) - def test_quoted_param_with_semicolon(self): + def test_quoted_param_with_semicolon(self) -> None: t, p = parse_options_header(b'application/json;param="quoted;with;semicolons"') self.assertEqual(p[b"param"], b"quoted;with;semicolons") - def test_quoted_param_with_escapes(self): + def test_quoted_param_with_escapes(self) -> None: t, p = parse_options_header(b'application/json;param="This \\" is \\" a \\" quote"') self.assertEqual(p[b"param"], b'This " is " a " quote') - def test_handles_ie6_bug(self): + def test_handles_ie6_bug(self) -> None: t, p = parse_options_header(b'text/plain; filename="C:\\this\\is\\a\\path\\file.txt"') self.assertEqual(p[b"filename"], b"file.txt") - def test_redos_attack_header(self): + def test_redos_attack_header(self) -> None: t, p = parse_options_header( b'application/x-www-form-urlencoded; !="' b"\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\" @@ -282,47 +291,47 @@ def test_redos_attack_header(self): # If vulnerable, this test wouldn't finish, the line above would hang self.assertIn(b'"\\', p[b"!"]) - def test_handles_rfc_2231(self): + def test_handles_rfc_2231(self) -> None: t, p = parse_options_header(b"text/plain; param*=us-ascii'en-us'encoded%20message") self.assertEqual(p[b"param"], b"encoded message") class TestBaseParser(unittest.TestCase): - def setUp(self): + def setUp(self) -> None: self.b = BaseParser() self.b.callbacks = {} - def test_callbacks(self): + def test_callbacks(self) -> None: called = 0 - def on_foo(): + def on_foo() -> None: nonlocal called called += 1 - self.b.set_callback("foo", on_foo) - self.b.callback("foo") + self.b.set_callback("foo", on_foo) # type: ignore[arg-type] + self.b.callback("foo") # type: ignore[arg-type] self.assertEqual(called, 1) - self.b.set_callback("foo", None) - self.b.callback("foo") + self.b.set_callback("foo", None) # type: ignore[arg-type] + self.b.callback("foo") # type: ignore[arg-type] self.assertEqual(called, 1) class TestQuerystringParser(unittest.TestCase): - def assert_fields(self, *args, **kwargs): + def assert_fields(self, *args: tuple[bytes, bytes], **kwargs: Any) -> None: if kwargs.pop("finalize", True): self.p.finalize() self.assertEqual(self.f, list(args)) if kwargs.get("reset", True): - self.f = [] + self.f: list[tuple[bytes, bytes]] = [] - def setUp(self): + def setUp(self) -> None: self.reset() - def reset(self): - self.f: list[tuple[bytes, bytes]] = [] + def reset(self) -> None: + self.f = [] name_buffer: list[bytes] = [] data_buffer: list[bytes] = [] @@ -333,7 +342,7 @@ def on_field_name(data: bytes, start: int, end: int) -> None: def on_field_data(data: bytes, start: int, end: int) -> None: data_buffer.append(data[start:end]) - def on_field_end(): + def on_field_end() -> None: self.f.append((b"".join(name_buffer), b"".join(data_buffer))) del name_buffer[:] @@ -343,34 +352,34 @@ def on_field_end(): callbacks={"on_field_name": on_field_name, "on_field_data": on_field_data, "on_field_end": on_field_end} ) - def test_simple_querystring(self): + def test_simple_querystring(self) -> None: self.p.write(b"foo=bar") self.assert_fields((b"foo", b"bar")) - def test_querystring_blank_beginning(self): + def test_querystring_blank_beginning(self) -> None: self.p.write(b"&foo=bar") self.assert_fields((b"foo", b"bar")) - def test_querystring_blank_end(self): + def test_querystring_blank_end(self) -> None: self.p.write(b"foo=bar&") self.assert_fields((b"foo", b"bar")) - def test_multiple_querystring(self): + def test_multiple_querystring(self) -> None: self.p.write(b"foo=bar&asdf=baz") self.assert_fields((b"foo", b"bar"), (b"asdf", b"baz")) - def test_streaming_simple(self): + def test_streaming_simple(self) -> None: self.p.write(b"foo=bar&") self.assert_fields((b"foo", b"bar"), finalize=False) self.p.write(b"asdf=baz") self.assert_fields((b"asdf", b"baz")) - def test_streaming_break(self): + def test_streaming_break(self) -> None: self.p.write(b"foo=one") self.assert_fields(finalize=False) @@ -386,12 +395,12 @@ def test_streaming_break(self): self.p.write(b"f=baz") self.assert_fields((b"asdf", b"baz")) - def test_semicolon_separator(self): + def test_semicolon_separator(self) -> None: self.p.write(b"foo=bar;asdf=baz") self.assert_fields((b"foo", b"bar"), (b"asdf", b"baz")) - def test_too_large_field(self): + def test_too_large_field(self) -> None: self.p.max_size = 15 # Note: len = 8 @@ -402,11 +411,11 @@ def test_too_large_field(self): self.p.write(b"a=123456") self.assert_fields((b"a", b"12345")) - def test_invalid_max_size(self): + def test_invalid_max_size(self) -> None: with self.assertRaises(ValueError): p = QuerystringParser(max_size=-100) - def test_strict_parsing_pass(self): + def test_strict_parsing_pass(self) -> None: data = b"foo=bar&another=asdf" for first, last in split_all(data): self.reset() @@ -418,7 +427,7 @@ def test_strict_parsing_pass(self): self.p.write(last) self.assert_fields((b"foo", b"bar"), (b"another", b"asdf")) - def test_strict_parsing_fail_double_sep(self): + def test_strict_parsing_fail_double_sep(self) -> None: data = b"foo=bar&&another=asdf" for first, last in split_all(data): self.reset() @@ -435,7 +444,7 @@ def test_strict_parsing_fail_double_sep(self): if cm is not None: self.assertEqual(cm.exception.offset, 8 - cnt) - def test_double_sep(self): + def test_double_sep(self) -> None: data = b"foo=bar&&another=asdf" for first, last in split_all(data): print(f" {first!r} / {last!r} ") @@ -447,7 +456,7 @@ def test_double_sep(self): self.assert_fields((b"foo", b"bar"), (b"another", b"asdf")) - def test_strict_parsing_fail_no_value(self): + def test_strict_parsing_fail_no_value(self) -> None: self.p.strict_parsing = True with self.assertRaises(QuerystringParseError) as cm: self.p.write(b"foo=bar&blank&another=asdf") @@ -455,18 +464,18 @@ def test_strict_parsing_fail_no_value(self): if cm is not None: self.assertEqual(cm.exception.offset, 8) - def test_success_no_value(self): + def test_success_no_value(self) -> None: self.p.write(b"foo=bar&blank&another=asdf") self.assert_fields((b"foo", b"bar"), (b"blank", b""), (b"another", b"asdf")) - def test_repr(self): + def test_repr(self) -> None: # Issue #29; verify we don't assert on repr() _ignored = repr(self.p) class TestOctetStreamParser(unittest.TestCase): - def setUp(self): - self.d = [] + def setUp(self) -> None: + self.d: list[bytes] = [] self.started = 0 self.finished = 0 @@ -481,23 +490,23 @@ def on_end() -> None: self.p = OctetStreamParser(callbacks={"on_start": on_start, "on_data": on_data, "on_end": on_end}) - def assert_data(self, data, finalize=True): + def assert_data(self, data: bytes, finalize: bool = True) -> None: self.assertEqual(b"".join(self.d), data) self.d = [] - def assert_started(self, val=True): + def assert_started(self, val: bool = True) -> None: if val: self.assertEqual(self.started, 1) else: self.assertEqual(self.started, 0) - def assert_finished(self, val=True): + def assert_finished(self, val: bool = True) -> None: if val: self.assertEqual(self.finished, 1) else: self.assertEqual(self.finished, 0) - def test_simple(self): + def test_simple(self) -> None: # Assert is not started self.assert_started(False) @@ -511,7 +520,7 @@ def test_simple(self): self.p.finalize() self.assert_finished() - def test_multiple_chunks(self): + def test_multiple_chunks(self) -> None: self.p.write(b"foo") self.p.write(b"bar") self.p.write(b"baz") @@ -520,7 +529,7 @@ def test_multiple_chunks(self): self.assert_data(b"foobarbaz") self.assert_finished() - def test_max_size(self): + def test_max_size(self) -> None: self.p.max_size = 5 self.p.write(b"0123456789") @@ -529,18 +538,18 @@ def test_max_size(self): self.assert_data(b"01234") self.assert_finished() - def test_invalid_max_size(self): + def test_invalid_max_size(self) -> None: with self.assertRaises(ValueError): - q = OctetStreamParser(max_size="foo") + q = OctetStreamParser(max_size="foo") # type: ignore[arg-type] class TestBase64Decoder(unittest.TestCase): # Note: base64('foobar') == 'Zm9vYmFy' - def setUp(self): + def setUp(self) -> None: self.f = BytesIO() self.d = Base64Decoder(self.f) - def assert_data(self, data, finalize=True): + def assert_data(self, data: bytes, finalize: bool = True) -> None: if finalize: self.d.finalize() @@ -549,20 +558,20 @@ def assert_data(self, data, finalize=True): self.f.seek(0) self.f.truncate() - def test_simple(self): + def test_simple(self) -> None: self.d.write(b"Zm9vYmFy") self.assert_data(b"foobar") - def test_bad(self): + def test_bad(self) -> None: with self.assertRaises(DecodeError): self.d.write(b"Zm9v!mFy") - def test_split_properly(self): + def test_split_properly(self) -> None: self.d.write(b"Zm9v") self.d.write(b"YmFy") self.assert_data(b"foobar") - def test_bad_split(self): + def test_bad_split(self) -> None: buff = b"Zm9v" for i in range(1, 4): first, second = buff[:i], buff[i:] @@ -572,7 +581,7 @@ def test_bad_split(self): self.d.write(second) self.assert_data(b"foo") - def test_long_bad_split(self): + def test_long_bad_split(self) -> None: buff = b"Zm9vYmFy" for i in range(5, 8): first, second = buff[:i], buff[i:] @@ -582,7 +591,7 @@ def test_long_bad_split(self): self.d.write(second) self.assert_data(b"foobar") - def test_close_and_finalize(self): + def test_close_and_finalize(self) -> None: parser = Mock() f = Base64Decoder(parser) @@ -592,7 +601,7 @@ def test_close_and_finalize(self): f.close() parser.close.assert_called_once_with() - def test_bad_length(self): + def test_bad_length(self) -> None: self.d.write(b"Zm9vYmF") # missing ending 'y' with self.assertRaises(DecodeError): @@ -600,11 +609,11 @@ def test_bad_length(self): class TestQuotedPrintableDecoder(unittest.TestCase): - def setUp(self): + def setUp(self) -> None: self.f = BytesIO() self.d = QuotedPrintableDecoder(self.f) - def assert_data(self, data, finalize=True): + def assert_data(self, data: bytes, finalize: bool = True) -> None: if finalize: self.d.finalize() @@ -613,38 +622,38 @@ def assert_data(self, data, finalize=True): self.f.seek(0) self.f.truncate() - def test_simple(self): + def test_simple(self) -> None: self.d.write(b"foobar") self.assert_data(b"foobar") - def test_with_escape(self): + def test_with_escape(self) -> None: self.d.write(b"foo=3Dbar") self.assert_data(b"foo=bar") - def test_with_newline_escape(self): + def test_with_newline_escape(self) -> None: self.d.write(b"foo=\r\nbar") self.assert_data(b"foobar") - def test_with_only_newline_escape(self): + def test_with_only_newline_escape(self) -> None: self.d.write(b"foo=\nbar") self.assert_data(b"foobar") - def test_with_split_escape(self): + def test_with_split_escape(self) -> None: self.d.write(b"foo=3") self.d.write(b"Dbar") self.assert_data(b"foo=bar") - def test_with_split_newline_escape_1(self): + def test_with_split_newline_escape_1(self) -> None: self.d.write(b"foo=\r") self.d.write(b"\nbar") self.assert_data(b"foobar") - def test_with_split_newline_escape_2(self): + def test_with_split_newline_escape_2(self) -> None: self.d.write(b"foo=") self.d.write(b"\r\nbar") self.assert_data(b"foobar") - def test_close_and_finalize(self): + def test_close_and_finalize(self) -> None: parser = Mock() f = QuotedPrintableDecoder(parser) @@ -654,7 +663,7 @@ def test_close_and_finalize(self): f.close() parser.close.assert_called_once_with() - def test_not_aligned(self): + def test_not_aligned(self) -> None: """ https://github.com/andrew-d/python-multipart/issues/6 """ @@ -675,7 +684,7 @@ def test_not_aligned(self): # Read in all test cases and load them. NON_PARAMETRIZED_TESTS = {"single_field_blocks"} -http_tests = [] +http_tests: list[TestParams] = [] for f in os.listdir(http_tests_dir): # Only load the HTTP test cases. fname, ext = os.path.splitext(f) @@ -687,11 +696,11 @@ def test_not_aligned(self): yaml_file = os.path.join(http_tests_dir, fname + ".yaml") # Load both. - with open(os.path.join(http_tests_dir, f), "rb") as f: - test_data = f.read() + with open(os.path.join(http_tests_dir, f), "rb") as fh: + test_data = fh.read() - with open(yaml_file, "rb") as f: - yaml_data = yaml.safe_load(f) + with open(yaml_file, "rb") as fy: + yaml_data = yaml.safe_load(fy) http_tests.append({"name": fname, "test": test_data, "result": yaml_data}) @@ -704,7 +713,8 @@ def test_not_aligned(self): "single_field_single_file", ] -def split_all(val): + +def split_all(val: bytes) -> Iterator[tuple[bytes, bytes]]: """ This function will split an array all possible ways. For example: split_all([1,2,3,4]) @@ -717,30 +727,30 @@ def split_all(val): @parametrize_class class TestFormParser(unittest.TestCase): - def make(self, boundary, config={}): + def make(self, boundary: str | bytes, config: dict[str, Any] = {}) -> None: self.ended = False self.files: list[File] = [] self.fields: list[Field] = [] - def on_field(f: Field) -> None: - self.fields.append(f) + def on_field(f: FieldProtocol) -> None: + self.fields.append(cast(Field, f)) - def on_file(f: File) -> None: - self.files.append(f) + def on_file(f: FileProtocol) -> None: + self.files.append(cast(File, f)) - def on_end(): + def on_end() -> None: self.ended = True # Get a form-parser instance. self.f = FormParser("multipart/form-data", on_field, on_file, on_end, boundary=boundary, config=config) - def assert_file_data(self, f, data): + def assert_file_data(self, f: File, data: bytes) -> None: o = f.file_object o.seek(0) file_data = o.read() self.assertEqual(file_data, data) - def assert_file(self, field_name, file_name, data): + def assert_file(self, field_name: bytes, file_name: bytes, data: bytes) -> None: # Find this file. found = None for f in self.files: @@ -750,6 +760,7 @@ def assert_file(self, field_name, file_name, data): # Assert that we found it. self.assertIsNotNone(found) + assert found is not None try: # Assert about this file. @@ -762,7 +773,7 @@ def assert_file(self, field_name, file_name, data): # Close our file found.close() - def assert_field(self, name, value): + def assert_field(self, name: bytes, value: bytes) -> None: # Find this field in our fields list. found = None for f in self.fields: @@ -772,13 +783,14 @@ def assert_field(self, name, value): # Assert that it exists and matches. self.assertIsNotNone(found) + assert found is not None # typing self.assertEqual(value, found.value) # Remove it for future iterations. self.fields.remove(found) @parametrize("param", http_tests) - def test_http(self, param): + def test_http(self, param: TestParams) -> None: # Firstly, create our parser with the given boundary. boundary = param["result"]["boundary"] if isinstance(boundary, str): @@ -790,9 +802,9 @@ def test_http(self, param): try: processed = self.f.write(param["test"]) self.f.finalize() - except MultipartParseError as e: + except MultipartParseError as err: processed = 0 - exc = e + exc = err # print(repr(param)) # print("") @@ -802,6 +814,7 @@ def test_http(self, param): # Do we expect an error? if "error" in param["result"]["expected"]: self.assertIsNotNone(exc) + assert exc is not None self.assertEqual(param["result"]["expected"]["error"], exc.offset) return @@ -823,7 +836,7 @@ def test_http(self, param): else: assert False - def test_random_splitting(self): + def test_random_splitting(self) -> None: """ This test runs a simple multipart body with one field and one file through every possible split. @@ -851,8 +864,8 @@ def test_random_splitting(self): self.assert_field(b"field", b"test1") self.assert_file(b"file", b"file.txt", b"test2") - @parametrize("param", [ t for t in http_tests if t["name"] in single_byte_tests]) - def test_feed_single_bytes(self, param): + @parametrize("param", [t for t in http_tests if t["name"] in single_byte_tests]) + def test_feed_single_bytes(self, param: TestParams) -> None: """ This test parses multipart bodies 1 byte at a time. """ @@ -893,7 +906,7 @@ def test_feed_single_bytes(self, param): else: assert False - def test_feed_blocks(self): + def test_feed_blocks(self) -> None: """ This test parses a simple multipart body 1 byte at a time. """ @@ -926,7 +939,7 @@ def test_feed_blocks(self): # Assert that our field is here. self.assert_field(b"field", b"0123456789ABCDEFGHIJ0123456789ABCDEFGHIJ") - def test_request_body_fuzz(self): + def test_request_body_fuzz(self) -> None: """ This test randomly fuzzes the request body to ensure that no strange exceptions are raised and we don't end up in a strange state. The @@ -998,7 +1011,7 @@ def test_request_body_fuzz(self): print("Failures: %d" % (failures,)) print("Exceptions: %d" % (exceptions,)) - def test_request_body_fuzz_random_data(self): + def test_request_body_fuzz_random_data(self) -> None: """ This test will fuzz the multipart parser with some number of iterations of randomly-generated data. @@ -1035,7 +1048,7 @@ def test_request_body_fuzz_random_data(self): print("Failures: %d" % (failures,)) print("Exceptions: %d" % (exceptions,)) - def test_bad_start_boundary(self): + def test_bad_start_boundary(self) -> None: self.make("boundary") data = b"--boundary\rfoobar" with self.assertRaises(MultipartParseError): @@ -1046,11 +1059,11 @@ def test_bad_start_boundary(self): with self.assertRaises(MultipartParseError): i = self.f.write(data) - def test_octet_stream(self): - files = [] + def test_octet_stream(self) -> None: + files: list[File] = [] - def on_file(f): - files.append(f) + def on_file(f: FileProtocol) -> None: + files.append(cast(File, f)) on_field = Mock() on_end = Mock() @@ -1068,16 +1081,16 @@ def on_file(f): self.assert_file_data(files[0], b"test1234") self.assertTrue(on_end.called) - def test_querystring(self): - fields = [] + def test_querystring(self) -> None: + fields: list[Field] = [] - def on_field(f): - fields.append(f) + def on_field(f: FieldProtocol) -> None: + fields.append(cast(Field, f)) on_file = Mock() on_end = Mock() - def simple_test(f): + def simple_test(f: FormParser) -> None: # Reset tracking. del fields[:] on_file.reset_mock() @@ -1110,7 +1123,7 @@ def simple_test(f): self.assertTrue(isinstance(f.parser, QuerystringParser)) simple_test(f) - def test_close_methods(self): + def test_close_methods(self) -> None: parser = Mock() f = FormParser("application/x-url-encoded", None, None) f.parser = parser @@ -1121,18 +1134,18 @@ def test_close_methods(self): f.close() parser.close.assert_called_once_with() - def test_bad_content_type(self): + def test_bad_content_type(self) -> None: # We should raise a ValueError for a bad Content-Type with self.assertRaises(ValueError): f = FormParser("application/bad", None, None) - def test_no_boundary_given(self): + def test_no_boundary_given(self) -> None: # We should raise a FormParserError when parsing a multipart message # without a boundary. with self.assertRaises(FormParserError): f = FormParser("multipart/form-data", None, None) - def test_bad_content_transfer_encoding(self): + def test_bad_content_transfer_encoding(self) -> None: data = ( b'----boundary\r\nContent-Disposition: form-data; name="file"; filename="test.txt"\r\n' b"Content-Type: text/plain\r\n" @@ -1140,10 +1153,10 @@ def test_bad_content_transfer_encoding(self): b"Test\r\n----boundary--\r\n" ) - files = [] + files: list[File] = [] - def on_file(f): - files.append(f) + def on_file(f: FileProtocol) -> None: + files.append(cast(File, f)) on_field = Mock() on_end = Mock() @@ -1164,11 +1177,11 @@ def on_file(f): f.finalize() self.assert_file_data(files[0], b"Test") - def test_handles_None_fields(self): - fields = [] + def test_handles_None_fields(self) -> None: + fields: list[Field] = [] - def on_field(f): - fields.append(f) + def on_field(f: FieldProtocol) -> None: + fields.append(cast(Field, f)) on_file = Mock() on_end = Mock() @@ -1186,7 +1199,7 @@ def on_field(f): self.assertEqual(fields[2].field_name, b"baz") self.assertEqual(fields[2].value, b"asdf") - def test_max_size_multipart(self): + def test_max_size_multipart(self) -> None: # Load test data. test_file = "single_field_single_file.http" with open(os.path.join(http_tests_dir, test_file), "rb") as f: @@ -1197,7 +1210,8 @@ def test_max_size_multipart(self): # Set the maximum length that we can process to be halfway through the # given data. - self.f.parser.max_size = len(test_data) / 2 + assert self.f.parser is not None + self.f.parser.max_size = float(len(test_data)) / 2 i = self.f.write(test_data) self.f.finalize() @@ -1205,7 +1219,7 @@ def test_max_size_multipart(self): # Assert we processed the correct amount. self.assertEqual(i, len(test_data) / 2) - def test_max_size_form_parser(self): + def test_max_size_form_parser(self) -> None: # Load test data. test_file = "single_field_single_file.http" with open(os.path.join(http_tests_dir, test_file), "rb") as f: @@ -1222,11 +1236,11 @@ def test_max_size_form_parser(self): # Assert we processed the correct amount. self.assertEqual(i, len(test_data) / 2) - def test_octet_stream_max_size(self): - files = [] + def test_octet_stream_max_size(self) -> None: + files: list[File] = [] - def on_file(f): - files.append(f) + def on_file(f: FileProtocol) -> None: + files.append(cast(File, f)) on_field = Mock() on_end = Mock() @@ -1245,11 +1259,11 @@ def on_file(f): self.assert_file_data(files[0], b"0123456789") - def test_invalid_max_size_multipart(self): + def test_invalid_max_size_multipart(self) -> None: with self.assertRaises(ValueError): - MultipartParser(b"bound", max_size="foo") + MultipartParser(b"bound", max_size="foo") # type: ignore[arg-type] - def test_header_begin_callback(self): + def test_header_begin_callback(self) -> None: """ This test verifies we call the `on_header_begin` callback. See GitHub issue #23 @@ -1280,20 +1294,20 @@ def on_header_begin() -> None: class TestHelperFunctions(unittest.TestCase): - def test_create_form_parser(self): - r = create_form_parser({"Content-Type": "application/octet-stream"}, None, None) + def test_create_form_parser(self) -> None: + r = create_form_parser({"Content-Type": b"application/octet-stream"}, None, None) self.assertTrue(isinstance(r, FormParser)) - def test_create_form_parser_error(self): - headers = {} + def test_create_form_parser_error(self) -> None: + headers: dict[str, bytes] = {} with self.assertRaises(ValueError): create_form_parser(headers, None, None) - def test_parse_form(self): + def test_parse_form(self) -> None: on_field = Mock() on_file = Mock() - parse_form({"Content-Type": "application/octet-stream"}, BytesIO(b"123456789012345"), on_field, on_file) + parse_form({"Content-Type": b"application/octet-stream"}, BytesIO(b"123456789012345"), on_field, on_file) assert on_file.call_count == 1 @@ -1301,24 +1315,27 @@ def test_parse_form(self): # 15 - i.e. all data is written. self.assertEqual(on_file.call_args[0][0].size, 15) - def test_parse_form_content_length(self): - files = [] + def test_parse_form_content_length(self) -> None: + files: list[FileProtocol] = [] - def on_file(file): + def on_field(field: FieldProtocol) -> None: + pass + + def on_file(file: FileProtocol) -> None: files.append(file) parse_form( - {"Content-Type": "application/octet-stream", "Content-Length": "10"}, + {"Content-Type": b"application/octet-stream", "Content-Length": b"10"}, BytesIO(b"123456789012345"), - None, + on_field, on_file, ) self.assertEqual(len(files), 1) - self.assertEqual(files[0].size, 10) + self.assertEqual(files[0].size, 10) # type: ignore[attr-defined] -def suite(): +def suite() -> unittest.TestSuite: suite = unittest.TestSuite() suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(TestFile)) suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(TestParseOptionsHeader)) diff --git a/uv.lock b/uv.lock index 69f3835..2ae1c4e 100644 --- a/uv.lock +++ b/uv.lock @@ -524,6 +524,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/50/e2/8e10e465ee3987bb7c9ab69efb91d867d93959095f4807db102d07995d94/more_itertools-10.2.0-py3-none-any.whl", hash = "sha256:686b06abe565edfab151cb8fd385a05651e1fdf8f0a14191e4439283421f8684", size = 57015 }, ] +[[package]] +name = "mypy" +version = "1.11.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/86/5d7cbc4974fd564550b80fbb8103c05501ea11aa7835edf3351d90095896/mypy-1.11.2.tar.gz", hash = "sha256:7f9993ad3e0ffdc95c2a14b66dee63729f021968bff8ad911867579c65d13a79", size = 3078806 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/cd/815368cd83c3a31873e5e55b317551500b12f2d1d7549720632f32630333/mypy-1.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d42a6dd818ffce7be66cce644f1dff482f1d97c53ca70908dff0b9ddc120b77a", size = 10939401 }, + { url = "https://files.pythonhosted.org/packages/f1/27/e18c93a195d2fad75eb96e1f1cbc431842c332e8eba2e2b77eaf7313c6b7/mypy-1.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:801780c56d1cdb896eacd5619a83e427ce436d86a3bdf9112527f24a66618fef", size = 10111697 }, + { url = "https://files.pythonhosted.org/packages/dc/08/cdc1fc6d0d5a67d354741344cc4aa7d53f7128902ebcbe699ddd4f15a61c/mypy-1.11.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41ea707d036a5307ac674ea172875f40c9d55c5394f888b168033177fce47383", size = 12500508 }, + { url = "https://files.pythonhosted.org/packages/64/12/aad3af008c92c2d5d0720ea3b6674ba94a98cdb86888d389acdb5f218c30/mypy-1.11.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6e658bd2d20565ea86da7d91331b0eed6d2eee22dc031579e6297f3e12c758c8", size = 13020712 }, + { url = "https://files.pythonhosted.org/packages/03/e6/a7d97cc124a565be5e9b7d5c2a6ebf082379ffba99646e4863ed5bbcb3c3/mypy-1.11.2-cp310-cp310-win_amd64.whl", hash = "sha256:478db5f5036817fe45adb7332d927daa62417159d49783041338921dcf646fc7", size = 9567319 }, + { url = "https://files.pythonhosted.org/packages/e2/aa/cc56fb53ebe14c64f1fe91d32d838d6f4db948b9494e200d2f61b820b85d/mypy-1.11.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75746e06d5fa1e91bfd5432448d00d34593b52e7e91a187d981d08d1f33d4385", size = 10859630 }, + { url = "https://files.pythonhosted.org/packages/04/c8/b19a760fab491c22c51975cf74e3d253b8c8ce2be7afaa2490fbf95a8c59/mypy-1.11.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a976775ab2256aadc6add633d44f100a2517d2388906ec4f13231fafbb0eccca", size = 10037973 }, + { url = "https://files.pythonhosted.org/packages/88/57/7e7e39f2619c8f74a22efb9a4c4eff32b09d3798335625a124436d121d89/mypy-1.11.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd953f221ac1379050a8a646585a29574488974f79d8082cedef62744f0a0104", size = 12416659 }, + { url = "https://files.pythonhosted.org/packages/fc/a6/37f7544666b63a27e46c48f49caeee388bf3ce95f9c570eb5cfba5234405/mypy-1.11.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:57555a7715c0a34421013144a33d280e73c08df70f3a18a552938587ce9274f4", size = 12897010 }, + { url = "https://files.pythonhosted.org/packages/84/8b/459a513badc4d34acb31c736a0101c22d2bd0697b969796ad93294165cfb/mypy-1.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:36383a4fcbad95f2657642a07ba22ff797de26277158f1cc7bd234821468b1b6", size = 9562873 }, + { url = "https://files.pythonhosted.org/packages/35/3a/ed7b12ecc3f6db2f664ccf85cb2e004d3e90bec928e9d7be6aa2f16b7cdf/mypy-1.11.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e8960dbbbf36906c5c0b7f4fbf2f0c7ffb20f4898e6a879fcf56a41a08b0d318", size = 10990335 }, + { url = "https://files.pythonhosted.org/packages/04/e4/1a9051e2ef10296d206519f1df13d2cc896aea39e8683302f89bf5792a59/mypy-1.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:06d26c277962f3fb50e13044674aa10553981ae514288cb7d0a738f495550b36", size = 10007119 }, + { url = "https://files.pythonhosted.org/packages/f3/3c/350a9da895f8a7e87ade0028b962be0252d152e0c2fbaafa6f0658b4d0d4/mypy-1.11.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7184632d89d677973a14d00ae4d03214c8bc301ceefcdaf5c474866814c987", size = 12506856 }, + { url = "https://files.pythonhosted.org/packages/b6/49/ee5adf6a49ff13f4202d949544d3d08abb0ea1f3e7f2a6d5b4c10ba0360a/mypy-1.11.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3a66169b92452f72117e2da3a576087025449018afc2d8e9bfe5ffab865709ca", size = 12952066 }, + { url = "https://files.pythonhosted.org/packages/27/c0/b19d709a42b24004d720db37446a42abadf844d5c46a2c442e2a074d70d9/mypy-1.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:969ea3ef09617aff826885a22ece0ddef69d95852cdad2f60c8bb06bf1f71f70", size = 9664000 }, + { url = "https://files.pythonhosted.org/packages/42/ad/5a8567700410f8aa7c755b0ebd4cacff22468cbc5517588773d65075c0cb/mypy-1.11.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:37c7fa6121c1cdfcaac97ce3d3b5588e847aa79b580c1e922bb5d5d2902df19b", size = 10876550 }, + { url = "https://files.pythonhosted.org/packages/1b/bc/9fc16ea7a27ceb93e123d300f1cfe27a6dd1eac9a8beea4f4d401e737e9d/mypy-1.11.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4a8a53bc3ffbd161b5b2a4fff2f0f1e23a33b0168f1c0778ec70e1a3d66deb86", size = 10068086 }, + { url = "https://files.pythonhosted.org/packages/cd/8f/a1e460f1288405a13352dad16b24aba6dce4f850fc76510c540faa96eda3/mypy-1.11.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ff93107f01968ed834f4256bc1fc4475e2fecf6c661260066a985b52741ddce", size = 12459214 }, + { url = "https://files.pythonhosted.org/packages/c7/74/746b31aef7cc7512dab8bdc2311ef88d63fadc1c453a09c8cab7e57e59bf/mypy-1.11.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:edb91dded4df17eae4537668b23f0ff6baf3707683734b6a818d5b9d0c0c31a1", size = 12962942 }, + { url = "https://files.pythonhosted.org/packages/28/a4/7fae712240b640d75bb859294ad4776b9960b3216ccb7fa747f578e6c632/mypy-1.11.2-cp38-cp38-win_amd64.whl", hash = "sha256:ee23de8530d99b6db0573c4ef4bd8f39a2a6f9b60655bf7a1357e585a3486f2b", size = 9545616 }, + { url = "https://files.pythonhosted.org/packages/16/64/bb5ed751487e2bea0dfaa6f640a7e3bb88083648f522e766d5ef4a76f578/mypy-1.11.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:801ca29f43d5acce85f8e999b1e431fb479cb02d0e11deb7d2abb56bdaf24fd6", size = 10937294 }, + { url = "https://files.pythonhosted.org/packages/a9/a3/67a0069abed93c3bf3b0bebb8857e2979a02828a4a3fd82f107f8f1143e8/mypy-1.11.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af8d155170fcf87a2afb55b35dc1a0ac21df4431e7d96717621962e4b9192e70", size = 10107707 }, + { url = "https://files.pythonhosted.org/packages/2f/4d/0379daf4258b454b1f9ed589a9dabd072c17f97496daea7b72fdacf7c248/mypy-1.11.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7821776e5c4286b6a13138cc935e2e9b6fde05e081bdebf5cdb2bb97c9df81d", size = 12498367 }, + { url = "https://files.pythonhosted.org/packages/3b/dc/3976a988c280b3571b8eb6928882dc4b723a403b21735a6d8ae6ed20e82b/mypy-1.11.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:539c570477a96a4e6fb718b8d5c3e0c0eba1f485df13f86d2970c91f0673148d", size = 13018014 }, + { url = "https://files.pythonhosted.org/packages/83/84/adffc7138fb970e7e2a167bd20b33bb78958370179853a4ebe9008139342/mypy-1.11.2-cp39-cp39-win_amd64.whl", hash = "sha256:3f14cd3d386ac4d05c5a39a51b84387403dadbd936e17cb35882134d4f8f0d24", size = 9568056 }, + { url = "https://files.pythonhosted.org/packages/42/3a/bdf730640ac523229dd6578e8a581795720a9321399de494374afc437ec5/mypy-1.11.2-py3-none-any.whl", hash = "sha256:b499bc07dbdcd3de92b0a8b29fdf592c111276f6a12fe29c30f6c417dd546d12", size = 2619625 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, +] + [[package]] name = "packaging" version = "24.1" @@ -665,7 +713,7 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.10" +version = "0.0.11" source = { editable = "." } [package.dev-dependencies] @@ -680,6 +728,7 @@ dev = [ { name = "mkdocs-material" }, { name = "mkdocstrings-python" }, { name = "more-itertools" }, + { name = "mypy" }, { name = "pbr" }, { name = "pluggy" }, { name = "py" }, @@ -688,6 +737,7 @@ dev = [ { name = "pytest-timeout" }, { name = "pyyaml" }, { name = "ruff" }, + { name = "types-pyyaml" }, ] [package.metadata] @@ -704,6 +754,7 @@ dev = [ { name = "mkdocs-material" }, { name = "mkdocstrings-python" }, { name = "more-itertools", specifier = "==10.2.0" }, + { name = "mypy" }, { name = "pbr", specifier = "==6.0.0" }, { name = "pluggy", specifier = "==1.4.0" }, { name = "py", specifier = "==1.11.0" }, @@ -712,6 +763,7 @@ dev = [ { name = "pytest-timeout", specifier = "==2.3.1" }, { name = "pyyaml", specifier = "==6.0.1" }, { name = "ruff", specifier = "==0.3.4" }, + { name = "types-pyyaml" }, ] [[package]] @@ -939,6 +991,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", size = 12757 }, ] +[[package]] +name = "types-pyyaml" +version = "6.0.12.20240917" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/7d/a95df0a11f95c8f48d7683f03e4aed1a2c0fc73e9de15cca4d38034bea1a/types-PyYAML-6.0.12.20240917.tar.gz", hash = "sha256:d1405a86f9576682234ef83bcb4e6fff7c9305c8b1fbad5e0bcd4f7dbdc9c587", size = 12381 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/2c/c1d81d680997d24b0542aa336f0a65bd7835e5224b7670f33a7d617da379/types_PyYAML-6.0.12.20240917-py3-none-any.whl", hash = "sha256:392b267f1c0fe6022952462bf5d6523f31e37f6cea49b14cee7ad634b6301570", size = 15264 }, +] + [[package]] name = "typing-extensions" version = "4.12.2" From 83191cb3ee0fd69aef474914e001b985e043c46a Mon Sep 17 00:00:00 2001 From: yecril23pl <151100823+yecril23pl@users.noreply.github.com> Date: Sun, 29 Sep 2024 10:05:42 +0200 Subject: [PATCH 06/31] ensure our boundary matches: improve the message (#124) * ensure our boundary matches: improve the message The error message should report expected actual mismatch. * improve syntax * use walrus operator * Oops, walrus not supported * reverse random paste * Test multi parser error boundary mismatch error message * Fix expected error message * rebase --------- Co-authored-by: Marcelo Trylesinski --- multipart/multipart.py | 2 +- tests/test_multipart.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/multipart/multipart.py b/multipart/multipart.py index 137d6e7..158b7e6 100644 --- a/multipart/multipart.py +++ b/multipart/multipart.py @@ -1150,7 +1150,7 @@ def data_callback(name: CallbackName, end_i: int, remaining: bool = False) -> No else: # Check to ensure our boundary matches if c != boundary[index + 2]: - msg = "Did not find boundary character %r at index " "%d" % (c, index + 2) + msg = "Expected boundary character %r, got %r at index %d" % (boundary[index + 2], c, index + 2) self.logger.warning(msg) e = MultipartParseError(msg) e.offset = i diff --git a/tests/test_multipart.py b/tests/test_multipart.py index f55e228..b824f19 100644 --- a/tests/test_multipart.py +++ b/tests/test_multipart.py @@ -1057,7 +1057,12 @@ def test_bad_start_boundary(self) -> None: self.make("boundary") data = b"--boundaryfoobar" with self.assertRaises(MultipartParseError): - i = self.f.write(data) + self.f.write(data) + + self.make("boundary") + data = b"--Boundary\r\nfoobar" + with self.assertRaisesRegex(MultipartParseError, "Expected boundary character %r, got %r" % (b"b"[0], b"B"[0])): + self.f.write(data) def test_octet_stream(self) -> None: files: list[File] = [] From 3f7233d02196d3b66cb07be294b17f2425241959 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 29 Sep 2024 10:10:50 +0200 Subject: [PATCH 07/31] Version 0.0.12 (#160) --- CHANGELOG.md | 6 ++++++ multipart/__init__.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da7ae82..abf5bda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.0.12 (2024-09-29) + +* Improve error message when boundary character does not match [#124](https://github.com/Kludex/python-multipart/pull/124). +* Add mypy strict typing [#140](https://github.com/Kludex/python-multipart/pull/140). +* Enforce 100% coverage [#159](https://github.com/Kludex/python-multipart/pull/159). + ## 0.0.11 (2024-09-28) * Improve performance, especially in data with many CR-LF [#137](https://github.com/Kludex/python-multipart/pull/137). diff --git a/multipart/__init__.py b/multipart/__init__.py index a813076..5fd2f41 100644 --- a/multipart/__init__.py +++ b/multipart/__init__.py @@ -2,7 +2,7 @@ __author__ = "Andrew Dunham" __license__ = "Apache" __copyright__ = "Copyright (c) 2012-2013, Andrew Dunham" -__version__ = "0.0.11" +__version__ = "0.0.12" from .multipart import ( BaseParser, From f92851f1fd4f32350063bc69f8b21d9da681da6e Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 29 Sep 2024 10:12:33 +0200 Subject: [PATCH 08/31] Remove old instructions to run the test suite (#161) --- README.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/README.md b/README.md index 5a2aba8..29dd249 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,3 @@ Test coverage is currently 100%. ## Why? Because streaming uploads are awesome for large files. - -## How to Test - -If you want to test: - -```bash -$ pip install '.[dev]' -$ inv test -``` From 5303590e767c8f6125729912923cddedb1cc0946 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Oct 2024 07:45:53 +0200 Subject: [PATCH 09/31] Bump astral-sh/setup-uv from 2 to 3 in the github-actions group (#163) --- .github/workflows/docs.yml | 2 +- .github/workflows/main.yml | 2 +- .github/workflows/publish.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index c600001..b970488 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -20,7 +20,7 @@ jobs: git config user.email 41898282+github-actions[bot]@users.noreply.github.com - name: Install uv - uses: astral-sh/setup-uv@v2 + uses: astral-sh/setup-uv@v3 with: version: "0.4.12" enable-cache: true diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1881a56..2fbd796 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -16,7 +16,7 @@ jobs: - uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@v2 + uses: astral-sh/setup-uv@v3 with: version: "0.4.12" enable-cache: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2d12eb3..66915ad 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@v2 + uses: astral-sh/setup-uv@v3 with: version: "0.4.12" enable-cache: true From a629ae0224e6b617ad8511189b010ee24f6a7c5f Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Sun, 20 Oct 2024 08:06:15 -0400 Subject: [PATCH 10/31] refactor: rename import to python_multipart (#166) --- .github/workflows/main.yml | 3 ++ _python_multipart.pth | 1 + _python_multipart_loader.py | 37 +++++++++++++++++++ docs/api.md | 4 +- docs/index.md | 20 +++++++--- fuzz/fuzz_decoders.py | 2 +- fuzz/fuzz_form.py | 4 +- fuzz/fuzz_options_header.py | 2 +- noxfile.py | 29 +++++++++++++++ pyproject.toml | 14 ++++--- {multipart => python_multipart}/__init__.py | 0 {multipart => python_multipart}/decoders.py | 8 ++-- {multipart => python_multipart}/exceptions.py | 0 {multipart => python_multipart}/multipart.py | 12 +++--- {multipart => python_multipart}/py.typed | 0 scripts/check | 2 +- tests/test_multipart.py | 14 +++++-- 17 files changed, 120 insertions(+), 32 deletions(-) create mode 100644 _python_multipart.pth create mode 100644 _python_multipart_loader.py create mode 100644 noxfile.py rename {multipart => python_multipart}/__init__.py (100%) rename {multipart => python_multipart}/decoders.py (95%) rename {multipart => python_multipart}/exceptions.py (100%) rename {multipart => python_multipart}/multipart.py (99%) rename {multipart => python_multipart}/py.typed (100%) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2fbd796..fdeed33 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -33,6 +33,9 @@ jobs: - name: Run tests run: scripts/test + - name: Run rename test + run: uvx nox -s rename -P ${{ matrix.python-version }} + # https://github.com/marketplace/actions/alls-green#why used for branch protection checks check: if: always() diff --git a/_python_multipart.pth b/_python_multipart.pth new file mode 100644 index 0000000..e681c13 --- /dev/null +++ b/_python_multipart.pth @@ -0,0 +1 @@ +import _python_multipart_loader diff --git a/_python_multipart_loader.py b/_python_multipart_loader.py new file mode 100644 index 0000000..7d34377 --- /dev/null +++ b/_python_multipart_loader.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +# The purpose of this file is to allow `import multipart` to continue to work +# unless `multipart` (the PyPI package) is also installed, in which case +# a collision is avoided, and `import multipart` is no longer injected. +import importlib +import importlib.abc +import importlib.machinery +import importlib.util +import sys +import warnings + + +class PythonMultipartCompatFinder(importlib.abc.MetaPathFinder): + def find_spec( + self, fullname: str, path: object = None, target: object = None + ) -> importlib.machinery.ModuleSpec | None: + if fullname != "multipart": + return None + old_sys_meta_path = sys.meta_path + try: + sys.meta_path = [p for p in sys.meta_path if not isinstance(p, type(self))] + if multipart := importlib.util.find_spec("multipart"): + return multipart + + warnings.warn("Please use `import python_multipart` instead.", FutureWarning, stacklevel=2) + sys.modules["multipart"] = importlib.import_module("python_multipart") + return importlib.util.find_spec("python_multipart") + finally: + sys.meta_path = old_sys_meta_path + + +def install() -> None: + sys.meta_path.insert(0, PythonMultipartCompatFinder()) + + +install() diff --git a/docs/api.md b/docs/api.md index cc102fd..aab37c0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,3 +1,3 @@ -::: multipart +::: python_multipart -::: multipart.exceptions +::: python_multipart.exceptions diff --git a/docs/index.md b/docs/index.md index 0640374..7802011 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,7 +9,7 @@ Python-Multipart is a streaming multipart parser for Python. The following example shows a quick example of parsing an incoming request body in a simple WSGI application: ```python -import multipart +import python_multipart def simple_app(environ, start_response): ret = [] @@ -31,7 +31,7 @@ def simple_app(environ, start_response): headers['Content-Length'] = environ['CONTENT_LENGTH'] # Parse the form. - multipart.parse_form(headers, environ['wsgi.input'], on_field, on_file) + python_multipart.parse_form(headers, environ['wsgi.input'], on_field, on_file) # Return something. start_response('200 OK', [('Content-type', 'text/plain')]) @@ -67,7 +67,7 @@ In this section, we’ll build an application that computes the SHA-256 hash of To start, we need a simple WSGI application. We could do this with a framework like Flask, Django, or Tornado, but for now let’s stick to plain WSGI: ```python -import multipart +import python_multipart def simple_app(environ, start_response): start_response('200 OK', [('Content-type', 'text/plain')]) @@ -100,8 +100,8 @@ The final code should look like this: ```python import hashlib -import multipart -from multipart.multipart import parse_options_header +import python_multipart +from python_multipart.multipart import parse_options_header def simple_app(environ, start_response): ret = [] @@ -136,7 +136,7 @@ def simple_app(environ, start_response): } # Create the parser. - parser = multipart.MultipartParser(boundary, callbacks) + parser = python_multipart.MultipartParser(boundary, callbacks) # The input stream is from the WSGI environ. inp = environ['wsgi.input'] @@ -176,3 +176,11 @@ Content-type: text/plain Hashes: Part hash: 0b64696c0f7ddb9e3435341720988d5455b3b0f0724688f98ec8e6019af3d931 ``` + + +## Historical note + +This package used to be accessed via `import multipart`. This still works for +now (with a warning) as long as the Python package `multipart` is not also +installed. If both are installed, you need to use the full PyPI name +`python_multipart` for this package. diff --git a/fuzz/fuzz_decoders.py b/fuzz/fuzz_decoders.py index 1c4425e..543c299 100644 --- a/fuzz/fuzz_decoders.py +++ b/fuzz/fuzz_decoders.py @@ -5,7 +5,7 @@ from helpers import EnhancedDataProvider with atheris.instrument_imports(): - from multipart.decoders import Base64Decoder, DecodeError, QuotedPrintableDecoder + from python_multipart.decoders import Base64Decoder, DecodeError, QuotedPrintableDecoder def fuzz_base64_decoder(fdp: EnhancedDataProvider) -> None: diff --git a/fuzz/fuzz_form.py b/fuzz/fuzz_form.py index 0a7646a..c990639 100644 --- a/fuzz/fuzz_form.py +++ b/fuzz/fuzz_form.py @@ -6,8 +6,8 @@ from helpers import EnhancedDataProvider with atheris.instrument_imports(): - from multipart.exceptions import FormParserError - from multipart.multipart import parse_form + from python_multipart.exceptions import FormParserError + from python_multipart.multipart import parse_form on_field = Mock() on_file = Mock() diff --git a/fuzz/fuzz_options_header.py b/fuzz/fuzz_options_header.py index dd1cb44..2546eaf 100644 --- a/fuzz/fuzz_options_header.py +++ b/fuzz/fuzz_options_header.py @@ -4,7 +4,7 @@ from helpers import EnhancedDataProvider with atheris.instrument_imports(): - from multipart.multipart import parse_options_header + from python_multipart.multipart import parse_options_header def TestOneInput(data: bytes) -> None: diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 0000000..fda8050 --- /dev/null +++ b/noxfile.py @@ -0,0 +1,29 @@ +import nox + +nox.needs_version = ">=2024.4.15" +nox.options.default_venv_backend = "uv|virtualenv" + +ALL_PYTHONS = [ + c.split()[-1] + for c in nox.project.load_toml("pyproject.toml")["project"]["classifiers"] + if c.startswith("Programming Language :: Python :: 3.") +] + + +@nox.session(python=ALL_PYTHONS) +def rename(session: nox.Session) -> None: + session.install(".") + assert "import python_multipart" in session.run("python", "-c", "import multipart", silent=True) + assert "import python_multipart" in session.run("python", "-c", "import multipart.exceptions", silent=True) + assert "import python_multipart" in session.run("python", "-c", "from multipart import exceptions", silent=True) + assert "import python_multipart" in session.run( + "python", "-c", "from multipart.exceptions import FormParserError", silent=True + ) + + session.install("multipart") + assert "import python_multipart" not in session.run( + "python", "-c", "import multipart; multipart.parse_form_data", silent=True + ) + assert "import python_multipart" not in session.run( + "python", "-c", "import python_multipart; python_multipart.parse_form", silent=True + ) diff --git a/pyproject.toml b/pyproject.toml index fb03f83..1a81077 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,9 @@ dev-dependencies = [ "mkdocs-autorefs", ] +[tool.uv.pip] +reinstall-package = ["python-multipart"] + [project.urls] Homepage = "https://github.com/Kludex/python-multipart" Documentation = "https://kludex.github.io/python-multipart/" @@ -62,13 +65,14 @@ Changelog = "https://github.com/Kludex/python-multipart/blob/master/CHANGELOG.md Source = "https://github.com/Kludex/python-multipart" [tool.hatch.version] -path = "multipart/__init__.py" - -[tool.hatch.build.targets.wheel] -packages = ["multipart"] +path = "python_multipart/__init__.py" [tool.hatch.build.targets.sdist] -include = ["/multipart", "/tests", "CHANGELOG.md", "LICENSE.txt"] +include = ["/python_multipart", "/tests", "CHANGELOG.md", "LICENSE.txt", "_python_multipart.pth", "_python_multipart_loader.py"] + +[tool.hatch.build.targets.wheel.force-include] +"_python_multipart.pth" = "_python_multipart.pth" +"_python_multipart_loader.py" = "_python_multipart_loader.py" [tool.mypy] strict = true diff --git a/multipart/__init__.py b/python_multipart/__init__.py similarity index 100% rename from multipart/__init__.py rename to python_multipart/__init__.py diff --git a/multipart/decoders.py b/python_multipart/decoders.py similarity index 95% rename from multipart/decoders.py rename to python_multipart/decoders.py index 07bf742..82b56a1 100644 --- a/multipart/decoders.py +++ b/python_multipart/decoders.py @@ -25,7 +25,7 @@ class Base64Decoder: call write() on the underlying object. This is primarily used for decoding form data encoded as Base64, but can be used for other purposes:: - from multipart.decoders import Base64Decoder + from python_multipart.decoders import Base64Decoder fd = open("notb64.txt", "wb") decoder = Base64Decoder(fd) try: @@ -55,7 +55,7 @@ def write(self, data: bytes) -> int: """Takes any input data provided, decodes it as base64, and passes it on to the underlying object. If the data provided is invalid base64 data, then this method will raise - a :class:`multipart.exceptions.DecodeError` + a :class:`python_multipart.exceptions.DecodeError` :param data: base64 data to decode """ @@ -97,7 +97,7 @@ def close(self) -> None: def finalize(self) -> None: """Finalize this object. This should be called when no more data should be written to the stream. This function can raise a - :class:`multipart.exceptions.DecodeError` if there is some remaining + :class:`python_multipart.exceptions.DecodeError` if there is some remaining data in the cache. If the underlying object has a `finalize()` method, this function will @@ -118,7 +118,7 @@ def __repr__(self) -> str: class QuotedPrintableDecoder: """This object provides an interface to decode a stream of quoted-printable data. It is instantiated with an "underlying object", in the same manner - as the :class:`multipart.decoders.Base64Decoder` class. This class behaves + as the :class:`python_multipart.decoders.Base64Decoder` class. This class behaves in exactly the same way, including maintaining a cache of quoted-printable chunks. diff --git a/multipart/exceptions.py b/python_multipart/exceptions.py similarity index 100% rename from multipart/exceptions.py rename to python_multipart/exceptions.py diff --git a/multipart/multipart.py b/python_multipart/multipart.py similarity index 99% rename from multipart/multipart.py rename to python_multipart/multipart.py index 158b7e6..ace4a8f 100644 --- a/multipart/multipart.py +++ b/python_multipart/multipart.py @@ -241,7 +241,7 @@ def from_value(cls, name: bytes, value: bytes | None) -> Field: value: the value of the form field - either a bytestring or None. Returns: - A new instance of a [`Field`][multipart.Field]. + A new instance of a [`Field`][python_multipart.Field]. """ f = cls(name) @@ -351,7 +351,7 @@ class File: | MAX_MEMORY_FILE_SIZE | `int` | 1 MiB | The maximum number of bytes of a File to keep in memory. By default, the contents of a File are kept into memory until a certain limit is reached, after which the contents of the File are written to a temporary file. This behavior can be disabled by setting this value to an appropriately large value (or, for example, infinity, such as `float('inf')`. | Args: - file_name: The name of the file that this [`File`][multipart.File] represents. + file_name: The name of the file that this [`File`][python_multipart.File] represents. field_name: The name of the form field that this file was uploaded with. This can be None, if, for example, the file was uploaded with Content-Type application/octet-stream. config: The configuration for this File. See above for valid configuration keys and their corresponding values. @@ -663,7 +663,7 @@ class OctetStreamParser(BaseParser): | on_end | None | Called when the parser is finished parsing all data.| Args: - callbacks: A dictionary of callbacks. See the documentation for [`BaseParser`][multipart.BaseParser]. + callbacks: A dictionary of callbacks. See the documentation for [`BaseParser`][python_multipart.BaseParser]. max_size: The maximum size of body to parse. Defaults to infinity - i.e. unbounded. """ @@ -733,12 +733,12 @@ class QuerystringParser(BaseParser): | on_end | None | Called when the parser is finished parsing all data.| Args: - callbacks: A dictionary of callbacks. See the documentation for [`BaseParser`][multipart.BaseParser]. + callbacks: A dictionary of callbacks. See the documentation for [`BaseParser`][python_multipart.BaseParser]. strict_parsing: Whether or not to parse the body strictly. Defaults to False. If this is set to True, then the behavior of the parser changes as the following: if a field has a value with an equal sign (e.g. "foo=bar", or "foo="), it is always included. If a field has no equals sign (e.g. "...&name&..."), it will be treated as an error if 'strict_parsing' is True, otherwise included. If an error is encountered, - then a [`QuerystringParseError`][multipart.exceptions.QuerystringParseError] will be raised. + then a [`QuerystringParseError`][python_multipart.exceptions.QuerystringParseError] will be raised. max_size: The maximum size of body to parse. Defaults to infinity - i.e. unbounded. """ # noqa: E501 @@ -969,7 +969,7 @@ class MultipartParser(BaseParser): Args: boundary: The multipart boundary. This is required, and must match what is given in the HTTP request - usually in the Content-Type header. - callbacks: A dictionary of callbacks. See the documentation for [`BaseParser`][multipart.BaseParser]. + callbacks: A dictionary of callbacks. See the documentation for [`BaseParser`][python_multipart.BaseParser]. max_size: The maximum size of body to parse. Defaults to infinity - i.e. unbounded. """ # noqa: E501 diff --git a/multipart/py.typed b/python_multipart/py.typed similarity index 100% rename from multipart/py.typed rename to python_multipart/py.typed diff --git a/scripts/check b/scripts/check index 0b6a294..f38e9c0 100755 --- a/scripts/check +++ b/scripts/check @@ -2,7 +2,7 @@ set -x -SOURCE_FILES="multipart tests" +SOURCE_FILES="python_multipart tests" uvx ruff format --check --diff $SOURCE_FILES uvx ruff check $SOURCE_FILES diff --git a/tests/test_multipart.py b/tests/test_multipart.py index b824f19..be01fbf 100644 --- a/tests/test_multipart.py +++ b/tests/test_multipart.py @@ -11,9 +11,15 @@ import yaml -from multipart.decoders import Base64Decoder, QuotedPrintableDecoder -from multipart.exceptions import DecodeError, FileError, FormParserError, MultipartParseError, QuerystringParseError -from multipart.multipart import ( +from python_multipart.decoders import Base64Decoder, QuotedPrintableDecoder +from python_multipart.exceptions import ( + DecodeError, + FileError, + FormParserError, + MultipartParseError, + QuerystringParseError, +) +from python_multipart.multipart import ( BaseParser, Field, File, @@ -31,7 +37,7 @@ if TYPE_CHECKING: from typing import Any, Iterator, TypedDict - from multipart.multipart import FieldProtocol, FileConfig, FileProtocol + from python_multipart.multipart import FieldProtocol, FileConfig, FileProtocol class TestParams(TypedDict): name: str From 72e30eabb9ec4440c9b420700bee799953810150 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 20 Oct 2024 14:10:10 +0200 Subject: [PATCH 11/31] Version 0.0.13 (#167) --- CHANGELOG.md | 4 ++++ python_multipart/__init__.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index abf5bda..c177948 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.0.13 (2024-10-20) + +* Rename import to `python_multipart` [#166](https://github.com/Kludex/python-multipart/pull/166). + ## 0.0.12 (2024-09-29) * Improve error message when boundary character does not match [#124](https://github.com/Kludex/python-multipart/pull/124). diff --git a/python_multipart/__init__.py b/python_multipart/__init__.py index 5fd2f41..312194a 100644 --- a/python_multipart/__init__.py +++ b/python_multipart/__init__.py @@ -2,7 +2,7 @@ __author__ = "Andrew Dunham" __license__ = "Apache" __copyright__ = "Copyright (c) 2012-2013, Andrew Dunham" -__version__ = "0.0.12" +__version__ = "0.0.13" from .multipart import ( BaseParser, From 0c04f4ecdc9209da7b054b9a22c4c89316494391 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Thu, 24 Oct 2024 10:28:38 -0400 Subject: [PATCH 12/31] fix: use alternate scheme for importing multipart (#168) Co-authored-by: Marcelo Trylesinski --- .github/workflows/main.yml | 3 +-- _python_multipart.pth | 1 - _python_multipart_loader.py | 37 ------------------------------------- multipart/__init__.py | 20 ++++++++++++++++++++ multipart/decoders.py | 1 + multipart/exceptions.py | 1 + multipart/multipart.py | 1 + noxfile.py | 28 +++++++++++++++++++++------- pyproject.toml | 8 +++++--- scripts/README.md | 1 + scripts/check | 2 +- scripts/rename | 7 +++++++ 12 files changed, 59 insertions(+), 51 deletions(-) delete mode 100644 _python_multipart.pth delete mode 100644 _python_multipart_loader.py create mode 100644 multipart/__init__.py create mode 100644 multipart/decoders.py create mode 100644 multipart/exceptions.py create mode 100644 multipart/multipart.py create mode 100755 scripts/rename diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index fdeed33..4ceafb7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -34,8 +34,7 @@ jobs: run: scripts/test - name: Run rename test - run: uvx nox -s rename -P ${{ matrix.python-version }} - + run: scripts/rename # https://github.com/marketplace/actions/alls-green#why used for branch protection checks check: if: always() diff --git a/_python_multipart.pth b/_python_multipart.pth deleted file mode 100644 index e681c13..0000000 --- a/_python_multipart.pth +++ /dev/null @@ -1 +0,0 @@ -import _python_multipart_loader diff --git a/_python_multipart_loader.py b/_python_multipart_loader.py deleted file mode 100644 index 7d34377..0000000 --- a/_python_multipart_loader.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import annotations - -# The purpose of this file is to allow `import multipart` to continue to work -# unless `multipart` (the PyPI package) is also installed, in which case -# a collision is avoided, and `import multipart` is no longer injected. -import importlib -import importlib.abc -import importlib.machinery -import importlib.util -import sys -import warnings - - -class PythonMultipartCompatFinder(importlib.abc.MetaPathFinder): - def find_spec( - self, fullname: str, path: object = None, target: object = None - ) -> importlib.machinery.ModuleSpec | None: - if fullname != "multipart": - return None - old_sys_meta_path = sys.meta_path - try: - sys.meta_path = [p for p in sys.meta_path if not isinstance(p, type(self))] - if multipart := importlib.util.find_spec("multipart"): - return multipart - - warnings.warn("Please use `import python_multipart` instead.", FutureWarning, stacklevel=2) - sys.modules["multipart"] = importlib.import_module("python_multipart") - return importlib.util.find_spec("python_multipart") - finally: - sys.meta_path = old_sys_meta_path - - -def install() -> None: - sys.meta_path.insert(0, PythonMultipartCompatFinder()) - - -install() diff --git a/multipart/__init__.py b/multipart/__init__.py new file mode 100644 index 0000000..212af4e --- /dev/null +++ b/multipart/__init__.py @@ -0,0 +1,20 @@ +# This only works if using a file system, other loaders not implemented. + +import importlib.util +import sys +import warnings +from pathlib import Path + +for p in sys.path: + file_path = Path(p, "multipart.py") + if file_path.is_file(): + spec = importlib.util.spec_from_file_location("multipart", file_path) + assert spec is not None, f"{file_path} found but not loadable!" + module = importlib.util.module_from_spec(spec) + sys.modules["multipart"] = module + assert spec.loader is not None, f"{file_path} must be loadable!" + spec.loader.exec_module(module) + break +else: + warnings.warn("Please use `import python_multipart` instead.", FutureWarning, stacklevel=2) + from python_multipart import * diff --git a/multipart/decoders.py b/multipart/decoders.py new file mode 100644 index 0000000..31acdfb --- /dev/null +++ b/multipart/decoders.py @@ -0,0 +1 @@ +from python_multipart.decoders import * diff --git a/multipart/exceptions.py b/multipart/exceptions.py new file mode 100644 index 0000000..36815d1 --- /dev/null +++ b/multipart/exceptions.py @@ -0,0 +1 @@ +from python_multipart.exceptions import * diff --git a/multipart/multipart.py b/multipart/multipart.py new file mode 100644 index 0000000..7bf567d --- /dev/null +++ b/multipart/multipart.py @@ -0,0 +1 @@ +from python_multipart.multipart import * diff --git a/noxfile.py b/noxfile.py index fda8050..1df0fd7 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,16 +1,12 @@ +import inspect + import nox nox.needs_version = ">=2024.4.15" nox.options.default_venv_backend = "uv|virtualenv" -ALL_PYTHONS = [ - c.split()[-1] - for c in nox.project.load_toml("pyproject.toml")["project"]["classifiers"] - if c.startswith("Programming Language :: Python :: 3.") -] - -@nox.session(python=ALL_PYTHONS) +@nox.session def rename(session: nox.Session) -> None: session.install(".") assert "import python_multipart" in session.run("python", "-c", "import multipart", silent=True) @@ -27,3 +23,21 @@ def rename(session: nox.Session) -> None: assert "import python_multipart" not in session.run( "python", "-c", "import python_multipart; python_multipart.parse_form", silent=True ) + + +@nox.session +def rename_inline(session: nox.Session) -> None: + session.install("pip") + res = session.run( + "python", + "-c", + inspect.cleandoc(""" + import subprocess + + subprocess.run(["pip", "install", "."]) + + import multipart + """), + silent=True, + ) + assert "FutureWarning: Please use `import python_multipart` instead." in res diff --git a/pyproject.toml b/pyproject.toml index 1a81077..29206de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,9 +70,8 @@ path = "python_multipart/__init__.py" [tool.hatch.build.targets.sdist] include = ["/python_multipart", "/tests", "CHANGELOG.md", "LICENSE.txt", "_python_multipart.pth", "_python_multipart_loader.py"] -[tool.hatch.build.targets.wheel.force-include] -"_python_multipart.pth" = "_python_multipart.pth" -"_python_multipart_loader.py" = "_python_multipart_loader.py" +[tool.hatch.build.targets.wheel] +packages = ["python_multipart", "multipart"] [tool.mypy] strict = true @@ -91,6 +90,9 @@ skip-magic-trailing-comma = true combine-as-imports = true split-on-trailing-comma = false +[tool.ruff.lint.per-file-ignores] +"multipart/*.py" = ["F403"] + [tool.coverage.run] branch = false omit = ["tests/*"] diff --git a/scripts/README.md b/scripts/README.md index 1742ebd..dce491e 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -4,5 +4,6 @@ * `scripts/test` - Run the test suite. * `scripts/lint` - Run the code format. * `scripts/check` - Run the lint in check mode, and the type checker. +* `scripts/rename` - Check that the backward-compat `multipart` name works as expected. Styled after GitHub's ["Scripts to Rule Them All"](https://github.com/github/scripts-to-rule-them-all). diff --git a/scripts/check b/scripts/check index f38e9c0..141f2a9 100755 --- a/scripts/check +++ b/scripts/check @@ -2,7 +2,7 @@ set -x -SOURCE_FILES="python_multipart tests" +SOURCE_FILES="python_multipart multipart tests" uvx ruff format --check --diff $SOURCE_FILES uvx ruff check $SOURCE_FILES diff --git a/scripts/rename b/scripts/rename new file mode 100755 index 0000000..251cc1d --- /dev/null +++ b/scripts/rename @@ -0,0 +1,7 @@ +#!/bin/sh -e + +set -x + +uvx nox -s rename + +uvx nox -s rename_inline From 6e0a3d89ab78e64356ce7b1eaac4a8993cab39e4 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Thu, 24 Oct 2024 16:32:06 +0200 Subject: [PATCH 13/31] Version 0.0.14 (#169) --- CHANGELOG.md | 4 ++++ python_multipart/__init__.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c177948..4e341a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.0.14 (2024-10-24) + +* Fix import scheme for `multipart` module ([#168](https://github.com/user/repo/issues/168)). + ## 0.0.13 (2024-10-20) * Rename import to `python_multipart` [#166](https://github.com/Kludex/python-multipart/pull/166). diff --git a/python_multipart/__init__.py b/python_multipart/__init__.py index 312194a..80d5cc0 100644 --- a/python_multipart/__init__.py +++ b/python_multipart/__init__.py @@ -2,7 +2,7 @@ __author__ = "Andrew Dunham" __license__ = "Apache" __copyright__ = "Copyright (c) 2012-2013, Andrew Dunham" -__version__ = "0.0.13" +__version__ = "0.0.14" from .multipart import ( BaseParser, From 5578583c8c9b0c58ed60fc82a5d0d861103b4c2b Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Sun, 27 Oct 2024 03:46:05 -0400 Subject: [PATCH 14/31] fix: reduce visibility of warning for next release (#174) --- multipart/__init__.py | 2 +- noxfile.py | 23 ++++++++++++++--------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/multipart/__init__.py b/multipart/__init__.py index 212af4e..f28c0c3 100644 --- a/multipart/__init__.py +++ b/multipart/__init__.py @@ -16,5 +16,5 @@ spec.loader.exec_module(module) break else: - warnings.warn("Please use `import python_multipart` instead.", FutureWarning, stacklevel=2) + warnings.warn("Please use `import python_multipart` instead.", PendingDeprecationWarning, stacklevel=2) from python_multipart import * diff --git a/noxfile.py b/noxfile.py index 1df0fd7..20de33e 100644 --- a/noxfile.py +++ b/noxfile.py @@ -7,21 +7,25 @@ @nox.session -def rename(session: nox.Session) -> None: - session.install(".") - assert "import python_multipart" in session.run("python", "-c", "import multipart", silent=True) - assert "import python_multipart" in session.run("python", "-c", "import multipart.exceptions", silent=True) - assert "import python_multipart" in session.run("python", "-c", "from multipart import exceptions", silent=True) +@nox.parametrize("editable", [True, False]) +def rename(session: nox.Session, editable: bool) -> None: + session.install("-e." if editable else ".") + # Ensure warning is not visible by default + assert "import python_multipart" not in session.run("python", "-c", "import multipart", silent=True) + + assert "import python_multipart" in session.run("python", "-Wdefault", "-c", "import multipart", silent=True) + assert "import python_multipart" in session.run("python", "-Wdefault", "-c", "import multipart.exceptions", silent=True) + assert "import python_multipart" in session.run("python", "-Wdefault", "-c", "from multipart import exceptions", silent=True) assert "import python_multipart" in session.run( - "python", "-c", "from multipart.exceptions import FormParserError", silent=True + "python", "-Wdefault", "-c", "from multipart.exceptions import FormParserError", silent=True ) session.install("multipart") assert "import python_multipart" not in session.run( - "python", "-c", "import multipart; multipart.parse_form_data", silent=True + "python", "-Wdefault", "-c", "import multipart; multipart.parse_form_data", silent=True ) assert "import python_multipart" not in session.run( - "python", "-c", "import python_multipart; python_multipart.parse_form", silent=True + "python", "-Wdefault", "-c", "import python_multipart; python_multipart.parse_form", silent=True ) @@ -30,6 +34,7 @@ def rename_inline(session: nox.Session) -> None: session.install("pip") res = session.run( "python", + "-Wdefault", "-c", inspect.cleandoc(""" import subprocess @@ -40,4 +45,4 @@ def rename_inline(session: nox.Session) -> None: """), silent=True, ) - assert "FutureWarning: Please use `import python_multipart` instead." in res + assert "Please use `import python_multipart` instead." in res From c06830d2b7d0dc4060f1cac6e53f49cc887b6e71 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Sun, 27 Oct 2024 03:47:51 -0400 Subject: [PATCH 15/31] fix: add missing files to SDist (#171) Co-authored-by: Marcelo Trylesinski --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 29206de..ea348fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,7 +68,7 @@ Source = "https://github.com/Kludex/python-multipart" path = "python_multipart/__init__.py" [tool.hatch.build.targets.sdist] -include = ["/python_multipart", "/tests", "CHANGELOG.md", "LICENSE.txt", "_python_multipart.pth", "_python_multipart_loader.py"] +include = ["/python_multipart", "/multipart", "/tests", "CHANGELOG.md", "LICENSE.txt"] [tool.hatch.build.targets.wheel] packages = ["python_multipart", "multipart"] From 73fb55d1f8fec576759fcc3c11cc0807d246af00 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Sun, 27 Oct 2024 03:50:04 -0400 Subject: [PATCH 16/31] ci: check-sdist (#172) --- .github/workflows/main.yml | 1 + pyproject.toml | 9 +++++++++ scripts/check | 1 + 3 files changed, 11 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4ceafb7..c3d9d99 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -35,6 +35,7 @@ jobs: - name: Run rename test run: scripts/rename + # https://github.com/marketplace/actions/alls-green#why used for branch protection checks check: if: always() diff --git a/pyproject.toml b/pyproject.toml index ea348fc..12e4922 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -113,3 +113,12 @@ exclude_lines = [ "if self\\.debug:", "except ImportError:", ] + +[tool.check-sdist] +git-only = [ + "docs", + "fuzz", + "scripts", + "mkdocs.yml", + "uv.lock" +] diff --git a/scripts/check b/scripts/check index 141f2a9..13ce9ed 100755 --- a/scripts/check +++ b/scripts/check @@ -7,3 +7,4 @@ SOURCE_FILES="python_multipart multipart tests" uvx ruff format --check --diff $SOURCE_FILES uvx ruff check $SOURCE_FILES uvx --with types-PyYAML mypy $SOURCE_FILES +uvx check-sdist From ce85154ff138227654e19d5a47eea6b316bba427 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 27 Oct 2024 08:54:43 +0100 Subject: [PATCH 17/31] Version 0.0.15 (#175) --- CHANGELOG.md | 7 ++++++- python_multipart/__init__.py | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e341a2..a08dc65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,13 @@ # Changelog +## 0.0.15 (2024-10-27) + +* Replace `FutureWarning` to `PendingDeprecationWarning` [#174](https://github.com/Kludex/python-multipart/pull/174). +* Add missing files to SDist [#171](https://github.com/Kludex/python-multipart/pull/171). + ## 0.0.14 (2024-10-24) -* Fix import scheme for `multipart` module ([#168](https://github.com/user/repo/issues/168)). +* Fix import scheme for `multipart` module ([#168](https://github.com/Kludex/python-multipart/pull/168)). ## 0.0.13 (2024-10-20) diff --git a/python_multipart/__init__.py b/python_multipart/__init__.py index 80d5cc0..88df444 100644 --- a/python_multipart/__init__.py +++ b/python_multipart/__init__.py @@ -2,7 +2,7 @@ __author__ = "Andrew Dunham" __license__ = "Apache" __copyright__ = "Copyright (c) 2012-2013, Andrew Dunham" -__version__ = "0.0.14" +__version__ = "0.0.15" from .multipart import ( BaseParser, From 876406774d9b98c7b3afa24c3a0c901215f87029 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 27 Oct 2024 12:52:23 +0100 Subject: [PATCH 18/31] Version 0.0.16 (#177) --- CHANGELOG.md | 4 ++++ multipart/__init__.py | 1 + pyproject.toml | 17 +++++++++-------- python_multipart/__init__.py | 2 +- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a08dc65..56dbb3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.0.16 (2024-10-27) + +* Add dunder attributes to `multipart` package [#177](https://github.com/Kludex/python-multipart/pull/177). + ## 0.0.15 (2024-10-27) * Replace `FutureWarning` to `PendingDeprecationWarning` [#174](https://github.com/Kludex/python-multipart/pull/174). diff --git a/multipart/__init__.py b/multipart/__init__.py index f28c0c3..cdc0154 100644 --- a/multipart/__init__.py +++ b/multipart/__init__.py @@ -18,3 +18,4 @@ else: warnings.warn("Please use `import python_multipart` instead.", PendingDeprecationWarning, stacklevel=2) from python_multipart import * + from python_multipart import __all__, __author__, __copyright__, __license__, __version__ diff --git a/pyproject.toml b/pyproject.toml index 12e4922..e22ea1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,7 +68,13 @@ Source = "https://github.com/Kludex/python-multipart" path = "python_multipart/__init__.py" [tool.hatch.build.targets.sdist] -include = ["/python_multipart", "/multipart", "/tests", "CHANGELOG.md", "LICENSE.txt"] +include = [ + "/python_multipart", + "/multipart", + "/tests", + "CHANGELOG.md", + "LICENSE.txt", +] [tool.hatch.build.targets.wheel] packages = ["python_multipart", "multipart"] @@ -92,6 +98,7 @@ split-on-trailing-comma = false [tool.ruff.lint.per-file-ignores] "multipart/*.py" = ["F403"] +"__init__.py" = ["F401"] [tool.coverage.run] branch = false @@ -115,10 +122,4 @@ exclude_lines = [ ] [tool.check-sdist] -git-only = [ - "docs", - "fuzz", - "scripts", - "mkdocs.yml", - "uv.lock" -] +git-only = ["docs", "fuzz", "scripts", "mkdocs.yml", "uv.lock"] diff --git a/python_multipart/__init__.py b/python_multipart/__init__.py index 88df444..a2d50d2 100644 --- a/python_multipart/__init__.py +++ b/python_multipart/__init__.py @@ -2,7 +2,7 @@ __author__ = "Andrew Dunham" __license__ = "Apache" __copyright__ = "Copyright (c) 2012-2013, Andrew Dunham" -__version__ = "0.0.15" +__version__ = "0.0.16" from .multipart import ( BaseParser, From ca52662eda368bd61fbb9508bfaffb0fc4af6028 Mon Sep 17 00:00:00 2001 From: Marcel Hellkamp Date: Thu, 31 Oct 2024 08:04:56 +0100 Subject: [PATCH 19/31] Handle PermissionError in fallback code for old import name (#182) --- multipart/__init__.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/multipart/__init__.py b/multipart/__init__.py index cdc0154..67f0e5b 100644 --- a/multipart/__init__.py +++ b/multipart/__init__.py @@ -7,14 +7,17 @@ for p in sys.path: file_path = Path(p, "multipart.py") - if file_path.is_file(): - spec = importlib.util.spec_from_file_location("multipart", file_path) - assert spec is not None, f"{file_path} found but not loadable!" - module = importlib.util.module_from_spec(spec) - sys.modules["multipart"] = module - assert spec.loader is not None, f"{file_path} must be loadable!" - spec.loader.exec_module(module) - break + try: + if file_path.is_file(): + spec = importlib.util.spec_from_file_location("multipart", file_path) + assert spec is not None, f"{file_path} found but not loadable!" + module = importlib.util.module_from_spec(spec) + sys.modules["multipart"] = module + assert spec.loader is not None, f"{file_path} must be loadable!" + spec.loader.exec_module(module) + break + except PermissionError: + pass else: warnings.warn("Please use `import python_multipart` instead.", PendingDeprecationWarning, stacklevel=2) from python_multipart import * From 616b81e72fe67ce67e332c446513ef89b9d816dc Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Thu, 31 Oct 2024 08:07:53 +0100 Subject: [PATCH 20/31] Version 0.0.17 (#183) --- CHANGELOG.md | 4 ++++ python_multipart/__init__.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56dbb3f..9a6fbba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.0.17 (2024-10-31) + +* Handle PermissionError in fallback code for old import name [#182](https://github.com/Kludex/python-multipart/pull/182). + ## 0.0.16 (2024-10-27) * Add dunder attributes to `multipart` package [#177](https://github.com/Kludex/python-multipart/pull/177). diff --git a/python_multipart/__init__.py b/python_multipart/__init__.py index a2d50d2..be8327f 100644 --- a/python_multipart/__init__.py +++ b/python_multipart/__init__.py @@ -2,7 +2,7 @@ __author__ = "Andrew Dunham" __license__ = "Apache" __copyright__ = "Copyright (c) 2012-2013, Andrew Dunham" -__version__ = "0.0.16" +__version__ = "0.0.17" from .multipart import ( BaseParser, From 02d1ec148b40470b8bbb25dd833c1e1aace51a8b Mon Sep 17 00:00:00 2001 From: manunio Date: Thu, 31 Oct 2024 12:40:56 +0530 Subject: [PATCH 21/31] fuzz: fix boundary error (#179) Co-authored-by: Marcelo Trylesinski --- fuzz/fuzz_form.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/fuzz/fuzz_form.py b/fuzz/fuzz_form.py index c990639..9a3d854 100644 --- a/fuzz/fuzz_form.py +++ b/fuzz/fuzz_form.py @@ -29,8 +29,15 @@ def parse_form_urlencoded(fdp: EnhancedDataProvider) -> None: def parse_multipart_form_data(fdp: EnhancedDataProvider) -> None: - header = {"Content-Type": "multipart/form-data; boundary=--boundary"} - parse_form(header, io.BytesIO(fdp.ConsumeRandomBytes()), on_field, on_file) + boundary = "boundary" + header = {"Content-Type": f"multipart/form-data; boundary={boundary}"} + body = ( + f"--{boundary}\r\n" + f"Content-Type: multipart/form-data; boundary={boundary}\r\n\r\n" + f"{fdp.ConsumeRandomString()}\r\n" + f"--{boundary}--\r\n" + ) + parse_form(header, io.BytesIO(body.encode("latin1", errors="ignore")), on_field, on_file) def TestOneInput(data: bytes) -> None: From e53b541356981b2353914ef5dbf6a1b0605f31c5 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 23 Nov 2024 14:03:21 +0100 Subject: [PATCH 22/31] Create SECURITY.md (#187) --- SECURITY.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..759bd10 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,11 @@ +# Security Policy + +If you think you have identified a security issue with `python-multipart`, **do not open a public issue**. + +To responsibly report a security issue, please navigate to the Security tab for the repo and click "Report a vulnerability." + +![Screenshot of repo security tab showing "Report a vulnerability" button](https://github.com/encode/.github/raw/master/img/github-demos-private-vulnerability-reporting.png) + +Be sure to include as much detail as necessary in your report. As with reporting normal issues, a minimal reproducible example will help the maintainers address the issue faster. + +Thank you. From 170e6043ffeb8f9fb6ad622729f3eda3f45b98cb Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 24 Nov 2024 13:45:21 +0100 Subject: [PATCH 23/31] Update ruff & mypy (#188) --- noxfile.py | 8 +++- pyproject.toml | 4 +- uv.lock | 102 ++++++++++++++++++++++++++----------------------- 3 files changed, 62 insertions(+), 52 deletions(-) diff --git a/noxfile.py b/noxfile.py index 20de33e..8c12ee4 100644 --- a/noxfile.py +++ b/noxfile.py @@ -14,8 +14,12 @@ def rename(session: nox.Session, editable: bool) -> None: assert "import python_multipart" not in session.run("python", "-c", "import multipart", silent=True) assert "import python_multipart" in session.run("python", "-Wdefault", "-c", "import multipart", silent=True) - assert "import python_multipart" in session.run("python", "-Wdefault", "-c", "import multipart.exceptions", silent=True) - assert "import python_multipart" in session.run("python", "-Wdefault", "-c", "from multipart import exceptions", silent=True) + assert "import python_multipart" in session.run( + "python", "-Wdefault", "-c", "import multipart.exceptions", silent=True + ) + assert "import python_multipart" in session.run( + "python", "-Wdefault", "-c", "from multipart import exceptions", silent=True + ) assert "import python_multipart" in session.run( "python", "-Wdefault", "-c", "from multipart.exceptions import FormParserError", silent=True ) diff --git a/pyproject.toml b/pyproject.toml index e22ea1d..01a907d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dev-dependencies = [ "PyYAML==6.0.1", "invoke==2.2.0", "pytest-timeout==2.3.1", - "ruff==0.3.4", + "ruff==0.8.0", "mypy", "types-PyYAML", "atheris==2.3.0; python_version != '3.12'", @@ -122,4 +122,4 @@ exclude_lines = [ ] [tool.check-sdist] -git-only = ["docs", "fuzz", "scripts", "mkdocs.yml", "uv.lock"] +git-only = ["docs", "fuzz", "scripts", "mkdocs.yml", "uv.lock", "SECURITY.md"] diff --git a/uv.lock b/uv.lock index 2ae1c4e..dbdacc9 100644 --- a/uv.lock +++ b/uv.lock @@ -526,41 +526,46 @@ wheels = [ [[package]] name = "mypy" -version = "1.11.2" +version = "1.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mypy-extensions" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5c/86/5d7cbc4974fd564550b80fbb8103c05501ea11aa7835edf3351d90095896/mypy-1.11.2.tar.gz", hash = "sha256:7f9993ad3e0ffdc95c2a14b66dee63729f021968bff8ad911867579c65d13a79", size = 3078806 } +sdist = { url = "https://files.pythonhosted.org/packages/e8/21/7e9e523537991d145ab8a0a2fd98548d67646dc2aaaf6091c31ad883e7c1/mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e", size = 3152532 } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/cd/815368cd83c3a31873e5e55b317551500b12f2d1d7549720632f32630333/mypy-1.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d42a6dd818ffce7be66cce644f1dff482f1d97c53ca70908dff0b9ddc120b77a", size = 10939401 }, - { url = "https://files.pythonhosted.org/packages/f1/27/e18c93a195d2fad75eb96e1f1cbc431842c332e8eba2e2b77eaf7313c6b7/mypy-1.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:801780c56d1cdb896eacd5619a83e427ce436d86a3bdf9112527f24a66618fef", size = 10111697 }, - { url = "https://files.pythonhosted.org/packages/dc/08/cdc1fc6d0d5a67d354741344cc4aa7d53f7128902ebcbe699ddd4f15a61c/mypy-1.11.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41ea707d036a5307ac674ea172875f40c9d55c5394f888b168033177fce47383", size = 12500508 }, - { url = "https://files.pythonhosted.org/packages/64/12/aad3af008c92c2d5d0720ea3b6674ba94a98cdb86888d389acdb5f218c30/mypy-1.11.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6e658bd2d20565ea86da7d91331b0eed6d2eee22dc031579e6297f3e12c758c8", size = 13020712 }, - { url = "https://files.pythonhosted.org/packages/03/e6/a7d97cc124a565be5e9b7d5c2a6ebf082379ffba99646e4863ed5bbcb3c3/mypy-1.11.2-cp310-cp310-win_amd64.whl", hash = "sha256:478db5f5036817fe45adb7332d927daa62417159d49783041338921dcf646fc7", size = 9567319 }, - { url = "https://files.pythonhosted.org/packages/e2/aa/cc56fb53ebe14c64f1fe91d32d838d6f4db948b9494e200d2f61b820b85d/mypy-1.11.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75746e06d5fa1e91bfd5432448d00d34593b52e7e91a187d981d08d1f33d4385", size = 10859630 }, - { url = "https://files.pythonhosted.org/packages/04/c8/b19a760fab491c22c51975cf74e3d253b8c8ce2be7afaa2490fbf95a8c59/mypy-1.11.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a976775ab2256aadc6add633d44f100a2517d2388906ec4f13231fafbb0eccca", size = 10037973 }, - { url = "https://files.pythonhosted.org/packages/88/57/7e7e39f2619c8f74a22efb9a4c4eff32b09d3798335625a124436d121d89/mypy-1.11.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd953f221ac1379050a8a646585a29574488974f79d8082cedef62744f0a0104", size = 12416659 }, - { url = "https://files.pythonhosted.org/packages/fc/a6/37f7544666b63a27e46c48f49caeee388bf3ce95f9c570eb5cfba5234405/mypy-1.11.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:57555a7715c0a34421013144a33d280e73c08df70f3a18a552938587ce9274f4", size = 12897010 }, - { url = "https://files.pythonhosted.org/packages/84/8b/459a513badc4d34acb31c736a0101c22d2bd0697b969796ad93294165cfb/mypy-1.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:36383a4fcbad95f2657642a07ba22ff797de26277158f1cc7bd234821468b1b6", size = 9562873 }, - { url = "https://files.pythonhosted.org/packages/35/3a/ed7b12ecc3f6db2f664ccf85cb2e004d3e90bec928e9d7be6aa2f16b7cdf/mypy-1.11.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e8960dbbbf36906c5c0b7f4fbf2f0c7ffb20f4898e6a879fcf56a41a08b0d318", size = 10990335 }, - { url = "https://files.pythonhosted.org/packages/04/e4/1a9051e2ef10296d206519f1df13d2cc896aea39e8683302f89bf5792a59/mypy-1.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:06d26c277962f3fb50e13044674aa10553981ae514288cb7d0a738f495550b36", size = 10007119 }, - { url = "https://files.pythonhosted.org/packages/f3/3c/350a9da895f8a7e87ade0028b962be0252d152e0c2fbaafa6f0658b4d0d4/mypy-1.11.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7184632d89d677973a14d00ae4d03214c8bc301ceefcdaf5c474866814c987", size = 12506856 }, - { url = "https://files.pythonhosted.org/packages/b6/49/ee5adf6a49ff13f4202d949544d3d08abb0ea1f3e7f2a6d5b4c10ba0360a/mypy-1.11.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3a66169b92452f72117e2da3a576087025449018afc2d8e9bfe5ffab865709ca", size = 12952066 }, - { url = "https://files.pythonhosted.org/packages/27/c0/b19d709a42b24004d720db37446a42abadf844d5c46a2c442e2a074d70d9/mypy-1.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:969ea3ef09617aff826885a22ece0ddef69d95852cdad2f60c8bb06bf1f71f70", size = 9664000 }, - { url = "https://files.pythonhosted.org/packages/42/ad/5a8567700410f8aa7c755b0ebd4cacff22468cbc5517588773d65075c0cb/mypy-1.11.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:37c7fa6121c1cdfcaac97ce3d3b5588e847aa79b580c1e922bb5d5d2902df19b", size = 10876550 }, - { url = "https://files.pythonhosted.org/packages/1b/bc/9fc16ea7a27ceb93e123d300f1cfe27a6dd1eac9a8beea4f4d401e737e9d/mypy-1.11.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4a8a53bc3ffbd161b5b2a4fff2f0f1e23a33b0168f1c0778ec70e1a3d66deb86", size = 10068086 }, - { url = "https://files.pythonhosted.org/packages/cd/8f/a1e460f1288405a13352dad16b24aba6dce4f850fc76510c540faa96eda3/mypy-1.11.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ff93107f01968ed834f4256bc1fc4475e2fecf6c661260066a985b52741ddce", size = 12459214 }, - { url = "https://files.pythonhosted.org/packages/c7/74/746b31aef7cc7512dab8bdc2311ef88d63fadc1c453a09c8cab7e57e59bf/mypy-1.11.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:edb91dded4df17eae4537668b23f0ff6baf3707683734b6a818d5b9d0c0c31a1", size = 12962942 }, - { url = "https://files.pythonhosted.org/packages/28/a4/7fae712240b640d75bb859294ad4776b9960b3216ccb7fa747f578e6c632/mypy-1.11.2-cp38-cp38-win_amd64.whl", hash = "sha256:ee23de8530d99b6db0573c4ef4bd8f39a2a6f9b60655bf7a1357e585a3486f2b", size = 9545616 }, - { url = "https://files.pythonhosted.org/packages/16/64/bb5ed751487e2bea0dfaa6f640a7e3bb88083648f522e766d5ef4a76f578/mypy-1.11.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:801ca29f43d5acce85f8e999b1e431fb479cb02d0e11deb7d2abb56bdaf24fd6", size = 10937294 }, - { url = "https://files.pythonhosted.org/packages/a9/a3/67a0069abed93c3bf3b0bebb8857e2979a02828a4a3fd82f107f8f1143e8/mypy-1.11.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af8d155170fcf87a2afb55b35dc1a0ac21df4431e7d96717621962e4b9192e70", size = 10107707 }, - { url = "https://files.pythonhosted.org/packages/2f/4d/0379daf4258b454b1f9ed589a9dabd072c17f97496daea7b72fdacf7c248/mypy-1.11.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7821776e5c4286b6a13138cc935e2e9b6fde05e081bdebf5cdb2bb97c9df81d", size = 12498367 }, - { url = "https://files.pythonhosted.org/packages/3b/dc/3976a988c280b3571b8eb6928882dc4b723a403b21735a6d8ae6ed20e82b/mypy-1.11.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:539c570477a96a4e6fb718b8d5c3e0c0eba1f485df13f86d2970c91f0673148d", size = 13018014 }, - { url = "https://files.pythonhosted.org/packages/83/84/adffc7138fb970e7e2a167bd20b33bb78958370179853a4ebe9008139342/mypy-1.11.2-cp39-cp39-win_amd64.whl", hash = "sha256:3f14cd3d386ac4d05c5a39a51b84387403dadbd936e17cb35882134d4f8f0d24", size = 9568056 }, - { url = "https://files.pythonhosted.org/packages/42/3a/bdf730640ac523229dd6578e8a581795720a9321399de494374afc437ec5/mypy-1.11.2-py3-none-any.whl", hash = "sha256:b499bc07dbdcd3de92b0a8b29fdf592c111276f6a12fe29c30f6c417dd546d12", size = 2619625 }, + { url = "https://files.pythonhosted.org/packages/5e/8c/206de95a27722b5b5a8c85ba3100467bd86299d92a4f71c6b9aa448bfa2f/mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a", size = 11020731 }, + { url = "https://files.pythonhosted.org/packages/ab/bb/b31695a29eea76b1569fd28b4ab141a1adc9842edde080d1e8e1776862c7/mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80", size = 10184276 }, + { url = "https://files.pythonhosted.org/packages/a5/2d/4a23849729bb27934a0e079c9c1aad912167d875c7b070382a408d459651/mypy-1.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7", size = 12587706 }, + { url = "https://files.pythonhosted.org/packages/5c/c3/d318e38ada50255e22e23353a469c791379825240e71b0ad03e76ca07ae6/mypy-1.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f", size = 13105586 }, + { url = "https://files.pythonhosted.org/packages/4a/25/3918bc64952370c3dbdbd8c82c363804678127815febd2925b7273d9482c/mypy-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372", size = 9632318 }, + { url = "https://files.pythonhosted.org/packages/d0/19/de0822609e5b93d02579075248c7aa6ceaddcea92f00bf4ea8e4c22e3598/mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d", size = 10939027 }, + { url = "https://files.pythonhosted.org/packages/c8/71/6950fcc6ca84179137e4cbf7cf41e6b68b4a339a1f5d3e954f8c34e02d66/mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d", size = 10108699 }, + { url = "https://files.pythonhosted.org/packages/26/50/29d3e7dd166e74dc13d46050b23f7d6d7533acf48f5217663a3719db024e/mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b", size = 12506263 }, + { url = "https://files.pythonhosted.org/packages/3f/1d/676e76f07f7d5ddcd4227af3938a9c9640f293b7d8a44dd4ff41d4db25c1/mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73", size = 12984688 }, + { url = "https://files.pythonhosted.org/packages/9c/03/5a85a30ae5407b1d28fab51bd3e2103e52ad0918d1e68f02a7778669a307/mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca", size = 9626811 }, + { url = "https://files.pythonhosted.org/packages/fb/31/c526a7bd2e5c710ae47717c7a5f53f616db6d9097caf48ad650581e81748/mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5", size = 11077900 }, + { url = "https://files.pythonhosted.org/packages/83/67/b7419c6b503679d10bd26fc67529bc6a1f7a5f220bbb9f292dc10d33352f/mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e", size = 10074818 }, + { url = "https://files.pythonhosted.org/packages/ba/07/37d67048786ae84e6612575e173d713c9a05d0ae495dde1e68d972207d98/mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2", size = 12589275 }, + { url = "https://files.pythonhosted.org/packages/1f/17/b1018c6bb3e9f1ce3956722b3bf91bff86c1cefccca71cec05eae49d6d41/mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0", size = 13037783 }, + { url = "https://files.pythonhosted.org/packages/cb/32/cd540755579e54a88099aee0287086d996f5a24281a673f78a0e14dba150/mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2", size = 9726197 }, + { url = "https://files.pythonhosted.org/packages/11/bb/ab4cfdc562cad80418f077d8be9b4491ee4fb257440da951b85cbb0a639e/mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7", size = 11069721 }, + { url = "https://files.pythonhosted.org/packages/59/3b/a393b1607cb749ea2c621def5ba8c58308ff05e30d9dbdc7c15028bca111/mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62", size = 10063996 }, + { url = "https://files.pythonhosted.org/packages/d1/1f/6b76be289a5a521bb1caedc1f08e76ff17ab59061007f201a8a18cc514d1/mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8", size = 12584043 }, + { url = "https://files.pythonhosted.org/packages/a6/83/5a85c9a5976c6f96e3a5a7591aa28b4a6ca3a07e9e5ba0cec090c8b596d6/mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7", size = 13036996 }, + { url = "https://files.pythonhosted.org/packages/b4/59/c39a6f752f1f893fccbcf1bdd2aca67c79c842402b5283563d006a67cf76/mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc", size = 9737709 }, + { url = "https://files.pythonhosted.org/packages/5e/2a/13e9ad339131c0fba5c70584f639005a47088f5eed77081a3d00479df0ca/mypy-1.13.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:100fac22ce82925f676a734af0db922ecfea991e1d7ec0ceb1e115ebe501301a", size = 10955147 }, + { url = "https://files.pythonhosted.org/packages/94/39/02929067dc16b72d78109195cfed349ac4ec85f3d52517ac62b9a5263685/mypy-1.13.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bcb0bb7f42a978bb323a7c88f1081d1b5dee77ca86f4100735a6f541299d8fb", size = 10138373 }, + { url = "https://files.pythonhosted.org/packages/4a/cc/066709bb01734e3dbbd1375749f8789bf9693f8b842344fc0cf52109694f/mypy-1.13.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bde31fc887c213e223bbfc34328070996061b0833b0a4cfec53745ed61f3519b", size = 12543621 }, + { url = "https://files.pythonhosted.org/packages/f5/a2/124df839025348c7b9877d0ce134832a9249968e3ab36bb826bab0e9a1cf/mypy-1.13.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:07de989f89786f62b937851295ed62e51774722e5444a27cecca993fc3f9cd74", size = 13050348 }, + { url = "https://files.pythonhosted.org/packages/45/86/cc94b1e7f7e756a63043cf425c24fb7470013ee1c032180282db75b1b335/mypy-1.13.0-cp38-cp38-win_amd64.whl", hash = "sha256:4bde84334fbe19bad704b3f5b78c4abd35ff1026f8ba72b29de70dda0916beb6", size = 9615311 }, + { url = "https://files.pythonhosted.org/packages/5f/d4/b33ddd40dad230efb317898a2d1c267c04edba73bc5086bf77edeb410fb2/mypy-1.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0246bcb1b5de7f08f2826451abd947bf656945209b140d16ed317f65a17dc7dc", size = 11013906 }, + { url = "https://files.pythonhosted.org/packages/f4/e6/f414bca465b44d01cd5f4a82761e15044bedd1bf8025c5af3cc64518fac5/mypy-1.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7f5b7deae912cf8b77e990b9280f170381fdfbddf61b4ef80927edd813163732", size = 10180657 }, + { url = "https://files.pythonhosted.org/packages/38/e9/fc3865e417722f98d58409770be01afb961e2c1f99930659ff4ae7ca8b7e/mypy-1.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7029881ec6ffb8bc233a4fa364736789582c738217b133f1b55967115288a2bc", size = 12586394 }, + { url = "https://files.pythonhosted.org/packages/2e/35/f4d8b6d2cb0b3dad63e96caf159419dda023f45a358c6c9ac582ccaee354/mypy-1.13.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3e38b980e5681f28f033f3be86b099a247b13c491f14bb8b1e1e134d23bb599d", size = 13103591 }, + { url = "https://files.pythonhosted.org/packages/22/1d/80594aef135f921dd52e142fa0acd19df197690bd0cde42cea7b88cf5aa2/mypy-1.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:a6789be98a2017c912ae6ccb77ea553bbaf13d27605d2ca20a76dfbced631b24", size = 9634690 }, + { url = "https://files.pythonhosted.org/packages/3b/86/72ce7f57431d87a7ff17d442f521146a6585019eb8f4f31b7c02801f78ad/mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a", size = 2647043 }, ] [[package]] @@ -713,7 +718,7 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.11" +version = "0.0.17" source = { editable = "." } [package.dev-dependencies] @@ -762,7 +767,7 @@ dev = [ { name = "pytest-cov", specifier = "==5.0.0" }, { name = "pytest-timeout", specifier = "==2.3.1" }, { name = "pyyaml", specifier = "==6.0.1" }, - { name = "ruff", specifier = "==0.3.4" }, + { name = "ruff", specifier = "==0.8.0" }, { name = "types-pyyaml" }, ] @@ -951,26 +956,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.3.4" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a0/98/91e1ad8a6777c300b15cad46a1b507375010f8a53cfeaa17f0385bde1103/ruff-0.3.4.tar.gz", hash = "sha256:f0f4484c6541a99862b693e13a151435a279b271cff20e37101116a21e2a1ad1", size = 2129882 } +sdist = { url = "https://files.pythonhosted.org/packages/b2/d6/a2373f3ba7180ddb44420d2a9d1f1510e1a4d162b3d27282bedcb09c8da9/ruff-0.8.0.tar.gz", hash = "sha256:a7ccfe6331bf8c8dad715753e157457faf7351c2b69f62f32c165c2dbcbacd44", size = 3276537 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/61/797dce050c288fc8325e6b723baa1dd6aff4851ee1b769350b54fd3e0fe5/ruff-0.3.4-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:60c870a7d46efcbc8385d27ec07fe534ac32f3b251e4fc44b3cbfd9e09609ef4", size = 16472324 }, - { url = "https://files.pythonhosted.org/packages/b9/3c/5025d7eee9dd76abb489c1a98c05797e1889329abf8b8b4efcd7095e74f5/ruff-0.3.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6fc14fa742e1d8f24910e1fff0bd5e26d395b0e0e04cc1b15c7c5e5fe5b4af91", size = 8447934 }, - { url = "https://files.pythonhosted.org/packages/5e/c3/2e6aca190ac828dc94bf86384e89513a4a987816c6ddd6a1db4fca0fdd17/ruff-0.3.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3ee7880f653cc03749a3bfea720cf2a192e4f884925b0cf7eecce82f0ce5854", size = 8106257 }, - { url = "https://files.pythonhosted.org/packages/03/92/57b9193e5600445a20d331c9a23dc6c17d27fc50642315bde6fbdaa83499/ruff-0.3.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf133dd744f2470b347f602452a88e70dadfbe0fcfb5fd46e093d55da65f82f7", size = 7470593 }, - { url = "https://files.pythonhosted.org/packages/93/80/26e4cc40921d759bbdf49b898861aeaf7e1bed80001fc26073a97aac613f/ruff-0.3.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f3860057590e810c7ffea75669bdc6927bfd91e29b4baa9258fd48b540a4365", size = 8635128 }, - { url = "https://files.pythonhosted.org/packages/b5/42/b90b05d167c056aeb71b954cb61fad97a61aaea2a4d5e4e6cba4570c8221/ruff-0.3.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:986f2377f7cf12efac1f515fc1a5b753c000ed1e0a6de96747cdf2da20a1b369", size = 9389231 }, - { url = "https://files.pythonhosted.org/packages/8e/d7/cd9e7e8d8ca4034577fd28e9ff11551df8d2df9e77a16eecee12121d0f7d/ruff-0.3.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fd98e85869603e65f554fdc5cddf0712e352fe6e61d29d5a6fe087ec82b76c", size = 9094312 }, - { url = "https://files.pythonhosted.org/packages/f6/bb/c583d2a0c8e91ee84a13c31b714070a89863348bbecd2e31ca6ed9b18924/ruff-0.3.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64abeed785dad51801b423fa51840b1764b35d6c461ea8caef9cf9e5e5ab34d9", size = 9909854 }, - { url = "https://files.pythonhosted.org/packages/2e/95/ec159b3cae9960811fe573586ca905578ff78d33f025ae054d30ef6c2b73/ruff-0.3.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df52972138318bc7546d92348a1ee58449bc3f9eaf0db278906eb511889c4b50", size = 8658269 }, - { url = "https://files.pythonhosted.org/packages/0e/27/13e2cf723209f8e8169de81d4be5b985ff46549b452d112d3e36899ec2ef/ruff-0.3.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:98e98300056445ba2cc27d0b325fd044dc17fcc38e4e4d2c7711585bd0a958ed", size = 8008722 }, - { url = "https://files.pythonhosted.org/packages/c6/04/036aa4328dfcb50009e80baac7bc78b8532ea9e8c0b6a1d4b75a684301a5/ruff-0.3.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:519cf6a0ebed244dce1dc8aecd3dc99add7a2ee15bb68cf19588bb5bf58e0488", size = 7463983 }, - { url = "https://files.pythonhosted.org/packages/32/cc/728245664c1fe2adbe90af1044ff2f548527ed12fc607bae74043387990f/ruff-0.3.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bb0acfb921030d00070539c038cd24bb1df73a2981e9f55942514af8b17be94e", size = 8232832 }, - { url = "https://files.pythonhosted.org/packages/a1/ff/88a45e7b8b87c7a8dac38786ebb800325f9523a9af89f21382104874d9d9/ruff-0.3.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cf187a7e7098233d0d0c71175375c5162f880126c4c716fa28a8ac418dcf3378", size = 8705875 }, - { url = "https://files.pythonhosted.org/packages/b9/4b/290e829a7c33fa996f0a598f2cdc954b4820262bb027e0a2edd888c3600d/ruff-0.3.4-py3-none-win32.whl", hash = "sha256:af27ac187c0a331e8ef91d84bf1c3c6a5dea97e912a7560ac0cef25c526a4102", size = 7645340 }, - { url = "https://files.pythonhosted.org/packages/09/a1/ecbd844e714a4bed4b9072f5a73bbdc2a3a6e6ee9d9c5b3962be83d5bac8/ruff-0.3.4-py3-none-win_amd64.whl", hash = "sha256:de0d5069b165e5a32b3c6ffbb81c350b1e3d3483347196ffdf86dc0ef9e37dd6", size = 8436394 }, - { url = "https://files.pythonhosted.org/packages/f3/c4/afb3bb366074fa98faeb6389618bf10b3eb00bd1eb48d980c205da9b2022/ruff-0.3.4-py3-none-win_arm64.whl", hash = "sha256:6810563cc08ad0096b57c717bd78aeac888a1bfd38654d9113cb3dc4d3f74232", size = 7991316 }, + { url = "https://files.pythonhosted.org/packages/ec/77/e889ee3ce7fd8baa3ed1b77a03b9fb8ec1be68be1418261522fd6a5405e0/ruff-0.8.0-py3-none-linux_armv6l.whl", hash = "sha256:fcb1bf2cc6706adae9d79c8d86478677e3bbd4ced796ccad106fd4776d395fea", size = 10518283 }, + { url = "https://files.pythonhosted.org/packages/da/c8/0a47de01edf19fb22f5f9b7964f46a68d0bdff20144d134556ffd1ba9154/ruff-0.8.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:295bb4c02d58ff2ef4378a1870c20af30723013f441c9d1637a008baaf928c8b", size = 10317691 }, + { url = "https://files.pythonhosted.org/packages/41/17/9885e4a0eeae07abd2a4ebabc3246f556719f24efa477ba2739146c4635a/ruff-0.8.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7b1f1c76b47c18fa92ee78b60d2d20d7e866c55ee603e7d19c1e991fad933a9a", size = 9940999 }, + { url = "https://files.pythonhosted.org/packages/3e/cd/46b6f7043597eb318b5f5482c8ae8f5491cccce771e85f59d23106f2d179/ruff-0.8.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb0d4f250a7711b67ad513fde67e8870109e5ce590a801c3722580fe98c33a99", size = 10772437 }, + { url = "https://files.pythonhosted.org/packages/5d/87/afc95aeb8bc78b1d8a3461717a4419c05aa8aa943d4c9cbd441630f85584/ruff-0.8.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e55cce9aa93c5d0d4e3937e47b169035c7e91c8655b0974e61bb79cf398d49c", size = 10299156 }, + { url = "https://files.pythonhosted.org/packages/65/fa/04c647bb809c4d65e8eae1ed1c654d9481b21dd942e743cd33511687b9f9/ruff-0.8.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f4cd64916d8e732ce6b87f3f5296a8942d285bbbc161acee7fe561134af64f9", size = 11325819 }, + { url = "https://files.pythonhosted.org/packages/90/26/7dad6e7d833d391a8a1afe4ee70ca6f36c4a297d3cca83ef10e83e9aacf3/ruff-0.8.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c5c1466be2a2ebdf7c5450dd5d980cc87c8ba6976fb82582fea18823da6fa362", size = 12023927 }, + { url = "https://files.pythonhosted.org/packages/24/a0/be5296dda6428ba8a13bda8d09fbc0e14c810b485478733886e61597ae2b/ruff-0.8.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2dabfd05b96b7b8f2da00d53c514eea842bff83e41e1cceb08ae1966254a51df", size = 11589702 }, + { url = "https://files.pythonhosted.org/packages/26/3f/7602eb11d2886db545834182a9dbe500b8211fcbc9b4064bf9d358bbbbb4/ruff-0.8.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:facebdfe5a5af6b1588a1d26d170635ead6892d0e314477e80256ef4a8470cf3", size = 12782936 }, + { url = "https://files.pythonhosted.org/packages/4c/5d/083181bdec4ec92a431c1291d3fff65eef3ded630a4b55eb735000ef5f3b/ruff-0.8.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87a8e86bae0dbd749c815211ca11e3a7bd559b9710746c559ed63106d382bd9c", size = 11138488 }, + { url = "https://files.pythonhosted.org/packages/b7/23/c12cdef58413cee2436d6a177aa06f7a366ebbca916cf10820706f632459/ruff-0.8.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:85e654f0ded7befe2d61eeaf3d3b1e4ef3894469cd664ffa85006c7720f1e4a2", size = 10744474 }, + { url = "https://files.pythonhosted.org/packages/29/61/a12f3b81520083cd7c5caa24ba61bb99fd1060256482eff0ef04cc5ccd1b/ruff-0.8.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:83a55679c4cb449fa527b8497cadf54f076603cc36779b2170b24f704171ce70", size = 10369029 }, + { url = "https://files.pythonhosted.org/packages/08/2a/c013f4f3e4a54596c369cee74c24870ed1d534f31a35504908b1fc97017a/ruff-0.8.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:812e2052121634cf13cd6fddf0c1871d0ead1aad40a1a258753c04c18bb71bbd", size = 10867481 }, + { url = "https://files.pythonhosted.org/packages/d5/f7/685b1e1d42a3e94ceb25eab23c70bdd8c0ab66a43121ef83fe6db5a58756/ruff-0.8.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:780d5d8523c04202184405e60c98d7595bdb498c3c6abba3b6d4cdf2ca2af426", size = 11237117 }, + { url = "https://files.pythonhosted.org/packages/03/20/401132c0908e8837625e3b7e32df9962e7cd681a4df1e16a10e2a5b4ecda/ruff-0.8.0-py3-none-win32.whl", hash = "sha256:5fdb6efecc3eb60bba5819679466471fd7d13c53487df7248d6e27146e985468", size = 8783511 }, + { url = "https://files.pythonhosted.org/packages/1d/5c/4d800fca7854f62ad77f2c0d99b4b585f03e2d87a6ec1ecea85543a14a3c/ruff-0.8.0-py3-none-win_amd64.whl", hash = "sha256:582891c57b96228d146725975fbb942e1f30a0c4ba19722e692ca3eb25cc9b4f", size = 9559876 }, + { url = "https://files.pythonhosted.org/packages/5b/bc/cc8a6a5ca4960b226dc15dd8fb511dd11f2014ff89d325c0b9b9faa9871f/ruff-0.8.0-py3-none-win_arm64.whl", hash = "sha256:ba93e6294e9a737cd726b74b09a6972e36bb511f9a102f1d9a7e1ce94dd206a6", size = 8939733 }, ] [[package]] From 9205a0ec8c646b9f705430a6bfb52bd957b76c19 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Thu, 28 Nov 2024 20:10:45 +0100 Subject: [PATCH 24/31] Hard break if found data after last boundary on `MultipartParser` (#189) --- python_multipart/multipart.py | 8 +++---- tests/test_multipart.py | 40 ++++++++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/python_multipart/multipart.py b/python_multipart/multipart.py index ace4a8f..be76d24 100644 --- a/python_multipart/multipart.py +++ b/python_multipart/multipart.py @@ -1105,7 +1105,6 @@ def data_callback(name: CallbackName, end_i: int, remaining: bool = False) -> No # Skip leading newlines if c == CR or c == LF: i += 1 - self.logger.debug("Skipping leading CR/LF at %d", i) continue # index is used as in index into our boundary. Set to 0. @@ -1398,9 +1397,10 @@ def data_callback(name: CallbackName, end_i: int, remaining: bool = False) -> No i -= 1 elif state == MultipartState.END: - # Do nothing and just consume a byte in the end state. - if c not in (CR, LF): - self.logger.warning("Consuming a byte '0x%x' in the end state", c) # pragma: no cover + # Skip data after the last boundary. + self.logger.warning("Skipping data after last boundary") + i = length + break else: # pragma: no cover (error case) # We got into a strange state somehow! Just stop processing. diff --git a/tests/test_multipart.py b/tests/test_multipart.py index be01fbf..7fbeff7 100644 --- a/tests/test_multipart.py +++ b/tests/test_multipart.py @@ -825,7 +825,7 @@ def test_http(self, param: TestParams) -> None: return # No error! - self.assertEqual(processed, len(param["test"])) + self.assertEqual(processed, len(param["test"]), param["name"]) # Assert that the parser gave us the appropriate fields/files. for e in param["result"]["expected"]: @@ -1210,6 +1210,44 @@ def on_field(f: FieldProtocol) -> None: self.assertEqual(fields[2].field_name, b"baz") self.assertEqual(fields[2].value, b"asdf") + def test_multipart_parser_newlines_before_first_boundary(self) -> None: + """This test makes sure that the parser does not handle when there is junk data after the last boundary.""" + num = 5_000_000 + data = ( + "\r\n" * num + "--boundary\r\n" + 'Content-Disposition: form-data; name="file"; filename="filename.txt"\r\n' + "Content-Type: text/plain\r\n\r\n" + "hello\r\n" + "--boundary--" + ) + + files: list[File] = [] + + def on_file(f: FileProtocol) -> None: + files.append(cast(File, f)) + + f = FormParser("multipart/form-data", on_field=Mock(), on_file=on_file, boundary="boundary") + f.write(data.encode("latin-1")) + + def test_multipart_parser_data_after_last_boundary(self) -> None: + """This test makes sure that the parser does not handle when there is junk data after the last boundary.""" + num = 50_000_000 + data = ( + "--boundary\r\n" + 'Content-Disposition: form-data; name="file"; filename="filename.txt"\r\n' + "Content-Type: text/plain\r\n\r\n" + "hello\r\n" + "--boundary--" + "-" * num + "\r\n" + ) + + files: list[File] = [] + + def on_file(f: FileProtocol) -> None: + files.append(cast(File, f)) + + f = FormParser("multipart/form-data", on_field=Mock(), on_file=on_file, boundary="boundary") + f.write(data.encode("latin-1")) + def test_max_size_multipart(self) -> None: # Load test data. test_file = "single_field_single_file.http" From 5b1aed83adadbff1677779cd0df53723cd80a0d6 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Thu, 28 Nov 2024 20:14:32 +0100 Subject: [PATCH 25/31] Version 0.0.18 (#191) --- CHANGELOG.md | 4 ++++ python_multipart/__init__.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a6fbba..2c7faf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.0.18 (2024-11-28) + +* Hard break if found data after last boundary on `MultipartParser` [#189](https://github.com/Kludex/python-multipart/pull/189). + ## 0.0.17 (2024-10-31) * Handle PermissionError in fallback code for old import name [#182](https://github.com/Kludex/python-multipart/pull/182). diff --git a/python_multipart/__init__.py b/python_multipart/__init__.py index be8327f..69a3ed4 100644 --- a/python_multipart/__init__.py +++ b/python_multipart/__init__.py @@ -2,7 +2,7 @@ __author__ = "Andrew Dunham" __license__ = "Apache" __copyright__ = "Copyright (c) 2012-2013, Andrew Dunham" -__version__ = "0.0.17" +__version__ = "0.0.18" from .multipart import ( BaseParser, From c4fe4d3cebc08c660e57dd709af1ffa7059b3177 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 1 Dec 2024 07:59:34 +0100 Subject: [PATCH 26/31] Don't warn when CRLF is found after last boundary (#193) --- CHANGELOG.md | 4 ++++ python_multipart/__init__.py | 2 +- python_multipart/multipart.py | 4 ++++ scripts/check | 2 +- tests/test_multipart.py | 26 ++++++++++++++++++++++++++ 5 files changed, 36 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c7faf0..50074c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.0.19 (2024-11-30) + +* Don't warn when CRLF is found after last boundary on `MultipartParser` [#193](https://github.com/Kludex/python-multipart/pull/193). + ## 0.0.18 (2024-11-28) * Hard break if found data after last boundary on `MultipartParser` [#189](https://github.com/Kludex/python-multipart/pull/189). diff --git a/python_multipart/__init__.py b/python_multipart/__init__.py index 69a3ed4..d555f80 100644 --- a/python_multipart/__init__.py +++ b/python_multipart/__init__.py @@ -2,7 +2,7 @@ __author__ = "Andrew Dunham" __license__ = "Apache" __copyright__ = "Copyright (c) 2012-2013, Andrew Dunham" -__version__ = "0.0.18" +__version__ = "0.0.19" from .multipart import ( BaseParser, diff --git a/python_multipart/multipart.py b/python_multipart/multipart.py index be76d24..a996379 100644 --- a/python_multipart/multipart.py +++ b/python_multipart/multipart.py @@ -1397,6 +1397,10 @@ def data_callback(name: CallbackName, end_i: int, remaining: bool = False) -> No i -= 1 elif state == MultipartState.END: + # Don't do anything if chunk ends with CRLF. + if c == CR and i + 1 < length and data[i + 1] == LF: + i += 2 + continue # Skip data after the last boundary. self.logger.warning("Skipping data after last boundary") i = length diff --git a/scripts/check b/scripts/check index 13ce9ed..bc37333 100755 --- a/scripts/check +++ b/scripts/check @@ -6,5 +6,5 @@ SOURCE_FILES="python_multipart multipart tests" uvx ruff format --check --diff $SOURCE_FILES uvx ruff check $SOURCE_FILES -uvx --with types-PyYAML mypy $SOURCE_FILES +uv run mypy $SOURCE_FILES uvx check-sdist diff --git a/tests/test_multipart.py b/tests/test_multipart.py index 7fbeff7..ce92ff4 100644 --- a/tests/test_multipart.py +++ b/tests/test_multipart.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging import os import random import sys @@ -9,6 +10,7 @@ from typing import TYPE_CHECKING, cast from unittest.mock import Mock +import pytest import yaml from python_multipart.decoders import Base64Decoder, QuotedPrintableDecoder @@ -1248,6 +1250,30 @@ def on_file(f: FileProtocol) -> None: f = FormParser("multipart/form-data", on_field=Mock(), on_file=on_file, boundary="boundary") f.write(data.encode("latin-1")) + @pytest.fixture(autouse=True) + def inject_fixtures(self, caplog: pytest.LogCaptureFixture) -> None: + self._caplog = caplog + + def test_multipart_parser_data_end_with_crlf_without_warnings(self) -> None: + """This test makes sure that the parser does not handle when the data ends with a CRLF.""" + data = ( + "--boundary\r\n" + 'Content-Disposition: form-data; name="file"; filename="filename.txt"\r\n' + "Content-Type: text/plain\r\n\r\n" + "hello\r\n" + "--boundary--\r\n" + ) + + files: list[File] = [] + + def on_file(f: FileProtocol) -> None: + files.append(cast(File, f)) + + f = FormParser("multipart/form-data", on_field=Mock(), on_file=on_file, boundary="boundary") + with self._caplog.at_level(logging.WARNING): + f.write(data.encode("latin-1")) + assert len(self._caplog.records) == 0 + def test_max_size_multipart(self) -> None: # Load test data. test_file = "single_field_single_file.http" From 6f3295bc79a1f8decdb23ce1720a6428908d8e33 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 6 Dec 2024 07:09:56 +0000 Subject: [PATCH 27/31] Bump astral-sh/setup-uv from 3 to 4 in the github-actions group (#194) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Marcelo Trylesinski --- .github/workflows/docs.yml | 2 +- .github/workflows/main.yml | 2 +- .github/workflows/publish.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index b970488..08a70f6 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -20,7 +20,7 @@ jobs: git config user.email 41898282+github-actions[bot]@users.noreply.github.com - name: Install uv - uses: astral-sh/setup-uv@v3 + uses: astral-sh/setup-uv@v4 with: version: "0.4.12" enable-cache: true diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c3d9d99..05a5597 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -16,7 +16,7 @@ jobs: - uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@v3 + uses: astral-sh/setup-uv@v4 with: version: "0.4.12" enable-cache: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 66915ad..4208117 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@v3 + uses: astral-sh/setup-uv@v4 with: version: "0.4.12" enable-cache: true From 4bffa0c7c6c836ace85486b95c1e144e340059d8 Mon Sep 17 00:00:00 2001 From: yecril23pl <151100823+yecril23pl@users.noreply.github.com> Date: Fri, 6 Dec 2024 08:15:24 +0100 Subject: [PATCH 28/31] doc: A file parameter is not a field (#127) Co-authored-by: Marcelo Trylesinski --- docs/index.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/index.md b/docs/index.md index 7802011..c84bb99 100644 --- a/docs/index.md +++ b/docs/index.md @@ -16,10 +16,10 @@ def simple_app(environ, start_response): # The following two callbacks just append the name to the return value. def on_field(field): - ret.append(b"Parsed field named: %s" % (field.field_name,)) + ret.append(b"Parsed value parameter named: %s" % (field.field_name,)) def on_file(file): - ret.append(b"Parsed file named: %s" % (file.field_name,)) + ret.append(b"Parsed file parameter named: %s" % (file.field_name,)) # Create headers object. We need to convert from WSGI to the actual # name of the header, since this library does not assume that you are @@ -55,7 +55,7 @@ Date: Sun, 07 Apr 2013 01:40:52 GMT Server: WSGIServer/0.1 Python/2.7.3 Content-type: text/plain -Parsed field named: foo +Parsed value parameter named: foo ``` For a more in-depth example showing how the various parts fit together, check out the next section. From f1c5a2821b24786f418ae535aa2fbb5ae4c60d6c Mon Sep 17 00:00:00 2001 From: Kanishk Pachauri Date: Fri, 6 Dec 2024 13:05:43 +0530 Subject: [PATCH 29/31] feat: Add python 3.13 in CI matrix. (#185) Co-authored-by: Marcelo Trylesinski --- .github/workflows/main.yml | 9 +++------ pyproject.toml | 2 +- scripts/check | 1 + uv.lock | 6 +++--- 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 05a5597..13495cf 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -11,21 +11,18 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 - name: Install uv uses: astral-sh/setup-uv@v4 with: - version: "0.4.12" + python-version: ${{ matrix.python-version }} enable-cache: true - - name: Set up Python ${{ matrix.python-version }} - run: uv python install ${{ matrix.python-version }} - - name: Install dependencies - run: uv sync --python ${{ matrix.python-version }} --frozen + run: uv sync --frozen - name: Run linters run: scripts/check diff --git a/pyproject.toml b/pyproject.toml index 01a907d..48d12a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dev-dependencies = [ "ruff==0.8.0", "mypy", "types-PyYAML", - "atheris==2.3.0; python_version != '3.12'", + "atheris==2.3.0; python_version <= '3.11'", # Documentation "mkdocs", "mkdocs-material", diff --git a/scripts/check b/scripts/check index bc37333..294cb9f 100755 --- a/scripts/check +++ b/scripts/check @@ -8,3 +8,4 @@ uvx ruff format --check --diff $SOURCE_FILES uvx ruff check $SOURCE_FILES uv run mypy $SOURCE_FILES uvx check-sdist +uv lock diff --git a/uv.lock b/uv.lock index dbdacc9..21940b3 100644 --- a/uv.lock +++ b/uv.lock @@ -718,12 +718,12 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.17" +version = "0.0.19" source = { editable = "." } [package.dev-dependencies] dev = [ - { name = "atheris", marker = "python_full_version != '3.12.*'" }, + { name = "atheris", marker = "python_full_version < '3.12'" }, { name = "atomicwrites" }, { name = "attrs" }, { name = "coverage" }, @@ -749,7 +749,7 @@ dev = [ [package.metadata.requires-dev] dev = [ - { name = "atheris", marker = "python_full_version != '3.12.*'", specifier = "==2.3.0" }, + { name = "atheris", marker = "python_full_version < '3.12'", specifier = "==2.3.0" }, { name = "atomicwrites", specifier = "==1.4.1" }, { name = "attrs", specifier = "==23.2.0" }, { name = "coverage", specifier = "==7.4.4" }, From 04d3cf5ef58c8ac8d28d36ea410fba131f5eff3f Mon Sep 17 00:00:00 2001 From: John Stark Date: Wed, 11 Dec 2024 16:42:43 +0000 Subject: [PATCH 30/31] Handle messages containing only end boundary, fixes #38 (#142) --- python_multipart/multipart.py | 24 +++++++++++++++---- tests/test_data/http/empty_message.http | 1 + tests/test_data/http/empty_message.yaml | 2 ++ .../http/empty_message_with_bad_end.http | 1 + .../http/empty_message_with_bad_end.yaml | 3 +++ 5 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 tests/test_data/http/empty_message.http create mode 100644 tests/test_data/http/empty_message.yaml create mode 100644 tests/test_data/http/empty_message_with_bad_end.http create mode 100644 tests/test_data/http/empty_message_with_bad_end.yaml diff --git a/python_multipart/multipart.py b/python_multipart/multipart.py index a996379..f26a815 100644 --- a/python_multipart/multipart.py +++ b/python_multipart/multipart.py @@ -130,7 +130,8 @@ class MultipartState(IntEnum): PART_DATA_START = 8 PART_DATA = 9 PART_DATA_END = 10 - END = 11 + END_BOUNDARY = 11 + END = 12 # Flags for the multipart parser. @@ -1119,7 +1120,10 @@ def data_callback(name: CallbackName, end_i: int, remaining: bool = False) -> No # Check to ensure that the last 2 characters in our boundary # are CRLF. if index == len(boundary) - 2: - if c != CR: + if c == HYPHEN: + # Potential empty message. + state = MultipartState.END_BOUNDARY + elif c != CR: # Error! msg = "Did not find CR at end of boundary (%d)" % (i,) self.logger.warning(msg) @@ -1396,6 +1400,18 @@ def data_callback(name: CallbackName, end_i: int, remaining: bool = False) -> No # the start of the boundary itself. i -= 1 + elif state == MultipartState.END_BOUNDARY: + if index == len(boundary) - 2 + 1: + if c != HYPHEN: + msg = "Did not find - at end of boundary (%d)" % (i,) + self.logger.warning(msg) + e = MultipartParseError(msg) + e.offset = i + raise e + index += 1 + self.callback("end") + state = MultipartState.END + elif state == MultipartState.END: # Don't do anything if chunk ends with CRLF. if c == CR and i + 1 < length and data[i + 1] == LF: @@ -1707,8 +1723,8 @@ def on_headers_finished() -> None: def _on_end() -> None: nonlocal writer - assert writer is not None - writer.finalize() + if writer is not None: + writer.finalize() if self.on_end is not None: self.on_end() diff --git a/tests/test_data/http/empty_message.http b/tests/test_data/http/empty_message.http new file mode 100644 index 0000000..baff7d5 --- /dev/null +++ b/tests/test_data/http/empty_message.http @@ -0,0 +1 @@ +----boundary-- diff --git a/tests/test_data/http/empty_message.yaml b/tests/test_data/http/empty_message.yaml new file mode 100644 index 0000000..ab33940 --- /dev/null +++ b/tests/test_data/http/empty_message.yaml @@ -0,0 +1,2 @@ +boundary: --boundary +expected: [] diff --git a/tests/test_data/http/empty_message_with_bad_end.http b/tests/test_data/http/empty_message_with_bad_end.http new file mode 100644 index 0000000..a085714 --- /dev/null +++ b/tests/test_data/http/empty_message_with_bad_end.http @@ -0,0 +1 @@ +----boundary-X diff --git a/tests/test_data/http/empty_message_with_bad_end.yaml b/tests/test_data/http/empty_message_with_bad_end.yaml new file mode 100644 index 0000000..ae920bf --- /dev/null +++ b/tests/test_data/http/empty_message_with_bad_end.yaml @@ -0,0 +1,3 @@ +boundary: --boundary +expected: + error: 13 From b083cef4d6c68cf036bae1d9c68a986c6e1e3cc4 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Mon, 16 Dec 2024 20:44:25 +0100 Subject: [PATCH 31/31] Version 0.0.20 (#197) --- CHANGELOG.md | 4 ++++ python_multipart/__init__.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50074c8..f0d80aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.0.20 (2024-12-16) + +* Handle messages containing only end boundary [#142](https://github.com/Kludex/python-multipart/pull/142). + ## 0.0.19 (2024-11-30) * Don't warn when CRLF is found after last boundary on `MultipartParser` [#193](https://github.com/Kludex/python-multipart/pull/193). diff --git a/python_multipart/__init__.py b/python_multipart/__init__.py index d555f80..e426526 100644 --- a/python_multipart/__init__.py +++ b/python_multipart/__init__.py @@ -2,7 +2,7 @@ __author__ = "Andrew Dunham" __license__ = "Apache" __copyright__ = "Copyright (c) 2012-2013, Andrew Dunham" -__version__ = "0.0.19" +__version__ = "0.0.20" from .multipart import ( BaseParser,