asgi-tools 1.2.0__cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.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.
- asgi_tools/__init__.py +65 -0
- asgi_tools/_compat.py +259 -0
- asgi_tools/app.py +303 -0
- asgi_tools/constants.py +6 -0
- asgi_tools/errors.py +25 -0
- asgi_tools/forms.c +19218 -0
- asgi_tools/forms.cpython-311-aarch64-linux-gnu.so +0 -0
- asgi_tools/forms.py +166 -0
- asgi_tools/forms.pyx +167 -0
- asgi_tools/logs.py +6 -0
- asgi_tools/middleware.py +458 -0
- asgi_tools/multipart.c +19234 -0
- asgi_tools/multipart.cpython-311-aarch64-linux-gnu.so +0 -0
- asgi_tools/multipart.pxd +34 -0
- asgi_tools/multipart.py +589 -0
- asgi_tools/multipart.pyx +565 -0
- asgi_tools/py.typed +0 -0
- asgi_tools/request.py +337 -0
- asgi_tools/response.py +537 -0
- asgi_tools/router.py +15 -0
- asgi_tools/tests.py +405 -0
- asgi_tools/types.py +31 -0
- asgi_tools/utils.py +110 -0
- asgi_tools/view.py +69 -0
- asgi_tools-1.2.0.dist-info/METADATA +214 -0
- asgi_tools-1.2.0.dist-info/RECORD +29 -0
- asgi_tools-1.2.0.dist-info/WHEEL +7 -0
- asgi_tools-1.2.0.dist-info/licenses/LICENSE +21 -0
- asgi_tools-1.2.0.dist-info/top_level.txt +1 -0
Binary file
|
asgi_tools/forms.py
ADDED
@@ -0,0 +1,166 @@
|
|
1
|
+
"""Work with multipart."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
from io import BytesIO
|
6
|
+
from tempfile import SpooledTemporaryFile
|
7
|
+
from typing import TYPE_CHECKING, Callable
|
8
|
+
from urllib.parse import unquote_to_bytes
|
9
|
+
|
10
|
+
from multidict import MultiDict
|
11
|
+
|
12
|
+
from .multipart import BaseParser, MultipartParser, QueryStringParser
|
13
|
+
from .utils import parse_options_header
|
14
|
+
|
15
|
+
if TYPE_CHECKING:
|
16
|
+
from asgi_tools.request import Request
|
17
|
+
|
18
|
+
|
19
|
+
async def read_formdata(
|
20
|
+
request: "Request",
|
21
|
+
max_size: int,
|
22
|
+
upload_to: Callable | None,
|
23
|
+
file_memory_limit: int = 1024 * 1024,
|
24
|
+
) -> MultiDict:
|
25
|
+
"""Read formdata from the given request."""
|
26
|
+
if request.content_type == "multipart/form-data":
|
27
|
+
reader: FormReader = MultipartReader(
|
28
|
+
request.charset,
|
29
|
+
upload_to,
|
30
|
+
file_memory_limit,
|
31
|
+
)
|
32
|
+
else:
|
33
|
+
reader = FormReader(request.charset)
|
34
|
+
|
35
|
+
parser = reader.init_parser(request, max_size)
|
36
|
+
async for chunk in request.stream():
|
37
|
+
parser.write(chunk)
|
38
|
+
|
39
|
+
parser.finalize()
|
40
|
+
return reader.form
|
41
|
+
|
42
|
+
|
43
|
+
class FormReader:
|
44
|
+
"""Process querystring form data."""
|
45
|
+
|
46
|
+
__slots__ = "charset", "curname", "curvalue", "form"
|
47
|
+
|
48
|
+
def __init__(self, charset: str):
|
49
|
+
self.charset = charset
|
50
|
+
self.curname = bytearray()
|
51
|
+
self.curvalue = bytearray()
|
52
|
+
self.form: MultiDict = MultiDict()
|
53
|
+
|
54
|
+
def init_parser(self, _: "Request", max_size: int) -> BaseParser:
|
55
|
+
return QueryStringParser(
|
56
|
+
{
|
57
|
+
"field_name": self.on_field_name,
|
58
|
+
"field_data": self.on_field_data,
|
59
|
+
"field_end": self.on_field_end,
|
60
|
+
},
|
61
|
+
max_size=max_size,
|
62
|
+
)
|
63
|
+
|
64
|
+
def on_field_name(self, data: bytes, start: int, end: int):
|
65
|
+
self.curname += data[start:end]
|
66
|
+
|
67
|
+
def on_field_data(self, data: bytes, start: int, end: int):
|
68
|
+
self.curvalue += data[start:end]
|
69
|
+
|
70
|
+
def on_field_end(self, *_):
|
71
|
+
self.form.add(
|
72
|
+
unquote_plus(self.curname).decode(self.charset),
|
73
|
+
unquote_plus(self.curvalue).decode(self.charset),
|
74
|
+
)
|
75
|
+
self.curname.clear()
|
76
|
+
self.curvalue.clear()
|
77
|
+
|
78
|
+
|
79
|
+
class MultipartReader(FormReader):
|
80
|
+
"""Process multipart formdata."""
|
81
|
+
|
82
|
+
__slots__ = (
|
83
|
+
"charset",
|
84
|
+
"curname",
|
85
|
+
"curvalue",
|
86
|
+
"file_memory_limit",
|
87
|
+
"form",
|
88
|
+
"headers",
|
89
|
+
"name",
|
90
|
+
"partdata",
|
91
|
+
"upload_to",
|
92
|
+
)
|
93
|
+
|
94
|
+
def __init__(self, charset: str, upload_to: Callable | None, file_memory_limit: int):
|
95
|
+
super().__init__(charset)
|
96
|
+
self.name = ""
|
97
|
+
self.headers: dict[bytes, bytes] = {}
|
98
|
+
self.partdata = BytesIO()
|
99
|
+
self.upload_to = upload_to
|
100
|
+
self.file_memory_limit = file_memory_limit
|
101
|
+
|
102
|
+
def init_parser(self, request: "Request", max_size: int) -> BaseParser:
|
103
|
+
boundary = request.media.get("boundary", "")
|
104
|
+
if not boundary:
|
105
|
+
raise ValueError("Invalid content type boundary")
|
106
|
+
|
107
|
+
return MultipartParser(
|
108
|
+
boundary,
|
109
|
+
{
|
110
|
+
"header_end": self.on_header_end,
|
111
|
+
"header_field": self.on_header_field,
|
112
|
+
"headers_finished": self.on_headers_finished,
|
113
|
+
"header_value": self.on_header_value,
|
114
|
+
"part_data": self.on_part_data,
|
115
|
+
"part_end": self.on_part_end,
|
116
|
+
},
|
117
|
+
max_size=max_size,
|
118
|
+
)
|
119
|
+
|
120
|
+
def on_header_field(self, data: bytes, start: int, end: int):
|
121
|
+
self.curname += data[start:end]
|
122
|
+
|
123
|
+
def on_header_value(self, data: bytes, start: int, end: int):
|
124
|
+
self.curvalue += data[start:end]
|
125
|
+
|
126
|
+
def on_header_end(self, *_):
|
127
|
+
self.headers[bytes(self.curname.lower())] = bytes(self.curvalue)
|
128
|
+
self.curname.clear()
|
129
|
+
self.curvalue.clear()
|
130
|
+
|
131
|
+
def on_headers_finished(self, *_):
|
132
|
+
_, options = parse_options_header(
|
133
|
+
self.headers[b"content-disposition"].decode(self.charset),
|
134
|
+
)
|
135
|
+
self.name = options["name"]
|
136
|
+
if "filename" in options:
|
137
|
+
upload_to = self.upload_to
|
138
|
+
if upload_to is not None:
|
139
|
+
filename = upload_to(options["filename"])
|
140
|
+
self.partdata = f = open(filename, "wb+") # noqa: SIM115, PTH123
|
141
|
+
|
142
|
+
else:
|
143
|
+
self.partdata = f = SpooledTemporaryFile(self.file_memory_limit) # noqa: SIM115
|
144
|
+
f._file.name = options["filename"] # type: ignore[]
|
145
|
+
|
146
|
+
f.content_type = self.headers[b"content-type"].decode(self.charset)
|
147
|
+
|
148
|
+
def on_part_data(self, data: bytes, start: int, end: int):
|
149
|
+
self.partdata.write(data[start:end])
|
150
|
+
|
151
|
+
def on_part_end(self, *_):
|
152
|
+
field_data = self.partdata
|
153
|
+
if isinstance(field_data, BytesIO):
|
154
|
+
self.form.add(self.name, field_data.getvalue().decode(self.charset))
|
155
|
+
|
156
|
+
else:
|
157
|
+
field_data.seek(0)
|
158
|
+
self.form.add(self.name, field_data)
|
159
|
+
|
160
|
+
self.partdata = BytesIO()
|
161
|
+
self.headers = {}
|
162
|
+
|
163
|
+
|
164
|
+
def unquote_plus(value: bytearray) -> bytes:
|
165
|
+
value = value.replace(b"+", b" ")
|
166
|
+
return unquote_to_bytes(bytes(value))
|
asgi_tools/forms.pyx
ADDED
@@ -0,0 +1,167 @@
|
|
1
|
+
# cython: language_level=3
|
2
|
+
|
3
|
+
"""Work with multipart."""
|
4
|
+
|
5
|
+
from io import BytesIO
|
6
|
+
from pathlib import Path
|
7
|
+
from tempfile import SpooledTemporaryFile
|
8
|
+
from urllib.parse import unquote_to_bytes
|
9
|
+
|
10
|
+
from multidict import MultiDict
|
11
|
+
|
12
|
+
from .multipart cimport QueryStringParser, MultipartParser, BaseParser
|
13
|
+
from .utils import parse_options_header
|
14
|
+
|
15
|
+
|
16
|
+
async def read_formdata(object request, int max_size, object upload_to,
|
17
|
+
int file_memory_limit=1024 * 1024) -> MultiDict:
|
18
|
+
"""Read formdata from the given request."""
|
19
|
+
cdef str content_type = request.content_type
|
20
|
+
if content_type == 'multipart/form-data':
|
21
|
+
reader = MultipartReader(request.charset, upload_to, file_memory_limit)
|
22
|
+
else:
|
23
|
+
reader = FormReader(request.charset)
|
24
|
+
|
25
|
+
parser = reader.init_parser(request, max_size)
|
26
|
+
async for chunk in request.stream():
|
27
|
+
parser.write(chunk)
|
28
|
+
|
29
|
+
parser.finalize()
|
30
|
+
return reader.form
|
31
|
+
|
32
|
+
|
33
|
+
cdef class FormReader:
|
34
|
+
"""Parse querystring form data."""
|
35
|
+
|
36
|
+
cdef str charset
|
37
|
+
cdef bytearray curname
|
38
|
+
cdef bytearray curvalue
|
39
|
+
cdef public object form
|
40
|
+
|
41
|
+
def __init__(self, str charset):
|
42
|
+
self.charset = charset
|
43
|
+
self.curname = bytearray()
|
44
|
+
self.curvalue = bytearray()
|
45
|
+
self.form: MultiDict = MultiDict()
|
46
|
+
|
47
|
+
cpdef BaseParser init_parser(self, object request, int max_size):
|
48
|
+
return QueryStringParser({
|
49
|
+
'field_name': self.on_field_name,
|
50
|
+
'field_data': self.on_field_data,
|
51
|
+
'field_end': self.on_field_end
|
52
|
+
}, max_size=max_size)
|
53
|
+
|
54
|
+
def on_field_name(self, bytes data, int start, int end):
|
55
|
+
self.curname += data[start:end]
|
56
|
+
|
57
|
+
def on_field_data(self, bytes data, int start, int end):
|
58
|
+
self.curvalue += data[start:end]
|
59
|
+
|
60
|
+
def on_field_end(self, bytes data, int start, int end):
|
61
|
+
self.form.add(
|
62
|
+
unquote_plus(bytes(self.curname)).decode(self.charset),
|
63
|
+
unquote_plus(bytes(self.curvalue)).decode(self.charset),
|
64
|
+
)
|
65
|
+
self.curname.clear()
|
66
|
+
self.curvalue.clear()
|
67
|
+
|
68
|
+
|
69
|
+
cdef class MultipartReader(FormReader):
|
70
|
+
"""Parse multipart formdata."""
|
71
|
+
|
72
|
+
cdef str name
|
73
|
+
cdef dict headers
|
74
|
+
cdef object partdata
|
75
|
+
cdef object upload_to
|
76
|
+
cdef int file_memory_limit
|
77
|
+
|
78
|
+
def __init__(self, str charset, object upload_to, int file_memory_limit):
|
79
|
+
self.curname = bytearray()
|
80
|
+
self.curvalue = bytearray()
|
81
|
+
self.form: MultiDict = MultiDict()
|
82
|
+
self.charset = charset
|
83
|
+
self.name = ''
|
84
|
+
self.headers = {}
|
85
|
+
self.partdata = BytesIO()
|
86
|
+
self.upload_to = upload_to
|
87
|
+
self.file_memory_limit = file_memory_limit
|
88
|
+
|
89
|
+
cpdef BaseParser init_parser(self, object request, int max_size):
|
90
|
+
cdef str boundary = request.media.get('boundary', '')
|
91
|
+
if not len(boundary):
|
92
|
+
raise ValueError('Invalid content type boundary')
|
93
|
+
|
94
|
+
return MultipartParser(request.media.get('boundary'), {
|
95
|
+
'header_end': self.on_header_end,
|
96
|
+
'header_field': self.on_header_field,
|
97
|
+
'headers_finished': self.on_headers_finished,
|
98
|
+
'header_value': self.on_header_value,
|
99
|
+
'part_data': self.on_part_data,
|
100
|
+
'part_end': self.on_part_end,
|
101
|
+
}, max_size=max_size)
|
102
|
+
|
103
|
+
def on_header_field(self, data: bytes, start: int, end: int):
|
104
|
+
self.curname += data[start:end]
|
105
|
+
|
106
|
+
def on_header_value(self, data: bytes, start: int, end: int):
|
107
|
+
self.curvalue += data[start:end]
|
108
|
+
|
109
|
+
def on_header_end(self, data: bytes, start: int, end: int):
|
110
|
+
self.headers[bytes(self.curname.lower())] = bytes(self.curvalue)
|
111
|
+
self.curname.clear()
|
112
|
+
self.curvalue.clear()
|
113
|
+
|
114
|
+
def on_headers_finished(self, data: bytes, start: int, end: int):
|
115
|
+
_, options = parse_options_header(self.headers[b'content-disposition'].decode(self.charset))
|
116
|
+
self.name = options['name']
|
117
|
+
upload_to = self.upload_to
|
118
|
+
if 'filename' in options:
|
119
|
+
if upload_to is not None:
|
120
|
+
filename = upload_to(options['filename'])
|
121
|
+
self.partdata = f = open(filename, 'wb+')
|
122
|
+
|
123
|
+
else:
|
124
|
+
self.partdata = f = SpooledTemporaryFile(self.file_memory_limit)
|
125
|
+
f._file.name = options['filename']
|
126
|
+
|
127
|
+
f.content_type = self.headers[b'content-type'].decode(self.charset)
|
128
|
+
|
129
|
+
def on_part_data(self, data: bytes, start: int, end: int):
|
130
|
+
self.partdata.write(data[start:end])
|
131
|
+
|
132
|
+
def on_part_end(self, data: bytes, start: int, end: int):
|
133
|
+
field_data = self.partdata
|
134
|
+
if isinstance(field_data, BytesIO):
|
135
|
+
self.form.add(self.name, field_data.getvalue().decode(self.charset))
|
136
|
+
|
137
|
+
else:
|
138
|
+
field_data.seek(0)
|
139
|
+
self.form.add(self.name, field_data)
|
140
|
+
|
141
|
+
self.partdata = BytesIO()
|
142
|
+
self.headers = {}
|
143
|
+
|
144
|
+
|
145
|
+
cdef dict _hextobyte = {
|
146
|
+
(a + b).encode(): bytes.fromhex(a + b)
|
147
|
+
for a in '0123456789ABCDEFabcdef' for b in '0123456789ABCDEFabcdef'
|
148
|
+
}
|
149
|
+
|
150
|
+
|
151
|
+
cdef bytes unquote_plus(value: bytes):
|
152
|
+
value = value.replace(b'+', b' ')
|
153
|
+
bits = value.split(b'%')
|
154
|
+
if len(bits) == 1:
|
155
|
+
return value
|
156
|
+
res = bits[0]
|
157
|
+
for item in bits[1:]:
|
158
|
+
try:
|
159
|
+
res += _hextobyte[item[:2]]
|
160
|
+
res += item[2:]
|
161
|
+
except KeyError:
|
162
|
+
res += b'%'
|
163
|
+
res += item
|
164
|
+
|
165
|
+
return res
|
166
|
+
|
167
|
+
# pylama: ignore=D
|