Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/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

706 statements  

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, TypeVar, Union, cast 

13from urllib.parse import parse_qsl, unquote, urlencode 

14 

15from multidict import CIMultiDict, CIMultiDictProxy 

16 

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 CHAR, DEFAULT_CHUNK_SIZE, TOKEN, parse_mimetype, reify 

27from .http import HeadersParser 

28from .http_exceptions import BadHttpMessage 

29from .log import internal_logger 

30from .payload import ( 

31 JsonPayload, 

32 LookupError, 

33 Order, 

34 Payload, 

35 StringPayload, 

36 get_payload, 

37 payload_type, 

38) 

39from .streams import StreamReader 

40 

41if sys.version_info >= (3, 11): 

42 from typing import Self 

43else: 

44 Self = TypeVar("Self", bound="BodyPartReader") 

45 

46if sys.version_info >= (3, 12): 

47 from collections.abc import Buffer 

48else: 

49 Buffer = Union[bytes, bytearray, "memoryview[int]", "memoryview[bytes]"] 

50 

51_Buffer = TypeVar("_Buffer", bound=Buffer) 

52 

53__all__ = ( 

54 "MultipartReader", 

55 "MultipartWriter", 

56 "BodyPartReader", 

57 "BadContentDispositionHeader", 

58 "BadContentDispositionParam", 

59 "parse_content_disposition", 

60 "content_disposition_filename", 

61) 

62 

63 

64if TYPE_CHECKING: 

65 from .client_reqrep import ClientResponse 

66 

67 

68class BadContentDispositionHeader(RuntimeWarning): 

69 pass 

70 

71 

72class BadContentDispositionParam(RuntimeWarning): 

73 pass 

74 

75 

76def parse_content_disposition( 

77 header: str | None, 

78) -> tuple[str | None, dict[str, str]]: 

79 def is_token(string: str) -> bool: 

80 return bool(string) and TOKEN >= set(string) 

81 

82 def is_quoted(string: str) -> bool: 

83 return len(string) >= 2 and string[0] == string[-1] == '"' 

84 

85 def is_rfc5987(string: str) -> bool: 

86 return is_token(string) and string.count("'") == 2 

87 

88 def is_extended_param(string: str) -> bool: 

89 return string.endswith("*") 

90 

91 def is_continuous_param(string: str) -> bool: 

92 pos = string.find("*") + 1 

93 if not pos: 

94 return False 

95 substring = string[pos:-1] if string.endswith("*") else string[pos:] 

96 return substring.isdigit() 

97 

98 def unescape(text: str, *, chars: str = "".join(map(re.escape, CHAR))) -> str: 

99 return re.sub(f"\\\\([{chars}])", "\\1", text) 

100 

101 if not header: 

102 return None, {} 

103 

104 # https://www.rfc-editor.org/info/rfc9110/#section-5.6.6-2 

105 disptype, *parts = header.split(";") 

106 disptype = disptype.strip() 

107 if not is_token(disptype): 

108 warnings.warn(BadContentDispositionHeader(header)) 

109 return None, {} 

110 

111 params: dict[str, str] = {} 

112 while parts: 

113 item = parts.pop(0) 

114 

115 if not item: # To handle trailing semicolons 

116 warnings.warn(BadContentDispositionHeader(header)) 

117 continue 

118 

119 if "=" not in item: 

120 warnings.warn(BadContentDispositionHeader(header)) 

121 return None, {} 

122 

123 key, value = item.split("=", 1) 

124 key = key.lower().strip() 

125 value = value.lstrip() 

126 

127 if key in params: 

128 warnings.warn(BadContentDispositionHeader(header)) 

129 return None, {} 

130 

131 if not is_token(key): 

132 warnings.warn(BadContentDispositionParam(item)) 

133 continue 

134 

135 elif is_continuous_param(key): 

136 if is_quoted(value): 

137 value = unescape(value[1:-1]) 

138 elif not is_token(value): 

139 warnings.warn(BadContentDispositionParam(item)) 

140 continue 

141 

142 elif is_extended_param(key): 

143 if is_rfc5987(value): 

144 encoding, _, value = value.split("'", 2) 

145 encoding = encoding or "utf-8" 

146 else: 

147 warnings.warn(BadContentDispositionParam(item)) 

148 continue 

149 

150 try: 

151 value = unquote(value, encoding, "strict") 

152 except (builtins.LookupError, UnicodeDecodeError): 

153 # The charset is attacker-controlled here; an unknown name 

154 # raises the builtin LookupError (the bare name is shadowed in 

155 # this module by payload.LookupError). 

156 warnings.warn(BadContentDispositionParam(item)) 

157 continue 

158 

159 else: 

160 failed = True 

161 rstripped = value.rstrip() 

162 if is_quoted(rstripped): 

163 failed = False 

164 value = unescape(rstripped[1:-1].lstrip("\\/")) 

165 elif is_token(value): 

166 failed = False 

167 elif parts: 

168 # maybe just ; in filename, in any case this is just 

169 # one case fix, for proper fix we need to redesign parser 

170 _value = f"{value};{parts[0]}" 

171 if is_quoted(_value): 

172 parts.pop(0) 

173 value = unescape(_value[1:-1].lstrip("\\/")) 

174 failed = False 

175 

176 if failed: 

177 warnings.warn(BadContentDispositionHeader(header)) 

178 return None, {} 

179 

180 params[key] = value 

181 

182 return disptype.lower(), params 

183 

184 

185def content_disposition_filename( 

186 params: Mapping[str, str], name: str = "filename" 

187) -> str | None: 

188 name_suf = "%s*" % name 

189 if not params: 

190 return None 

191 elif name_suf in params: 

192 return params[name_suf] 

193 elif name in params: 

194 return params[name] 

195 else: 

196 parts = [] 

