python-multipart 0.0.19__tar.gz → 0.0.21__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. {python_multipart-0.0.19 → python_multipart-0.0.21}/CHANGELOG.md +8 -0
  2. {python_multipart-0.0.19 → python_multipart-0.0.21}/PKG-INFO +7 -6
  3. {python_multipart-0.0.19 → python_multipart-0.0.21}/pyproject.toml +7 -7
  4. {python_multipart-0.0.19 → python_multipart-0.0.21}/python_multipart/__init__.py +1 -1
  5. {python_multipart-0.0.19 → python_multipart-0.0.21}/python_multipart/decoders.py +1 -1
  6. {python_multipart-0.0.19 → python_multipart-0.0.21}/python_multipart/multipart.py +28 -13
  7. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/compat.py +2 -1
  8. python_multipart-0.0.21/tests/test_data/http/empty_message.http +1 -0
  9. python_multipart-0.0.21/tests/test_data/http/empty_message.yaml +2 -0
  10. python_multipart-0.0.21/tests/test_data/http/empty_message_with_bad_end.http +1 -0
  11. python_multipart-0.0.21/tests/test_data/http/empty_message_with_bad_end.yaml +3 -0
  12. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_multipart.py +5 -2
  13. {python_multipart-0.0.19 → python_multipart-0.0.21}/.gitignore +0 -0
  14. {python_multipart-0.0.19 → python_multipart-0.0.21}/LICENSE.txt +0 -0
  15. {python_multipart-0.0.19 → python_multipart-0.0.21}/README.md +0 -0
  16. {python_multipart-0.0.19 → python_multipart-0.0.21}/multipart/__init__.py +0 -0
  17. {python_multipart-0.0.19 → python_multipart-0.0.21}/multipart/decoders.py +0 -0
  18. {python_multipart-0.0.19 → python_multipart-0.0.21}/multipart/exceptions.py +0 -0
  19. {python_multipart-0.0.19 → python_multipart-0.0.21}/multipart/multipart.py +0 -0
  20. {python_multipart-0.0.19 → python_multipart-0.0.21}/python_multipart/exceptions.py +0 -0
  21. {python_multipart-0.0.19 → python_multipart-0.0.21}/python_multipart/py.typed +0 -0
  22. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/__init__.py +0 -0
  23. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/CRLF_in_header.http +0 -0
  24. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/CRLF_in_header.yaml +0 -0
  25. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/CR_in_header.http +0 -0
  26. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/CR_in_header.yaml +0 -0
  27. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/CR_in_header_value.http +0 -0
  28. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/CR_in_header_value.yaml +0 -0
  29. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/almost_match_boundary.http +0 -0
  30. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/almost_match_boundary.yaml +0 -0
  31. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/almost_match_boundary_without_CR.http +0 -0
  32. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/almost_match_boundary_without_CR.yaml +0 -0
  33. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/almost_match_boundary_without_LF.http +0 -0
  34. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/almost_match_boundary_without_LF.yaml +0 -0
  35. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/almost_match_boundary_without_final_hyphen.http +0 -0
  36. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/almost_match_boundary_without_final_hyphen.yaml +0 -0
  37. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/bad_end_of_headers.http +0 -0
  38. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/bad_end_of_headers.yaml +0 -0
  39. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/bad_header_char.http +0 -0
  40. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/bad_header_char.yaml +0 -0
  41. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/bad_initial_boundary.http +0 -0
  42. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/bad_initial_boundary.yaml +0 -0
  43. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/base64_encoding.http +0 -0
  44. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/base64_encoding.yaml +0 -0
  45. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/empty_header.http +0 -0
  46. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/empty_header.yaml +0 -0
  47. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/header_with_number.http +0 -0
  48. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/header_with_number.yaml +0 -0
  49. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/mixed_plain_and_base64_encoding.http +0 -0
  50. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/mixed_plain_and_base64_encoding.yaml +0 -0
  51. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/multiple_fields.http +0 -0
  52. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/multiple_fields.yaml +0 -0
  53. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/multiple_files.http +0 -0
  54. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/multiple_files.yaml +0 -0
  55. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/quoted_printable_encoding.http +0 -0
  56. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/quoted_printable_encoding.yaml +0 -0
  57. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/single_field.http +0 -0
  58. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/single_field.yaml +0 -0
  59. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/single_field_blocks.http +0 -0
  60. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/single_field_blocks.yaml +0 -0
  61. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/single_field_longer.http +0 -0
  62. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/single_field_longer.yaml +0 -0
  63. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/single_field_single_file.http +0 -0
  64. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/single_field_single_file.yaml +0 -0
  65. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/single_field_with_leading_newlines.http +0 -0
  66. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/single_field_with_leading_newlines.yaml +0 -0
  67. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/single_file.http +0 -0
  68. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/single_file.yaml +0 -0
  69. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/utf8_filename.http +0 -0
  70. {python_multipart-0.0.19 → python_multipart-0.0.21}/tests/test_data/http/utf8_filename.yaml +0 -0
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.21 (2025-12-17)
4
+
5
+ * Add support for Python 3.14 and drop EOL 3.8 and 3.9 [#216](https://github.com/Kludex/python-multipart/pull/216).
6
+
7
+ ## 0.0.20 (2024-12-16)
8
+
9
+ * Handle messages containing only end boundary [#142](https://github.com/Kludex/python-multipart/pull/142).
10
+
3
11
  ## 0.0.19 (2024-11-30)
4
12
 
5
13
  * Don't warn when CRLF is found after last boundary on `MultipartParser` [#193](https://github.com/Kludex/python-multipart/pull/193).
@@ -1,13 +1,14 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: python-multipart
3
- Version: 0.0.19
3
+ Version: 0.0.21
4
4
  Summary: A streaming multipart parser for Python
5
5
  Project-URL: Homepage, https://github.com/Kludex/python-multipart
6
6
  Project-URL: Documentation, https://kludex.github.io/python-multipart/
7
7
  Project-URL: Changelog, https://github.com/Kludex/python-multipart/blob/master/CHANGELOG.md
8
8
  Project-URL: Source, https://github.com/Kludex/python-multipart
9
9
  Author-email: Andrew Dunham <andrew@du.nham.ca>, Marcelo Trylesinski <marcelotryle@gmail.com>
10
- License: Apache-2.0
10
+ License-Expression: Apache-2.0
11
+ License-File: LICENSE.txt
11
12
  Classifier: Development Status :: 5 - Production/Stable
12
13
  Classifier: Environment :: Web Environment
13
14
  Classifier: Intended Audience :: Developers
@@ -15,13 +16,13 @@ Classifier: License :: OSI Approved :: Apache Software License
15
16
  Classifier: Operating System :: OS Independent
16
17
  Classifier: Programming Language :: Python :: 3
17
18
  Classifier: Programming Language :: Python :: 3 :: Only
18
- Classifier: Programming Language :: Python :: 3.8
19
- Classifier: Programming Language :: Python :: 3.9
20
19
  Classifier: Programming Language :: Python :: 3.10
21
20
  Classifier: Programming Language :: Python :: 3.11
22
21
  Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
23
24
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
- Requires-Python: >=3.8
25
+ Requires-Python: >=3.10
25
26
  Description-Content-Type: text/markdown
26
27
 
27
28
  # [Python-Multipart](https://kludex.github.io/python-multipart/)
@@ -8,7 +8,7 @@ dynamic = ["version"]
8
8
  description = "A streaming multipart parser for Python"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
11
- requires-python = ">=3.8"
11
+ requires-python = ">=3.10"
12
12
  authors = [
13
13
  { name = "Andrew Dunham", email = "andrew@du.nham.ca" },
14
14
  { name = "Marcelo Trylesinski", email = "marcelotryle@gmail.com" },
@@ -21,17 +21,17 @@ classifiers = [
21
21
  'Operating System :: OS Independent',
22
22
  'Programming Language :: Python :: 3 :: Only',
23
23
  'Programming Language :: Python :: 3',
24
- 'Programming Language :: Python :: 3.8',
25
- 'Programming Language :: Python :: 3.9',
26
24
  'Programming Language :: Python :: 3.10',
27
25
  'Programming Language :: Python :: 3.11',
28
26
  'Programming Language :: Python :: 3.12',
27
+ 'Programming Language :: Python :: 3.13',
28
+ 'Programming Language :: Python :: 3.14',
29
29
  'Topic :: Software Development :: Libraries :: Python Modules',
30
30
  ]
31
31
  dependencies = []
32
32
 
33
- [tool.uv]
34
- dev-dependencies = [
33
+ [dependency-groups]
34
+ dev = [
35
35
  "atomicwrites==1.4.1",
36
36
  "attrs==23.2.0",
37
37
  "coverage==7.4.4",
@@ -44,10 +44,10 @@ dev-dependencies = [
44
44
  "PyYAML==6.0.1",
45
45
  "invoke==2.2.0",
46
46
  "pytest-timeout==2.3.1",
47
- "ruff==0.8.0",
47
+ "ruff==0.11.7",
48
48
  "mypy",
49
49
  "types-PyYAML",
50
- "atheris==2.3.0; python_version != '3.12'",
50
+ "atheris==2.3.0; python_version <= '3.11'",
51
51
  # Documentation
52
52
  "mkdocs",
53
53
  "mkdocs-material",
@@ -2,7 +2,7 @@
2
2
  __author__ = "Andrew Dunham"
3
3
  __license__ = "Apache"
4
4
  __copyright__ = "Copyright (c) 2012-2013, Andrew Dunham"
5
- __version__ = "0.0.19"
5
+ __version__ = "0.0.21"
6
6
 
7
7
  from .multipart import (
8
8
  BaseParser,
@@ -62,7 +62,7 @@ class Base64Decoder:
62
62
 
63
63
  # Prepend any cache info to our data.
64
64
  if len(self.cache) > 0:
65
- data = self.cache + data
65
+ data = bytes(self.cache) + data
66
66
 
67
67
  # Slice off a string that's a multiple of 4.
68
68
  decode_len = (len(data) // 4) * 4
@@ -15,9 +15,8 @@ from .decoders import Base64Decoder, QuotedPrintableDecoder
15
15
  from .exceptions import FileError, FormParserError, MultipartParseError, QuerystringParseError
16
16
 
17
17
  if TYPE_CHECKING: # pragma: no cover
18
- from typing import Any, Callable, Literal, Protocol, TypedDict
19
-
20
- from typing_extensions import TypeAlias
18
+ from collections.abc import Callable
19
+ from typing import Any, Literal, Protocol, TypeAlias, TypedDict
21
20
 
22
21
  class SupportsRead(Protocol):
23
22
  def read(self, __n: int) -> bytes: ...
@@ -130,7 +129,8 @@ class MultipartState(IntEnum):
130
129
  PART_DATA_START = 8
131
130
  PART_DATA = 9
132
131
  PART_DATA_END = 10
133
- END = 11
132
+ END_BOUNDARY = 11
133
+ END = 12
134
134
 
135
135
 
136
136
  # Flags for the multipart parser.
@@ -331,7 +331,7 @@ class Field:
331
331
  else:
332
332
  v = repr(self.value)
333
333
 
334
- return "{}(field_name={!r}, value={})".format(self.__class__.__name__, self.field_name, v)
334
+ return f"{self.__class__.__name__}(field_name={self.field_name!r}, value={v})"
335
335
 
336
336
 
337
337
  class File:
@@ -569,7 +569,7 @@ class File:
569
569
  self._fileobj.close()
570
570
 
571
571
  def __repr__(self) -> str:
572
- return "{}(file_name={!r}, field_name={!r})".format(self.__class__.__name__, self.file_name, self.field_name)
572
+ return f"{self.__class__.__name__}(file_name={self.file_name!r}, field_name={self.field_name!r})"
573
573
 
574
574
 
575
575
  class BaseParser:
@@ -1119,7 +1119,10 @@ class MultipartParser(BaseParser):
1119
1119
  # Check to ensure that the last 2 characters in our boundary
1120
1120
  # are CRLF.
1121
1121
  if index == len(boundary) - 2:
1122
- if c != CR:
1122
+ if c == HYPHEN:
1123
+ # Potential empty message.
1124
+ state = MultipartState.END_BOUNDARY
1125
+ elif c != CR:
1123
1126
  # Error!
1124
1127
  msg = "Did not find CR at end of boundary (%d)" % (i,)
1125
1128
  self.logger.warning(msg)
@@ -1237,7 +1240,7 @@ class MultipartParser(BaseParser):
1237
1240
  elif state == MultipartState.HEADER_VALUE_ALMOST_DONE:
1238
1241
  # The last character should be a LF. If not, it's an error.
1239
1242
  if c != LF:
1240
- msg = "Did not find LF character at end of header " "(found %r)" % (c,)
1243
+ msg = f"Did not find LF character at end of header (found {c!r})"
1241
1244
  self.logger.warning(msg)
1242
1245
  e = MultipartParseError(msg)
1243
1246
  e.offset = i
@@ -1396,6 +1399,18 @@ class MultipartParser(BaseParser):
1396
1399
  # the start of the boundary itself.
1397
1400
  i -= 1
1398
1401
 
1402
+ elif state == MultipartState.END_BOUNDARY:
1403
+ if index == len(boundary) - 2 + 1:
1404
+ if c != HYPHEN:
1405
+ msg = "Did not find - at end of boundary (%d)" % (i,)
1406
+ self.logger.warning(msg)
1407
+ e = MultipartParseError(msg)
1408
+ e.offset = i
1409
+ raise e
1410
+ index += 1
1411
+ self.callback("end")
1412
+ state = MultipartState.END
1413
+
1399
1414
  elif state == MultipartState.END:
1400
1415
  # Don't do anything if chunk ends with CRLF.
1401
1416
  if c == CR and i + 1 < length and data[i + 1] == LF:
@@ -1699,7 +1714,7 @@ class FormParser:
1699
1714
  else:
1700
1715
  self.logger.warning("Unknown Content-Transfer-Encoding: %r", transfer_encoding)
1701
1716
  if self.config["UPLOAD_ERROR_ON_BAD_CTE"]:
1702
- raise FormParserError('Unknown Content-Transfer-Encoding "{!r}"'.format(transfer_encoding))
1717
+ raise FormParserError(f'Unknown Content-Transfer-Encoding "{transfer_encoding!r}"')
1703
1718
  else:
1704
1719
  # If we aren't erroring, then we just treat this as an
1705
1720
  # unencoded Content-Transfer-Encoding.
@@ -1707,8 +1722,8 @@ class FormParser:
1707
1722
 
1708
1723
  def _on_end() -> None:
1709
1724
  nonlocal writer
1710
- assert writer is not None
1711
- writer.finalize()
1725
+ if writer is not None:
1726
+ writer.finalize()
1712
1727
  if self.on_end is not None:
1713
1728
  self.on_end()
1714
1729
 
@@ -1730,7 +1745,7 @@ class FormParser:
1730
1745
 
1731
1746
  else:
1732
1747
  self.logger.warning("Unknown Content-Type: %r", content_type)
1733
- raise FormParserError("Unknown Content-Type: {}".format(content_type))
1748
+ raise FormParserError(f"Unknown Content-Type: {content_type}")
1734
1749
 
1735
1750
  self.parser = parser
1736
1751
 
@@ -1760,7 +1775,7 @@ class FormParser:
1760
1775
  self.parser.close()
1761
1776
 
1762
1777
  def __repr__(self) -> str:
1763
- return "{}(content_type={!r}, parser={!r})".format(self.__class__.__name__, self.content_type, self.parser)
1778
+ return f"{self.__class__.__name__}(content_type={self.content_type!r}, parser={self.parser!r})"
1764
1779
 
1765
1780
 
1766
1781
  def create_form_parser(
@@ -8,7 +8,8 @@ import types
8
8
  from typing import TYPE_CHECKING
9
9
 
10
10
  if TYPE_CHECKING:
11
- from typing import Any, Callable
11
+ from collections.abc import Callable
12
+ from typing import Any
12
13
 
13
14
 
14
15
  def ensure_in_path(path: str) -> None:
@@ -0,0 +1 @@
1
+ ----boundary--
@@ -0,0 +1,2 @@
1
+ boundary: --boundary
2
+ expected: []
@@ -0,0 +1,3 @@
1
+ boundary: --boundary
2
+ expected:
3
+ error: 13
@@ -37,7 +37,8 @@ from python_multipart.multipart import (
37
37
  from .compat import parametrize, parametrize_class
38
38
 
39
39
  if TYPE_CHECKING:
40
- from typing import Any, Iterator, TypedDict
40
+ from collections.abc import Iterator
41
+ from typing import Any, TypedDict
41
42
 
42
43
  from python_multipart.multipart import FieldProtocol, FileConfig, FileProtocol
43
44
 
@@ -1069,7 +1070,9 @@ class TestFormParser(unittest.TestCase):
1069
1070
 
1070
1071
  self.make("boundary")
1071
1072
  data = b"--Boundary\r\nfoobar"
1072
- with self.assertRaisesRegex(MultipartParseError, "Expected boundary character %r, got %r" % (b"b"[0], b"B"[0])):
1073
+ with self.assertRaisesRegex(
1074
+ MultipartParseError, "Expected boundary character {!r}, got {!r}".format(b"b"[0], b"B"[0])
1075
+ ):
1073
1076
  self.f.write(data)
1074
1077
 
1075
1078
  def test_octet_stream(self) -> None: