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

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

13from urllib.parse import parse_qsl, unquote, urlencode 

14 

15from multidict import CIMultiDict 

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 ( 

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 

47 

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

49 from typing import Self 

50else: 

51 from typing import TypeVar 

52 

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

54 

55__all__ = ( 

56 "MultipartReader", 

57 "MultipartWriter", 

58 "BodyPartReader", 

59 "BadContentDispositionHeader", 

60 "BadContentDispositionParam", 

61 "parse_content_disposition", 

62 "content_disposition_filename", 

63) 

64 

65 

66if TYPE_CHECKING: 

67 from .client_reqrep import ClientResponse 

68 

69 

70class BadContentDispositionHeader(RuntimeWarning): 

71 pass 

72 

73 

74class BadContentDispositionParam(RuntimeWarning): 

75 pass 

76 

77 

78def parse_content_disposition( 

79 header: str | None, 

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

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

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

83 

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

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

86 

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

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

89 

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

91 return string.endswith("*") 

92 

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

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

95 if not pos: 

96 return False 

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

98 return substring.isdigit() 

99 

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

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

102 

103 if not header: 

104 return None, {} 

105 

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

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

108 disptype = disptype.strip() 

109 if not is_token(disptype): 

110 warnings.warn(BadContentDispositionHeader(header)) 

111 return None, {} 

112 

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

114 while parts: 

115 item = parts.pop(0) 

116 

117 if not item: # To handle trailing semicolons 

118 warnings.warn(BadContentDispositionHeader(header)) 

119 continue 

120 

121 if "=" not in item: 

122 warnings.warn(BadContentDispositionHeader(header)) 

123 return None, {} 

124 

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

126 key = key.lower().strip() 

127 value = value.lstrip() 

128 

129 if key in params: 

130 warnings.warn(BadContentDispositionHeader(header)) 

131 return None, {} 

132 

133 if not is_token(key): 

134 warnings.warn(BadContentDispositionParam(item)) 

135 continue 

136 

137 elif is_continuous_param(key): 

138 if is_quoted(value): 

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

140 elif not is_token(value): 

141 warnings.warn(BadContentDispositionParam(item)) 

142 continue 

143 

144 elif is_extended_param(key): 

145 if is_rfc5987(value): 

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

147 encoding = encoding or "utf-8" 

148 else: 

149 warnings.warn(BadContentDispositionParam(item)) 

150 continue 

151 

152 try: 

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

154 except (builtins.LookupError, UnicodeDecodeError): 

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

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

157 # this module by payload.LookupError). 

158 warnings.warn(BadContentDispositionParam(item)) 

159 continue 

160 

161 else: 

162 failed = True 

163 rstripped = value.rstrip() 

164 if is_quoted(rstripped): 

165 failed = False 

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

167 elif is_token(value): 

168 failed = False 

169 elif parts: 

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

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

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

173 if is_quoted(_value): 

174 parts.pop(0) 

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

176 failed = False 

177 

178 if failed: 

179 warnings.warn(BadContentDispositionHeader(header)) 

180 return None, {} 

181 

182 params[key] = value 

183 

184 return disptype.lower(), params 

185 

186 

187def content_disposition_filename( 

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

189) -> str | None: 

190 name_suf = "%s*" % name 

191 if not params: 

192 return None 

193 elif name_suf in params: 

194 return params[name_suf] 

195 elif name in params: 

196 return params[name] 

197 else: 

198 parts = [] 

199 fnparams = sorted( 

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

201 ) 

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

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

204 if tail.endswith("*"): 

205 tail = tail[:-1] 

206 if tail == str(num): 

207 parts.append(value) 

208 else: 

209 break 

210 if not parts: 

211 return None 

212 value = "".join(parts) 

213 if "'" in value: 

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

215 encoding = encoding or "utf-8" 

216 try: 

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

218 except (builtins.LookupError, UnicodeDecodeError): 

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

220 # here; an unknown encoding raises the builtin LookupError 

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

222 # undecodable bytes raise UnicodeDecodeError. 

223 return None 

224 return value 

225 

226 

227class MultipartResponseWrapper: 

228 """Wrapper around the MultipartReader. 

229 

230 It takes care about 

231 underlying connection and close it when it needs in. 

232 """ 

233 

234 def __init__( 

235 self, 

236 resp: "ClientResponse", 

237 stream: "MultipartReader", 

238 ) -> None: 

239 self.resp = resp 

240 self.stream = stream 

241 

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

243 return self 

244 

245 async def __anext__( 

246 self, 

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

248 part = await self.next() 

249 if part is None: 

250 raise StopAsyncIteration 

251 return part 

252 

253 def at_eof(self) -> bool: 

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

255 return self.resp.content.at_eof() 

256 

257 async def next( 

258 self, 

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

260 """Emits next multipart reader object.""" 

261 item = await self.stream.next() 

262 if self.stream.at_eof(): 

263 await self.release() 

264 return item 

265 

266 async def release(self) -> None: 

267 """Release the connection gracefully. 

268 

269 All remaining content is read to the void. 

270 """ 

271 self.resp.release() 

272 

273 

274class BodyPartReader: 

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

276 

277 chunk_size = 8192 

278 

279 def __init__( 

280 self, 

281 boundary: bytes, 

282 headers: HeadersDictProxy, 

283 content: StreamReader, 

284 *, 

285 subtype: str = "mixed", 

286 default_charset: str | None = None, 

287 max_decompress_size: int = DEFAULT_CHUNK_SIZE, 

288 client_max_size: int = sys.maxsize, 

289 max_size_error_cls: type[Exception] = ValueError, 

290 ) -> None: 

291 self.headers = headers 

292 self._boundary = boundary 

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

294 self._content = content 

295 self._default_charset = default_charset 

296 self._at_eof = False 

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

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

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

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

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

302 # digits that int() would otherwise accept. 

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

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

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

306 self._read_bytes = 0 

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

308 self._prev_chunk: bytes | None = None 

309 self._content_eof = 0 

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

311 self._max_decompress_size = max_decompress_size 

312 self._client_max_size = client_max_size 

313 self._max_size_error_cls = max_size_error_cls 

314 

315 def __aiter__(self) -> Self: 

316 return self 

317 

318 async def __anext__(self) -> bytes: 

319 part = await self.next() 

320 if part is None: 

321 raise StopAsyncIteration 

322 return part 

323 

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

325 item = await self.read() 

326 if not item: 

327 return None 

328 return item 

329 

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

331 """Reads body part data. 

332 

333 decode: Decodes data following by encoding 

334 method from Content-Encoding header. If it missed 

335 data remains untouched 

336 """ 

337 if self._at_eof: 

338 return b"" 

339 data = bytearray() 

340 while not self._at_eof: 

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

342 if len(data) > self._client_max_size: 

343 raise self._max_size_error_cls(self._client_max_size) 

344 # https://github.com/python/mypy/issues/17537 

345 if decode: # type: ignore[unreachable] 

346 decoded_data = bytearray() 

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

348 decoded_data.extend(d) 

349 if len(decoded_data) > self._client_max_size: 

350 raise self._max_size_error_cls(self._client_max_size) 

351 return decoded_data 

352 return data 

353 

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

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

356 

357 size: chunk size 

358 """ 

359 if self._at_eof: 

360 return b"" 

361 if self._length: 

362 chunk = await self._read_chunk_from_length(size) 

363 else: 

364 chunk = await self._read_chunk_from_stream(size) 

365 

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

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

368 encoding = self.headers.get(CONTENT_TRANSFER_ENCODING) 

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

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

371 remainder = len(stripped_chunk) % 4 

372 

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

374 over_chunk_size = 4 - remainder 

375 over_chunk = b"" 

376 

377 if self._prev_chunk: 

378 over_chunk = self._prev_chunk[:over_chunk_size] 

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

380 

381 if len(over_chunk) != over_chunk_size: 

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

383 

384 if not over_chunk: 

385 self._at_eof = True 

386 

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

388 chunk += over_chunk 

389 remainder = len(stripped_chunk) % 4 

390 

391 self._read_bytes += len(chunk) 

392 if self._read_bytes == self._length: 

393 self._at_eof = True 

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

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

396 return chunk 

397 

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

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

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

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

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

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

404 if self._content.at_eof(): 

405 self._at_eof = True 

406 return chunk 

407 

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

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

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

411 assert ( 

412 size >= self._boundary_len 

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

414 first_chunk = self._prev_chunk is None 

415 if first_chunk: 

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

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

418 

419 chunk = b"" 

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

421 # we have enough data to detect the boundary. 

422 while len(chunk) < self._boundary_len: 

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

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

425 if self._content_eof > 2: 

426 raise ValueError("Reading after EOF") 

427 if self._content_eof: 

428 break 

429 if len(chunk) > size: 

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

431 chunk = chunk[:size] 

432 

433 assert self._prev_chunk is not None 

434 window = self._prev_chunk + chunk 

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

436 if first_chunk: 

437 idx = window.find(sub) 

438 else: 

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

440 if idx >= 0: 

441 # pushing boundary back to content 

442 with warnings.catch_warnings(): 

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

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

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

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

447 if not chunk: 

448 self._at_eof = True 

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

450 self._prev_chunk = chunk 

451 return result 

452 

453 async def readline(self) -> bytes: 

454 """Reads body part by line by line.""" 

455 if self._at_eof: 

456 return b"" 

457 

458 if self._unread: 

459 line = self._unread.popleft() 

460 else: 

461 line = await self._content.readline() 

462 

463 if line.startswith(self._boundary): 

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

465 # so set single rules for everyone 

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

467 boundary = self._boundary 

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

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

470 if sline == boundary or sline == last_boundary: 

471 self._at_eof = True 

472 self._unread.append(line) 

473 return b"" 

474 else: 

475 next_line = await self._content.readline() 

476 if next_line.startswith(self._boundary): 

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

478 self._unread.append(next_line) 

479 

480 return line 

481 

482 async def release(self) -> None: 

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

484 if self._at_eof: 

485 return 

486 while not self._at_eof: 

487 await self.read_chunk(self.chunk_size) 

488 

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

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

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

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

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

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

495 return data.decode(encoding) 

496 

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

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

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

500 if not data: 

501 return None 

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

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

504 

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

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

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

508 if not data: 

509 return [] 

510 if encoding is not None: 

511 real_encoding = encoding 

512 else: 

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

514 try: 

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

516 except UnicodeDecodeError: 

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

518 

519 return parse_qsl( 

520 decoded_data, 

521 keep_blank_values=True, 

522 encoding=real_encoding, 

523 ) 

524 

525 def at_eof(self) -> bool: 

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

527 return self._at_eof 

528 

529 def _apply_content_transfer_decoding(self, data: bytes) -> bytes: 

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

531 if CONTENT_TRANSFER_ENCODING in self.headers: 

532 return self._decode_content_transfer(data) 

533 return data 

534 

535 def _needs_content_decoding(self) -> bool: 

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

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

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

539 

540 def decode(self, data: bytes) -> bytes: 

541 """Decodes data synchronously. 

542 

543 Decodes data according the specified Content-Encoding 

544 or Content-Transfer-Encoding headers value. 

545 

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

547 to avoid blocking the event loop during decompression. 

548 """ 

549 data = self._apply_content_transfer_decoding(data) 

550 if self._needs_content_decoding(): 

551 return self._decode_content(data) 

552 return data 

553 

554 async def decode_iter(self, data: bytes) -> AsyncIterator[bytes]: 

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

556 

557 Decodes data according the specified Content-Encoding 

558 or Content-Transfer-Encoding headers value. 

559 

560 This method offloads decompression to an executor for large payloads 

561 to avoid blocking the event loop. 

562 """ 

563 data = self._apply_content_transfer_decoding(data) 

564 if self._needs_content_decoding(): 

565 async for d in self._decode_content_async(data): 

566 yield d 

567 else: 

568 yield data 

569 

570 def _decode_content(self, data: bytes) -> bytes: 

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

572 if encoding == "identity": 

573 return data 

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

575 return ZLibDecompressor( 

576 encoding=encoding, 

577 suppress_deflate_header=True, 

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

579 

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

581 

582 async def _decode_content_async(self, data: bytes) -> AsyncIterator[bytes]: 

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

584 if encoding == "identity": 

585 yield data 

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

587 d = ZLibDecompressor( 

588 encoding=encoding, 

589 suppress_deflate_header=True, 

590 ) 

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

592 while d.data_available: 

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

594 else: 

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

596 

597 def _decode_content_transfer(self, data: bytes) -> bytes: 

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

599 

600 if encoding == "base64": 

601 return base64.b64decode(data) 

602 elif encoding == "quoted-printable": 

603 return binascii.a2b_qp(data) 

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

605 return data 

606 else: 

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

608 

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

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

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

612 mimetype = parse_mimetype(ctype) 

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

614 

615 @reify 

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

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

618 

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

620 """ 

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

622 return content_disposition_filename(params, "name") 

623 

624 @reify 

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

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

627 

628 Returns None if the header is missing or malformed. 

629 """ 

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

631 return content_disposition_filename(params, "filename") 

632 

633 

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

635class BodyPartReaderPayload(Payload): 

636 _value: BodyPartReader 

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

638 

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

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

641 

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

643 if value.name is not None: 

644 params["name"] = value.name 

645 if value.filename is not None: 

646 params["filename"] = value.filename 

647 

648 if params: 

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

650 

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

652 raise TypeError("Unable to decode.") 

653 

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

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

656 

657 This is intentional: BodyPartReader payloads are designed for streaming 

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

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

660 in memory for reuse. 

661 """ 

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

663 

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

665 field = self._value 

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

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

668 await writer.write(d) 

669 

670 

671class MultipartReader: 

672 """Multipart body reader.""" 

673 

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

675 response_wrapper_cls = MultipartResponseWrapper 

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

677 #: None points to type(self) 

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

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

680 part_reader_cls = BodyPartReader 

681 

682 def __init__( 

683 self, 

684 headers: Mapping[str, str], 

685 content: StreamReader, 

686 *, 

687 client_max_size: int = sys.maxsize, 

688 max_field_size: int = 8190, 

689 max_headers: int = 128, 

690 max_size_error_cls: type[Exception] = ValueError, 

691 ) -> None: 

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

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

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

695 raise ValueError( 

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

697 ) 

698 

699 self.headers = headers 

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

701 self._client_max_size = client_max_size 

702 self._content = content 

703 self._default_charset: str | None = None 

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

705 self._max_field_size = max_field_size 

706 self._max_headers = max_headers 

707 self._max_size_error_cls = max_size_error_cls 

708 self._at_eof = False 

709 self._at_bof = True 

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

711 

712 def __aiter__(self) -> Self: 

713 return self 

714 

715 async def __anext__( 

716 self, 

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

718 part = await self.next() 

719 if part is None: 

720 raise StopAsyncIteration 

721 return part 

722 

723 @classmethod 

724 def from_response( 

725 cls, 

726 response: "ClientResponse", 

727 ) -> MultipartResponseWrapper: 

728 """Constructs reader instance from HTTP response. 

729 

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

731 """ 

732 obj = cls.response_wrapper_cls( 

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

734 ) 

735 return obj 

736 

737 def at_eof(self) -> bool: 

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

739 return self._at_eof 

740 

741 async def next( 

742 self, 

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

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

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

746 if self._at_eof: 

747 return None 

748 await self._maybe_release_last_part() 

749 if self._at_bof: 

750 await self._read_until_first_boundary() 

751 self._at_bof = False 

752 else: 

753 await self._read_boundary() 

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

755 # https://github.com/python/mypy/issues/17537 

756 return None # type: ignore[unreachable] 

757 

758 part = await self.fetch_next_part() 

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

760 if ( 

761 self._last_part is None 

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

763 and isinstance(part, BodyPartReader) 

764 ): 

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

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

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

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

769 charset = await part.read_chunk(32) 

770 if len(charset) > 31: 

771 raise RuntimeError("Invalid default charset") 

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

773 part = await self.fetch_next_part() 

774 self._last_part = part 

775 return self._last_part 

776 

777 async def release(self) -> None: 

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

779 while not self._at_eof: 

780 item = await self.next() 

781 if item is None: 

782 break 

783 await item.release() 

784 

785 async def fetch_next_part( 

786 self, 

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

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

789 headers = await self._read_headers() 

790 return self._get_part_reader(headers) 

791 

792 def _get_part_reader( 

793 self, 

794 headers: HeadersDictProxy, 

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

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

797 

798 Returns a suitable reader instance. 

799 

800 :param dict headers: Response headers 

801 """ 

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

803 mimetype = parse_mimetype(ctype) 

804 

805 if mimetype.type == "multipart": 

806 if self.multipart_reader_cls is None: 

807 return type(self)( 

808 headers, 

809 self._content, 

810 client_max_size=self._client_max_size, 

811 max_field_size=self._max_field_size, 

812 max_headers=self._max_headers, 

813 max_size_error_cls=self._max_size_error_cls, 

814 ) 

815 return self.multipart_reader_cls( 

816 headers, 

817 self._content, 

818 client_max_size=self._client_max_size, 

819 max_field_size=self._max_field_size, 

820 max_headers=self._max_headers, 

821 max_size_error_cls=self._max_size_error_cls, 

822 ) 

823 else: 

824 return self.part_reader_cls( 

825 self._boundary, 

826 headers, 

827 self._content, 

828 subtype=self._mimetype.subtype, 

829 default_charset=self._default_charset, 

830 client_max_size=self._client_max_size, 

831 max_size_error_cls=self._max_size_error_cls, 

832 ) 

833 

834 def _get_boundary(self) -> str: 

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

836 if len(boundary) > 70: 

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

838 

839 return boundary 

840 

841 async def _readline(self) -> bytes: 

842 if self._unread: 

843 return self._unread.pop() 

844 return await self._content.readline() 

845 

846 async def _read_until_first_boundary(self) -> None: 

847 while True: 

848 chunk = await self._readline() 

849 if chunk == b"": 

850 raise ValueError(f"Could not find starting boundary {self._boundary!r}") 

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) -> HeadersDictProxy: 

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, _ = 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 

926 if len(boundary) > 70: 

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

928 

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

930 

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

932 

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

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

935 

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

937 return self 

938 

939 def __exit__( 

940 self, 

941 exc_type: type[BaseException] | None, 

942 exc_val: BaseException | None, 

943 exc_tb: TracebackType | None, 

944 ) -> None: 

945 pass 

946 

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

948 return iter(self._parts) 

949 

950 def __len__(self) -> int: 

951 return len(self._parts) 

952 

953 def __bool__(self) -> bool: 

954 return True 

955 

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

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

958 

959 @property 

960 def _boundary_value(self) -> str: 

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

962 

963 Reads self.boundary and returns a unicode string. 

964 """ 

965 # Refer to RFCs 7231, 7230, 5234. 

966 # 

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

968 # token = 1*tchar 

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

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

971 # obs-text = %x80-FF 

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

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

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

975 # / DIGIT / ALPHA 

976 # ; any VCHAR, except delimiters 

977 # VCHAR = %x21-7E 

978 value = self._boundary 

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

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

981 

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

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

984 

985 # escape %x5C and %x22 

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

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

988 

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

990 

991 @property 

992 def boundary(self) -> str: 

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

994 

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

996 if headers is None: 

997 headers = CIMultiDict() 

998 

999 if isinstance(obj, Payload): 

1000 obj.headers.update(headers) 

1001 return self.append_payload(obj) 

1002 else: 

1003 try: 

1004 payload = get_payload(obj, headers=headers) 

1005 except LookupError: 

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

1007 else: 

1008 return self.append_payload(payload) 

1009 

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

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

1012 encoding: str | None = None 

1013 te_encoding: str | None = None 

1014 if self._is_form_data: 

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

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

1017 assert ( 

1018 not {CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TRANSFER_ENCODING} 

1019 & payload.headers.keys() 

1020 ) 

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

1022 if CONTENT_DISPOSITION not in payload.headers: 

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

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

1025 else: 

1026 # compression 

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

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

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

1030 if encoding == "identity": 

1031 encoding = None 

1032 

1033 # te encoding 

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

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

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

1037 if te_encoding == "binary": 

1038 te_encoding = None 

1039 

1040 # size 

1041 size = payload.size 

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

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

1044 

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

1046 return payload 

1047 

1048 def append_json( 

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

1050 ) -> Payload: 

1051 """Helper to append JSON part.""" 

1052 if headers is None: 

1053 headers = CIMultiDict() 

1054 

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

1056 

1057 def append_form( 

1058 self, 

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

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

1061 ) -> Payload: 

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

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

1064 

1065 if headers is None: 

1066 headers = CIMultiDict() 

1067 

1068 if isinstance(obj, Mapping): 

1069 obj = list(obj.items()) 

1070 data = urlencode(obj, doseq=True) 

1071 

1072 return self.append_payload( 

1073 StringPayload( 

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

1075 ) 

1076 ) 

1077 

1078 @property 

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

1080 """Size of the payload.""" 

1081 total = 0 

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

1083 part_size = part.size 

1084 if encoding or te_encoding or part_size is None: 

1085 return None 

1086 

1087 total += int( 

1088 2 

1089 + len(self._boundary) 

1090 + 2 

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

1092 + len(part._binary_headers) 

1093 + 2 # b'\r\n' 

1094 ) 

1095 

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

1097 return total 

1098 

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

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

1101 

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

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

1104 """ 

1105 return "".join( 

1106 "--" 

1107 + self.boundary 

1108 + "\r\n" 

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

1110 + part.decode() 

1111 for part, _e, _te in self._parts 

1112 ) 

1113 

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

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

1116 

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

1118 """ 

1119 parts: list[bytes] = [] 

1120 

1121 # Process each part 

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

1123 # Add boundary 

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

1125 

1126 # Add headers 

1127 parts.append(part._binary_headers) 

1128 

1129 # Add payload content using as_bytes for async safety 

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

1131 parts.append(part_bytes) 

1132 

1133 # Add trailing CRLF 

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

1135 

1136 # Add closing boundary 

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

1138 

1139 return b"".join(parts) 

1140 

1141 async def write( 

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

1143 ) -> None: 

1144 """Write body.""" 

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

1146 if self._is_form_data: 

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

1148 assert CONTENT_DISPOSITION in part.headers 

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

1150 

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

1152 await writer.write(part._binary_headers) 

1153 

1154 if encoding or te_encoding: 

1155 w = MultipartPayloadWriter(writer) 

1156 if encoding: 

1157 w.enable_compression(encoding) 

1158 if te_encoding: 

1159 w.enable_encoding(te_encoding) 

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

1161 await w.write_eof() 

1162 else: 

1163 await part.write(writer) 

1164 

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

1166 

1167 if close_boundary: 

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

1169 

1170 async def close(self) -> None: 

1171 """ 

1172 Close all part payloads that need explicit closing. 

1173 

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

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

1176 any long-running operations without awaiting them. 

1177 """ 

1178 if self._consumed: 

1179 return 

1180 self._consumed = True 

1181 

1182 # Close all parts that need explicit closing 

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

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

1185 # to suspend given we may be called during cleanup 

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

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

1188 try: 

1189 await part.close() 

1190 except Exception as exc: 

1191 internal_logger.error( 

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

1193 ) 

1194 

1195 

1196class MultipartPayloadWriter: 

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

1198 self._writer = writer 

1199 self._encoding: str | None = None 

1200 self._compress: ZLibCompressor | None = None 

1201 self._encoding_buffer: bytearray | None = None 

1202 

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

1204 if encoding == "base64": 

1205 self._encoding = encoding 

1206 self._encoding_buffer = bytearray() 

1207 elif encoding == "quoted-printable": 

1208 self._encoding = "quoted-printable" 

1209 

1210 def enable_compression( 

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

1212 ) -> None: 

1213 self._compress = ZLibCompressor( 

1214 encoding=encoding, 

1215 suppress_deflate_header=True, 

1216 strategy=strategy, 

1217 ) 

1218 

1219 async def write_eof(self) -> None: 

1220 if self._compress is not None: 

1221 chunk = self._compress.flush() 

1222 if chunk: 

1223 self._compress = None 

1224 await self.write(chunk) 

1225 

1226 if self._encoding == "base64": 

1227 if self._encoding_buffer: 

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

1229 

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

1231 if self._compress is not None: 

1232 if chunk: 

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

1234 if not chunk: 

1235 return 

1236 

1237 if self._encoding == "base64": 

1238 buf = self._encoding_buffer 

1239 assert buf is not None 

1240 buf.extend(chunk) 

1241 

1242 if buf: 

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

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

1245 if enc_chunk: 

1246 b64chunk = base64.b64encode(enc_chunk) 

1247 await self._writer.write(b64chunk) 

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

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

1250 else: 

1251 await self._writer.write(chunk)