197 fnparams = sorted( 

198 (key, value) for key, value in params.items() if key.startswith(name_suf) 

199 ) 

200 for num, (key, value) in enumerate(fnparams): 

201 _, tail = key.split("*", 1) 

202 if tail.endswith("*"): 

203 tail = tail[:-1] 

204 if tail == str(num): 

205 parts.append(value) 

206 else: 

207 break 

208 if not parts: 

209 return None 

210 value = "".join(parts) 

211 if "'" in value: 

212 encoding, _, value = value.split("'", 2) 

213 encoding = encoding or "utf-8" 

214 try: 

215 return unquote(value, encoding, "strict") 

216 except (builtins.LookupError, UnicodeDecodeError): 

217 # Both the charset name and the octets are attacker-controlled 

218 # here; an unknown encoding raises the builtin LookupError 

219 # (shadowed in this module by payload.LookupError) and 

220 # undecodable bytes raise UnicodeDecodeError. 

221 return None 

222 return value 

223 

224 

225class MultipartResponseWrapper: 

226 """Wrapper around the MultipartReader. 

227 

228 It takes care about 

229 underlying connection and close it when it needs in. 

230 """ 

231 

232 def __init__( 

233 self, 

234 resp: "ClientResponse", 

235 stream: "MultipartReader", 

236 ) -> None: 

237 self.resp = resp 

238 self.stream = stream 

239 

240 def __aiter__(self) -> "MultipartResponseWrapper": 

241 return self 

242 

243 async def __anext__( 

244 self, 

245 ) -> Union["MultipartReader", "BodyPartReader"]: 

246 part = await self.next() 

247 if part is None: 

248 raise StopAsyncIteration 

249 return part 

250 

251 def at_eof(self) -> bool: 

252 """Returns True when all response data had been read.""" 

253 return self.resp.content.at_eof() 

254 

255 async def next( 

256 self, 

257 ) -> Union["MultipartReader", "BodyPartReader"] | None: 

258 """Emits next multipart reader object.""" 

259 item = await self.stream.next() 

260 if self.stream.at_eof(): 

261 await self.release() 

262 return item 

263 

264 async def release(self) -> None: 

265 """Release the connection gracefully. 

266 

267 All remaining content is read to the void. 

268 """ 

269 await self.resp.release() 

270 

271 

272class BodyPartReader: 

273 """Multipart reader for single body part.""" 

274 

275 chunk_size = 8192 

276 

277 def __init__( 

278 self, 

279 boundary: bytes, 

280 headers: "CIMultiDictProxy[str]", 

281 content: StreamReader, 

282 *, 

283 subtype: str = "mixed", 

284 default_charset: str | None = None, 

285 max_decompress_size: int = DEFAULT_CHUNK_SIZE, 

286 client_max_size: int = sys.maxsize, 

287 max_size_error_cls: type[Exception] = ValueError, 

288 ) -> None: 

289 self.headers = headers 

290 self._boundary = boundary 

291 self._boundary_len = len(boundary) + 2 # Boundary + \r\n 

292 self._content = content 

293 self._default_charset = default_charset 

294 self._at_eof = False 

295 self._is_form_data = subtype == "form-data" 

296 # https://datatracker.ietf.org/doc/html/rfc7578#section-4.8 

297 length = None if self._is_form_data else self.headers.get(CONTENT_LENGTH, None) 

298 if length is not None and not (length.isascii() and length.isdigit()): 

299 # Reject sign prefixes, underscores, whitespace and non-ASCII 

300 # digits that int() would otherwise accept. 

301 # https://www.rfc-editor.org/rfc/rfc9110#section-8.6 

302 raise ValueError(f"invalid Content-Length: {length!r}") 

303 self._length = int(length) if length is not None else None 

304 self._read_bytes = 0 

305 self._unread: deque[bytes] = deque() 

306 self._prev_chunk: bytes | None = None 

307 self._content_eof = 0 

308 self._cache: dict[str, Any] = {} 

309 self._max_decompress_size = max_decompress_size 

310 self._client_max_size = client_max_size 

311 self._max_size_error_cls = max_size_error_cls 

312 

313 def __aiter__(self: Self) -> Self: 

314 return self 

315 

316 async def __anext__(self) -> bytes: 

317 part = await self.next() 

318 if part is None: 

319 raise StopAsyncIteration 

320 return part 

321 

322 async def next(self) -> bytes | None: 

323 item = await self.read() 

324 if not item: 

325 return None 

326 return item 

327 

328 async def read(self, *, decode: bool = False) -> bytes: 

329 """Reads body part data. 

330 

331 decode: Decodes data following by encoding 

332 method from Content-Encoding header. If it missed 

333 data remains untouched 

334 """ 

335 if self._at_eof: 

336 return b"" 

337 data = bytearray() 

338 while not self._at_eof: 

339 data.extend(await self.read_chunk(self.chunk_size)) 

340 if len(data) > self._client_max_size: 

341 raise self._max_size_error_cls(self._client_max_size) 

342 if decode: 

343 decoded_data = bytearray() 

344 async for d in self.decode_iter(data): 

345 decoded_data.extend(d) 

346 if len(decoded_data) > self._client_max_size: 

347 raise self._max_size_error_cls(self._client_max_size) 

348 return decoded_data 

349 return data 

350 

351 async def read_chunk(self, size: int = chunk_size) -> bytes: 

352 """Reads body part content chunk of the specified size. 

353 

354 size: chunk size 

355 """ 

356 if self._at_eof: 

357 return b"" 

358 if self._length: 

359 chunk = await self._read_chunk_from_length(size) 

360 else: 

361 chunk = await self._read_chunk_from_stream(size) 

362 

363 # For the case of base64 data, we must read a fragment of size with a 

364 # remainder of 0 by dividing by 4 for string without symbols \n or \r 

365 encoding = self.headers.get(CONTENT_TRANSFER_ENCODING) 

366 if encoding and encoding.lower() == "base64": 

367 stripped_chunk = b"".join(chunk.split()) 

368 remainder = len(stripped_chunk) % 4 

369 

370 while remainder != 0 and not self.at_eof(): 

371 over_chunk_size = 4 - remainder 

372 over_chunk = b"" 

373 

374 if self._prev_chunk: 

375 over_chunk = self._prev_chunk[:over_chunk_size] 

376 self._prev_chunk = self._prev_chunk[len(over_chunk) :] 

377 

378 if len(over_chunk) != over_chunk_size: 

379 over_chunk += await self._content.read(4 - len(over_chunk)) 

380 

381 if not over_chunk: 

382 self._at_eof = True 

383 

384 stripped_chunk += b"".join(over_chunk.split()) 

385 chunk += over_chunk 

386 remainder = len(stripped_chunk) % 4 

387 

388 self._read_bytes += len(chunk) 

389 if self._read_bytes == self._length: 

390 self._at_eof = True 

391 if self._at_eof and await self._content.readline() != b"\r\n": 

392 raise ValueError("Reader did not read all the data or it is malformed") 

393 return chunk 

394 

395 async def _read_chunk_from_length(self, size: int) -> bytes: 

396 # Reads body part content chunk of the specified size. 

397 # The body part must has Content-Length header with proper value. 

398 assert self._length is not None, "Content-Length required for chunked read" 

399 chunk_size = min(size, self._length - self._read_bytes) 

400 chunk = await self._content.read(chunk_size) 

401 if self._content.at_eof(): 

402 self._at_eof = True 

403 return chunk 

404 

405 async def _read_chunk_from_stream(self, size: int) -> bytes: 

406 # Reads content chunk of body part with unknown length. 

407 # The Content-Length header for body part is not necessary. 

408 assert ( 

409 size >= self._boundary_len 

410 ), "Chunk size must be greater or equal than boundary length + 2" 

411 first_chunk = self._prev_chunk is None 

412 if first_chunk: 

413 # We need to re-add the CRLF that got removed from headers parsing. 

414 self._prev_chunk = b"\r\n" + await self._content.read(size) 

415 

416 chunk = b"" 

417 # content.read() may return less than size, so we need to loop to ensure 

418 # we have enough data to detect the boundary. 

419 while len(chunk) < self._boundary_len: 

420 chunk += await self._content.read(size) 

421 self._content_eof += int(self._content.at_eof()) 

422 if self._content_eof > 2: 

423 raise ValueError("Reading after EOF") 

424 if self._content_eof: 

425 break 

426 if len(chunk) > size: 

427 self._content.unread_data(chunk[size:]) 

428 chunk = chunk[:size] 

429 

430 assert self._prev_chunk is not None 

431 window = self._prev_chunk + chunk 

432 sub = b"\r\n" + self._boundary 

433 if first_chunk: 

434 idx = window.find(sub) 

435 else: 

436 idx = window.find(sub, max(0, len(self._prev_chunk) - len(sub))) 

437 if idx >= 0: 

438 # pushing boundary back to content 

439 with warnings.catch_warnings(): 

440 warnings.filterwarnings("ignore", category=DeprecationWarning) 

441 self._content.unread_data(window[idx:]) 

442 self._prev_chunk = self._prev_chunk[:idx] 

443 chunk = window[len(self._prev_chunk) : idx] 

444 if not chunk: 

445 self._at_eof = True 

446 result = self._prev_chunk[2 if first_chunk else 0 :] # Strip initial CRLF 

447 self._prev_chunk = chunk 

448 return result 

449 

450 async def readline(self) -> bytes: 

451 """Reads body part by line by line.""" 

452 if self._at_eof: 

453 return b"" 

454 

455 if self._unread: 

456 line = self._unread.popleft() 

457 else: 

458 line = await self._content.readline() 

459 

460 if line.startswith(self._boundary): 

461 # the very last boundary may not come with \r\n, 

462 # so set single rules for everyone 

463 sline = line.rstrip(b"\r\n") 

464 boundary = self._boundary 

465 last_boundary = self._boundary + b"--" 

466 # ensure that we read exactly the boundary, not something alike 

467 if sline == boundary or sline == last_boundary: 

468 self._at_eof = True 

469 self._unread.append(line) 

470 return b"" 

471 else: 

472 next_line = await self._content.readline() 

473 if next_line.startswith(self._boundary): 

474 line = line[:-2] # strip CRLF but only once 

475 self._unread.append(next_line) 

476 

477 return line 

478 

479 async def release(self) -> None: 

480 """Like read(), but reads all the data to the void.""" 

481 if self._at_eof: 

482 return 

483 while not self._at_eof: 

484 await self.read_chunk(self.chunk_size) 

485 

486 async def text(self, *, encoding: str | None = None) -> str: 

487 """Like read(), but assumes that body part contains text data.""" 

488 data = await self.read(decode=True) 

489 # see https://www.w3.org/TR/html5/forms.html#multipart/form-data-encoding-algorithm 

490 # and https://dvcs.w3.org/hg/xhr/raw-file/tip/Overview.html#dom-xmlhttprequest-send 

491 encoding = encoding or self.get_charset(default="utf-8") 

492 return data.decode(encoding) 

493 

494 async def json(self, *, encoding: str | None = None) -> dict[str, Any] | None: 

495 """Like read(), but assumes that body parts contains JSON data.""" 

496 data = await self.read(decode=True) 

497 if not data: 

498 return None 

499 encoding = encoding or self.get_charset(default="utf-8") 

500 return cast(dict[str, Any], json.loads(data.decode(encoding))) 

501 

