Coverage for /pythoncovmergedfiles/medio/medio/src/aiohttp/aiohttp/multipart.py: 18%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1import base64
2import binascii
3import builtins
4import json
5import re
6import sys
7import uuid
8import warnings
9from collections import deque
10from collections.abc import AsyncIterator, Iterator, Mapping, Sequence
11from types import TracebackType
12from typing import TYPE_CHECKING, Any, Union, cast
13from urllib.parse import parse_qsl, unquote, urlencode
15from multidict import CIMultiDict
17from .abc import AbstractStreamWriter
18from .compression_utils import ZLibCompressor, ZLibDecompressor
19from .hdrs import (
20 CONTENT_DISPOSITION,
21 CONTENT_ENCODING,
22 CONTENT_LENGTH,
23 CONTENT_TRANSFER_ENCODING,
24 CONTENT_TYPE,
25)
26from .helpers import (
27 CHAR,
28 DEFAULT_CHUNK_SIZE,
29 TOKEN,
30 HeadersDictProxy,
31 parse_mimetype,
32 reify,
33)
34from .http import HeadersParser
35from .http_exceptions import BadHttpMessage
36from .log import internal_logger
37from .payload import (
38 JsonPayload,
39 LookupError,
40 Order,
41 Payload,
42 StringPayload,
43 get_payload,
44 payload_type,
45)
46from .streams import StreamReader
48if sys.version_info >= (3, 11):
49 from typing import Self
50else:
51 from typing import TypeVar
53 Self = TypeVar("Self", bound="BodyPartReader")
55__all__ = (
56 "MultipartReader",
57 "MultipartWriter",
58 "BodyPartReader",
59 "BadContentDispositionHeader",
60 "BadContentDispositionParam",
61 "parse_content_disposition",
62 "content_disposition_filename",
63)
66# The base64 alphabet plus the padding character.
67_BASE64_CHARS = frozenset(
68 b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
69)
70_NON_BASE64_BYTES = bytes(b for b in range(256) if b not in _BASE64_CHARS)
73if TYPE_CHECKING:
74 from .client_reqrep import ClientResponse
77class BadContentDispositionHeader(RuntimeWarning):
78 pass
81class BadContentDispositionParam(RuntimeWarning):
82 pass
85def parse_content_disposition(
86 header: str | None,
87) -> tuple[str | None, dict[str, str]]:
88 def is_token(string: str) -> bool:
89 return bool(string) and TOKEN >= set(string)
91 def is_quoted(string: str) -> bool:
92 return len(string) >= 2 and string[0] == string[-1] == '"'
94 def is_rfc5987(string: str) -> bool:
95 return is_token(string) and string.count("'") == 2
97 def is_extended_param(string: str) -> bool:
98 return string.endswith("*")
100 def is_continuous_param(string: str) -> bool:
101 pos = string.find("*") + 1
102 if not pos:
103 return False
104 substring = string[pos:-1] if string.endswith("*") else string[pos:]
105 return substring.isdigit()
107 def unescape(text: str, *, chars: str = "".join(map(re.escape, CHAR))) -> str:
108 return re.sub(f"\\\\([{chars}])", "\\1", text)
110 if not header:
111 return None, {}
113 # https://www.rfc-editor.org/info/rfc9110/#section-5.6.6-2
114 disptype, *parts = header.split(";")
115 disptype = disptype.strip()
116 if not is_token(disptype):
117 warnings.warn(BadContentDispositionHeader(header))
118 return None, {}
120 params: dict[str, str] = {}
121 while parts:
122 item = parts.pop(0)
124 if not item: # To handle trailing semicolons
125 warnings.warn(BadContentDispositionHeader(header))
126 continue
128 if "=" not in item:
129 warnings.warn(BadContentDispositionHeader(header))
130 return None, {}
132 key, value = item.split("=", 1)
133 key = key.lower().strip()
134 value = value.lstrip()
136 if key in params:
137 warnings.warn(BadContentDispositionHeader(header))
138 return None, {}
140 if not is_token(key):
141 warnings.warn(BadContentDispositionParam(item))
142 continue
144 elif is_continuous_param(key):
145 if is_quoted(value):
146 value = unescape(value[1:-1])
147 elif not is_token(value):
148 warnings.warn(BadContentDispositionParam(item))
149 continue
151 elif is_extended_param(key):
152 if is_rfc5987(value):
153 encoding, _, value = value.split("'", 2)
154 encoding = encoding or "utf-8"
155 else:
156 warnings.warn(BadContentDispositionParam(item))
157 continue
159 try:
160 value = unquote(value, encoding, "strict").lstrip("\\/")
161 except (builtins.LookupError, UnicodeDecodeError):
162 # The charset is attacker-controlled here; an unknown name
163 # raises the builtin LookupError (the bare name is shadowed in
164 # this module by payload.LookupError).
165 warnings.warn(BadContentDispositionParam(item))
166 continue
168 else:
169 failed = True
170 rstripped = value.rstrip()
171 if is_quoted(rstripped):
172 failed = False
173 value = unescape(rstripped[1:-1].lstrip("\\/"))
174 elif is_token(value):
175 failed = False
176 elif parts:
177 # maybe just ; in filename, in any case this is just
178 # one case fix, for proper fix we need to redesign parser
179 _value = f"{value};{parts[0]}"
180 if is_quoted(_value):
181 parts.pop(0)
182 value = unescape(_value[1:-1].lstrip("\\/"))
183 failed = False
185 if failed:
186 warnings.warn(BadContentDispositionHeader(header))
187 return None, {}
189 params[key] = value
191 return disptype.lower(), params
194def content_disposition_filename(
195 params: Mapping[str, str], name: str = "filename"
196) -> str | None:
197 name_suf = "%s*" % name
198 if not params:
199 return None
200 elif name_suf in params:
201 return params[name_suf]
202 elif name in params:
203 return params[name]
204 else:
205 # The index is capped at six digits so a header cannot push int() past
206 # CPython's int-to-str limit; a longer run of digits simply never matches.
207 section_re = re.compile(re.escape(name) + r"\*([0-9]{1,6})(\*)?")
208 matches = (
209 (m, value)
210 for key, value in params.items()
211 if (m := section_re.fullmatch(key)) is not None
212 )
213 # https://www.rfc-editor.org/info/rfc2231/#section-3
214 # Order numerically.
215 fnparams = sorted(matches, key=lambda mv: int(mv[0].group(1)))
216 parts: list[str] = []
217 # Consecutive encoded sections are decoded as one unit, because a
218 # single multibyte character may be split across a section boundary.
219 pending: list[str] = []
220 encoding = "utf-8"
221 for num, (m, value) in enumerate(fnparams):
222 if m.group(1) != str(num): # Missing section or leading zero.
223 return None
224 if m.group(2) is not None: # encoded parameter
225 # https://www.rfc-editor.org/info/rfc2231/#section-4.1
226 if num == 0 and value.count("'") >= 2:
227 encoding, _, value = value.split("'", 2)
228 encoding = encoding or "utf-8"
229 pending.append(value)
230 continue
231 if pending:
232 # Current value is not encoded, so process encoding of previous parts
233 try:
234 parts.append(unquote("".join(pending), encoding, "strict"))
235 except (builtins.LookupError, UnicodeDecodeError):
236 return None
237 pending.clear()
238 parts.append(value)
239 if pending:
240 try:
241 parts.append(unquote("".join(pending), encoding, "strict"))
242 except (builtins.LookupError, UnicodeDecodeError):
243 return None
244 if not parts:
245 return None
246 return "".join(parts).lstrip("\\/")
249class MultipartResponseWrapper:
250 """Wrapper around the MultipartReader.
252 It takes care about
253 underlying connection and close it when it needs in.
254 """
256 def __init__(
257 self,
258 resp: "ClientResponse",
259 stream: "MultipartReader",
260 ) -> None:
261 self.resp = resp
262 self.stream = stream
264 def __aiter__(self) -> "MultipartResponseWrapper":
265 return self
267 async def __anext__(
268 self,
269 ) -> Union["MultipartReader", "BodyPartReader"]:
270 part = await self.next()
271 if part is None:
272 raise StopAsyncIteration
273 return part
275 def at_eof(self) -> bool:
276 """Returns True when all response data had been read."""
277 return self.resp.content.at_eof()
279 async def next(
280 self,
281 ) -> Union["MultipartReader", "BodyPartReader"] | None:
282 """Emits next multipart reader object."""
283 item = await self.stream.next()
284 if self.stream.at_eof():
285 await self.release()
286 return item
288 async def release(self) -> None:
289 """Release the connection gracefully.
291 All remaining content is read to the void.
292 """
293 self.resp.release()
296class BodyPartReader:
297 """Multipart reader for single body part."""
299 chunk_size = 8192
301 def __init__(
302 self,
303 boundary: bytes,
304 headers: HeadersDictProxy,
305 content: StreamReader,
306 *,
307 subtype: str = "mixed",
308 default_charset: str | None = None,
309 max_decompress_size: int = DEFAULT_CHUNK_SIZE,
310 client_max_size: int = sys.maxsize,
311 max_size_error_cls: type[Exception] = ValueError,
312 ) -> None:
313 self.headers = headers
314 self._boundary = boundary
315 self._boundary_len = len(boundary) + 2 # Boundary + \r\n
316 self._content = content
317 self._default_charset = default_charset
318 self._at_eof = False
319 self._is_form_data = subtype == "form-data"
320 # https://datatracker.ietf.org/doc/html/rfc7578#section-4.8
321 length = None if self._is_form_data else self.headers.get(CONTENT_LENGTH, None)
322 if length is not None and not (length.isascii() and length.isdigit()):
323 # Reject sign prefixes, underscores, whitespace and non-ASCII
324 # digits that int() would otherwise accept.
325 # https://www.rfc-editor.org/rfc/rfc9110#section-8.6
326 raise ValueError(f"invalid Content-Length: {length!r}")
327 self._length = int(length) if length is not None else None
328 self._read_bytes = 0
329 self._b64_carry = b""
330 self._unread: deque[bytes] = deque()
331 self._prev_chunk: bytes | None = None
332 self._content_eof = 0
333 self._cache: dict[str, Any] = {}
334 self._max_decompress_size = max_decompress_size
335 self._client_max_size = client_max_size
336 self._max_size_error_cls = max_size_error_cls
338 def __aiter__(self) -> Self:
339 return self
341 async def __anext__(self) -> bytes:
342 part = await self.next()
343 if part is None:
344 raise StopAsyncIteration
345 return part
347 async def next(self) -> bytes | None:
348 item = await self.read()
349 if not item:
350 return None
351 return item
353 async def read(self, *, decode: bool = False) -> bytes:
354 """Reads body part data.
356 decode: Decodes data following by encoding
357 method from Content-Encoding header. If it missed
358 data remains untouched
359 """
360 if self._at_eof:
361 return b""
362 data = bytearray()
363 while not self._at_eof:
364 data.extend(await self.read_chunk(self.chunk_size))
365 if len(data) > self._client_max_size:
366 raise self._max_size_error_cls(self._client_max_size)
367 # https://github.com/python/mypy/issues/17537
368 if decode: # type: ignore[unreachable]
369 decoded_data = bytearray()
370 async for d in self.decode_iter(data):
371 decoded_data.extend(d)
372 if len(decoded_data) > self._client_max_size:
373 raise self._max_size_error_cls(self._client_max_size)
374 return decoded_data
375 return data
377 async def read_chunk(self, size: int = chunk_size) -> bytes:
378 """Reads body part content chunk of the specified size.
380 size: chunk size
381 """
382 if self._at_eof:
383 return b""
384 carry = self._b64_carry
385 want = size - len(carry)
386 if carry:
387 self._b64_carry = b""
388 want = max(want, self._boundary_len)
389 if self._length:
390 fresh = await self._read_chunk_from_length(want)
391 else:
392 fresh = await self._read_chunk_from_stream(want)
393 chunk = carry + fresh
394 self._read_bytes += len(fresh)
396 # base64 decodes in quartets and every chunk is decoded on its own, so
397 # a chunk should not end mid-quartet.
398 encoding = self.headers.get(CONTENT_TRANSFER_ENCODING)
399 if encoding and encoding.lower() == "base64":
400 chunk = self._align_base64_chunk(chunk, len(carry) + want)
402 if self._read_bytes == self._length:
403 self._at_eof = True
404 if self._at_eof and await self._content.readline() != b"\r\n":
405 raise ValueError("Reader did not read all the data or it is malformed")
406 return chunk
408 def _align_base64_chunk(self, chunk: bytes, size: int) -> bytes:
409 at_end = self._at_eof or (
410 self._length is not None and self._read_bytes >= self._length
411 )
412 if not at_end and len(chunk) > size:
413 self._b64_carry = chunk[size:]
414 chunk = chunk[:size]
416 remainder = len(chunk.translate(None, _NON_BASE64_BYTES)) % 4
417 if not remainder or at_end:
418 return chunk
420 # Walk back over the trailing partial quartet and carry it into the
421 # next chunk.
422 cut = len(chunk)
423 left = remainder
424 while left:
425 cut -= 1
426 if chunk[cut] in _BASE64_CHARS:
427 left -= 1
428 if not cut:
429 # No whole quartet to hand back, and carrying the lot would make
430 # no progress: the caller asked for this many bytes, and a part
431 # that holds no quartet within them holds none to give.
432 return chunk
434 self._b64_carry = chunk[cut:] + self._b64_carry
435 return chunk[:cut]
437 async def _read_chunk_from_length(self, size: int) -> bytes:
438 # Reads body part content chunk of the specified size.
439 # The body part must has Content-Length header with proper value.
440 assert self._length is not None, "Content-Length required for chunked read"
441 chunk_size = min(size, self._length - self._read_bytes)
442 chunk = await self._content.read(chunk_size)
443 if self._content.at_eof():
444 self._at_eof = True
445 return chunk
447 async def _read_chunk_from_stream(self, size: int) -> bytes:
448 # Reads content chunk of body part with unknown length.
449 # The Content-Length header for body part is not necessary.
450 assert (
451 size >= self._boundary_len
452 ), "Chunk size must be greater or equal than boundary length + 2"
453 first_chunk = self._prev_chunk is None
454 if first_chunk:
455 # We need to re-add the CRLF that got removed from headers parsing.
456 self._prev_chunk = b"\r\n" + await self._content.read(size)
458 chunk = b""
459 # content.read() may return less than size, so we need to loop to ensure
460 # we have enough data to detect the boundary.
461 while len(chunk) < self._boundary_len:
462 chunk += await self._content.read(size)
463 self._content_eof += int(self._content.at_eof())
464 if self._content_eof > 2:
465 raise ValueError("Reading after EOF")
466 if self._content_eof:
467 break
468 if len(chunk) > size:
469 self._content.unread_data(chunk[size:])
470 chunk = chunk[:size]
472 assert self._prev_chunk is not None
473 window = self._prev_chunk + chunk
474 sub = b"\r\n" + self._boundary
475 if first_chunk:
476 idx = window.find(sub)
477 else:
478 idx = window.find(sub, max(0, len(self._prev_chunk) - len(sub)))
479 if idx >= 0:
480 # pushing boundary back to content
481 with warnings.catch_warnings():
482 warnings.filterwarnings("ignore", category=DeprecationWarning)
483 self._content.unread_data(window[idx:])
484 self._prev_chunk = self._prev_chunk[:idx]
485 chunk = window[len(self._prev_chunk) : idx]
486 if not chunk:
487 self._at_eof = True
488 result = self._prev_chunk[2 if first_chunk else 0 :] # Strip initial CRLF
489 self._prev_chunk = chunk
490 return result
492 async def readline(self) -> bytes:
493 """Reads body part by line by line."""
494 if self._at_eof:
495 return b""
497 if self._unread:
498 line = self._unread.popleft()
499 else:
500 line = await self._content.readline()
502 if line.startswith(self._boundary):
503 # the very last boundary may not come with \r\n,
504 # so set single rules for everyone
505 sline = line.rstrip(b"\r\n")
506 boundary = self._boundary
507 last_boundary = self._boundary + b"--"
508 # ensure that we read exactly the boundary, not something alike
509 if sline == boundary or sline == last_boundary:
510 self._at_eof = True
511 self._unread.append(line)
512 return b""
513 else:
514 next_line = await self._content.readline()
515 if next_line.startswith(self._boundary):
516 line = line[:-2] # strip CRLF but only once
517 self._unread.append(next_line)
519 return line
521 async def release(self) -> None:
522 """Like read(), but reads all the data to the void."""
523 if self._at_eof:
524 return
525 while not self._at_eof:
526 await self.read_chunk(self.chunk_size)
528 async def text(self, *, encoding: str | None = None) -> str:
529 """Like read(), but assumes that body part contains text data."""
530 data = await self.read(decode=True)
531 # see https://www.w3.org/TR/html5/forms.html#multipart/form-data-encoding-algorithm
532 # and https://dvcs.w3.org/hg/xhr/raw-file/tip/Overview.html#dom-xmlhttprequest-send
533 encoding = encoding or self.get_charset(default="utf-8")
534 return data.decode(encoding)
536 async def json(self, *, encoding: str | None = None) -> dict[str, Any] | None:
537 """Like read(), but assumes that body parts contains JSON data."""
538 data = await self.read(decode=True)
539 if not data:
540 return None
541 encoding = encoding or self.get_charset(default="utf-8")
542 return cast(dict[str, Any], json.loads(data.decode(encoding)))
544 async def form(self, *, encoding: str | None = None) -> list[tuple[str, str]]:
545 """Like read(), but assumes that body parts contain form urlencoded data."""
546 data = await self.read(decode=True)
547 if not data:
548 return []
549 if encoding is not None:
550 real_encoding = encoding
551 else:
552 real_encoding = self.get_charset(default="utf-8")
553 try:
554 decoded_data = data.rstrip().decode(real_encoding)
555 except UnicodeDecodeError:
556 raise ValueError("data cannot be decoded with %s encoding" % real_encoding)
558 return parse_qsl(
559 decoded_data,
560 keep_blank_values=True,
561 encoding=real_encoding,
562 )
564 def at_eof(self) -> bool:
565 """Returns True if the boundary was reached or False otherwise."""
566 return self._at_eof
568 def _apply_content_transfer_decoding(self, data: bytes) -> bytes:
569 """Apply Content-Transfer-Encoding decoding if header is present."""
570 if CONTENT_TRANSFER_ENCODING in self.headers:
571 return self._decode_content_transfer(data)
572 return data
574 def _needs_content_decoding(self) -> bool:
575 """Check if Content-Encoding decoding should be applied."""
576 # https://datatracker.ietf.org/doc/html/rfc7578#section-4.8
577 return not self._is_form_data and CONTENT_ENCODING in self.headers
579 def decode(self, data: bytes) -> bytes:
580 """Decodes data synchronously.
582 Decodes data according the specified Content-Encoding
583 or Content-Transfer-Encoding headers value.
585 Note: For large payloads, consider using decode_iter() instead
586 to avoid blocking the event loop during decompression.
587 """
588 data = self._apply_content_transfer_decoding(data)
589 if self._needs_content_decoding():
590 return self._decode_content(data)
591 return data
593 async def decode_iter(self, data: bytes) -> AsyncIterator[bytes]:
594 """Async generator that yields decoded data chunks.
596 Decodes data according the specified Content-Encoding
597 or Content-Transfer-Encoding headers value.
599 This method offloads decompression to an executor for large payloads
600 to avoid blocking the event loop.
601 """
602 data = self._apply_content_transfer_decoding(data)
603 if self._needs_content_decoding():
604 async for d in self._decode_content_async(data):
605 yield d
606 else:
607 yield data
609 def _decode_content(self, data: bytes) -> bytes:
610 encoding = self.headers.get(CONTENT_ENCODING, "").lower()
611 if encoding == "identity":
612 return data
613 if encoding in {"deflate", "gzip"}:
614 return ZLibDecompressor(
615 encoding=encoding,
616 suppress_deflate_header=True,
617 ).decompress_sync(data, max_length=self._max_decompress_size)
619 raise RuntimeError(f"unknown content encoding: {encoding}")
621 async def _decode_content_async(self, data: bytes) -> AsyncIterator[bytes]:
622 encoding = self.headers.get(CONTENT_ENCODING, "").lower()
623 if encoding == "identity":
624 yield data
625 elif encoding in {"deflate", "gzip"}:
626 d = ZLibDecompressor(
627 encoding=encoding,
628 suppress_deflate_header=True,
629 )
630 yield await d.decompress(data, max_length=self._max_decompress_size)
631 while d.data_available:
632 yield await d.decompress(b"", max_length=self._max_decompress_size)
633 else:
634 raise RuntimeError(f"unknown content encoding: {encoding}")
636 def _decode_content_transfer(self, data: bytes) -> bytes:
637 encoding = self.headers.get(CONTENT_TRANSFER_ENCODING, "").lower()
639 if encoding == "base64":
640 return base64.b64decode(data)
641 elif encoding == "quoted-printable":
642 return binascii.a2b_qp(data)
643 elif encoding in ("binary", "8bit", "7bit"):
644 return data
645 else:
646 raise RuntimeError(f"unknown content transfer encoding: {encoding}")
648 def get_charset(self, default: str) -> str:
649 """Returns charset parameter from Content-Type header or default."""
650 ctype = self.headers.get(CONTENT_TYPE, "")
651 mimetype = parse_mimetype(ctype)
652 return mimetype.parameters.get("charset", self._default_charset or default)
654 @reify
655 def name(self) -> str | None:
656 """Returns name specified in Content-Disposition header.
658 If the header is missing or malformed, returns None.
659 """
660 _, params = parse_content_disposition(self.headers.get(CONTENT_DISPOSITION))
661 return content_disposition_filename(params, "name")
663 @reify
664 def filename(self) -> str | None:
665 """Returns filename specified in Content-Disposition header.
667 Returns None if the header is missing or malformed.
668 """
669 _, params = parse_content_disposition(self.headers.get(CONTENT_DISPOSITION))
670 return content_disposition_filename(params, "filename")
673@payload_type(BodyPartReader, order=Order.try_first)
674class BodyPartReaderPayload(Payload):
675 _value: BodyPartReader
676 # _autoclose = False (inherited) - Streaming reader that may have resources
678 def __init__(self, value: BodyPartReader, *args: Any, **kwargs: Any) -> None:
679 super().__init__(value, *args, **kwargs)
681 params: dict[str, str] = {}
682 if value.name is not None:
683 params["name"] = value.name
684 if value.filename is not None:
685 params["filename"] = value.filename
687 if params:
688 self.set_content_disposition("attachment", True, **params)
690 def decode(self, encoding: str = "utf-8", errors: str = "strict") -> str:
691 raise TypeError("Unable to decode.")
693 async def as_bytes(self, encoding: str = "utf-8", errors: str = "strict") -> bytes:
694 """Raises TypeError as body parts should be consumed via write().
696 This is intentional: BodyPartReader payloads are designed for streaming
697 large data (potentially gigabytes) and must be consumed only once via
698 the write() method to avoid memory exhaustion. They cannot be buffered
699 in memory for reuse.
700 """
701 raise TypeError("Unable to read body part as bytes. Use write() to consume.")
703 async def write(self, writer: AbstractStreamWriter) -> None:
704 field = self._value
705 # Reading the part drains the underlying stream irreversibly, so mark the
706 # payload consumed up front: even an interrupted write leaves nothing that
707 # a retry or redirect could replay.
708 self._consumed = True
709 while chunk := await field.read_chunk(size=DEFAULT_CHUNK_SIZE):
710 async for d in field.decode_iter(chunk):
711 await writer.write(d)
714class MultipartReader:
715 """Multipart body reader."""
717 #: Response wrapper, used when multipart readers constructs from response.
718 response_wrapper_cls = MultipartResponseWrapper
719 #: Multipart reader class, used to handle multipart/* body parts.
720 #: None points to type(self)
721 multipart_reader_cls: type["MultipartReader"] | None = None
722 #: Body part reader class for non multipart/* content types.
723 part_reader_cls = BodyPartReader
725 def __init__(
726 self,
727 headers: Mapping[str, str],
728 content: StreamReader,
729 *,
730 client_max_size: int = sys.maxsize,
731 max_field_size: int = 8190,
732 max_headers: int = 128,
733 max_size_error_cls: type[Exception] = ValueError,
734 ) -> None:
735 self._mimetype = parse_mimetype(headers[CONTENT_TYPE])
736 assert self._mimetype.type == "multipart", "multipart/* content type expected"
737 if "boundary" not in self._mimetype.parameters:
738 raise ValueError(
739 "boundary missed for Content-Type: %s" % headers[CONTENT_TYPE]
740 )
742 self.headers = headers
743 self._boundary = ("--" + self._get_boundary()).encode()
744 self._client_max_size = client_max_size
745 self._content = content
746 self._default_charset: str | None = None
747 self._last_part: MultipartReader | BodyPartReader | None = None
748 self._max_field_size = max_field_size
749 self._max_headers = max_headers
750 self._max_size_error_cls = max_size_error_cls
751 self._at_eof = False
752 self._at_bof = True
753 self._unread: list[bytes] = []
755 def __aiter__(self) -> Self:
756 return self
758 async def __anext__(
759 self,
760 ) -> Union["MultipartReader", BodyPartReader] | None:
761 part = await self.next()
762 if part is None:
763 raise StopAsyncIteration
764 return part
766 @classmethod
767 def from_response(
768 cls,
769 response: "ClientResponse",
770 ) -> MultipartResponseWrapper:
771 """Constructs reader instance from HTTP response.
773 :param response: :class:`~aiohttp.client.ClientResponse` instance
774 """
775 obj = cls.response_wrapper_cls(
776 response, cls(response.headers, response.content)
777 )
778 return obj
780 def at_eof(self) -> bool:
781 """Returns True if the final boundary was reached, false otherwise."""
782 return self._at_eof
784 async def next(
785 self,
786 ) -> Union["MultipartReader", BodyPartReader] | None:
787 """Emits the next multipart body part."""
788 # So, if we're at BOF, we need to skip till the boundary.
789 if self._at_eof:
790 return None
791 await self._maybe_release_last_part()
792 if self._at_bof:
793 await self._read_until_first_boundary()
794 self._at_bof = False
795 else:
796 await self._read_boundary()
797 if self._at_eof: # we just read the last boundary, nothing to do there
798 # https://github.com/python/mypy/issues/17537
799 return None # type: ignore[unreachable]
801 part = await self.fetch_next_part()
802 # https://datatracker.ietf.org/doc/html/rfc7578#section-4.6
803 if (
804 self._last_part is None
805 and self._mimetype.subtype == "form-data"
806 and isinstance(part, BodyPartReader)
807 ):
808 _, params = parse_content_disposition(part.headers.get(CONTENT_DISPOSITION))
809 if params.get("name") == "_charset_":
810 # Longest encoding in https://encoding.spec.whatwg.org/encodings.json
811 # is 19 characters, so 32 should be more than enough for any valid encoding.
812 charset = await part.read_chunk(32)
813 if len(charset) > 31:
814 raise RuntimeError("Invalid default charset")
815 self._default_charset = charset.strip().decode()
816 part = await self.fetch_next_part()
817 self._last_part = part
818 return self._last_part
820 async def release(self) -> None:
821 """Reads all the body parts to the void till the final boundary."""
822 while not self._at_eof:
823 item = await self.next()
824 if item is None:
825 break
826 await item.release()
828 async def fetch_next_part(
829 self,
830 ) -> Union["MultipartReader", BodyPartReader]:
831 """Returns the next body part reader."""
832 headers = await self._read_headers()
833 return self._get_part_reader(headers)
835 def _get_part_reader(
836 self,
837 headers: HeadersDictProxy,
838 ) -> Union["MultipartReader", BodyPartReader]:
839 """Dispatches the response by the `Content-Type` header.
841 Returns a suitable reader instance.
843 :param dict headers: Response headers
844 """
845 ctype = headers.get(CONTENT_TYPE, "")
846 mimetype = parse_mimetype(ctype)
848 if mimetype.type == "multipart":
849 if self.multipart_reader_cls is None:
850 return type(self)(
851 headers,
852 self._content,
853 client_max_size=self._client_max_size,
854 max_field_size=self._max_field_size,
855 max_headers=self._max_headers,
856 max_size_error_cls=self._max_size_error_cls,
857 )
858 return self.multipart_reader_cls(
859 headers,
860 self._content,
861 client_max_size=self._client_max_size,
862 max_field_size=self._max_field_size,
863 max_headers=self._max_headers,
864 max_size_error_cls=self._max_size_error_cls,
865 )
866 else:
867 return self.part_reader_cls(
868 self._boundary,
869 headers,
870 self._content,
871 subtype=self._mimetype.subtype,
872 default_charset=self._default_charset,
873 client_max_size=self._client_max_size,
874 max_size_error_cls=self._max_size_error_cls,
875 )
877 def _get_boundary(self) -> str:
878 boundary = self._mimetype.parameters["boundary"]
879 if len(boundary) > 70:
880 raise ValueError("boundary %r is too long (70 chars max)" % boundary)
882 return boundary
884 async def _readline(self) -> bytes:
885 if self._unread:
886 return self._unread.pop()
887 return await self._content.readline()
889 async def _read_until_first_boundary(self) -> None:
890 while True:
891 chunk = await self._readline()
892 if chunk == b"":
893 raise ValueError(f"Could not find starting boundary {self._boundary!r}")
894 chunk = chunk.rstrip()
895 if chunk == self._boundary:
896 return
897 elif chunk == self._boundary + b"--":
898 self._at_eof = True
899 return
901 async def _read_boundary(self) -> None:
902 chunk = (await self._readline()).rstrip()
903 if chunk == self._boundary:
904 pass
905 elif chunk == self._boundary + b"--":
906 self._at_eof = True
907 epilogue = await self._readline()
908 next_line = await self._readline()
910 # the epilogue is expected and then either the end of input or the
911 # parent multipart boundary, if the parent boundary is found then
912 # it should be marked as unread and handed to the parent for
913 # processing
914 if next_line[:2] == b"--":
915 self._unread.append(next_line)
916 # otherwise the request is likely missing an epilogue and both
917 # lines should be passed to the parent for processing
918 # (this handles the old behavior gracefully)
919 else:
920 self._unread.extend([next_line, epilogue])
921 else:
922 raise ValueError(f"Invalid boundary {chunk!r}, expected {self._boundary!r}")
924 async def _read_headers(self) -> HeadersDictProxy:
925 lines = []
926 while True:
927 chunk = await self._content.readline(max_line_length=self._max_field_size)
928 chunk = chunk.rstrip(b"\r\n")
929 lines.append(chunk)
930 if not chunk:
931 break
932 if len(lines) > self._max_headers:
933 raise BadHttpMessage("Too many headers received")
934 parser = HeadersParser(max_field_size=self._max_field_size)
935 headers, _ = parser.parse_headers(lines)
936 return headers
938 async def _maybe_release_last_part(self) -> None:
939 """Ensures that the last read body part is read completely."""
940 if self._last_part is not None:
941 if not self._last_part.at_eof():
942 await self._last_part.release()
943 self._unread.extend(self._last_part._unread)
944 self._last_part = None
947_Part = tuple[Payload, str, str]
950class MultipartWriter(Payload):
951 """Multipart body writer."""
953 _value: None
954 # _consumed = False (inherited) - Can be encoded multiple times
955 _autoclose = True # No file handles, just collects parts in memory
957 def __init__(self, subtype: str = "mixed", boundary: str | None = None) -> None:
958 boundary = boundary if boundary is not None else uuid.uuid4().hex
959 # The underlying Payload API demands a str (utf-8), not bytes,
960 # so we need to ensure we don't lose anything during conversion.
961 # As a result, require the boundary to be ASCII only.
962 # In both situations.
964 try:
965 self._boundary = boundary.encode("ascii")
966 except UnicodeEncodeError:
967 raise ValueError("boundary should contain ASCII only chars") from None
969 if len(boundary) > 70:
970 raise ValueError("boundary %r is too long (70 chars max)" % boundary)
972 ctype = f"multipart/{subtype}; boundary={self._boundary_value}"
974 super().__init__(None, content_type=ctype)
976 self._parts: list[_Part] = []
977 self._is_form_data = subtype == "form-data"
979 def __enter__(self) -> "MultipartWriter":
980 return self
982 def __exit__(
983 self,
984 exc_type: type[BaseException] | None,
985 exc_val: BaseException | None,
986 exc_tb: TracebackType | None,
987 ) -> None:
988 pass
990 @property
991 def consumed(self) -> bool:
992 """Whether the writer or any of its parts can no longer be replayed."""
993 return self._consumed or any(part.consumed for part, _, _ in self._parts)
995 def __iter__(self) -> Iterator[_Part]:
996 return iter(self._parts)
998 def __len__(self) -> int:
999 return len(self._parts)
1001 def __bool__(self) -> bool:
1002 return True
1004 _valid_tchar_regex = re.compile(rb"\A[!#$%&'*+\-.^_`|~\w]+\Z")
1005 _invalid_qdtext_char_regex = re.compile(rb"[\x00-\x08\x0A-\x1F\x7F]")
1007 @property
1008 def _boundary_value(self) -> str:
1009 """Wrap boundary parameter value in quotes, if necessary.
1011 Reads self.boundary and returns a unicode string.
1012 """
1013 # Refer to RFCs 7231, 7230, 5234.
1014 #
1015 # parameter = token "=" ( token / quoted-string )
1016 # token = 1*tchar
1017 # quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE
1018 # qdtext = HTAB / SP / %x21 / %x23-5B / %x5D-7E / obs-text
1019 # obs-text = %x80-FF
1020 # quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text )
1021 # tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*"
1022 # / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~"
1023 # / DIGIT / ALPHA
1024 # ; any VCHAR, except delimiters
1025 # VCHAR = %x21-7E
1026 value = self._boundary
1027 if re.match(self._valid_tchar_regex, value):
1028 return value.decode("ascii") # cannot fail
1030 if re.search(self._invalid_qdtext_char_regex, value):
1031 raise ValueError("boundary value contains invalid characters")
1033 # escape %x5C and %x22
1034 quoted_value_content = value.replace(b"\\", b"\\\\")
1035 quoted_value_content = quoted_value_content.replace(b'"', b'\\"')
1037 return '"' + quoted_value_content.decode("ascii") + '"'
1039 @property
1040 def boundary(self) -> str:
1041 return self._boundary.decode("ascii")
1043 def append(self, obj: Any, headers: Mapping[str, str] | None = None) -> Payload:
1044 if headers is None:
1045 headers = CIMultiDict()
1047 if isinstance(obj, Payload):
1048 obj.headers.update(headers)
1049 return self.append_payload(obj)
1050 else:
1051 try:
1052 payload = get_payload(obj, headers=headers)
1053 except LookupError:
1054 raise TypeError("Cannot create payload from %r" % obj)
1055 else:
1056 return self.append_payload(payload)
1058 def append_payload(self, payload: Payload) -> Payload:
1059 """Adds a new body part to multipart writer."""
1060 encoding: str | None = None
1061 te_encoding: str | None = None
1062 if self._is_form_data:
1063 # https://datatracker.ietf.org/doc/html/rfc7578#section-4.7
1064 # https://datatracker.ietf.org/doc/html/rfc7578#section-4.8
1065 assert (
1066 not {CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TRANSFER_ENCODING}
1067 & payload.headers.keys()
1068 )
1069 # Set default Content-Disposition in case user doesn't create one
1070 if CONTENT_DISPOSITION not in payload.headers:
1071 name = f"section-{len(self._parts)}"
1072 payload.set_content_disposition("form-data", name=name)
1073 else:
1074 # compression
1075 encoding = payload.headers.get(CONTENT_ENCODING, "").lower()
1076 if encoding and encoding not in ("deflate", "gzip", "identity"):
1077 raise RuntimeError(f"unknown content encoding: {encoding}")
1078 if encoding == "identity":
1079 encoding = None
1081 # te encoding
1082 te_encoding = payload.headers.get(CONTENT_TRANSFER_ENCODING, "").lower()
1083 if te_encoding not in ("", "base64", "quoted-printable", "binary"):
1084 raise RuntimeError(f"unknown content transfer encoding: {te_encoding}")
1085 if te_encoding == "binary":
1086 te_encoding = None
1088 # size
1089 size = payload.size
1090 if size is not None and not (encoding or te_encoding):
1091 payload.headers[CONTENT_LENGTH] = str(size)
1093 self._parts.append((payload, encoding, te_encoding)) # type: ignore[arg-type]
1094 return payload
1096 def append_json(
1097 self, obj: Any, headers: Mapping[str, str] | None = None
1098 ) -> Payload:
1099 """Helper to append JSON part."""
1100 if headers is None:
1101 headers = CIMultiDict()
1103 return self.append_payload(JsonPayload(obj, headers=headers))
1105 def append_form(
1106 self,
1107 obj: Sequence[tuple[str, str]] | Mapping[str, str],
1108 headers: Mapping[str, str] | None = None,
1109 ) -> Payload:
1110 """Helper to append form urlencoded part."""
1111 assert isinstance(obj, (Sequence, Mapping))
1113 if headers is None:
1114 headers = CIMultiDict()
1116 if isinstance(obj, Mapping):
1117 obj = list(obj.items())
1118 data = urlencode(obj, doseq=True)
1120 return self.append_payload(
1121 StringPayload(
1122 data, headers=headers, content_type="application/x-www-form-urlencoded"
1123 )
1124 )
1126 @property
1127 def size(self) -> int | None:
1128 """Size of the payload."""
1129 total = 0
1130 for part, encoding, te_encoding in self._parts:
1131 part_size = part.size
1132 if encoding or te_encoding or part_size is None:
1133 return None
1135 total += int(
1136 2
1137 + len(self._boundary)
1138 + 2
1139 + part_size # b'--'+self._boundary+b'\r\n'
1140 + len(part._binary_headers)
1141 + 2 # b'\r\n'
1142 )
1144 total += 2 + len(self._boundary) + 4 # b'--'+self._boundary+b'--\r\n'
1145 return total
1147 def decode(self, encoding: str = "utf-8", errors: str = "strict") -> str:
1148 """Return string representation of the multipart data.
1150 WARNING: This method may do blocking I/O if parts contain file payloads.
1151 It should not be called in the event loop. Use as_bytes().decode() instead.
1152 """
1153 return "".join(
1154 "--"
1155 + self.boundary
1156 + "\r\n"
1157 + part._binary_headers.decode(encoding, errors)
1158 + part.decode()
1159 for part, _e, _te in self._parts
1160 )
1162 async def as_bytes(self, encoding: str = "utf-8", errors: str = "strict") -> bytes:
1163 """Return bytes representation of the multipart data.
1165 This method is async-safe and calls as_bytes on underlying payloads.
1166 """
1167 parts: list[bytes] = []
1169 # Process each part
1170 for part, _e, _te in self._parts:
1171 # Add boundary
1172 parts.append(b"--" + self._boundary + b"\r\n")
1174 # Add headers
1175 parts.append(part._binary_headers)
1177 # Add payload content using as_bytes for async safety
1178 part_bytes = await part.as_bytes(encoding, errors)
1179 parts.append(part_bytes)
1181 # Add trailing CRLF
1182 parts.append(b"\r\n")
1184 # Add closing boundary
1185 parts.append(b"--" + self._boundary + b"--\r\n")
1187 return b"".join(parts)
1189 async def write(
1190 self, writer: AbstractStreamWriter, close_boundary: bool = True
1191 ) -> None:
1192 """Write body."""
1193 for part, encoding, te_encoding in self._parts:
1194 if self._is_form_data:
1195 # https://datatracker.ietf.org/doc/html/rfc7578#section-4.2
1196 assert CONTENT_DISPOSITION in part.headers
1197 assert "name=" in part.headers[CONTENT_DISPOSITION]
1199 await writer.write(b"--" + self._boundary + b"\r\n")
1200 await writer.write(part._binary_headers)
1202 if encoding or te_encoding:
1203 w = MultipartPayloadWriter(writer)
1204 if encoding:
1205 w.enable_compression(encoding)
1206 if te_encoding:
1207 w.enable_encoding(te_encoding)
1208 await part.write(w) # type: ignore[arg-type]
1209 await w.write_eof()
1210 else:
1211 await part.write(writer)
1213 await writer.write(b"\r\n")
1215 if close_boundary:
1216 await writer.write(b"--" + self._boundary + b"--\r\n")
1218 async def close(self) -> None:
1219 """
1220 Close all part payloads that need explicit closing.
1222 IMPORTANT: This method must not await anything that might not finish
1223 immediately, as it may be called during cleanup/cancellation. Schedule
1224 any long-running operations without awaiting them.
1225 """
1226 if self._consumed:
1227 return
1228 self._consumed = True
1230 # Close all parts that need explicit closing
1231 # We catch and log exceptions to ensure all parts get a chance to close
1232 # we do not use asyncio.gather() here because we are not allowed
1233 # to suspend given we may be called during cleanup
1234 for idx, (part, _, _) in enumerate(self._parts):
1235 if not part.autoclose and not part.consumed:
1236 try:
1237 await part.close()
1238 except Exception as exc:
1239 internal_logger.error(
1240 "Failed to close multipart part %d: %s", idx, exc, exc_info=True
1241 )
1244class MultipartPayloadWriter:
1245 def __init__(self, writer: AbstractStreamWriter) -> None:
1246 self._writer = writer
1247 self._encoding: str | None = None
1248 self._compress: ZLibCompressor | None = None
1249 self._encoding_buffer: bytearray | None = None
1251 def enable_encoding(self, encoding: str) -> None:
1252 if encoding == "base64":
1253 self._encoding = encoding
1254 self._encoding_buffer = bytearray()
1255 elif encoding == "quoted-printable":
1256 self._encoding = "quoted-printable"
1258 def enable_compression(
1259 self, encoding: str = "deflate", strategy: int | None = None
1260 ) -> None:
1261 self._compress = ZLibCompressor(
1262 encoding=encoding,
1263 suppress_deflate_header=True,
1264 strategy=strategy,
1265 )
1267 async def write_eof(self) -> None:
1268 if self._compress is not None:
1269 chunk = self._compress.flush()
1270 if chunk:
1271 self._compress = None
1272 await self.write(chunk)
1274 if self._encoding == "base64":
1275 if self._encoding_buffer:
1276 await self._writer.write(base64.b64encode(self._encoding_buffer))
1278 async def write(self, chunk: bytes) -> None:
1279 if self._compress is not None:
1280 if chunk:
1281 chunk = await self._compress.compress(chunk)
1282 if not chunk:
1283 return
1285 if self._encoding == "base64":
1286 buf = self._encoding_buffer
1287 assert buf is not None
1288 buf.extend(chunk)
1290 if buf:
1291 div, mod = divmod(len(buf), 3)
1292 enc_chunk, self._encoding_buffer = (buf[: div * 3], buf[div * 3 :])
1293 if enc_chunk:
1294 b64chunk = base64.b64encode(enc_chunk)
1295 await self._writer.write(b64chunk)
1296 elif self._encoding == "quoted-printable":
1297 await self._writer.write(binascii.b2a_qp(chunk))
1298 else:
1299 await self._writer.write(chunk)