Skip to content

Commit 523cb97

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 96171a5 commit 523cb97

1 file changed

Lines changed: 291 additions & 6 deletions

File tree

multipart.py

Lines changed: 291 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,291 @@ 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 read 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+
#: True if all body data was read from this part.
985+
consumed: bool
986+
987+
#: True if the parser found the end of the segment and its final :attr:`size` is known.
988+
complete: 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__(
1012+
self, parser: AsyncMultipartStreamParser, segment: MultipartSegment
1013+
):
1014+
self.segment = segment
1015+
1016+
self.consumed = False
1017+
self.closed = False
1018+
1019+
self._parser = parser
1020+
self._buffer = b""
1021+
1022+
# Cache these for performance, as they are very likely used
1023+
self.name = segment.name
1024+
self.filename = segment.filename
1025+
1026+
def header(self, name: str, default=None):
1027+
"""Return the value of a header if present, or a default value."""
1028+
return self.segment.header(name, default)
1029+
1030+
def __getitem__(self, name):
1031+
return self.segment[name]
1032+
1033+
def __getattr__(self, name):
1034+
return getattr(self.segment, name)
1035+
1036+
async def __aenter__(self):
1037+
return self
1038+
1039+
async def __aexit__(self, **err):
1040+
await self.close()
1041+
1042+
@property
1043+
def position(self):
1044+
"""Number of bytes already consumed from the segment body."""
1045+
return self.segment.size - len(self._buffer)
1046+
1047+
async def read_chunk(self, limit=-1) -> bytes:
1048+
"""Read and return a single chunk of bytes from the segment body.
1049+
1050+
If `limit` is negative or omitted, read and return a single chunk of data
1051+
(up to :attr:`chunk_size <AsyncMultipartStreamParser.chunk_size>` bytes).
1052+
This is the most efficient way of reading as it requires the least amount
1053+
of copy operations or memory allocations.
1054+
1055+
If `limit` is positive, return up to `limit` bytes and buffer any leftover
1056+
bytes in memory for the next read operation. Fewer bytes may be returned if
1057+
the current chunk or buffer is smaller than `limit`.
1058+
1059+
Reading from a closed or fully consumed part will return an empty bytes object.
1060+
1061+
:param limit: Maximum size of the returned chunk in bytes, or -1 for a
1062+
full chunk.
1063+
"""
1064+
1065+
if self.closed or limit == 0:
1066+
return b""
1067+
1068+
if self._buffer:
1069+
chunk = self._buffer
1070+
self._buffer = b""
1071+
elif self.consumed:
1072+
return b""
1073+
else:
1074+
chunk = await anext(self._parser._events)
1075+
if not chunk:
1076+
self.consumed = True
1077+
return b""
1078+
assert isinstance(chunk, bytes)
1079+
1080+
if 0 < limit < len(chunk):
1081+
self._buffer = chunk[limit:]
1082+
chunk = chunk[:limit]
1083+
1084+
return chunk
1085+
1086+
async def iter_chunks(self, maxread=-1):
1087+
"""Iterate over chunks of data.
1088+
1089+
If `maxread` is negative (default), read until the end of the
1090+
part. Otherwise, read up to `maxread` bytes in total.
1091+
"""
1092+
if maxread < 0:
1093+
while chunk := await self.read_chunk():
1094+
yield chunk
1095+
else:
1096+
while chunk := await self.read_chunk(maxread):
1097+
maxread -= len(chunk)
1098+
yield chunk
1099+
1100+
async def read(self, limit: int) -> bytes:
1101+
"""Read and return up to `limit` bytes, combining multiple chunks if necessary.
1102+
Only the very last read operation may return fewer bytes than requested.
1103+
1104+
The `limit` parameter should be small enough to fit into memory at least
1105+
twice. If you do not care about the exact size of a read operation, use
1106+
the more efficient :meth:`read_chunk` method instead.
1107+
"""
1108+
1109+
if limit < 0:
1110+
raise AttributeError("Limit must not be negative")
1111+
1112+
result = await self.read_chunk(limit)
1113+
while len(result) < limit and not self.consumed:
1114+
result += await self.read_chunk(limit - len(result))
1115+
return result
1116+
1117+
async def as_text(self, limit, encoding=None, trunk_ok=False) -> str:
1118+
"""Read and return up to `limit` bytes as a string, and fail with
1119+
:exc:`ParserLimitReached` if the part did not fit into `limit`.
1120+
This is a safe-guard against accidentally reading very large parts
1121+
into memory.
1122+
1123+
:param limit: Maximum number of bytes to read.
1124+
:param encoding: If set, overrides the part header or parser defaults
1125+
for text encoding.
1126+
:param trunk_ok: If true, do not fail and instead return a potentially
1127+
truncated string. Note that you still risk :exc:`UnicodeDecodeError`
1128+
if the data ends in the middle of a multi-byte unicode glyph.
1129+
"""
1130+
if limit < 0:
1131+
raise AttributeError("Limit must not be negative")
1132+
data = await self.read(limit)
1133+
if not (trunk_ok or self.consumed):
1134+
raise ParserLimitReached("Text field exceeds size limit")
1135+
return data.decode(
1136+
encoding or self.segment.charset or self._parser.text_charset
1137+
)
1138+
1139+
async def transfer(self, async_write: t_AsyncWriter, limit=-1):
1140+
"""Transfer up to `limit` bytes of data to an async write function
1141+
and return the total number of bytes transferred.
1142+
1143+
:param async_write: An async function that accepts a bytes object and
1144+
returns the number of bytes written.
1145+
:param limit: Maximum number of bytes to transfer.
1146+
:return: Number of bytes transferred
1147+
"""
1148+
total = 0
1149+
async for chunk in self.iter_chunks(maxread=limit):
1150+
n = await async_write(chunk)
1151+
while n < len(chunk):
1152+
n += await async_write(chunk[n:])
1153+
total += n
1154+
return total
1155+
1156+
async def close(self):
1157+
"""Read and discard any remaining bytes from this part and mark it as closed."""
1158+
if not self.closed:
1159+
while not self.consumed:
1160+
await self.read_chunk()
1161+
self.closed = True
1162+
1163+
8791164
##############################################################################
8801165
################################## Multipart #################################
8811166
##############################################################################
@@ -900,8 +1185,8 @@ def __init__(
9001185
mem_limit=0,
9011186
memfile_limit=0,
9021187
):
903-
"""A parser that reads from a `multipart/form-data` encoded byte stream
904-
and yields buffered :class:`MultipartPart` instances.
1188+
"""A synchronous parser that reads from a `multipart/form-data` encoded byte
1189+
stream and yields buffered :class:`MultipartPart` instances.
9051190
9061191
The parse acts as a lazy iterator and will only read and parse as much
9071192
data as needed to return the next part. Results are cached and the same

0 commit comments

Comments
 (0)