Format: text/x-python
⬇ Download File (15.3 KB)
   1  from __future__ import annotations
   2  
   3  import functools
   4  import json
   5  import sys
   6  import typing
   7  
   8  import click
   9  import pygments.lexers
  10  import pygments.util
  11  import rich.console
  12  import rich.markup
  13  import rich.progress
  14  import rich.syntax
  15  import rich.table
  16  
  17  from ._client import Client
  18  from ._exceptions import RequestError
  19  from ._models import Response
  20  from ._status_codes import codes
  21  
  22  if typing.TYPE_CHECKING:
  23      import httpcore  # pragma: no cover
  24  
  25  
  26  def print_help() -> None:
  27      console = rich.console.Console()
  28  
  29      console.print("[bold]HTTPX :butterfly:", justify="center")
  30      console.print()
  31      console.print("A next generation HTTP client.", justify="center")
  32      console.print()
  33      console.print(
  34          "Usage: [bold]httpx[/bold] [cyan]<URL> [OPTIONS][/cyan] ", justify="left"
  35      )
  36      console.print()
  37  
  38      table = rich.table.Table.grid(padding=1, pad_edge=True)
  39      table.add_column("Parameter", no_wrap=True, justify="left", style="bold")
  40      table.add_column("Description")
  41      table.add_row(
  42          "-m, --method [cyan]METHOD",
  43          "Request method, such as GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD.\n"
  44          "[Default: GET, or POST if a request body is included]",
  45      )
  46      table.add_row(
  47          "-p, --params [cyan]<NAME VALUE> ...",
  48          "Query parameters to include in the request URL.",
  49      )
  50      table.add_row(
  51          "-c, --content [cyan]TEXT", "Byte content to include in the request body."
  52      )
  53      table.add_row(
  54          "-d, --data [cyan]<NAME VALUE> ...", "Form data to include in the request body."
  55      )
  56      table.add_row(
  57          "-f, --files [cyan]<NAME FILENAME> ...",
  58          "Form files to include in the request body.",
  59      )
  60      table.add_row("-j, --json [cyan]TEXT", "JSON data to include in the request body.")
  61      table.add_row(
  62          "-h, --headers [cyan]<NAME VALUE> ...",
  63          "Include additional HTTP headers in the request.",
  64      )
  65      table.add_row(
  66          "--cookies [cyan]<NAME VALUE> ...", "Cookies to include in the request."
  67      )
  68      table.add_row(
  69          "--auth [cyan]<USER PASS>",
  70          "Username and password to include in the request. Specify '-' for the password"
  71          " to use a password prompt. Note that using --verbose/-v will expose"
  72          " the Authorization header, including the password encoding"
  73          " in a trivially reversible format.",
  74      )
  75  
  76      table.add_row(
  77          "--proxy [cyan]URL",
  78          "Send the request via a proxy. Should be the URL giving the proxy address.",
  79      )
  80  
  81      table.add_row(
  82          "--timeout [cyan]FLOAT",
  83          "Timeout value to use for network operations, such as establishing the"
  84          " connection, reading some data, etc... [Default: 5.0]",
  85      )
  86  
  87      table.add_row("--follow-redirects", "Automatically follow redirects.")
  88      table.add_row("--no-verify", "Disable SSL verification.")
  89      table.add_row(
  90          "--http2", "Send the request using HTTP/2, if the remote server supports it."
  91      )
  92  
  93      table.add_row(
  94          "--download [cyan]FILE",
  95          "Save the response content as a file, rather than displaying it.",
  96      )
  97  
  98      table.add_row("-v, --verbose", "Verbose output. Show request as well as response.")
  99      table.add_row("--help", "Show this message and exit.")
 100      console.print(table)
 101  
 102  
 103  def get_lexer_for_response(response: Response) -> str:
 104      content_type = response.headers.get("Content-Type")
 105      if content_type is not None:
 106          mime_type, _, _ = content_type.partition(";")
 107          try:
 108              return typing.cast(
 109                  str, pygments.lexers.get_lexer_for_mimetype(mime_type.strip()).name
 110              )
 111          except pygments.util.ClassNotFound:  # pragma: no cover
 112              pass
 113      return ""  # pragma: no cover
 114  
 115  
 116  def format_request_headers(request: httpcore.Request, http2: bool = False) -> str:
 117      version = "HTTP/2" if http2 else "HTTP/1.1"
 118      headers = [
 119          (name.lower() if http2 else name, value) for name, value in request.headers
 120      ]
 121      method = request.method.decode("ascii")
 122      target = request.url.target.decode("ascii")
 123      lines = [f"{method} {target} {version}"] + [
 124          f"{name.decode('ascii')}: {value.decode('ascii')}" for name, value in headers
 125      ]
 126      return "\n".join(lines)
 127  
 128  
 129  def format_response_headers(
 130      http_version: bytes,
 131      status: int,
 132      reason_phrase: bytes | None,
 133      headers: list[tuple[bytes, bytes]],
 134  ) -> str:
 135      version = http_version.decode("ascii")
 136      reason = (
 137          codes.get_reason_phrase(status)
 138          if reason_phrase is None
 139          else reason_phrase.decode("ascii")
 140      )
 141      lines = [f"{version} {status} {reason}"] + [
 142          f"{name.decode('ascii')}: {value.decode('ascii')}" for name, value in headers
 143      ]
 144      return "\n".join(lines)
 145  
 146  
 147  def print_request_headers(request: httpcore.Request, http2: bool = False) -> None:
 148      console = rich.console.Console()
 149      http_text = format_request_headers(request, http2=http2)
 150      syntax = rich.syntax.Syntax(http_text, "http", theme="ansi_dark", word_wrap=True)
 151      console.print(syntax)
 152      syntax = rich.syntax.Syntax("", "http", theme="ansi_dark", word_wrap=True)
 153      console.print(syntax)
 154  
 155  
 156  def print_response_headers(
 157      http_version: bytes,
 158      status: int,
 159      reason_phrase: bytes | None,
 160      headers: list[tuple[bytes, bytes]],
 161  ) -> None:
 162      console = rich.console.Console()
 163      http_text = format_response_headers(http_version, status, reason_phrase, headers)
 164      syntax = rich.syntax.Syntax(http_text, "http", theme="ansi_dark", word_wrap=True)
 165      console.print(syntax)
 166      syntax = rich.syntax.Syntax("", "http", theme="ansi_dark", word_wrap=True)
 167      console.print(syntax)
 168  
 169  
 170  def print_response(response: Response) -> None:
 171      console = rich.console.Console()
 172      lexer_name = get_lexer_for_response(response)
 173      if lexer_name:
 174          if lexer_name.lower() == "json":
 175              try:
 176                  data = response.json()
 177                  text = json.dumps(data, indent=4)
 178              except ValueError:  # pragma: no cover
 179                  text = response.text
 180          else:
 181              text = response.text
 182  
 183          syntax = rich.syntax.Syntax(text, lexer_name, theme="ansi_dark", word_wrap=True)
 184          console.print(syntax)
 185      else:
 186          console.print(f"<{len(response.content)} bytes of binary data>")
 187  
 188  
 189  _PCTRTT = typing.Tuple[typing.Tuple[str, str], ...]
 190  _PCTRTTT = typing.Tuple[_PCTRTT, ...]
 191  _PeerCertRetDictType = typing.Dict[str, typing.Union[str, _PCTRTTT, _PCTRTT]]
 192  
 193  
 194  def format_certificate(cert: _PeerCertRetDictType) -> str:  # pragma: no cover
 195      lines = []
 196      for key, value in cert.items():
 197          if isinstance(value, (list, tuple)):
 198              lines.append(f"*   {key}:")
 199              for item in value:
 200                  if key in ("subject", "issuer"):
 201                      for sub_item in item:
 202                          lines.append(f"*     {sub_item[0]}: {sub_item[1]!r}")
 203                  elif isinstance(item, tuple) and len(item) == 2:
 204                      lines.append(f"*     {item[0]}: {item[1]!r}")
 205                  else:
 206                      lines.append(f"*     {item!r}")
 207          else:
 208              lines.append(f"*   {key}: {value!r}")
 209      return "\n".join(lines)
 210  
 211  
 212  def trace(
 213      name: str, info: typing.Mapping[str, typing.Any], verbose: bool = False
 214  ) -> None:
 215      console = rich.console.Console()
 216      if name == "connection.connect_tcp.started" and verbose:
 217          host = info["host"]
 218          console.print(f"* Connecting to {host!r}")
 219      elif name == "connection.connect_tcp.complete" and verbose:
 220          stream = info["return_value"]
 221          server_addr = stream.get_extra_info("server_addr")
 222          console.print(f"* Connected to {server_addr[0]!r} on port {server_addr[1]}")
 223      elif name == "connection.start_tls.complete" and verbose:  # pragma: no cover
 224          stream = info["return_value"]
 225          ssl_object = stream.get_extra_info("ssl_object")
 226          version = ssl_object.version()
 227          cipher = ssl_object.cipher()
 228          server_cert = ssl_object.getpeercert()
 229          alpn = ssl_object.selected_alpn_protocol()
 230          console.print(f"* SSL established using {version!r} / {cipher[0]!r}")
 231          console.print(f"* Selected ALPN protocol: {alpn!r}")
 232          if server_cert:
 233              console.print("* Server certificate:")
 234              console.print(format_certificate(server_cert))
 235      elif name == "http11.send_request_headers.started" and verbose:
 236          request = info["request"]
 237          print_request_headers(request, http2=False)
 238      elif name == "http2.send_request_headers.started" and verbose:  # pragma: no cover
 239          request = info["request"]
 240          print_request_headers(request, http2=True)
 241      elif name == "http11.receive_response_headers.complete":
 242          http_version, status, reason_phrase, headers = info["return_value"]
 243          print_response_headers(http_version, status, reason_phrase, headers)
 244      elif name == "http2.receive_response_headers.complete":  # pragma: no cover
 245          status, headers = info["return_value"]
 246          http_version = b"HTTP/2"
 247          reason_phrase = None
 248          print_response_headers(http_version, status, reason_phrase, headers)
 249  
 250  
 251  def download_response(response: Response, download: typing.BinaryIO) -> None:
 252      console = rich.console.Console()
 253      console.print()
 254      content_length = response.headers.get("Content-Length")
 255      with rich.progress.Progress(
 256          "[progress.description]{task.description}",
 257          "[progress.percentage]{task.percentage:>3.0f}%",
 258          rich.progress.BarColumn(bar_width=None),
 259          rich.progress.DownloadColumn(),
 260          rich.progress.TransferSpeedColumn(),
 261      ) as progress:
 262          description = f"Downloading [bold]{rich.markup.escape(download.name)}"
 263          download_task = progress.add_task(
 264              description,
 265              total=int(content_length or 0),
 266              start=content_length is not None,
 267          )
 268          for chunk in response.iter_bytes():
 269              download.write(chunk)
 270              progress.update(download_task, completed=response.num_bytes_downloaded)
 271  
 272  
 273  def validate_json(
 274      ctx: click.Context,
 275      param: click.Option | click.Parameter,
 276      value: typing.Any,
 277  ) -> typing.Any:
 278      if value is None:
 279          return None
 280  
 281      try:
 282          return json.loads(value)
 283      except json.JSONDecodeError:  # pragma: no cover
 284          raise click.BadParameter("Not valid JSON")
 285  
 286  
 287  def validate_auth(
 288      ctx: click.Context,
 289      param: click.Option | click.Parameter,
 290      value: typing.Any,
 291  ) -> typing.Any:
 292      if value == (None, None):
 293          return None
 294  
 295      username, password = value
 296      if password == "-":  # pragma: no cover
 297          password = click.prompt("Password", hide_input=True)
 298      return (username, password)
 299  
 300  
 301  def handle_help(
 302      ctx: click.Context,
 303      param: click.Option | click.Parameter,
 304      value: typing.Any,
 305  ) -> None:
 306      if not value or ctx.resilient_parsing:
 307          return
 308  
 309      print_help()
 310      ctx.exit()
 311  
 312  
 313  @click.command(add_help_option=False)
 314  @click.argument("url", type=str)
 315  @click.option(
 316      "--method",
 317      "-m",
 318      "method",
 319      type=str,
 320      help=(
 321          "Request method, such as GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD. "
 322          "[Default: GET, or POST if a request body is included]"
 323      ),
 324  )
 325  @click.option(
 326      "--params",
 327      "-p",
 328      "params",
 329      type=(str, str),
 330      multiple=True,
 331      help="Query parameters to include in the request URL.",
 332  )
 333  @click.option(
 334      "--content",
 335      "-c",
 336      "content",
 337      type=str,
 338      help="Byte content to include in the request body.",
 339  )
 340  @click.option(
 341      "--data",
 342      "-d",
 343      "data",
 344      type=(str, str),
 345      multiple=True,
 346      help="Form data to include in the request body.",
 347  )
 348  @click.option(
 349      "--files",
 350      "-f",
 351      "files",
 352      type=(str, click.File(mode="rb")),
 353      multiple=True,
 354      help="Form files to include in the request body.",
 355  )
 356  @click.option(
 357      "--json",
 358      "-j",
 359      "json",
 360      type=str,
 361      callback=validate_json,
 362      help="JSON data to include in the request body.",
 363  )
 364  @click.option(
 365      "--headers",
 366      "-h",
 367      "headers",
 368      type=(str, str),
 369      multiple=True,
 370      help="Include additional HTTP headers in the request.",
 371  )
 372  @click.option(
 373      "--cookies",
 374      "cookies",
 375      type=(str, str),
 376      multiple=True,
 377      help="Cookies to include in the request.",
 378  )
 379  @click.option(
 380      "--auth",
 381      "auth",
 382      type=(str, str),
 383      default=(None, None),
 384      callback=validate_auth,
 385      help=(
 386          "Username and password to include in the request. "
 387          "Specify '-' for the password to use a password prompt. "
 388          "Note that using --verbose/-v will expose the Authorization header, "
 389          "including the password encoding in a trivially reversible format."
 390      ),
 391  )
 392  @click.option(
 393      "--proxy",
 394      "proxy",
 395      type=str,
 396      default=None,
 397      help="Send the request via a proxy. Should be the URL giving the proxy address.",
 398  )
 399  @click.option(
 400      "--timeout",
 401      "timeout",
 402      type=float,
 403      default=5.0,
 404      help=(
 405          "Timeout value to use for network operations, such as establishing the "
 406          "connection, reading some data, etc... [Default: 5.0]"
 407      ),
 408  )
 409  @click.option(
 410      "--follow-redirects",
 411      "follow_redirects",
 412      is_flag=True,
 413      default=False,
 414      help="Automatically follow redirects.",
 415  )
 416  @click.option(
 417      "--no-verify",
 418      "verify",
 419      is_flag=True,
 420      default=True,
 421      help="Disable SSL verification.",
 422  )
 423  @click.option(
 424      "--http2",
 425      "http2",
 426      type=bool,
 427      is_flag=True,
 428      default=False,
 429      help="Send the request using HTTP/2, if the remote server supports it.",
 430  )
 431  @click.option(
 432      "--download",
 433      type=click.File("wb"),
 434      help="Save the response content as a file, rather than displaying it.",
 435  )
 436  @click.option(
 437      "--verbose",
 438      "-v",
 439      type=bool,
 440      is_flag=True,
 441      default=False,
 442      help="Verbose. Show request as well as response.",
 443  )
 444  @click.option(
 445      "--help",
 446      is_flag=True,
 447      is_eager=True,
 448      expose_value=False,
 449      callback=handle_help,
 450      help="Show this message and exit.",
 451  )
 452  def main(
 453      url: str,
 454      method: str,
 455      params: list[tuple[str, str]],
 456      content: str,
 457      data: list[tuple[str, str]],
 458      files: list[tuple[str, click.File]],
 459      json: str,
 460      headers: list[tuple[str, str]],
 461      cookies: list[tuple[str, str]],
 462      auth: tuple[str, str] | None,
 463      proxy: str,
 464      timeout: float,
 465      follow_redirects: bool,
 466      verify: bool,
 467      http2: bool,
 468      download: typing.BinaryIO | None,
 469      verbose: bool,
 470  ) -> None:
 471      """
 472      An HTTP command line client.
 473      Sends a request and displays the response.
 474      """
 475      if not method:
 476          method = "POST" if content or data or files or json else "GET"
 477  
 478      try:
 479          with Client(proxy=proxy, timeout=timeout, http2=http2, verify=verify) as client:
 480              with client.stream(
 481                  method,
 482                  url,
 483                  params=list(params),
 484                  content=content,
 485                  data=dict(data),
 486                  files=files,  # type: ignore
 487                  json=json,
 488                  headers=headers,
 489                  cookies=dict(cookies),
 490                  auth=auth,
 491                  follow_redirects=follow_redirects,
 492                  extensions={"trace": functools.partial(trace, verbose=verbose)},
 493              ) as response:
 494                  if download is not None:
 495                      download_response(response, download)
 496                  else:
 497                      response.read()
 498                      if response.content:
 499                          print_response(response)
 500  
 501      except RequestError as exc:
 502          console = rich.console.Console()
 503          console.print(f"[red]{type(exc).__name__}[/red]: {exc}")
 504          sys.exit(1)
 505  
 506      sys.exit(0 if response.is_success else 1)
 507