parse-multipart-form-data 0.1.0a0__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jifeng Wu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,142 @@
1
+ Metadata-Version: 2.4
2
+ Name: parse-multipart-form-data
3
+ Version: 0.1.0a0
4
+ Summary: A streaming parser for multipart/form-data request bodies.
5
+ Author-email: Jifeng Wu <jifengwu2k@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/jifengwu2k/parse-multipart-form-data
8
+ Project-URL: Bug Tracker, https://github.com/jifengwu2k/parse-multipart-form-data/issues
9
+ Classifier: Programming Language :: Python :: 2
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=2
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: enum34; python_version < "3.4"
16
+ Requires-Dist: parse-http-header-line
17
+ Requires-Dist: put-back-iterator
18
+ Requires-Dist: typing; python_version < "3.5"
19
+ Dynamic: license-file
20
+
21
+ # parse-multipart-form-data
22
+
23
+ A small, streaming parser for HTTP `multipart/form-data` request bodies.
24
+
25
+ The parser consumes an iterable of byte chunks and yields a streaming event
26
+ sequence for each uploaded file. It does not depend on a web framework or read
27
+ from sockets itself, so callers control request-body I/O and where uploaded
28
+ content is stored.
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install parse-multipart-form-data
34
+ ```
35
+
36
+ For local development from this checkout:
37
+
38
+ ```bash
39
+ pip install -e .
40
+ ```
41
+
42
+ ## Usage
43
+
44
+ ```python
45
+ from parse_multipart_form_data import (
46
+ PartBegin,
47
+ PartData,
48
+ PartEnd,
49
+ parse_multipart_form_data,
50
+ )
51
+
52
+ body_chunks = [
53
+ b"--BOUNDARY\r\n",
54
+ b'Content-Disposition: form-data; name="file"; filename="hello.txt"\r\n',
55
+ b"\r\n",
56
+ b"hello world\r\n",
57
+ b"--BOUNDARY--\r\n",
58
+ ]
59
+
60
+ output = None
61
+ for event in parse_multipart_form_data(
62
+ "multipart/form-data; boundary=BOUNDARY", body_chunks):
63
+ if isinstance(event, PartBegin):
64
+ output = open(event.filename, "wb")
65
+ elif isinstance(event, PartData):
66
+ output.write(event.data)
67
+ else: # PartEnd
68
+ output.close()
69
+ ```
70
+
71
+ The event stream is:
72
+
73
+ - `PartBegin(filename)` at the beginning of a file part;
74
+ - `PartData(bytes_chunk)` for each file-content chunk; and
75
+ - `PartEnd(is_final)` after its delimiter has been consumed.
76
+
77
+ Only parts with a `filename` or `filename*` parameter produce events; ordinary
78
+ form fields are consumed and skipped. A valid RFC 6266 `filename*` (UTF-8 or
79
+ ISO-8859-1) takes precedence over `filename`.
80
+
81
+ ## Delimiter compatibility
82
+
83
+ Only exact delimiter lines are supported:
84
+
85
+ ```text
86
+ --BOUNDARY\r\n
87
+ --BOUNDARY--\r\n
88
+ ```
89
+
90
+ MIME transport padding (spaces or tabs after a delimiter) is not supported.
91
+ Supporting arbitrary transport padding requires byte-by-byte input reading,
92
+ which is inefficient in Python and rare in real-world multipart form uploads.
93
+
94
+ Boundary values may be quoted or unquoted, including RFC 2046 boundary
95
+ characters such as `=`, `:`, `/`, `?`, `(`, `)`, and `,`. Preamble and
96
+ part-header lines are limited to 8192 bytes of content.
97
+
98
+ ## Parser states
99
+
100
+ The parser has one `MultipartState` vocabulary. Its transitions are:
101
+
102
+ ```text
103
+ SEEK_OPENING_BOUNDARY -> READ_HEADERS | DONE
104
+ READ_HEADERS -> MAYBE_BOUNDARY
105
+ READ_PART_BODY -> READ_PART_BODY | MAYBE_BOUNDARY
106
+ MAYBE_BOUNDARY -> MAYBE_BOUNDARY | READ_PART_BODY | READ_HEADERS | DONE
107
+ ```
108
+
109
+ ### Minimal wire-format machine
110
+
111
+ For the multipart example in the module documentation, the parser's essential
112
+ wire-level transitions are (`CRLF` denotes the literal `\r\n` byte pair):
113
+
114
+ ```mermaid
115
+ stateDiagram-v2
116
+ [*] --> SEEK_OPENING_BOUNDARY
117
+ SEEK_OPENING_BOUNDARY --> READ_HEADERS: --BOUNDARY CRLF
118
+ SEEK_OPENING_BOUNDARY --> DONE: --BOUNDARY-- CRLF
119
+ READ_HEADERS --> READ_HEADERS: header CRLF
120
+ READ_HEADERS --> MAYBE_BOUNDARY: blank CRLF / empty part check
121
+ READ_PART_BODY --> READ_PART_BODY: not *.CRLF
122
+ READ_PART_BODY --> MAYBE_BOUNDARY: *.CRLF
123
+ MAYBE_BOUNDARY --> MAYBE_BOUNDARY: non-boundary line ending CRLF
124
+ MAYBE_BOUNDARY --> READ_PART_BODY: non-boundary prefix
125
+ MAYBE_BOUNDARY --> READ_HEADERS: --BOUNDARY CRLF
126
+ MAYBE_BOUNDARY --> DONE: --BOUNDARY-- CRLF
127
+ DONE --> [*]
128
+ ```
129
+
130
+ The single parser generator holds its state across every event. A boundary
131
+ suffix is consumed before it emits `PartEnd`, so the next event always starts
132
+ a well-defined next transition.
133
+
134
+ ## Testing
135
+
136
+ ```bash
137
+ python -m unittest discover -s tests
138
+ ```
139
+
140
+ ## License
141
+
142
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,122 @@
1
+ # parse-multipart-form-data
2
+
3
+ A small, streaming parser for HTTP `multipart/form-data` request bodies.
4
+
5
+ The parser consumes an iterable of byte chunks and yields a streaming event
6
+ sequence for each uploaded file. It does not depend on a web framework or read
7
+ from sockets itself, so callers control request-body I/O and where uploaded
8
+ content is stored.
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ pip install parse-multipart-form-data
14
+ ```
15
+
16
+ For local development from this checkout:
17
+
18
+ ```bash
19
+ pip install -e .
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ```python
25
+ from parse_multipart_form_data import (
26
+ PartBegin,
27
+ PartData,
28
+ PartEnd,
29
+ parse_multipart_form_data,
30
+ )
31
+
32
+ body_chunks = [
33
+ b"--BOUNDARY\r\n",
34
+ b'Content-Disposition: form-data; name="file"; filename="hello.txt"\r\n',
35
+ b"\r\n",
36
+ b"hello world\r\n",
37
+ b"--BOUNDARY--\r\n",
38
+ ]
39
+
40
+ output = None
41
+ for event in parse_multipart_form_data(
42
+ "multipart/form-data; boundary=BOUNDARY", body_chunks):
43
+ if isinstance(event, PartBegin):
44
+ output = open(event.filename, "wb")
45
+ elif isinstance(event, PartData):
46
+ output.write(event.data)
47
+ else: # PartEnd
48
+ output.close()
49
+ ```
50
+
51
+ The event stream is:
52
+
53
+ - `PartBegin(filename)` at the beginning of a file part;
54
+ - `PartData(bytes_chunk)` for each file-content chunk; and
55
+ - `PartEnd(is_final)` after its delimiter has been consumed.
56
+
57
+ Only parts with a `filename` or `filename*` parameter produce events; ordinary
58
+ form fields are consumed and skipped. A valid RFC 6266 `filename*` (UTF-8 or
59
+ ISO-8859-1) takes precedence over `filename`.
60
+
61
+ ## Delimiter compatibility
62
+
63
+ Only exact delimiter lines are supported:
64
+
65
+ ```text
66
+ --BOUNDARY\r\n
67
+ --BOUNDARY--\r\n
68
+ ```
69
+
70
+ MIME transport padding (spaces or tabs after a delimiter) is not supported.
71
+ Supporting arbitrary transport padding requires byte-by-byte input reading,
72
+ which is inefficient in Python and rare in real-world multipart form uploads.
73
+
74
+ Boundary values may be quoted or unquoted, including RFC 2046 boundary
75
+ characters such as `=`, `:`, `/`, `?`, `(`, `)`, and `,`. Preamble and
76
+ part-header lines are limited to 8192 bytes of content.
77
+
78
+ ## Parser states
79
+
80
+ The parser has one `MultipartState` vocabulary. Its transitions are:
81
+
82
+ ```text
83
+ SEEK_OPENING_BOUNDARY -> READ_HEADERS | DONE
84
+ READ_HEADERS -> MAYBE_BOUNDARY
85
+ READ_PART_BODY -> READ_PART_BODY | MAYBE_BOUNDARY
86
+ MAYBE_BOUNDARY -> MAYBE_BOUNDARY | READ_PART_BODY | READ_HEADERS | DONE
87
+ ```
88
+
89
+ ### Minimal wire-format machine
90
+
91
+ For the multipart example in the module documentation, the parser's essential
92
+ wire-level transitions are (`CRLF` denotes the literal `\r\n` byte pair):
93
+
94
+ ```mermaid
95
+ stateDiagram-v2
96
+ [*] --> SEEK_OPENING_BOUNDARY
97
+ SEEK_OPENING_BOUNDARY --> READ_HEADERS: --BOUNDARY CRLF
98
+ SEEK_OPENING_BOUNDARY --> DONE: --BOUNDARY-- CRLF
99
+ READ_HEADERS --> READ_HEADERS: header CRLF
100
+ READ_HEADERS --> MAYBE_BOUNDARY: blank CRLF / empty part check
101
+ READ_PART_BODY --> READ_PART_BODY: not *.CRLF
102
+ READ_PART_BODY --> MAYBE_BOUNDARY: *.CRLF
103
+ MAYBE_BOUNDARY --> MAYBE_BOUNDARY: non-boundary line ending CRLF
104
+ MAYBE_BOUNDARY --> READ_PART_BODY: non-boundary prefix
105
+ MAYBE_BOUNDARY --> READ_HEADERS: --BOUNDARY CRLF
106
+ MAYBE_BOUNDARY --> DONE: --BOUNDARY-- CRLF
107
+ DONE --> [*]
108
+ ```
109
+
110
+ The single parser generator holds its state across every event. A boundary
111
+ suffix is consumed before it emits `PartEnd`, so the next event always starts
112
+ a well-defined next transition.
113
+
114
+ ## Testing
115
+
116
+ ```bash
117
+ python -m unittest discover -s tests
118
+ ```
119
+
120
+ ## License
121
+
122
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,142 @@
1
+ Metadata-Version: 2.4
2
+ Name: parse-multipart-form-data
3
+ Version: 0.1.0a0
4
+ Summary: A streaming parser for multipart/form-data request bodies.
5
+ Author-email: Jifeng Wu <jifengwu2k@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/jifengwu2k/parse-multipart-form-data
8
+ Project-URL: Bug Tracker, https://github.com/jifengwu2k/parse-multipart-form-data/issues
9
+ Classifier: Programming Language :: Python :: 2
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=2
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: enum34; python_version < "3.4"
16
+ Requires-Dist: parse-http-header-line
17
+ Requires-Dist: put-back-iterator
18
+ Requires-Dist: typing; python_version < "3.5"
19
+ Dynamic: license-file
20
+
21
+ # parse-multipart-form-data
22
+
23
+ A small, streaming parser for HTTP `multipart/form-data` request bodies.
24
+
25
+ The parser consumes an iterable of byte chunks and yields a streaming event
26
+ sequence for each uploaded file. It does not depend on a web framework or read
27
+ from sockets itself, so callers control request-body I/O and where uploaded
28
+ content is stored.
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install parse-multipart-form-data
34
+ ```
35
+
36
+ For local development from this checkout:
37
+
38
+ ```bash
39
+ pip install -e .
40
+ ```
41
+
42
+ ## Usage
43
+
44
+ ```python
45
+ from parse_multipart_form_data import (
46
+ PartBegin,
47
+ PartData,
48
+ PartEnd,
49
+ parse_multipart_form_data,
50
+ )
51
+
52
+ body_chunks = [
53
+ b"--BOUNDARY\r\n",
54
+ b'Content-Disposition: form-data; name="file"; filename="hello.txt"\r\n',
55
+ b"\r\n",
56
+ b"hello world\r\n",
57
+ b"--BOUNDARY--\r\n",
58
+ ]
59
+
60
+ output = None
61
+ for event in parse_multipart_form_data(
62
+ "multipart/form-data; boundary=BOUNDARY", body_chunks):
63
+ if isinstance(event, PartBegin):
64
+ output = open(event.filename, "wb")
65
+ elif isinstance(event, PartData):
66
+ output.write(event.data)
67
+ else: # PartEnd
68
+ output.close()
69
+ ```
70
+
71
+ The event stream is:
72
+
73
+ - `PartBegin(filename)` at the beginning of a file part;
74
+ - `PartData(bytes_chunk)` for each file-content chunk; and
75
+ - `PartEnd(is_final)` after its delimiter has been consumed.
76
+
77
+ Only parts with a `filename` or `filename*` parameter produce events; ordinary
78
+ form fields are consumed and skipped. A valid RFC 6266 `filename*` (UTF-8 or
79
+ ISO-8859-1) takes precedence over `filename`.
80
+
81
+ ## Delimiter compatibility
82
+
83
+ Only exact delimiter lines are supported:
84
+
85
+ ```text
86
+ --BOUNDARY\r\n
87
+ --BOUNDARY--\r\n
88
+ ```
89
+
90
+ MIME transport padding (spaces or tabs after a delimiter) is not supported.
91
+ Supporting arbitrary transport padding requires byte-by-byte input reading,
92
+ which is inefficient in Python and rare in real-world multipart form uploads.
93
+
94
+ Boundary values may be quoted or unquoted, including RFC 2046 boundary
95
+ characters such as `=`, `:`, `/`, `?`, `(`, `)`, and `,`. Preamble and
96
+ part-header lines are limited to 8192 bytes of content.
97
+
98
+ ## Parser states
99
+
100
+ The parser has one `MultipartState` vocabulary. Its transitions are:
101
+
102
+ ```text
103
+ SEEK_OPENING_BOUNDARY -> READ_HEADERS | DONE
104
+ READ_HEADERS -> MAYBE_BOUNDARY
105
+ READ_PART_BODY -> READ_PART_BODY | MAYBE_BOUNDARY
106
+ MAYBE_BOUNDARY -> MAYBE_BOUNDARY | READ_PART_BODY | READ_HEADERS | DONE
107
+ ```
108
+
109
+ ### Minimal wire-format machine
110
+
111
+ For the multipart example in the module documentation, the parser's essential
112
+ wire-level transitions are (`CRLF` denotes the literal `\r\n` byte pair):
113
+
114
+ ```mermaid
115
+ stateDiagram-v2
116
+ [*] --> SEEK_OPENING_BOUNDARY
117
+ SEEK_OPENING_BOUNDARY --> READ_HEADERS: --BOUNDARY CRLF
118
+ SEEK_OPENING_BOUNDARY --> DONE: --BOUNDARY-- CRLF
119
+ READ_HEADERS --> READ_HEADERS: header CRLF
120
+ READ_HEADERS --> MAYBE_BOUNDARY: blank CRLF / empty part check
121
+ READ_PART_BODY --> READ_PART_BODY: not *.CRLF
122
+ READ_PART_BODY --> MAYBE_BOUNDARY: *.CRLF
123
+ MAYBE_BOUNDARY --> MAYBE_BOUNDARY: non-boundary line ending CRLF
124
+ MAYBE_BOUNDARY --> READ_PART_BODY: non-boundary prefix
125
+ MAYBE_BOUNDARY --> READ_HEADERS: --BOUNDARY CRLF
126
+ MAYBE_BOUNDARY --> DONE: --BOUNDARY-- CRLF
127
+ DONE --> [*]
128
+ ```
129
+
130
+ The single parser generator holds its state across every event. A boundary
131
+ suffix is consumed before it emits `PartEnd`, so the next event always starts
132
+ a well-defined next transition.
133
+
134
+ ## Testing
135
+
136
+ ```bash
137
+ python -m unittest discover -s tests
138
+ ```
139
+
140
+ ## License
141
+
142
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ parse_multipart_form_data.py
4
+ pyproject.toml
5
+ setup.cfg
6
+ parse_multipart_form_data.egg-info/PKG-INFO
7
+ parse_multipart_form_data.egg-info/SOURCES.txt
8
+ parse_multipart_form_data.egg-info/dependency_links.txt
9
+ parse_multipart_form_data.egg-info/requires.txt
10
+ parse_multipart_form_data.egg-info/top_level.txt
11
+ tests/test_parse_multipart_form_data.py
@@ -0,0 +1,8 @@
1
+ parse-http-header-line
2
+ put-back-iterator
3
+
4
+ [:python_version < "3.4"]
5
+ enum34
6
+
7
+ [:python_version < "3.5"]
8
+ typing
@@ -0,0 +1,386 @@
1
+ # Copyright (c) 2026 Jifeng Wu
2
+ # Licensed under the MIT License. See LICENSE file in the project root
3
+ # for full license information.
4
+ """Streaming parser for ``multipart/form-data`` request bodies.
5
+
6
+ The parser is one explicit generator state machine. It emits a flat event
7
+ stream for file-part starts, content chunks, and ends; it does not create a
8
+ separate part object or nested content iterator.
9
+ """
10
+ from __future__ import unicode_literals
11
+ from enum import Enum
12
+ from typing import Iterable, Iterator, List, Optional, Text, Tuple
13
+ from parse_http_header_line import (
14
+ MalformedHeaderLineError,
15
+ MalformedParameterListError,
16
+ parse_name_value,
17
+ parse_parameters,
18
+ )
19
+ from put_back_iterator import PutBackIterator
20
+
21
+
22
+ class MultipartState(Enum):
23
+ """The semantic states of ``parse_multipart_form_data``."""
24
+
25
+ SEEK_OPENING_BOUNDARY = 1
26
+ READ_HEADERS = 2
27
+ READ_PART_BODY = 3
28
+ MAYBE_BOUNDARY = 4
29
+ DONE = 5
30
+
31
+
32
+ # Maximum content length of one preamble or part-header line. 8192 bytes is
33
+ # the de facto per-line limit used by Apache, nginx, Tomcat, and Jetty.
34
+ MAX_MULTIPART_HEADER_LINE = 8192
35
+
36
+
37
+ class MultipartEvent(object):
38
+ """Base class for the events yielded by :func:`parse_multipart_form_data`."""
39
+
40
+ __slots__ = ()
41
+
42
+
43
+ class PartBegin(MultipartEvent):
44
+ """A file part began with the decoded *filename*."""
45
+
46
+ __slots__ = ("filename",)
47
+
48
+ def __init__(self, filename):
49
+ # type: (Text) -> None
50
+ self.filename = filename
51
+
52
+
53
+ class PartData(MultipartEvent):
54
+ """A chunk of file-part content."""
55
+
56
+ __slots__ = ("data",)
57
+
58
+ def __init__(self, data):
59
+ # type: (bytes) -> None
60
+ self.data = data
61
+
62
+
63
+ class PartEnd(MultipartEvent):
64
+ """A file part ended; *is_final* marks the closing boundary."""
65
+
66
+ __slots__ = ("is_final",)
67
+
68
+ def __init__(self, is_final):
69
+ # type: (bool) -> None
70
+ self.is_final = is_final
71
+
72
+
73
+ def read_crlf_chunk(stream):
74
+ # type: (PutBackIterator[bytes]) -> Tuple[Optional[bytes], bool]
75
+ """Read one CRLF-terminated chunk of bytes from *stream*.
76
+
77
+ Returns ``(chunk, True)`` when *chunk* ends with CRLF. Returns
78
+ ``(chunk, False)`` when *chunk* does not end with CRLF. A ``False``
79
+ result guarantees there is no CRLF straddling the chunk boundary: any
80
+ trailing CR has already been checked and is not followed by LF.
81
+
82
+ Returns ``(None, False)`` at end of stream.
83
+
84
+ The returned bytes are never modified: a terminating CRLF is included,
85
+ and any CR or CRLF in the middle of *chunk* is preserved verbatim.
86
+ """
87
+ while stream.has_next():
88
+ chunk = next(stream)
89
+ if not chunk:
90
+ continue
91
+
92
+ crlf_index = chunk.find(b"\r\n")
93
+ if crlf_index != -1:
94
+ first = chunk[: crlf_index + 2]
95
+ rest = chunk[crlf_index + 2 :]
96
+ if rest:
97
+ stream.put_back(rest)
98
+ return first, True
99
+
100
+ if not chunk.endswith(b"\r"):
101
+ return chunk, False
102
+
103
+ # The chunk ends with CR; look ahead for the matching LF. Empty
104
+ # chunks are skipped so the CR pairs with the next real byte.
105
+ next_chunk = b""
106
+ while stream.has_next():
107
+ next_chunk = next(stream)
108
+ if next_chunk:
109
+ break
110
+ else:
111
+ return chunk, False
112
+
113
+ if next_chunk[:1] == b"\n":
114
+ if len(next_chunk) > 1:
115
+ stream.put_back(next_chunk[1:])
116
+ return chunk + b"\n", True
117
+
118
+ stream.put_back(next_chunk)
119
+ return chunk, False
120
+
121
+ return None, False
122
+
123
+
124
+ def read_crlf_line(stream, n=None):
125
+ # type: (PutBackIterator[bytes], Optional[int]) -> Tuple[Optional[bytes], bool]
126
+ """Read through one CRLF or at most *n* bytes when *n* is provided.
127
+
128
+ Returns ``(line, True)`` when *line* ends in CRLF. Returns ``(line,
129
+ False)`` when the optional limit is reached first or when end of stream
130
+ follows a non-terminated line. Returns ``(None, False)`` only when the
131
+ stream is already exhausted. The returned line includes its CRLF when
132
+ present.
133
+
134
+ Like :func:`read_crlf_chunk`, this function never splits a CRLF across a
135
+ returned non-terminated prefix and the unread stream. If *n* would cut
136
+ between CR and LF, it returns the shorter prefix and leaves the whole CRLF
137
+ unread.
138
+ """
139
+ fragments = [] # type: List[bytes]
140
+ remaining = n
141
+ while True:
142
+ if remaining == 0:
143
+ return b"".join(fragments), False
144
+ chunk, terminated = read_crlf_chunk(stream)
145
+ if chunk is None:
146
+ if fragments:
147
+ return b"".join(fragments), False
148
+ return None, False
149
+ if remaining is not None and len(chunk) > remaining:
150
+ length = remaining
151
+ if (
152
+ chunk[length - 1 : length] == b"\r"
153
+ and chunk[length : length + 1] == b"\n"
154
+ ):
155
+ length -= 1
156
+ fragments.append(chunk[:length])
157
+ stream.put_back(chunk[length:])
158
+ return b"".join(fragments), False
159
+ fragments.append(chunk)
160
+ if remaining is not None:
161
+ remaining -= len(chunk)
162
+ if terminated:
163
+ return b"".join(fragments), True
164
+
165
+
166
+ def boundary_from_content_type(content_type):
167
+ # type: (Text) -> Optional[Text]
168
+ """Return the usable boundary from a ``multipart/form-data`` value."""
169
+ try:
170
+ type_text, parameters = parse_parameters(content_type)
171
+ except MalformedParameterListError:
172
+ return None
173
+
174
+ if type_text != "multipart/form-data":
175
+ return None
176
+
177
+ for name, value in parameters:
178
+ if name == "boundary":
179
+ return value or None
180
+ return None
181
+
182
+
183
+ def filename_from_ext_value(value):
184
+ # type: (Text) -> Optional[Text]
185
+ """Decode an RFC 6266 ``filename*`` ext-value to a filename.
186
+
187
+ The ext-value is ``charset ' language ' percent-encoded-bytes``. UTF-8
188
+ and ISO-8859-1/Latin-1 charsets are supported; any other charset, a
189
+ malformed value, or bytes that do not decode are rejected by returning
190
+ ``None`` so the caller can fall back to a plain ``filename``.
191
+ """
192
+ parts = value.split("'", 2)
193
+ if len(parts) != 3:
194
+ return None
195
+ charset, language, encoded = parts
196
+ if not charset or not encoded:
197
+ return None
198
+
199
+ charset_lower = charset.lower()
200
+ if charset_lower in ("utf-8", "utf8"):
201
+ codec = "utf-8"
202
+ elif charset_lower in ("iso-8859-1", "latin-1", "latin1"):
203
+ codec = "latin-1"
204
+ else:
205
+ return None
206
+
207
+ data = encoded.encode("latin-1")
208
+ result = bytearray()
209
+ index = 0
210
+ length = len(data)
211
+ while index < length:
212
+ if data[index : index + 1] == b"%" and index + 2 < length:
213
+ try:
214
+ result.append(int(data[index + 1 : index + 3], 16))
215
+ index += 3
216
+ continue
217
+ except ValueError:
218
+ pass
219
+ result.append(data[index])
220
+ index += 1
221
+
222
+ try:
223
+ return bytes(result).decode(codec)
224
+ except UnicodeDecodeError:
225
+ return None
226
+
227
+
228
+ def filename_from_header_line(header_line):
229
+ # type: (Text) -> Optional[Text]
230
+ """Return a UTF-8 (or Latin-1 fallback) filename from one header line.
231
+
232
+ A valid ``filename*`` parameter takes precedence over ``filename``.
233
+ """
234
+ try:
235
+ name, value = parse_name_value(header_line)
236
+ except MalformedHeaderLineError:
237
+ return None
238
+
239
+ if name != "content-disposition":
240
+ return None
241
+
242
+ try:
243
+ disposition, parameters = parse_parameters(value)
244
+ except MalformedParameterListError:
245
+ return None
246
+
247
+ if disposition != "form-data":
248
+ return None
249
+
250
+ filename = None
251
+ extended_filename = None
252
+ has_name = False
253
+ for parameter_name, parameter_value in parameters:
254
+ if parameter_name == "name":
255
+ has_name = True
256
+ elif parameter_name == "filename" and filename is None:
257
+ filename_bytes = parameter_value.encode("latin-1")
258
+ try:
259
+ filename = filename_bytes.decode("utf-8")
260
+ except UnicodeDecodeError:
261
+ filename = filename_bytes.decode("latin-1")
262
+ elif parameter_name == "filename*" and extended_filename is None:
263
+ extended_filename = filename_from_ext_value(parameter_value)
264
+
265
+ if has_name:
266
+ if extended_filename is not None:
267
+ return extended_filename
268
+ return filename
269
+ return None
270
+
271
+
272
+ def read_header_line(stream):
273
+ # type: (PutBackIterator[bytes]) -> Text
274
+ """Read one CRLF-terminated part-header line as Latin-1 text.
275
+
276
+ The line's content is bounded by :data:`MAX_MULTIPART_HEADER_LINE` so a
277
+ malicious or corrupt body cannot force unbounded buffering.
278
+ """
279
+ line, terminated = read_crlf_line(stream, MAX_MULTIPART_HEADER_LINE)
280
+ if line is None or not terminated:
281
+ raise ValueError("multipart header line is not terminated or is too long")
282
+ return line[:-2].decode("latin-1")
283
+
284
+
285
+ def parse_multipart_form_data(content_type, body_chunks):
286
+ # type: (Text, Iterable[bytes]) -> Iterator[MultipartEvent]
287
+ """Yield file lifecycle events from one explicit multipart state machine.
288
+
289
+ Events are :class:`PartBegin`, :class:`PartData`, and :class:`PartEnd`.
290
+ Non-file fields are consumed without events.
291
+ """
292
+ boundary_text = boundary_from_content_type(content_type)
293
+ if boundary_text is None:
294
+ raise ValueError("Invalid Content-Type for multipart/form-data")
295
+
296
+ stream = PutBackIterator(body_chunks) # type: PutBackIterator[bytes]
297
+ boundary = b"--" + boundary_text.encode("utf-8")
298
+ next_boundary = boundary + b"\r\n"
299
+ final_boundary = boundary + b"--\r\n"
300
+ state = MultipartState.SEEK_OPENING_BOUNDARY
301
+ filename = None
302
+ held_chunk = b""
303
+
304
+ while state is not MultipartState.DONE:
305
+ if state is MultipartState.SEEK_OPENING_BOUNDARY:
306
+ # The opening delimiter is the first complete delimiter line; all
307
+ # preceding CRLF lines are the optional MIME preamble.
308
+ while True:
309
+ line, terminated = read_crlf_line(stream, MAX_MULTIPART_HEADER_LINE)
310
+ if line is None:
311
+ raise ValueError("multipart body ended before its boundary")
312
+ if terminated and line == next_boundary:
313
+ state = MultipartState.READ_HEADERS
314
+ break
315
+ if terminated and line == final_boundary:
316
+ state = MultipartState.DONE
317
+ break
318
+ if not terminated:
319
+ raise ValueError(
320
+ "multipart body ended before its boundary or "
321
+ "preamble line is too long"
322
+ )
323
+
324
+ elif state is MultipartState.READ_HEADERS:
325
+ filename = None
326
+ while True:
327
+ header_line = read_header_line(stream)
328
+ if not header_line:
329
+ if filename is not None:
330
+ yield PartBegin(filename)
331
+ held_chunk = b""
332
+ state = MultipartState.MAYBE_BOUNDARY
333
+ break
334
+ parsed_filename = filename_from_header_line(header_line)
335
+ if parsed_filename is not None:
336
+ filename = parsed_filename
337
+
338
+ elif state is MultipartState.MAYBE_BOUNDARY:
339
+ delimiter, terminated = read_crlf_line(stream, len(final_boundary))
340
+ is_final = terminated and delimiter == final_boundary
341
+ if terminated and delimiter == next_boundary:
342
+ if len(held_chunk) > 2 and filename is not None:
343
+ yield PartData(held_chunk[:-2])
344
+ held_chunk = b""
345
+ if filename is not None:
346
+ yield PartEnd(False)
347
+ state = MultipartState.READ_HEADERS
348
+ elif is_final:
349
+ if len(held_chunk) > 2 and filename is not None:
350
+ yield PartData(held_chunk[:-2])
351
+ held_chunk = b""
352
+ if filename is not None:
353
+ yield PartEnd(True)
354
+ state = MultipartState.DONE
355
+ else:
356
+ # The candidate was not a delimiter, so the held complete
357
+ # line is ordinary body content, including its CRLF.
358
+ if held_chunk and filename is not None:
359
+ yield PartData(held_chunk)
360
+ if terminated and delimiter:
361
+ # Hold this complete non-delimiter line while checking the
362
+ # bytes after its CRLF on the next MAYBE_BOUNDARY step.
363
+ held_chunk = delimiter
364
+ state = MultipartState.MAYBE_BOUNDARY
365
+ else:
366
+ if delimiter and filename is not None:
367
+ yield PartData(delimiter)
368
+ held_chunk = b""
369
+ state = MultipartState.READ_PART_BODY
370
+
371
+ elif state is MultipartState.READ_PART_BODY:
372
+ chunk, terminated = read_crlf_chunk(stream)
373
+ if chunk is None:
374
+ raise ValueError("multipart body ended before its boundary")
375
+ if terminated:
376
+ # Keep this complete line until MAYBE_BOUNDARY determines
377
+ # whether its trailing CRLF introduces a delimiter.
378
+ held_chunk = chunk
379
+ state = MultipartState.MAYBE_BOUNDARY
380
+ else:
381
+ if chunk and filename is not None:
382
+ yield PartData(chunk)
383
+ state = MultipartState.READ_PART_BODY
384
+
385
+ else:
386
+ raise ValueError("invalid multipart parser state")
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "parse-multipart-form-data"
7
+ version = "0.1.0a0"
8
+ description = "A streaming parser for multipart/form-data request bodies."
9
+ readme = "README.md"
10
+ requires-python = ">=2"
11
+ license = "MIT"
12
+ authors = [
13
+ { name="Jifeng Wu", email="jifengwu2k@gmail.com" }
14
+ ]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 2",
17
+ "Programming Language :: Python :: 3",
18
+ "Operating System :: OS Independent",
19
+ ]
20
+ dependencies = [
21
+ "enum34; python_version < '3.4'",
22
+ "parse-http-header-line",
23
+ "put-back-iterator",
24
+ "typing; python_version < '3.5'",
25
+ ]
26
+
27
+ [project.urls]
28
+ "Homepage" = "https://github.com/jifengwu2k/parse-multipart-form-data"
29
+ "Bug Tracker" = "https://github.com/jifengwu2k/parse-multipart-form-data/issues"
30
+
31
+ [tool.setuptools]
32
+ py-modules = ["parse_multipart_form_data"]
@@ -0,0 +1,7 @@
1
+ [bdist_wheel]
2
+ universal = 1
3
+
4
+ [egg_info]
5
+ tag_build =
6
+ tag_date = 0
7
+
@@ -0,0 +1,435 @@
1
+ # Copyright (c) 2026 Jifeng Wu
2
+ # Licensed under the MIT License. See LICENSE in the project root.
3
+ from __future__ import print_function
4
+
5
+ import unittest
6
+ from types import GeneratorType
7
+ from typing import List, Optional, Text, Tuple
8
+
9
+ from put_back_iterator import PutBackIterator
10
+
11
+ from parse_multipart_form_data import (
12
+ PartBegin,
13
+ PartData,
14
+ PartEnd,
15
+ boundary_from_content_type,
16
+ filename_from_header_line,
17
+ parse_multipart_form_data,
18
+ read_crlf_chunk,
19
+ read_crlf_line,
20
+ )
21
+
22
+
23
+ def collect(data, chunk_size):
24
+ # type: (bytes, int) -> List[Tuple[Optional[bytes], bool]]
25
+ chunks = (
26
+ data[offset:offset + chunk_size]
27
+ for offset in range(0, len(data), chunk_size)
28
+ )
29
+ stream = PutBackIterator(chunks)
30
+ result = []
31
+ while True:
32
+ chunk, terminated = read_crlf_chunk(stream)
33
+ if chunk is None:
34
+ result.append((None, terminated))
35
+ return result
36
+ result.append((chunk, terminated))
37
+
38
+
39
+ def event_summary(event):
40
+ # type: (object) -> Tuple[Text, object]
41
+ """Return a comparable ``(kind, value)`` shape for one multipart event."""
42
+ if isinstance(event, PartBegin):
43
+ return (u"begin", event.filename)
44
+ if isinstance(event, PartData):
45
+ return (u"data", event.data)
46
+ if isinstance(event, PartEnd):
47
+ return (u"end", event.is_final)
48
+ raise AssertionError(u"unexpected multipart event: %s" % (type(event).__name__,))
49
+
50
+
51
+ class MultipartFormDataTests(unittest.TestCase):
52
+ def collect_events(self, body, chunk_size=None):
53
+ if chunk_size is None:
54
+ chunks = [body]
55
+ else:
56
+ chunks = (
57
+ body[offset:offset + chunk_size]
58
+ for offset in range(0, len(body), chunk_size)
59
+ )
60
+ return list(parse_multipart_form_data(
61
+ u"multipart/form-data; boundary=BOUNDARY", chunks
62
+ ))
63
+
64
+ def test_yields_file_lifecycle_events_from_chunk_iterable(self):
65
+ body = b"".join((
66
+ b"--BOUNDARY\r\n",
67
+ b'Content-Disposition: form-data; name="file"; filename="hello.txt"\r\n',
68
+ b"\r\n",
69
+ b"hello world\r\n",
70
+ b"--BOUNDARY--\r\n",
71
+ ))
72
+
73
+ events = self.collect_events(body, 3)
74
+ self.assertIsInstance(events[0], PartBegin)
75
+ self.assertEqual(u"hello.txt", events[0].filename)
76
+ self.assertIsInstance(events[-1], PartEnd)
77
+ self.assertTrue(events[-1].is_final)
78
+ self.assertEqual(
79
+ b"hello world",
80
+ b"".join(event.data for event in events if isinstance(event, PartData)),
81
+ )
82
+
83
+ def test_skips_non_file_fields_and_emits_each_file(self):
84
+ body = b"".join((
85
+ b"--BOUNDARY\r\n",
86
+ b'Content-Disposition: form-data; name="note"\r\n',
87
+ b"\r\n",
88
+ b"ignored\r\n",
89
+ b"--BOUNDARY\r\n",
90
+ b'Content-Disposition: form-data; name="file"; filename="a.txt"\r\n',
91
+ b"\r\n",
92
+ b"A\r\n",
93
+ b"--BOUNDARY\r\n",
94
+ b'Content-Disposition: form-data; name="file"; filename="b.txt"\r\n',
95
+ b"\r\n",
96
+ b"B\r\n",
97
+ b"--BOUNDARY--\r\n",
98
+ ))
99
+
100
+ self.assertEqual(
101
+ [
102
+ (u"begin", u"a.txt"),
103
+ (u"data", b"A"),
104
+ (u"end", False),
105
+ (u"begin", u"b.txt"),
106
+ (u"data", b"B"),
107
+ (u"end", True),
108
+ ],
109
+ [event_summary(event) for event in self.collect_events(body)],
110
+ )
111
+
112
+ def test_ignores_marker_in_middle_of_preamble(self):
113
+ body = b"".join((
114
+ b"preamble--BOUNDARY\r\n",
115
+ b"\r\n",
116
+ b"--BOUNDARY\r\n",
117
+ b'Content-Disposition: form-data; name="file"; filename="ok.txt"\r\n',
118
+ b"\r\n",
119
+ b"ok\r\n",
120
+ b"--BOUNDARY--\r\n",
121
+ ))
122
+
123
+ self.assertEqual(
124
+ [
125
+ (u"begin", u"ok.txt"),
126
+ (u"data", b"ok"),
127
+ (u"end", True),
128
+ ],
129
+ [event_summary(event) for event in self.collect_events(body)],
130
+ )
131
+
132
+ def test_emits_empty_file_part(self):
133
+ body = b"".join((
134
+ b"--BOUNDARY\r\n",
135
+ b'Content-Disposition: form-data; name="file"; filename="empty.txt"\r\n',
136
+ b"\r\n",
137
+ b"--BOUNDARY--\r\n",
138
+ ))
139
+
140
+ self.assertEqual(
141
+ [(u"begin", u"empty.txt"), (u"end", True)],
142
+ [event_summary(event) for event in self.collect_events(body)],
143
+ )
144
+
145
+ def test_keeps_boundary_like_body_lines_as_content(self):
146
+ body = b"".join((
147
+ b"--BOUNDARY\r\n",
148
+ b'Content-Disposition: form-data; name="file"; filename="body.txt"\r\n',
149
+ b"\r\n",
150
+ b"first\r\n",
151
+ b"--BOUNDARY-not-a-delimiter\r\n",
152
+ b"last\r\n",
153
+ b"--BOUNDARY--\r\n",
154
+ ))
155
+
156
+ events = self.collect_events(body)
157
+ self.assertIsInstance(events[0], PartBegin)
158
+ self.assertEqual(u"body.txt", events[0].filename)
159
+ self.assertIsInstance(events[-1], PartEnd)
160
+ self.assertTrue(events[-1].is_final)
161
+ self.assertEqual(
162
+ b"first\r\n--BOUNDARY-not-a-delimiter\r\nlast",
163
+ b"".join(event.data for event in events if isinstance(event, PartData)),
164
+ )
165
+
166
+ def test_rejects_transport_padding(self):
167
+ body = b"".join((
168
+ b"--BOUNDARY\r\n",
169
+ b'Content-Disposition: form-data; name="file"; filename="one.txt"\r\n',
170
+ b"\r\n",
171
+ b"one\r\n",
172
+ b"--BOUNDARY-- \t\r\n",
173
+ ))
174
+
175
+ with self.assertRaises(ValueError):
176
+ self.collect_events(body)
177
+
178
+ def test_skips_non_form_data_content_disposition(self):
179
+ body = b"".join((
180
+ b"--BOUNDARY\r\n",
181
+ b'Content-Disposition: attachment; name="file"; filename="not-upload.txt"\r\n',
182
+ b"\r\n",
183
+ b"ignored\r\n",
184
+ b"--BOUNDARY\r\n",
185
+ b'Content-Disposition: form-data; filename="missing-name.txt"\r\n',
186
+ b"\r\n",
187
+ b"ignored\r\n",
188
+ b"--BOUNDARY\r\n",
189
+ b'Content-Disposition: form-data; name="file"; filename="upload.txt"\r\n',
190
+ b"\r\n",
191
+ b"uploaded\r\n",
192
+ b"--BOUNDARY--\r\n",
193
+ ))
194
+
195
+ self.assertEqual(
196
+ [
197
+ (u"begin", u"upload.txt"),
198
+ (u"data", b"uploaded"),
199
+ (u"end", True),
200
+ ],
201
+ [event_summary(event) for event in self.collect_events(body)],
202
+ )
203
+
204
+ def test_parser_is_the_single_event_generator(self):
205
+ body = b"".join((
206
+ b"--BOUNDARY\r\n",
207
+ b'Content-Disposition: form-data; name="file"; filename="state.txt"\r\n',
208
+ b"\r\n",
209
+ b"stateful\r\n",
210
+ b"--BOUNDARY--\r\n",
211
+ ))
212
+ events = parse_multipart_form_data(
213
+ u"multipart/form-data; boundary=BOUNDARY", [body]
214
+ )
215
+
216
+ self.assertTrue(isinstance(events, GeneratorType))
217
+ first = next(events)
218
+ self.assertIsInstance(first, PartBegin)
219
+ self.assertEqual(u"state.txt", first.filename)
220
+ second = next(events)
221
+ self.assertIsInstance(second, PartData)
222
+ self.assertEqual(b"stateful", second.data)
223
+ third = next(events)
224
+ self.assertIsInstance(third, PartEnd)
225
+ self.assertTrue(third.is_final)
226
+ with self.assertRaises(StopIteration):
227
+ next(events)
228
+
229
+ def test_rejects_invalid_content_type(self):
230
+ with self.assertRaises(ValueError):
231
+ list(parse_multipart_form_data(u"text/plain", [b"body"]))
232
+
233
+ def test_rejects_truncated_body(self):
234
+ body = b"".join((
235
+ b"--BOUNDARY\r\n",
236
+ b'Content-Disposition: form-data; name="file"; filename="partial.txt"\r\n',
237
+ b"\r\n",
238
+ b"partial",
239
+ ))
240
+
241
+ with self.assertRaises(ValueError):
242
+ self.collect_events(body)
243
+
244
+ def test_boundary_from_content_type_accepts_rfc2046_boundary_chars(self):
245
+ self.assertEqual(
246
+ u"abc=def",
247
+ boundary_from_content_type(u"multipart/form-data; boundary=abc=def"),
248
+ )
249
+ self.assertEqual(
250
+ u"abc:def/ghi?jkl(1,2)",
251
+ boundary_from_content_type(
252
+ u"multipart/form-data; boundary=abc:def/ghi?jkl(1,2)"
253
+ ),
254
+ )
255
+ self.assertEqual(
256
+ u"abc=def",
257
+ boundary_from_content_type(u"multipart/form-data; boundary = \"abc=def\""),
258
+ )
259
+ self.assertEqual(
260
+ u"abc=def",
261
+ boundary_from_content_type(u"multipart/form-data; BOUNDARY=abc=def"),
262
+ )
263
+ self.assertEqual(
264
+ u"x=y",
265
+ boundary_from_content_type(
266
+ u"multipart/form-data; charset=utf-8; boundary=x=y"
267
+ ),
268
+ )
269
+
270
+ def test_parses_parts_with_equals_boundary(self):
271
+ body = b"".join((
272
+ b"--abc=def\r\n",
273
+ b'Content-Disposition: form-data; name="file"; filename="ok.txt"\r\n',
274
+ b"\r\n",
275
+ b"data\r\n",
276
+ b"--abc=def--\r\n",
277
+ ))
278
+ events = list(parse_multipart_form_data(
279
+ u"multipart/form-data; boundary=abc=def", [body]
280
+ ))
281
+ self.assertIsInstance(events[0], PartBegin)
282
+ self.assertEqual(u"ok.txt", events[0].filename)
283
+ self.assertIsInstance(events[1], PartData)
284
+ self.assertEqual(b"data", events[1].data)
285
+ self.assertIsInstance(events[2], PartEnd)
286
+ self.assertTrue(events[2].is_final)
287
+
288
+ def test_filename_star_utf8_is_decoded(self):
289
+ header = (
290
+ u"Content-Disposition: form-data; name=\"file\"; "
291
+ u"filename*=UTF-8''caf%C3%A9.txt"
292
+ )
293
+ self.assertEqual(u"caf\u00e9.txt", filename_from_header_line(header))
294
+
295
+ def test_filename_star_iso8859_1_is_decoded(self):
296
+ header = (
297
+ u"Content-Disposition: form-data; name=\"file\"; "
298
+ u"filename*=ISO-8859-1''caf%E9.txt"
299
+ )
300
+ self.assertEqual(u"caf\u00e9.txt", filename_from_header_line(header))
301
+
302
+ def test_filename_star_takes_precedence_over_filename(self):
303
+ header = (
304
+ u"Content-Disposition: form-data; name=\"file\"; "
305
+ u"filename=\"fallback.txt\"; filename*=UTF-8''real%C3%A9.txt"
306
+ )
307
+ self.assertEqual(u"real\u00e9.txt", filename_from_header_line(header))
308
+
309
+ def test_filename_star_unknown_charset_falls_back_to_filename(self):
310
+ header = (
311
+ u"Content-Disposition: form-data; name=\"file\"; "
312
+ u"filename=\"fallback.txt\"; filename*=bogus''abc"
313
+ )
314
+ self.assertEqual(u"fallback.txt", filename_from_header_line(header))
315
+
316
+ def test_filename_star_malformed_is_ignored(self):
317
+ header = (
318
+ u"Content-Disposition: form-data; name=\"file\"; "
319
+ u"filename*=no-apostrophe"
320
+ )
321
+ self.assertIsNone(filename_from_header_line(header))
322
+
323
+ def test_emits_part_for_filename_star(self):
324
+ body = b"".join((
325
+ b"--BOUNDARY\r\n",
326
+ b"Content-Disposition: form-data; name=\"file\"; "
327
+ b"filename*=UTF-8''caf%C3%A9.txt\r\n",
328
+ b"\r\n",
329
+ b"data\r\n",
330
+ b"--BOUNDARY--\r\n",
331
+ ))
332
+ events = self.collect_events(body)
333
+ self.assertIsInstance(events[0], PartBegin)
334
+ self.assertEqual(u"caf\u00e9.txt", events[0].filename)
335
+
336
+ def test_rejects_overlong_header_line(self):
337
+ body = b"".join((
338
+ b"--BOUNDARY\r\n",
339
+ b"Content-Disposition: form-data; name=\"file\"; filename=\"x.txt\"; ",
340
+ b"x" * 9000,
341
+ b"\r\n",
342
+ b"\r\n",
343
+ b"data\r\n",
344
+ b"--BOUNDARY--\r\n",
345
+ ))
346
+ with self.assertRaises(ValueError):
347
+ self.collect_events(body)
348
+
349
+ def test_rejects_overlong_preamble_line(self):
350
+ body = b"".join((
351
+ b"preamble",
352
+ b"x" * 9000,
353
+ b"\r\n",
354
+ b"--BOUNDARY\r\n",
355
+ b'Content-Disposition: form-data; name="file"; filename="x.txt"\r\n',
356
+ b"\r\n",
357
+ b"data\r\n",
358
+ b"--BOUNDARY--\r\n",
359
+ ))
360
+ with self.assertRaises(ValueError):
361
+ self.collect_events(body)
362
+
363
+
364
+ class ReadCrlfChunkTests(unittest.TestCase):
365
+ def test_returns_none_at_end_of_stream(self):
366
+ self.assertEqual([(None, False)], collect(b"", 1))
367
+
368
+ def test_splits_at_first_crlf_and_pushes_back_remainder(self):
369
+ self.assertEqual(
370
+ [(b"abc\r\n", True), (b"def\r\n", True), (None, False)],
371
+ collect(b"abc\r\ndef\r\n", 100),
372
+ )
373
+
374
+ def test_preserves_bytes_across_chunk_sizes(self):
375
+ data = b"a\rb\r\nc\r\n"
376
+ for chunk_size in (1, 2, 3, 5, 100):
377
+ result = collect(data, chunk_size)
378
+ joined = b"".join(chunk for chunk, _ in result if chunk is not None)
379
+ self.assertEqual(data, joined, "chunk size %s" % (chunk_size,))
380
+
381
+ def test_detects_crlf_split_across_chunks(self):
382
+ self.assertEqual(
383
+ [(b"ab\r\n", True), (b"cd", False), (None, False)],
384
+ collect(b"ab\r\ncd", 3),
385
+ )
386
+
387
+ def test_keeps_lone_cr_when_not_followed_by_lf(self):
388
+ self.assertEqual(
389
+ [(b"ab\r", False), (b"cd", False), (None, False)],
390
+ collect(b"ab\rcd", 3),
391
+ )
392
+
393
+ def test_limits_line_length_and_puts_back_remainder(self):
394
+ stream = PutBackIterator(iter([b"abc", b"def"]))
395
+
396
+ self.assertEqual((b"abcd", False), read_crlf_line(stream, 4))
397
+ self.assertEqual((b"ef", False), read_crlf_line(stream, 4))
398
+ self.assertEqual((None, False), read_crlf_line(stream, 4))
399
+
400
+ def test_line_limit_does_not_split_crlf(self):
401
+ stream = PutBackIterator(iter([b"abc\r\n"]))
402
+
403
+ self.assertEqual((b"abc", False), read_crlf_line(stream, 4))
404
+ self.assertEqual((b"\r\n", True), read_crlf_line(stream))
405
+
406
+ def test_reads_one_complete_line_across_input_chunks(self):
407
+ stream = PutBackIterator(iter([b"abc", b"\r", b"\ndef\r\n"]))
408
+
409
+ self.assertEqual((b"abc\r\n", True), read_crlf_line(stream))
410
+ self.assertEqual((b"def\r\n", True), read_crlf_line(stream))
411
+ self.assertEqual((None, False), read_crlf_line(stream))
412
+
413
+ def test_returns_partial_line_at_end_of_stream(self):
414
+ stream = PutBackIterator(iter([b"partial", b" line"]))
415
+
416
+ self.assertEqual((b"partial line", False), read_crlf_line(stream))
417
+ self.assertEqual((None, False), read_crlf_line(stream))
418
+
419
+ def test_skips_empty_chunks(self):
420
+ stream = PutBackIterator(iter([b"", b"", b"a\r\n", b"", b"b"]))
421
+ result = []
422
+ while True:
423
+ chunk, terminated = read_crlf_chunk(stream)
424
+ if chunk is None:
425
+ result.append((None, terminated))
426
+ break
427
+ result.append((chunk, terminated))
428
+ self.assertEqual(
429
+ [(b"a\r\n", True), (b"b", False), (None, False)],
430
+ result,
431
+ )
432
+
433
+
434
+ if __name__ == "__main__":
435
+ unittest.main()