502 async def form(self, *, encoding: str | None = None) -> list[tuple[str, str]]: 

503 """Like read(), but assumes that body parts contain form urlencoded data.""" 

504 data = await self.read(decode=True) 

505 if not data: 

506 return [] 

507 if encoding is not None: 

508 real_encoding = encoding 

509 else: 

510 real_encoding = self.get_charset(default="utf-8") 

511 try: 

512 decoded_data = data.rstrip().decode(real_encoding) 

513 except UnicodeDecodeError: 

514 raise ValueError("data cannot be decoded with %s encoding" % real_encoding) 

515 

516 return parse_qsl( 

517 decoded_data, 

518 keep_blank_values=True, 

519 encoding=real_encoding, 

520 ) 

521 

522 def at_eof(self) -> bool: 

523 """Returns True if the boundary was reached or False otherwise.""" 

524 return self._at_eof 

525 

526 def _apply_content_transfer_decoding(self, data: _Buffer) -> _Buffer | bytes: 

527 """Apply Content-Transfer-Encoding decoding if header is present.""" 

528 if CONTENT_TRANSFER_ENCODING in self.headers: 

529 return self._decode_content_transfer(data) 

530 return data 

531 

532 def _needs_content_decoding(self) -> bool: 

533 """Check if Content-Encoding decoding should be applied.""" 

534 # https://datatracker.ietf.org/doc/html/rfc7578#section-4.8 

535 return not self._is_form_data and CONTENT_ENCODING in self.headers 

536 

537 def decode(self, data: _Buffer) -> _Buffer | bytes: 

538 """Decodes data synchronously. 

539 

540 Decodes data according the specified Content-Encoding 

541 or Content-Transfer-Encoding headers value. 

542 

543 Note: For large payloads, consider using decode_iter() instead 

544 to avoid blocking the event loop during decompression. 

545 """ 

546 decoded = self._apply_content_transfer_decoding(data) 

547 if self._needs_content_decoding(): 

548 return self._decode_content(decoded) 

549 return decoded 

550 

551 async def decode_iter(self, data: _Buffer) -> AsyncIterator[_Buffer | bytes]: 

552 """Async generator that yields decoded data chunks. 

553 

554 Decodes data according the specified Content-Encoding 

555 or Content-Transfer-Encoding headers value. 

556 

557 This method offloads decompression to an executor for large payloads 

558 to avoid blocking the event loop. 

559 """ 

560 decoded = self._apply_content_transfer_decoding(data) 

561 if self._needs_content_decoding(): 

562 async for d in self._decode_content_async(decoded): 

563 yield d 

564 else: 

565 yield decoded 

566 

567 def _decode_content(self, data: _Buffer) -> _Buffer | bytes: 

568 encoding = self.headers.get(CONTENT_ENCODING, "").lower() 

569 if encoding == "identity": 

570 return data 

571 if encoding in {"deflate", "gzip"}: 

572 return ZLibDecompressor( 

573 encoding=encoding, 

574 suppress_deflate_header=True, 

575 ).decompress_sync(data, max_length=self._max_decompress_size) 

576 

577 raise RuntimeError(f"unknown content encoding: {encoding}") 

578 

579 async def _decode_content_async( 

580 self, data: _Buffer 

581 ) -> AsyncIterator[_Buffer | bytes]: 

582 encoding = self.headers.get(CONTENT_ENCODING, "").lower() 

583 if encoding == "identity": 

584 yield data 

585 elif encoding in {"deflate", "gzip"}: 

586 d = ZLibDecompressor( 

587 encoding=encoding, 

588 suppress_deflate_header=True, 

589 ) 

590 yield await d.decompress(data, max_length=self._max_decompress_size) 

591 while d.data_available: 

592 yield await d.decompress(b"", max_length=self._max_decompress_size) 

593 else: 

594 raise RuntimeError(f"unknown content encoding: {encoding}") 

595 

596 def _decode_content_transfer(self, data: _Buffer) -> _Buffer | bytes: 

597 encoding = self.headers.get(CONTENT_TRANSFER_ENCODING, "").lower() 

598 

599 if encoding == "base64": 

600 return base64.b64decode(data) 

601 elif encoding == "quoted-printable": 

602 return binascii.a2b_qp(data) 

603 elif encoding in ("binary", "8bit", "7bit"): 

604 return data 

605 else: 

606 raise RuntimeError(f"unknown content transfer encoding: {encoding}") 

607 

608 def get_charset(self, default: str) -> str: 

609 """Returns charset parameter from Content-Type header or default.""" 

610 ctype = self.headers.get(CONTENT_TYPE, "") 

611 mimetype = parse_mimetype(ctype) 

612 return mimetype.parameters.get("charset", self._default_charset or default) 

613 

614 @reify 

615 def name(self) -> str | None: 

616 """Returns name specified in Content-Disposition header. 

617 

618 If the header is missing or malformed, returns None. 

619 """ 

620 _, params = parse_content_disposition(self.headers.get(CONTENT_DISPOSITION)) 

621 return content_disposition_filename(params, "name") 

622 

623 @reify 

624 def filename(self) -> str | None: 

625 """Returns filename specified in Content-Disposition header. 

626 

627 Returns None if the header is missing or malformed. 

628 """ 

629 _, params = parse_content_disposition(self.headers.get(CONTENT_DISPOSITION)) 

630 return content_disposition_filename(params, "filename") 

631 

632 

633@payload_type(BodyPartReader, order=Order.try_first) 

634class BodyPartReaderPayload(Payload): 

635 _value: BodyPartReader 

636 # _autoclose = False (inherited) - Streaming reader that may have resources 

637 

638 def __init__(self, value: BodyPartReader, *args: Any, **kwargs: Any) -> None: 

639 super().__init__(value, *args, **kwargs) 

640 

