Format: text/x-python
⬇ Download File (9.6 KB)
   1  from __future__ import annotations
   2  
   3  import io
   4  import mimetypes
   5  import os
   6  import re
   7  import typing
   8  from pathlib import Path
   9  
  10  from ._types import (
  11      AsyncByteStream,
  12      FileContent,
  13      FileTypes,
  14      RequestData,
  15      RequestFiles,
  16      SyncByteStream,
  17  )
  18  from ._utils import (
  19      peek_filelike_length,
  20      primitive_value_to_str,
  21      to_bytes,
  22  )
  23  
  24  _HTML5_FORM_ENCODING_REPLACEMENTS = {'"': "%22", "\\": "\\\\"}
  25  _HTML5_FORM_ENCODING_REPLACEMENTS.update(
  26      {chr(c): "%{:02X}".format(c) for c in range(0x1F + 1) if c != 0x1B}
  27  )
  28  _HTML5_FORM_ENCODING_RE = re.compile(
  29      r"|".join([re.escape(c) for c in _HTML5_FORM_ENCODING_REPLACEMENTS.keys()])
  30  )
  31  
  32  
  33  def _format_form_param(name: str, value: str) -> bytes:
  34      """
  35      Encode a name/value pair within a multipart form.
  36      """
  37  
  38      def replacer(match: typing.Match[str]) -> str:
  39          return _HTML5_FORM_ENCODING_REPLACEMENTS[match.group(0)]
  40  
  41      value = _HTML5_FORM_ENCODING_RE.sub(replacer, value)
  42      return f'{name}="{value}"'.encode()
  43  
  44  
  45  def _guess_content_type(filename: str | None) -> str | None:
  46      """
  47      Guesses the mimetype based on a filename. Defaults to `application/octet-stream`.
  48  
  49      Returns `None` if `filename` is `None` or empty.
  50      """
  51      if filename:
  52          return mimetypes.guess_type(filename)[0] or "application/octet-stream"
  53      return None
  54  
  55  
  56  def get_multipart_boundary_from_content_type(
  57      content_type: bytes | None,
  58  ) -> bytes | None:
  59      if not content_type or not content_type.startswith(b"multipart/form-data"):
  60          return None
  61      # parse boundary according to
  62      # https://www.rfc-editor.org/rfc/rfc2046#section-5.1.1
  63      if b";" in content_type:
  64          for section in content_type.split(b";"):
  65              if section.strip().lower().startswith(b"boundary="):
  66                  return section.strip()[len(b"boundary=") :].strip(b'"')
  67      return None
  68  
  69  
  70  class DataField:
  71      """
  72      A single form field item, within a multipart form field.
  73      """
  74  
  75      def __init__(self, name: str, value: str | bytes | int | float | None) -> None:
  76          if not isinstance(name, str):
  77              raise TypeError(
  78                  f"Invalid type for name. Expected str, got {type(name)}: {name!r}"
  79              )
  80          if value is not None and not isinstance(value, (str, bytes, int, float)):
  81              raise TypeError(
  82                  "Invalid type for value. Expected primitive type,"
  83                  f" got {type(value)}: {value!r}"
  84              )
  85          self.name = name
  86          self.value: str | bytes = (
  87              value if isinstance(value, bytes) else primitive_value_to_str(value)
  88          )
  89  
  90      def render_headers(self) -> bytes:
  91          if not hasattr(self, "_headers"):
  92              name = _format_form_param("name", self.name)
  93              self._headers = b"".join(
  94                  [b"Content-Disposition: form-data; ", name, b"\r\n\r\n"]
  95              )
  96  
  97          return self._headers
  98  
  99      def render_data(self) -> bytes:
 100          if not hasattr(self, "_data"):
 101              self._data = to_bytes(self.value)
 102  
 103          return self._data
 104  
 105      def get_length(self) -> int:
 106          headers = self.render_headers()
 107          data = self.render_data()
 108          return len(headers) + len(data)
 109  
 110      def render(self) -> typing.Iterator[bytes]:
 111          yield self.render_headers()
 112          yield self.render_data()
 113  
 114  
 115  class FileField:
 116      """
 117      A single file field item, within a multipart form field.
 118      """
 119  
 120      CHUNK_SIZE = 64 * 1024
 121  
 122      def __init__(self, name: str, value: FileTypes) -> None:
 123          self.name = name
 124  
 125          fileobj: FileContent
 126  
 127          headers: dict[str, str] = {}
 128          content_type: str | None = None
 129  
 130          # This large tuple based API largely mirror's requests' API
 131          # It would be good to think of better APIs for this that we could
 132          # include in httpx 2.0 since variable length tuples(especially of 4 elements)
 133          # are quite unwieldly
 134          if isinstance(value, tuple):
 135              if len(value) == 2:
 136                  # neither the 3rd parameter (content_type) nor the 4th (headers)
 137                  # was included
 138                  filename, fileobj = value
 139              elif len(value) == 3:
 140                  filename, fileobj, content_type = value
 141              else:
 142                  # all 4 parameters included
 143                  filename, fileobj, content_type, headers = value  # type: ignore
 144          else:
 145              filename = Path(str(getattr(value, "name", "upload"))).name
 146              fileobj = value
 147  
 148          if content_type is None:
 149              content_type = _guess_content_type(filename)
 150  
 151          has_content_type_header = any("content-type" in key.lower() for key in headers)
 152          if content_type is not None and not has_content_type_header:
 153              # note that unlike requests, we ignore the content_type provided in the 3rd
 154              # tuple element if it is also included in the headers requests does
 155              # the opposite (it overwrites the headerwith the 3rd tuple element)
 156              headers["Content-Type"] = content_type
 157  
 158          if isinstance(fileobj, io.StringIO):
 159              raise TypeError(
 160                  "Multipart file uploads require 'io.BytesIO', not 'io.StringIO'."
 161              )
 162          if isinstance(fileobj, io.TextIOBase):
 163              raise TypeError(
 164                  "Multipart file uploads must be opened in binary mode, not text mode."
 165              )
 166  
 167          self.filename = filename
 168          self.file = fileobj
 169          self.headers = headers
 170  
 171      def get_length(self) -> int | None:
 172          headers = self.render_headers()
 173  
 174          if isinstance(self.file, (str, bytes)):
 175              return len(headers) + len(to_bytes(self.file))
 176  
 177          file_length = peek_filelike_length(self.file)
 178  
 179          # If we can't determine the filesize without reading it into memory,
 180          # then return `None` here, to indicate an unknown file length.
 181          if file_length is None:
 182              return None
 183  
 184          return len(headers) + file_length
 185  
 186      def render_headers(self) -> bytes:
 187          if not hasattr(self, "_headers"):
 188              parts = [
 189                  b"Content-Disposition: form-data; ",
 190                  _format_form_param("name", self.name),
 191              ]
 192              if self.filename:
 193                  filename = _format_form_param("filename", self.filename)
 194                  parts.extend([b"; ", filename])
 195              for header_name, header_value in self.headers.items():
 196                  key, val = f"\r\n{header_name}: ".encode(), header_value.encode()
 197                  parts.extend([key, val])
 198              parts.append(b"\r\n\r\n")
 199              self._headers = b"".join(parts)
 200  
 201          return self._headers
 202  
 203      def render_data(self) -> typing.Iterator[bytes]:
 204          if isinstance(self.file, (str, bytes)):
 205              yield to_bytes(self.file)
 206              return
 207  
 208          if hasattr(self.file, "seek"):
 209              try:
 210                  self.file.seek(0)
 211              except io.UnsupportedOperation:
 212                  pass
 213  
 214          chunk = self.file.read(self.CHUNK_SIZE)
 215          while chunk:
 216              yield to_bytes(chunk)
 217              chunk = self.file.read(self.CHUNK_SIZE)
 218  
 219      def render(self) -> typing.Iterator[bytes]:
 220          yield self.render_headers()
 221          yield from self.render_data()
 222  
 223  
 224  class MultipartStream(SyncByteStream, AsyncByteStream):
 225      """
 226      Request content as streaming multipart encoded form data.
 227      """
 228  
 229      def __init__(
 230          self,
 231          data: RequestData,
 232          files: RequestFiles,
 233          boundary: bytes | None = None,
 234      ) -> None:
 235          if boundary is None:
 236              boundary = os.urandom(16).hex().encode("ascii")
 237  
 238          self.boundary = boundary
 239          self.content_type = "multipart/form-data; boundary=%s" % boundary.decode(
 240              "ascii"
 241          )
 242          self.fields = list(self._iter_fields(data, files))
 243  
 244      def _iter_fields(
 245          self, data: RequestData, files: RequestFiles
 246      ) -> typing.Iterator[FileField | DataField]:
 247          for name, value in data.items():
 248              if isinstance(value, (tuple, list)):
 249                  for item in value:
 250                      yield DataField(name=name, value=item)
 251              else:
 252                  yield DataField(name=name, value=value)
 253  
 254          file_items = files.items() if isinstance(files, typing.Mapping) else files
 255          for name, value in file_items:
 256              yield FileField(name=name, value=value)
 257  
 258      def iter_chunks(self) -> typing.Iterator[bytes]:
 259          for field in self.fields:
 260              yield b"--%s\r\n" % self.boundary
 261              yield from field.render()
 262              yield b"\r\n"
 263          yield b"--%s--\r\n" % self.boundary
 264  
 265      def get_content_length(self) -> int | None:
 266          """
 267          Return the length of the multipart encoded content, or `None` if
 268          any of the files have a length that cannot be determined upfront.
 269          """
 270          boundary_length = len(self.boundary)
 271          length = 0
 272  
 273          for field in self.fields:
 274              field_length = field.get_length()
 275              if field_length is None:
 276                  return None
 277  
 278              length += 2 + boundary_length + 2  # b"--{boundary}\r\n"
 279              length += field_length
 280              length += 2  # b"\r\n"
 281  
 282          length += 2 + boundary_length + 4  # b"--{boundary}--\r\n"
 283          return length
 284  
 285      # Content stream interface.
 286  
 287      def get_headers(self) -> dict[str, str]:
 288          content_length = self.get_content_length()
 289          content_type = self.content_type
 290          if content_length is None:
 291              return {"Transfer-Encoding": "chunked", "Content-Type": content_type}
 292          return {"Content-Length": str(content_length), "Content-Type": content_type}
 293  
 294      def __iter__(self) -> typing.Iterator[bytes]:
 295          for chunk in self.iter_chunks():
 296              yield chunk
 297  
 298      async def __aiter__(self) -> typing.AsyncIterator[bytes]:
 299          for chunk in self.iter_chunks():
 300              yield chunk
 301