Format: text/x-python
⬇ Download File (11.8 KB)
   1  """
   2  Handlers for Content-Encoding.
   3  
   4  See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding
   5  """
   6  
   7  from __future__ import annotations
   8  
   9  import codecs
  10  import io
  11  import typing
  12  import zlib
  13  
  14  from ._exceptions import DecodingError
  15  
  16  # Brotli support is optional
  17  try:
  18      # The C bindings in `brotli` are recommended for CPython.
  19      import brotli
  20  except ImportError:  # pragma: no cover
  21      try:
  22          # The CFFI bindings in `brotlicffi` are recommended for PyPy
  23          # and other environments.
  24          import brotlicffi as brotli
  25      except ImportError:
  26          brotli = None
  27  
  28  
  29  # Zstandard support is optional
  30  try:
  31      import zstandard
  32  except ImportError:  # pragma: no cover
  33      zstandard = None  # type: ignore
  34  
  35  
  36  class ContentDecoder:
  37      def decode(self, data: bytes) -> bytes:
  38          raise NotImplementedError()  # pragma: no cover
  39  
  40      def flush(self) -> bytes:
  41          raise NotImplementedError()  # pragma: no cover
  42  
  43  
  44  class IdentityDecoder(ContentDecoder):
  45      """
  46      Handle unencoded data.
  47      """
  48  
  49      def decode(self, data: bytes) -> bytes:
  50          return data
  51  
  52      def flush(self) -> bytes:
  53          return b""
  54  
  55  
  56  class DeflateDecoder(ContentDecoder):
  57      """
  58      Handle 'deflate' decoding.
  59  
  60      See: https://stackoverflow.com/questions/1838699
  61      """
  62  
  63      def __init__(self) -> None:
  64          self.first_attempt = True
  65          self.decompressor = zlib.decompressobj()
  66  
  67      def decode(self, data: bytes) -> bytes:
  68          was_first_attempt = self.first_attempt
  69          self.first_attempt = False
  70          try:
  71              return self.decompressor.decompress(data)
  72          except zlib.error as exc:
  73              if was_first_attempt:
  74                  self.decompressor = zlib.decompressobj(-zlib.MAX_WBITS)
  75                  return self.decode(data)
  76              raise DecodingError(str(exc)) from exc
  77  
  78      def flush(self) -> bytes:
  79          try:
  80              return self.decompressor.flush()
  81          except zlib.error as exc:  # pragma: no cover
  82              raise DecodingError(str(exc)) from exc
  83  
  84  
  85  class GZipDecoder(ContentDecoder):
  86      """
  87      Handle 'gzip' decoding.
  88  
  89      See: https://stackoverflow.com/questions/1838699
  90      """
  91  
  92      def __init__(self) -> None:
  93          self.decompressor = zlib.decompressobj(zlib.MAX_WBITS | 16)
  94  
  95      def decode(self, data: bytes) -> bytes:
  96          try:
  97              return self.decompressor.decompress(data)
  98          except zlib.error as exc:
  99              raise DecodingError(str(exc)) from exc
 100  
 101      def flush(self) -> bytes:
 102          try:
 103              return self.decompressor.flush()
 104          except zlib.error as exc:  # pragma: no cover
 105              raise DecodingError(str(exc)) from exc
 106  
 107  
 108  class BrotliDecoder(ContentDecoder):
 109      """
 110      Handle 'brotli' decoding.
 111  
 112      Requires `pip install brotlipy`. See: https://brotlipy.readthedocs.io/
 113          or   `pip install brotli`. See https://github.com/google/brotli
 114      Supports both 'brotlipy' and 'Brotli' packages since they share an import
 115      name. The top branches are for 'brotlipy' and bottom branches for 'Brotli'
 116      """
 117  
 118      def __init__(self) -> None:
 119          if brotli is None:  # pragma: no cover
 120              raise ImportError(
 121                  "Using 'BrotliDecoder', but neither of the 'brotlicffi' or 'brotli' "
 122                  "packages have been installed. "
 123                  "Make sure to install httpx using `pip install httpx[brotli]`."
 124              ) from None
 125  
 126          self.decompressor = brotli.Decompressor()
 127          self.seen_data = False
 128          self._decompress: typing.Callable[[bytes], bytes]
 129          if hasattr(self.decompressor, "decompress"):
 130              # The 'brotlicffi' package.
 131              self._decompress = self.decompressor.decompress  # pragma: no cover
 132          else:
 133              # The 'brotli' package.
 134              self._decompress = self.decompressor.process  # pragma: no cover
 135  
 136      def decode(self, data: bytes) -> bytes:
 137          if not data:
 138              return b""
 139          self.seen_data = True
 140          try:
 141              return self._decompress(data)
 142          except brotli.error as exc:
 143              raise DecodingError(str(exc)) from exc
 144  
 145      def flush(self) -> bytes:
 146          if not self.seen_data:
 147              return b""
 148          try:
 149              if hasattr(self.decompressor, "finish"):
 150                  # Only available in the 'brotlicffi' package.
 151  
 152                  # As the decompressor decompresses eagerly, this
 153                  # will never actually emit any data. However, it will potentially throw
 154                  # errors if a truncated or damaged data stream has been used.
 155                  self.decompressor.finish()  # pragma: no cover
 156              return b""
 157          except brotli.error as exc:  # pragma: no cover
 158              raise DecodingError(str(exc)) from exc
 159  
 160  
 161  class ZStandardDecoder(ContentDecoder):
 162      """
 163      Handle 'zstd' RFC 8878 decoding.
 164  
 165      Requires `pip install zstandard`.
 166      Can be installed as a dependency of httpx using `pip install httpx[zstd]`.
 167      """
 168  
 169      # inspired by the ZstdDecoder implementation in urllib3
 170      def __init__(self) -> None:
 171          if zstandard is None:  # pragma: no cover
 172              raise ImportError(
 173                  "Using 'ZStandardDecoder', ..."
 174                  "Make sure to install httpx using `pip install httpx[zstd]`."
 175              ) from None
 176  
 177          self.decompressor = zstandard.ZstdDecompressor().decompressobj()
 178          self.seen_data = False
 179  
 180      def decode(self, data: bytes) -> bytes:
 181          assert zstandard is not None
 182          self.seen_data = True
 183          output = io.BytesIO()
 184          try:
 185              output.write(self.decompressor.decompress(data))
 186              while self.decompressor.eof and self.decompressor.unused_data:
 187                  unused_data = self.decompressor.unused_data
 188                  self.decompressor = zstandard.ZstdDecompressor().decompressobj()
 189                  output.write(self.decompressor.decompress(unused_data))
 190          except zstandard.ZstdError as exc:
 191              raise DecodingError(str(exc)) from exc
 192          return output.getvalue()
 193  
 194      def flush(self) -> bytes:
 195          if not self.seen_data:
 196              return b""
 197          ret = self.decompressor.flush()  # note: this is a no-op
 198          if not self.decompressor.eof:
 199              raise DecodingError("Zstandard data is incomplete")  # pragma: no cover
 200          return bytes(ret)
 201  
 202  
 203  class MultiDecoder(ContentDecoder):
 204      """
 205      Handle the case where multiple encodings have been applied.
 206      """
 207  
 208      def __init__(self, children: typing.Sequence[ContentDecoder]) -> None:
 209          """
 210          'children' should be a sequence of decoders in the order in which
 211          each was applied.
 212          """
 213          # Note that we reverse the order for decoding.
 214          self.children = list(reversed(children))
 215  
 216      def decode(self, data: bytes) -> bytes:
 217          for child in self.children:
 218              data = child.decode(data)
 219          return data
 220  
 221      def flush(self) -> bytes:
 222          data = b""
 223          for child in self.children:
 224              data = child.decode(data) + child.flush()
 225          return data
 226  
 227  
 228  class ByteChunker:
 229      """
 230      Handles returning byte content in fixed-size chunks.
 231      """
 232  
 233      def __init__(self, chunk_size: int | None = None) -> None:
 234          self._buffer = io.BytesIO()
 235          self._chunk_size = chunk_size
 236  
 237      def decode(self, content: bytes) -> list[bytes]:
 238          if self._chunk_size is None:
 239              return [content] if content else []
 240  
 241          self._buffer.write(content)
 242          if self._buffer.tell() >= self._chunk_size:
 243              value = self._buffer.getvalue()
 244              chunks = [
 245                  value[i : i + self._chunk_size]
 246                  for i in range(0, len(value), self._chunk_size)
 247              ]
 248              if len(chunks[-1]) == self._chunk_size:
 249                  self._buffer.seek(0)
 250                  self._buffer.truncate()
 251                  return chunks
 252              else:
 253                  self._buffer.seek(0)
 254                  self._buffer.write(chunks[-1])
 255                  self._buffer.truncate()
 256                  return chunks[:-1]
 257          else:
 258              return []
 259  
 260      def flush(self) -> list[bytes]:
 261          value = self._buffer.getvalue()
 262          self._buffer.seek(0)
 263          self._buffer.truncate()
 264          return [value] if value else []
 265  
 266  
 267  class TextChunker:
 268      """
 269      Handles returning text content in fixed-size chunks.
 270      """
 271  
 272      def __init__(self, chunk_size: int | None = None) -> None:
 273          self._buffer = io.StringIO()
 274          self._chunk_size = chunk_size
 275  
 276      def decode(self, content: str) -> list[str]:
 277          if self._chunk_size is None:
 278              return [content] if content else []
 279  
 280          self._buffer.write(content)
 281          if self._buffer.tell() >= self._chunk_size:
 282              value = self._buffer.getvalue()
 283              chunks = [
 284                  value[i : i + self._chunk_size]
 285                  for i in range(0, len(value), self._chunk_size)
 286              ]
 287              if len(chunks[-1]) == self._chunk_size:
 288                  self._buffer.seek(0)
 289                  self._buffer.truncate()
 290                  return chunks
 291              else:
 292                  self._buffer.seek(0)
 293                  self._buffer.write(chunks[-1])
 294                  self._buffer.truncate()
 295                  return chunks[:-1]
 296          else:
 297              return []
 298  
 299      def flush(self) -> list[str]:
 300          value = self._buffer.getvalue()
 301          self._buffer.seek(0)
 302          self._buffer.truncate()
 303          return [value] if value else []
 304  
 305  
 306  class TextDecoder:
 307      """
 308      Handles incrementally decoding bytes into text
 309      """
 310  
 311      def __init__(self, encoding: str = "utf-8") -> None:
 312          self.decoder = codecs.getincrementaldecoder(encoding)(errors="replace")
 313  
 314      def decode(self, data: bytes) -> str:
 315          return self.decoder.decode(data)
 316  
 317      def flush(self) -> str:
 318          return self.decoder.decode(b"", True)
 319  
 320  
 321  class LineDecoder:
 322      """
 323      Handles incrementally reading lines from text.
 324  
 325      Has the same behaviour as the stdllib splitlines,
 326      but handling the input iteratively.
 327      """
 328  
 329      def __init__(self) -> None:
 330          self.buffer: list[str] = []
 331          self.trailing_cr: bool = False
 332  
 333      def decode(self, text: str) -> list[str]:
 334          # See https://docs.python.org/3/library/stdtypes.html#str.splitlines
 335          NEWLINE_CHARS = "\n\r\x0b\x0c\x1c\x1d\x1e\x85\u2028\u2029"
 336  
 337          # We always push a trailing `\r` into the next decode iteration.
 338          if self.trailing_cr:
 339              text = "\r" + text
 340              self.trailing_cr = False
 341          if text.endswith("\r"):
 342              self.trailing_cr = True
 343              text = text[:-1]
 344  
 345          if not text:
 346              # NOTE: the edge case input of empty text doesn't occur in practice,
 347              # because other httpx internals filter out this value
 348              return []  # pragma: no cover
 349  
 350          trailing_newline = text[-1] in NEWLINE_CHARS
 351          lines = text.splitlines()
 352  
 353          if len(lines) == 1 and not trailing_newline:
 354              # No new lines, buffer the input and continue.
 355              self.buffer.append(lines[0])
 356              return []
 357  
 358          if self.buffer:
 359              # Include any existing buffer in the first portion of the
 360              # splitlines result.
 361              lines = ["".join(self.buffer) + lines[0]] + lines[1:]
 362              self.buffer = []
 363  
 364          if not trailing_newline:
 365              # If the last segment of splitlines is not newline terminated,
 366              # then drop it from our output and start a new buffer.
 367              self.buffer = [lines.pop()]
 368  
 369          return lines
 370  
 371      def flush(self) -> list[str]:
 372          if not self.buffer and not self.trailing_cr:
 373              return []
 374  
 375          lines = ["".join(self.buffer)]
 376          self.buffer = []
 377          self.trailing_cr = False
 378          return lines
 379  
 380  
 381  SUPPORTED_DECODERS = {
 382      "identity": IdentityDecoder,
 383      "gzip": GZipDecoder,
 384      "deflate": DeflateDecoder,
 385      "br": BrotliDecoder,
 386      "zstd": ZStandardDecoder,
 387  }
 388  
 389  
 390  if brotli is None:
 391      SUPPORTED_DECODERS.pop("br")  # pragma: no cover
 392  if zstandard is None:
 393      SUPPORTED_DECODERS.pop("zstd")  # pragma: no cover
 394