641 params: dict[str, str] = {} 

642 if value.name is not None: 

643 params["name"] = value.name 

644 if value.filename is not None: 

645 params["filename"] = value.filename 

646 

647 if params: 

648 self.set_content_disposition("attachment", True, **params) 

649 

650 def decode(self, encoding: str = "utf-8", errors: str = "strict") -> str: 

651 raise TypeError("Unable to decode.") 

652 

653 async def as_bytes(self, encoding: str = "utf-8", errors: str = "strict") -> bytes: 

654 """Raises TypeError as body parts should be consumed via write(). 

655 

656 This is intentional: BodyPartReader payloads are designed for streaming 

657 large data (potentially gigabytes) and must be consumed only once via 

658 the write() method to avoid memory exhaustion. They cannot be buffered 

659 in memory for reuse. 

660 """ 

661 raise TypeError("Unable to read body part as bytes. Use write() to consume.") 

662 

663 async def write(self, writer: AbstractStreamWriter) -> None: 

664 field = self._value 

665 while chunk := await field.read_chunk(size=DEFAULT_CHUNK_SIZE): 

666 async for d in field.decode_iter(chunk): 

667 await writer.write(d) 

668 

669 

670class MultipartReader: 

671 """Multipart body reader.""" 

672 

673 #: Response wrapper, used when multipart readers constructs from response. 

674 response_wrapper_cls = MultipartResponseWrapper 

675 #: Multipart reader class, used to handle multipart/* body parts. 

676 #: None points to type(self) 

677 multipart_reader_cls: type["MultipartReader"] | None = None 

678 #: Body part reader class for non multipart/* content types. 

679 part_reader_cls = BodyPartReader 

680 

681 def __init__( 

682 self, 

683 headers: Mapping[str, str], 

684 content: StreamReader, 

685 *, 

686 client_max_size: int = sys.maxsize, 

687 max_field_size: int = 8190, 

688 max_headers: int = 128, 

689 max_size_error_cls: type[Exception] = ValueError, 

690 ) -> None: 

691 self._mimetype = parse_mimetype(headers[CONTENT_TYPE]) 

692 assert self._mimetype.type == "multipart", "multipart/* content type expected" 

693 if "boundary" not in self._mimetype.parameters: 

694 raise ValueError( 

695 "boundary missed for Content-Type: %s" % headers[CONTENT_TYPE] 

696 ) 

697 

698 self.headers = headers 

699 self._boundary = ("--" + self._get_boundary()).encode() 

700 self._client_max_size = client_max_size 

701 self._content = content 

702 self._default_charset: str | None = None 

703 self._last_part: MultipartReader | BodyPartReader | None = None 

704 self._max_field_size = max_field_size 

705 self._max_headers = max_headers 

706 self._max_size_error_cls = max_size_error_cls 

707 self._at_eof = False 

708 self._at_bof = True 

709 self._unread: list[bytes] = [] 

710 

711 def __aiter__(self: Self) -> Self: 

712 return self 

713 

714 async def __anext__( 

715 self, 

716 ) -> Union["MultipartReader", BodyPartReader] | None: 

717 part = await self.next() 

718 if part is None: 

719 raise StopAsyncIteration 

720 return part 

721 

722 @classmethod 

723 def from_response( 

724 cls, 

725 response: "ClientResponse", 

726 ) -> MultipartResponseWrapper: 

727 """Constructs reader instance from HTTP response. 

728 

729 :param response: :class:`~aiohttp.client.ClientResponse` instance 

730 """ 

731 obj = cls.response_wrapper_cls( 

732 response, cls(response.headers, response.content) 

733 ) 

734 return obj 

735 

736 def at_eof(self) -> bool: 

737 """Returns True if the final boundary was reached, false otherwise.""" 

738 return self._at_eof 

739 

740 async def next( 

741 self, 

742 ) -> Union["MultipartReader", BodyPartReader] | None: 

743 """Emits the next multipart body part.""" 

744 # So, if we're at BOF, we need to skip till the boundary. 

745 if self._at_eof: 

746 return None 

747 await self._maybe_release_last_part() 

748 if self._at_bof: 

749 await self._read_until_first_boundary() 

750 self._at_bof = False 

751 else: 

752 await self._read_boundary() 

753 if self._at_eof: # we just read the last boundary, nothing to do there 

754 return None 

755 

756 part = await self.fetch_next_part() 

757 # https://datatracker.ietf.org/doc/html/rfc7578#section-4.6 

758 if ( 

759 self._last_part is None 

760 and self._mimetype.subtype == "form-data" 

761 and isinstance(part, BodyPartReader) 

762 ): 

763 _, params = parse_content_disposition(part.headers.get(CONTENT_DISPOSITION)) 

764 if params.get("name") == "_charset_": 

765 # Longest encoding in https://encoding.spec.whatwg.org/encodings.json 

766 # is 19 characters, so 32 should be more than enough for any valid encoding. 

767 charset = await part.read_chunk(32) 

768 if len(charset) > 31: 

769 raise RuntimeError("Invalid default charset") 

770 self._default_charset = charset.strip().decode() 

771 part = await self.fetch_next_part() 

772 self._last_part = part 

773 return self._last_part 

774 

775 async def release(self) -> None: 

776 """Reads all the body parts to the void till the final boundary.""" 

777 while not self._at_eof: 

778 item = await self.next() 

779 if item is None: 

780 break 

781 await item.release() 

782 

783 async def fetch_next_part( 

784 self, 

785 ) -> Union["MultipartReader", BodyPartReader]: 

786 """Returns the next body part reader.""" 

787 headers = await self._read_headers() 

788 return self._get_part_reader(headers) 

789 

790 def _get_part_reader( 

791 self, 

792 headers: "CIMultiDictProxy[str]", 

793 ) -> Union["MultipartReader", BodyPartReader]: 

