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.
- parse_multipart_form_data-0.1.0a0/LICENSE +21 -0
- parse_multipart_form_data-0.1.0a0/PKG-INFO +142 -0
- parse_multipart_form_data-0.1.0a0/README.md +122 -0
- parse_multipart_form_data-0.1.0a0/parse_multipart_form_data.egg-info/PKG-INFO +142 -0
- parse_multipart_form_data-0.1.0a0/parse_multipart_form_data.egg-info/SOURCES.txt +11 -0
- parse_multipart_form_data-0.1.0a0/parse_multipart_form_data.egg-info/dependency_links.txt +1 -0
- parse_multipart_form_data-0.1.0a0/parse_multipart_form_data.egg-info/requires.txt +8 -0
- parse_multipart_form_data-0.1.0a0/parse_multipart_form_data.egg-info/top_level.txt +1 -0
- parse_multipart_form_data-0.1.0a0/parse_multipart_form_data.py +386 -0
- parse_multipart_form_data-0.1.0a0/pyproject.toml +32 -0
- parse_multipart_form_data-0.1.0a0/setup.cfg +7 -0
- parse_multipart_form_data-0.1.0a0/tests/test_parse_multipart_form_data.py +435 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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")
|
|
@@ -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,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()
|