parse-multipart-form-data 0.1.0a0__py2.py3-none-any.whl

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,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,6 @@
1
+ parse_multipart_form_data.py,sha256=7ahsPgdmTTewMd7TrPDXMBUj3IrPKDvULHZCOpQwv3g,13627
2
+ parse_multipart_form_data-0.1.0a0.dist-info/licenses/LICENSE,sha256=hvfX-ADssuMYgrXDUAjMMut4l8W3meA31ZAYfpdlJKY,1066
3
+ parse_multipart_form_data-0.1.0a0.dist-info/METADATA,sha256=X3m_2uPG6dMtM4aglmdgvPSElOp3JGPddXZxq1Pav3U,4303
4
+ parse_multipart_form_data-0.1.0a0.dist-info/WHEEL,sha256=4YBfCYNH4wlLpv3pzq1hbEuIlXA4WJabKLFurZ7eTL0,109
5
+ parse_multipart_form_data-0.1.0a0.dist-info/top_level.txt,sha256=ST9spZQ6x_5E_bOD1SjGsuSHFYd9w0mUrms0orcIYD4,26
6
+ parse_multipart_form_data-0.1.0a0.dist-info/RECORD,,
@@ -0,0 +1,6 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py2-none-any
5
+ Tag: py3-none-any
6
+
@@ -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 @@
1
+ parse_multipart_form_data
@@ -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")