794 """Dispatches the response by the `Content-Type` header. 

795 

796 Returns a suitable reader instance. 

797 

798 :param dict headers: Response headers 

799 """ 

800 ctype = headers.get(CONTENT_TYPE, "") 

801 mimetype = parse_mimetype(ctype) 

802 

803 if mimetype.type == "multipart": 

804 if self.multipart_reader_cls is None: 

805 return type(self)( 

806 headers, 

807 self._content, 

808 client_max_size=self._client_max_size, 

809 max_field_size=self._max_field_size, 

810 max_headers=self._max_headers, 

811 max_size_error_cls=self._max_size_error_cls, 

812 ) 

813 return self.multipart_reader_cls( 

814 headers, 

815 self._content, 

816 client_max_size=self._client_max_size, 

817 max_field_size=self._max_field_size, 

818 max_headers=self._max_headers, 

819 max_size_error_cls=self._max_size_error_cls, 

820 ) 

821 else: 

822 return self.part_reader_cls( 

823 self._boundary, 

824 headers, 

825 self._content, 

826 subtype=self._mimetype.subtype, 

827 default_charset=self._default_charset, 

828 client_max_size=self._client_max_size, 

829 max_size_error_cls=self._max_size_error_cls, 

830 ) 

831 

832 def _get_boundary(self) -> str: 

833 boundary = self._mimetype.parameters["boundary"] 

834 if len(boundary) > 70: 

835 raise ValueError("boundary %r is too long (70 chars max)" % boundary) 

836 

837 return boundary 

838 

839 async def _readline(self) -> bytes: 

840 if self._unread: 

841 return self._unread.pop() 

842 return await self._content.readline() 

843 

844 async def _read_until_first_boundary(self) -> None: 

845 while True: 

846 chunk = await self._readline() 

847 if chunk == b"": 

848 raise ValueError( 

849 "Could not find starting boundary %r" % (self._boundary) 

850 ) 

851 chunk = chunk.rstrip() 

852 if chunk == self._boundary: 

853 return 

854 elif chunk == self._boundary + b"--": 

855 self._at_eof = True 

856 return 

857 

858 async def _read_boundary(self) -> None: 

859 chunk = (await self._readline()).rstrip() 

860 if chunk == self._boundary: 

861 pass 

862 elif chunk == self._boundary + b"--": 

863 self._at_eof = True 

864 epilogue = await self._readline() 

865 next_line = await self._readline() 

866 

867 # the epilogue is expected and then either the end of input or the 

868 # parent multipart boundary, if the parent boundary is found then 

869 # it should be marked as unread and handed to the parent for 

870 # processing 

871 if next_line[:2] == b"--": 

872 self._unread.append(next_line) 

873 # otherwise the request is likely missing an epilogue and both 

874 # lines should be passed to the parent for processing 

875 # (this handles the old behavior gracefully) 

876 else: 

877 self._unread.extend([next_line, epilogue]) 

878 else: 

879 raise ValueError(f"Invalid boundary {chunk!r}, expected {self._boundary!r}") 

880 

881 async def _read_headers(self) -> "CIMultiDictProxy[str]": 

882 lines = [] 

883 while True: 

884 chunk = await self._content.readline(max_line_length=self._max_field_size) 

885 chunk = chunk.rstrip(b"\r\n") 

886 lines.append(chunk) 

887 if not chunk: 

888 break 

889 if len(lines) > self._max_headers: 

890 raise BadHttpMessage("Too many headers received") 

891 parser = HeadersParser(max_field_size=self._max_field_size) 

892 headers, raw_headers = parser.parse_headers(lines) 

893 return headers 

894 

895 async def _maybe_release_last_part(self) -> None: 

896 """Ensures that the last read body part is read completely.""" 

897 if self._last_part is not None: 

898 if not self._last_part.at_eof(): 

899 await self._last_part.release() 

900 self._unread.extend(self._last_part._unread) 

901 self._last_part = None 

902 

903 

904_Part = tuple[Payload, str, str] 

905 

906 

907class MultipartWriter(Payload): 

908 """Multipart body writer.""" 

909 

910 _value: None 

911 # _consumed = False (inherited) - Can be encoded multiple times 

912 _autoclose = True # No file handles, just collects parts in memory 

913 

914 def __init__(self, subtype: str = "mixed", boundary: str | None = None) -> None: 

915 boundary = boundary if boundary is not None else uuid.uuid4().hex 

916 # The underlying Payload API demands a str (utf-8), not bytes, 

917 # so we need to ensure we don't lose anything during conversion. 

918 # As a result, require the boundary to be ASCII only. 

919 # In both situations. 

920 

921 try: 

922 self._boundary = boundary.encode("ascii") 

923 except UnicodeEncodeError: 

924 raise ValueError("boundary should contain ASCII only chars") from None 

925 ctype = f"multipart/{subtype}; boundary={self._boundary_value}" 

926 

927 super().__init__(None, content_type=ctype) 

928 

929 self._parts: list[_Part] = [] 

930 self._is_form_data = subtype == "form-data" 

931 

932 def __enter__(self) -> "MultipartWriter": 

933 return self 

934 

935 def __exit__( 

936 self, 

937 exc_type: type[BaseException] | None, 

938 exc_val: BaseException | None, 

939 exc_tb: TracebackType | None, 

940 ) -> None: 

941 pass 

942 

943 def __iter__(self) -> Iterator[_Part]: 

944 return iter(self._parts) 

945 

946 def __len__(self) -> int: 

947 return len(self._parts) 

948 

949 def __bool__(self) -> bool: 

950 return True 

951 

952 _valid_tchar_regex = re.compile(rb"\A[!#$%&'*+\-.^_`|~\w]+\Z") 

