Skip to content

Commit 2316a4c

Browse files
committed
feat: AsyncMultipartStreamParser
An async-aware wrapper for PushMultipartParser that reads bytes from an awaitable read function on demand and returns AsyncMultipartPart instances that offer convenient async read methods for their payload.
1 parent 8110122 commit 2316a4c

1 file changed

Lines changed: 301 additions & 6 deletions

File tree

multipart.py

Lines changed: 301 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -610,8 +610,8 @@ def parse(self, chunk: t_ByteString) -> Generator[t_ParserEvent, None, None]:
610610
async def parse_async(
611611
self, read: t_AsyncReader, chunk_size=1024 * 64
612612
) -> AsyncGenerator[t_ParserEvent, None]:
613-
"""Parse the entire multipart stream by reading chunks from an async
614-
``read(size)`` function. The returned async generator yields parser
613+
"""Parse the entire multipart stream by reading chunks from an
614+
``async read(size)`` function. The returned async generator yields parser
615615
events similar to :meth:`parse` and can be used in ``async for`` loops.
616616
617617
The async ``read(size)`` function should read and return up to ``size``
@@ -623,8 +623,8 @@ async def parse_async(
623623
if known. It will also stop reading once the end of the multipart stream
624624
is detected, even if more data is available in the input stream.
625625
626-
:param read: An async read function returning chunks of data from an
627-
input stream.
626+
:param read: An async read function returning the next chunk of data from the
627+
multipart input stream.
628628
:param chunk_size: A positive integer limiting how many bytes are
629629
requested per read operation.
630630
:yields: Parser events (see :meth:`parse`)
@@ -876,6 +876,301 @@ def __getitem__(self, name):
876876
return self.header(name, KeyError)
877877

878878

879+
##############################################################################
880+
############################ Async Stream Wrapper #############################
881+
##############################################################################
882+
883+
884+
class AsyncMultipartStreamParser:
885+
"""An async-aware wrapper for :class:`PushMultipartParser` that reads bytes from an
886+
awaitable read function on demand and returns :class:`AsyncMultipartPart` instances
887+
that offer convenient async read methods for their payload.
888+
889+
This is still a stream parser, which means that parts are not buffered to disk and
890+
can only be consumed once, and only in the order they appear in the multipart stream.
891+
892+
.. versionadded:: 2.0
893+
(experimental, preview)
894+
"""
895+
896+
def __init__(
897+
self,
898+
parser: PushMultipartParser,
899+
read: t_AsyncReader,
900+
chunk_size=1024 * 64,
901+
text_charset="utf8",
902+
):
903+
"""Create a new async parser wrapper.
904+
905+
:param parser: A fresh and configured instance of :cls:`PushMultipartParser`.
906+
:param read: An awaitable read function returning th next chunk data.
907+
See :meth:`PushMultipartParser.parse_async`.
908+
:param chunk_size: A positive integer limiting how many bytes are requested per
909+
read operation.
910+
:param text_charset: Default charset for text fields.
911+
"""
912+
self.read = read
913+
self.parser = parser
914+
self.parse_event_stream = None
915+
self.chunk_size = chunk_size
916+
self.text_charset = text_charset
917+
918+
self.complete = False
919+
920+
self._events = self.parser.parse_async(self.read, self.chunk_size)
921+
self._current = None
922+
923+
def __enter__(self):
924+
return self
925+
926+
def __exit__(self, exc_type, exc_val, exc_tb):
927+
self.parser.close(check_complete=not exc_type)
928+
929+
async def __aenter__(self):
930+
return self.__enter__()
931+
932+
async def __aexit__(self, exc_type, exc_val, exc_tb):
933+
self.__exit__(exc_type, exc_val, exc_tb)
934+
935+
async def next(self) -> Optional["AsyncMultipartPart"]:
936+
"""Return the next :cls:`AsyncMultipartPart`, or `None` if the multipart
937+
stream ended.
938+
939+
Fetching the next part will close the previous part, which discards any
940+
remaining data in that part. See :meth:`AsyncMultipartPart.close`.
941+
942+
This can raise the same exceptions as :meth:`PushMultipartParser.parse_async`.
943+
"""
944+
if self.complete:
945+
return
946+
947+
if self._current and not self._current.closed:
948+
await self._current.close()
949+
self._current = None
950+
951+
try:
952+
segment = await anext(self._events)
953+
assert isinstance(segment, MultipartSegment)
954+
self._current = self._create_part(segment)
955+
return self._current
956+
except StopAsyncIteration:
957+
self.complete = True
958+
return
959+
960+
def _create_part(self, segment) -> "AsyncMultipartPart":
961+
"""(overrideable) Create the next :class:`AsyncMultipartPart`."""
962+
return AsyncMultipartPart(self, segment)
963+
964+
async def __aiter__(self) -> AsyncGenerator["AsyncMultipartPart", None]:
965+
"""Yield all (remaining) parts by calling :meth:`next` in a loop."""
966+
while part := await self.next():
967+
yield part
968+
969+
970+
class AsyncMultipartPart:
971+
"""A wrapper for :class:`MultipartSegment` that represens a single part of a
972+
multipart stream and also provides async read functions for its body content.
973+
974+
The body content is not stored or buffered, but streamed directly from the
975+
backing :cls:`AsyncMultipartStreamParser` instead. It can only be consumed once,
976+
and is no longer available once the part is closed or the next part has been
977+
requested.
978+
979+
All read operations may raise the same exceptions as :meth:`PushMultipartParser.parse_async`.
980+
"""
981+
982+
#: The underlying :cls:`MultipartSegment`.
983+
segment: MultipartSegment
984+
985+
#: True if the parser found the end of the segment and its final :attr:`size` is known.
986+
complete: bool
987+
#: True if there is no more data left to be read.
988+
consumed: bool
989+
#: True if the part was closed. Reading is no longer allowed.
990+
closed: bool
991+
992+
#: Ordered list of headers as (name, value) pairs. Header names are
993+
#: normalized (Title-Case) and values are stripped of leading or tailing
994+
#: whitespace.
995+
headerlist: List[Tuple[str, str]]
996+
997+
#: The cleaned up `Content-Disposition` header value without any header
998+
#: options. This will always be 'form-data' for HTTP form submissions.
999+
disposition: Optional[str]
1000+
#: The 'name' option of the `Content-Disposition` header. For `form-data`
1001+
#: this will always be a string, but the string may be empty.
1002+
name: Optional[str]
1003+
#: The optional 'filename' option of the `Content-Disposition` header.
1004+
filename: Optional[str]
1005+
1006+
#: The cleaned up `Content-Type` segment header without any header options.
1007+
content_type: Optional[str]
1008+
#: The optional 'charset' option of the `Content-Type` header.
1009+
charset: Optional[str]
1010+
1011+
def __init__(self, parser: AsyncMultipartStreamParser, segment: MultipartSegment):
1012+
self.segment = segment
1013+
1014+
self.consumed = False
1015+
self.closed = False
1016+
1017+
self._parser = parser
1018+
self._buffer = b""
1019+
1020+
# Cache these for performance, as they are very likely used
1021+
self.name = segment.name
1022+
self.filename = segment.filename
1023+
1024+
def header(self, name: str, default=None):
1025+
"""Return the value of a header if present, or a default value."""
1026+
return self.segment.header(name, default)
1027+
1028+
def __getitem__(self, name):
1029+
return self.segment[name]
1030+
1031+
def __getattr__(self, name):
1032+
return getattr(self.segment, name)
1033+
1034+
async def __aenter__(self):
1035+
return self
1036+
1037+
async def __aexit__(self, **err):
1038+
await self.close()
1039+
1040+
def tell(self):
1041+
"""Return the number of bytes already consumed from the segment body."""
1042+
return self.segment.size - len(self._buffer)
1043+
1044+
async def read_chunk(self, limit=-1) -> bytes:
1045+
"""Read and return a single chunk of bytes from the segment body.
1046+
1047+
If `limit` is negative or omitted, read and return a single chunk of data
1048+
(up to :attr:`chunk_size <AsyncMultipartStreamParser.chunk_size>` bytes).
1049+
This is the most efficient way of reading as it requires the least amount
1050+
of copy operations or memory allocations.
1051+
1052+
If `limit` is positive, return up to `limit` bytes and buffer any leftover
1053+
bytes in memory for the next read operation. Fewer bytes may be returned if
1054+
the current chunk or buffer is smaller than `limit`.
1055+
1056+
At least one byte is returned, unless the part was already completely
1057+
:attr:`consumed` or `limit` is `0`. Reading from a closed part is an error.
1058+
1059+
:param limit: Maximum size of the returned chunk in bytes, or -1 for a
1060+
full chunk.
1061+
"""
1062+
1063+
if self.closed:
1064+
raise ParserStateError("Multipart segment closed")
1065+
1066+
if limit == 0:
1067+
return b""
1068+
1069+
if self._buffer:
1070+
chunk = self._buffer
1071+
self._buffer = b""
1072+
elif self.consumed:
1073+
return b""
1074+
else:
1075+
chunk = await anext(self._parser._events)
1076+
if not chunk:
1077+
self.consumed = True
1078+
return b""
1079+
assert isinstance(chunk, bytes)
1080+
1081+
if 0 < limit < len(chunk):
1082+
self._buffer = chunk[limit:]
1083+
chunk = chunk[:limit]
1084+
1085+
return chunk
1086+
1087+
async def iter_chunks(self, maxread=-1):
1088+
"""Iterate over chunks of data by calling :meth:`read_chunk`
1089+
in a loop.
1090+
1091+
If `maxread` is negative (default), read until the end of the
1092+
part. Otherwise, read up to `maxread` bytes in total.
1093+
"""
1094+
if maxread < 0:
1095+
while chunk := await self.read_chunk():
1096+
yield chunk
1097+
else:
1098+
while chunk := await self.read_chunk(maxread):
1099+
maxread -= len(chunk)
1100+
yield chunk
1101+
1102+
async def read(self, limit: int) -> bytes:
1103+
"""Read and return up to `limit` bytes, combining the results of multiple
1104+
:meth:`read_chunk` calls if necessary. Only the very last read operation
1105+
may return fewer bytes than requested.
1106+
1107+
The `limit` parameter should be small enough to fit into memory at least
1108+
twice. If you do not care about the exact size of a chunk of data, use
1109+
the more efficient :meth:`read_chunk` method instead.
1110+
"""
1111+
1112+
if limit < 0:
1113+
raise AttributeError("Limit must not be negative")
1114+
1115+
result = await self.read_chunk(limit)
1116+
while len(result) < limit and not self.consumed:
1117+
result += await self.read_chunk(limit - len(result))
1118+
return result
1119+
1120+
async def as_text(self, limit, encoding=None, trunk_ok=False) -> str:
1121+
"""Read and decode all remaining bytes into a unicode string,
1122+
but fail with :exc:`ParserLimitReached` if this would require
1123+
reading more than `limit` bytes.
1124+
1125+
The `limit` is a safe-guard against accidentally reading very
1126+
large parts into memory. If you are fine with partial results,
1127+
set `trunk_ok` to `True`. Note that you risk :exc:`UnicodeDecodeError`
1128+
if the data is truncated in the middle of a multi-byte unicode glyph.
1129+
1130+
:param limit: Maximum number of bytes to read.
1131+
:param encoding: If set, overrides the part header or parser defaults
1132+
for text encoding.
1133+
:param trunk_ok: If true, do not fail after `limit` bytes but
1134+
instead return a partial result.
1135+
"""
1136+
if limit < 0:
1137+
raise AttributeError("Limit must not be negative")
1138+
data = await self.read(limit)
1139+
if not (trunk_ok or self.consumed):
1140+
raise ParserLimitReached("Text field exceeds size limit")
1141+
return data.decode(
1142+
encoding or self.segment.charset or self._parser.text_charset
1143+
)
1144+
1145+
async def transfer(self, async_write: t_AsyncWriter, limit=-1):
1146+
"""Transfer up to `limit` bytes of data to an async write function
1147+
and return the total number of bytes transferred.
1148+
1149+
:param async_write: An async function that accepts a bytes object and
1150+
returns the number of bytes written.
1151+
:param limit: Maximum number of bytes to transfer.
1152+
:return: Number of bytes transferred
1153+
"""
1154+
total = 0
1155+
async for chunk in self.iter_chunks(maxread=limit):
1156+
n = await async_write(chunk)
1157+
while n < len(chunk):
1158+
n += await async_write(chunk[n:])
1159+
total += n
1160+
return total
1161+
1162+
async def close(self):
1163+
"""Read and discard any remaining bytes from this part and mark it as closed.
1164+
1165+
Parts are usually automatically closed by the :cls:`AsyncMultipartStreamParser`
1166+
when requesting the next part or closing the parser. Reading from a closed part
1167+
is an error."""
1168+
if not self.closed:
1169+
while not self.consumed:
1170+
await self.read_chunk()
1171+
self.closed = True
1172+
1173+
8791174
##############################################################################
8801175
################################## Multipart #################################
8811176
##############################################################################
@@ -900,8 +1195,8 @@ def __init__(
9001195
mem_limit=0,
9011196
memfile_limit=0,
9021197
):
903-
"""A parser that reads from a `multipart/form-data` encoded byte stream
904-
and yields buffered :class:`MultipartPart` instances.
1198+
"""A synchronous parser that reads from a `multipart/form-data` encoded byte
1199+
stream and yields buffered :class:`MultipartPart` instances.
9051200
9061201
The parse acts as a lazy iterator and will only read and parse as much
9071202
data as needed to return the next part. Results are cached and the same

0 commit comments

Comments
 (0)