953 _invalid_qdtext_char_regex = re.compile(rb"[\x00-\x08\x0A-\x1F\x7F]") 

954 

955 @property 

956 def _boundary_value(self) -> str: 

957 """Wrap boundary parameter value in quotes, if necessary. 

958 

959 Reads self.boundary and returns a unicode string. 

960 """ 

961 # Refer to RFCs 7231, 7230, 5234. 

962 # 

963 # parameter = token "=" ( token / quoted-string ) 

964 # token = 1*tchar 

965 # quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE 

966 # qdtext = HTAB / SP / %x21 / %x23-5B / %x5D-7E / obs-text 

967 # obs-text = %x80-FF 

968 # quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text ) 

969 # tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" 

970 # / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" 

971 # / DIGIT / ALPHA 

972 # ; any VCHAR, except delimiters 

973 # VCHAR = %x21-7E 

974 value = self._boundary 

975 if re.match(self._valid_tchar_regex, value): 

976 return value.decode("ascii") # cannot fail 

977 

978 if re.search(self._invalid_qdtext_char_regex, value): 

979 raise ValueError("boundary value contains invalid characters") 

980 

981 # escape %x5C and %x22 

982 quoted_value_content = value.replace(b"\\", b"\\\\") 

983 quoted_value_content = quoted_value_content.replace(b'"', b'\\"') 

984 

985 return '"' + quoted_value_content.decode("ascii") + '"' 

986 

987 @property 

988 def boundary(self) -> str: 

989 return self._boundary.decode("ascii") 

990 

991 def append(self, obj: Any, headers: Mapping[str, str] | None = None) -> Payload: 

992 if headers is None: 

993 headers = CIMultiDict() 

994 

995 if isinstance(obj, Payload): 

996 obj.headers.update(headers) 

997 return self.append_payload(obj) 

998 else: 

999 try: 

1000 payload = get_payload(obj, headers=headers) 

1001 except LookupError: 

1002 raise TypeError("Cannot create payload from %r" % obj) 

1003 else: 

1004 return self.append_payload(payload) 

1005 

1006 def append_payload(self, payload: Payload) -> Payload: 

1007 """Adds a new body part to multipart writer.""" 

1008 encoding: str | None = None 

1009 te_encoding: str | None = None 

1010 if self._is_form_data: 

1011 # https://datatracker.ietf.org/doc/html/rfc7578#section-4.7 

1012 # https://datatracker.ietf.org/doc/html/rfc7578#section-4.8 

1013 assert ( 

1014 not {CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TRANSFER_ENCODING} 

1015 & payload.headers.keys() 

1016 ) 

1017 # Set default Content-Disposition in case user doesn't create one 

1018 if CONTENT_DISPOSITION not in payload.headers: 

1019 name = f"section-{len(self._parts)}" 

1020 payload.set_content_disposition("form-data", name=name) 

1021 else: 

1022 # compression 

1023 encoding = payload.headers.get(CONTENT_ENCODING, "").lower() 

1024 if encoding and encoding not in ("deflate", "gzip", "identity"): 

1025 raise RuntimeError(f"unknown content encoding: {encoding}") 

1026 if encoding == "identity": 

1027 encoding = None 

1028 

1029 # te encoding 

1030 te_encoding = payload.headers.get(CONTENT_TRANSFER_ENCODING, "").lower() 

1031 if te_encoding not in ("", "base64", "quoted-printable", "binary"): 

1032 raise RuntimeError(f"unknown content transfer encoding: {te_encoding}") 

1033 if te_encoding == "binary": 

1034 te_encoding = None 

1035 

1036 # size 

1037 size = payload.size 

1038 if size is not None and not (encoding or te_encoding): 

1039 payload.headers[CONTENT_LENGTH] = str(size) 

1040 

1041 self._parts.append((payload, encoding, te_encoding)) # type: ignore[arg-type] 

1042 return payload 

1043 

1044 def append_json( 

1045 self, obj: Any, headers: Mapping[str, str] | None = None 

1046 ) -> Payload: 

1047 """Helper to append JSON part.""" 

1048 if headers is None: 

1049 headers = CIMultiDict() 

1050 

1051 return self.append_payload(JsonPayload(obj, headers=headers)) 

1052 

1053 def append_form( 

1054 self, 

1055 obj: Sequence[tuple[str, str]] | Mapping[str, str], 

1056 headers: Mapping[str, str] | None = None, 

1057 ) -> Payload: 

1058 """Helper to append form urlencoded part.""" 

1059 assert isinstance(obj, (Sequence, Mapping)) 

1060 

1061 if headers is None: 

1062 headers = CIMultiDict() 

1063 

1064 if isinstance(obj, Mapping): 

1065 obj = list(obj.items()) 

1066 data = urlencode(obj, doseq=True) 

1067 

1068 return self.append_payload( 

1069 StringPayload( 

1070 data, headers=headers, content_type="application/x-www-form-urlencoded" 

1071 ) 

1072 ) 

1073 

1074 @property 

1075 def size(self) -> int | None: 

1076 """Size of the payload.""" 

1077 total = 0 

1078 for part, encoding, te_encoding in self._parts: 

1079 part_size = part.size 

1080 if encoding or te_encoding or part_size is None: 

1081 return None 

1082 

1083 total += int( 

1084 2 

1085 + len(self._boundary) 

1086 + 2 

1087 + part_size # b'--'+self._boundary+b'\r\n' 

1088 + len(part._binary_headers) 

1089 + 2 # b'\r\n' 

1090 ) 

1091 

1092 total += 2 + len(self._boundary) + 4 # b'--'+self._boundary+b'--\r\n' 

1093 return total 

1094 

1095 def decode(self, encoding: str = "utf-8", errors: str = "strict") -> str: 

1096 """Return string representation of the multipart data. 

1097 

1098 WARNING: This method may do blocking I/O if parts contain file payloads. 

1099 It should not be called in the event loop. Use as_bytes().decode() instead. 

1100 """ 

1101 return "".join( 

1102 "--" 

1103 + self.boundary 

1104 + "\r\n" 

1105 + part._binary_headers.decode(encoding, errors) 

1106 + part.decode() 

1107 for part, _e, _te in self._parts 

1108 ) 

1109 

1110 async def as_bytes(self, encoding: str = "utf-8", errors: str = "strict") -> bytes: 

1111 """Return bytes representation of the multipart data. 

1112 

1113 This method is async-safe and calls as_bytes on underlying payloads. 

1114 """ 

1115 parts: list[bytes] = [] 

1116 

1117 # Process each part 

1118 for part, _e, _te in self._parts: 

1119 # Add boundary 

1120 parts.append(b"--" + self._boundary + b"\r\n") 

1121 

1122 # Add headers 

1123 parts.append(part._binary_headers) 

1124 

1125 # Add payload content using as_bytes for async safety 

1126 part_bytes = await part.as_bytes(encoding, errors) 

1127 parts.append(part_bytes) 

1128 

1129 # Add trailing CRLF 

1130 parts.append(b"\r\n") 

1131 

1132 # Add closing boundary 

1133 parts.append(b"--" + self._boundary + b"--\r\n") 

1134 

1135 return b"".join(parts) 

1136 

1137 async def write( 

1138 self, writer: AbstractStreamWriter, close_boundary: bool = True 

1139 ) -> None: 

1140 """Write body.""" 

1141 for part, encoding, te_encoding in self._parts: 

1142 if self._is_form_data: 

1143 # https://datatracker.ietf.org/doc/html/rfc7578#section-4.2 

1144 assert CONTENT_DISPOSITION in part.headers 

1145 assert "name=" in part.headers[CONTENT_DISPOSITION] 

1146 

1147 await writer.write(b"--" + self._boundary + b"\r\n") 

1148 await writer.write(part._binary_headers) 

1149 

1150 if encoding or te_encoding: 

1151 w = MultipartPayloadWriter(writer) 

1152 if encoding: 

1153 w.enable_compression(encoding) 

1154 if te_encoding: 

1155 w.enable_encoding(te_encoding) 

1156 await part.write(w) # type: ignore[arg-type] 

1157 await w.write_eof() 

1158 else: 

1159 await part.write(writer) 

1160 

1161 await writer.write(b"\r\n") 

1162 

1163 if close_boundary: 

1164 await writer.write(b"--" + self._boundary + b"--\r\n") 

1165 

1166 async def close(self) -> None: 

1167 """ 

1168 Close all part payloads that need explicit closing. 

1169 

1170 IMPORTANT: This method must not await anything that might not finish 

1171 immediately, as it may be called during cleanup/cancellation. Schedule 

1172 any long-running operations without awaiting them. 

1173 """ 

1174 if self._consumed: 

1175 return 

1176 self._consumed = True 

1177 

1178 # Close all parts that need explicit closing 

1179 # We catch and log exceptions to ensure all parts get a chance to close 

1180 # we do not use asyncio.gather() here because we are not allowed 

1181 # to suspend given we may be called during cleanup 

1182 for idx, (part, _, _) in enumerate(self._parts): 

1183 if not part.autoclose and not part.consumed: 

1184 try: 

1185 await part.close() 

1186 except Exception as exc: 

1187 internal_logger.error( 

1188 "Failed to close multipart part %d: %s", idx, exc, exc_info=True 

1189 ) 

1190 

1191 

1192class MultipartPayloadWriter: 

1193 def __init__(self, writer: AbstractStreamWriter) -> None: 

1194 self._writer = writer 

1195 self._encoding: str | None = None 

1196 self._compress: ZLibCompressor | None = None 

1197 self._encoding_buffer: bytearray | None = None 

1198 

1199 def enable_encoding(self, encoding: str) -> None: 

1200 if encoding == "base64": 

1201 self._encoding = encoding 

1202 self._encoding_buffer = bytearray() 

1203 elif encoding == "quoted-printable": 

1204 self._encoding = "quoted-printable" 

1205 

1206 def enable_compression( 

1207 self, encoding: str = "deflate", strategy: int | None = None 

1208 ) -> None: 

1209 self._compress = ZLibCompressor( 

1210 encoding=encoding, 

1211 suppress_deflate_header=True, 

1212 strategy=strategy, 

1213 ) 

1214 

1215 async def write_eof(self) -> None: 

1216 if self._compress is not None: 

1217 chunk = self._compress.flush() 

1218 if chunk: 

1219 self._compress = None 

1220 await self.write(chunk) 

1221 

1222 if self._encoding == "base64": 

1223 if self._encoding_buffer: 

1224 await self._writer.write(base64.b64encode(self._encoding_buffer)) 

1225 

1226 async def write(self, chunk: bytes) -> None: 

1227 if self._compress is not None: 

1228 if chunk: 

1229 chunk = await self._compress.compress(chunk) 

1230 if not chunk: 

1231 return 

1232 

1233 if self._encoding == "base64": 

1234 buf = self._encoding_buffer 

1235 assert buf is not None 

1236 buf.extend(chunk) 

1237 

1238 if buf: 

1239 div, mod = divmod(len(buf), 3) 

1240 enc_chunk, self._encoding_buffer = (buf[: div * 3], buf[div * 3 :]) 

1241 if enc_chunk: 

1242 b64chunk = base64.b64encode(enc_chunk) 

1243 await self._writer.write(b64chunk) 

1244 elif self._encoding == "quoted-printable": 

1245 await self._writer.write(binascii.b2a_qp(chunk)) 

1246 else: 

1247 await self._writer.write(chunk)