from __future__ import annotations import functools import json import sys import typing import click import httpcore import pygments.lexers import pygments.util import rich.console import rich.markup import rich.progress import rich.syntax import rich.table from ._client import Client from ._exceptions import RequestError from ._models import Response from ._status_codes import codes def print_help() -> None: console = rich.console.Console() console.print("[bold]HTTPX :butterfly:", justify="center") console.print() console.print("A next generation HTTP client.", justify="center") console.print() console.print( "Usage: [bold]httpx[/bold] [cyan] [OPTIONS][/cyan] ", justify="left" ) console.print() table = rich.table.Table.grid(padding=1, pad_edge=True) table.add_column("Parameter", no_wrap=True, justify="left", style="bold") table.add_column("Description") table.add_row( "-m, --method [cyan]METHOD", "Request method, such as GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD.\n" "[Default: GET, or POST if a request body is included]", ) table.add_row( "-p, --params [cyan] ...", "Query parameters to include in the request URL.", ) table.add_row( "-c, --content [cyan]TEXT", "Byte content to include in the request body." ) table.add_row( "-d, --data [cyan] ...", "Form data to include in the request body." ) table.add_row( "-f, --files [cyan] ...", "Form files to include in the request body.", ) table.add_row("-j, --json [cyan]TEXT", "JSON data to include in the request body.") table.add_row( "-h, --headers [cyan] ...", "Include additional HTTP headers in the request.", ) table.add_row( "--cookies [cyan] ...", "Cookies to include in the request." ) table.add_row( "--auth [cyan]", "Username and password to include in the request. Specify '-' for the password" " to use a password prompt. Note that using --verbose/-v will expose" " the Authorization header, including the password encoding" " in a trivially reversible format.", ) table.add_row( "--proxy [cyan]URL", "Send the request via a proxy. Should be the URL giving the proxy address.", ) table.add_row( "--timeout [cyan]FLOAT", "Timeout value to use for network operations, such as establishing the" " connection, reading some data, etc... [Default: 5.0]", ) table.add_row("--follow-redirects", "Automatically follow redirects.") table.add_row("--no-verify", "Disable SSL verification.") table.add_row( "--http2", "Send the request using HTTP/2, if the remote server supports it." ) table.add_row( "--download [cyan]FILE", "Save the response content as a file, rather than displaying it.", ) table.add_row("-v, --verbose", "Verbose output. Show request as well as response.") table.add_row("--help", "Show this message and exit.") console.print(table) def get_lexer_for_response(response: Response) -> str: content_type = response.headers.get("Content-Type") if content_type is not None: mime_type, _, _ = content_type.partition(";") try: return typing.cast( str, pygments.lexers.get_lexer_for_mimetype(mime_type.strip()).name ) except pygments.util.ClassNotFound: # pragma: no cover pass return "" # pragma: no cover def format_request_headers(request: httpcore.Request, http2: bool = False) -> str: version = "HTTP/2" if http2 else "HTTP/1.1" headers = [ (name.lower() if http2 else name, value) for name, value in request.headers ] method = request.method.decode("ascii") target = request.url.target.decode("ascii") lines = [f"{method} {target} {version}"] + [ f"{name.decode('ascii')}: {value.decode('ascii')}" for name, value in headers ] return "\n".join(lines) def format_response_headers( http_version: bytes, status: int, reason_phrase: bytes | None, headers: list[tuple[bytes, bytes]], ) -> str: version = http_version.decode("ascii") reason = ( codes.get_reason_phrase(status) if reason_phrase is None else reason_phrase.decode("ascii") ) lines = [f"{version} {status} {reason}"] + [ f"{name.decode('ascii')}: {value.decode('ascii')}" for name, value in headers ] return "\n".join(lines) def print_request_headers(request: httpcore.Request, http2: bool = False) -> None: console = rich.console.Console() http_text = format_request_headers(request, http2=http2) syntax = rich.syntax.Syntax(http_text, "http", theme="ansi_dark", word_wrap=True) console.print(syntax) syntax = rich.syntax.Syntax("", "http", theme="ansi_dark", word_wrap=True) console.print(syntax) def print_response_headers( http_version: bytes, status: int, reason_phrase: bytes | None, headers: list[tuple[bytes, bytes]], ) -> None: console = rich.console.Console() http_text = format_response_headers(http_version, status, reason_phrase, headers) syntax = rich.syntax.Syntax(http_text, "http", theme="ansi_dark", word_wrap=True) console.print(syntax) syntax = rich.syntax.Syntax("", "http", theme="ansi_dark", word_wrap=True) console.print(syntax) def print_response(response: Response) -> None: console = rich.console.Console() lexer_name = get_lexer_for_response(response) if lexer_name: if lexer_name.lower() == "json": try: data = response.json() text = json.dumps(data, indent=4) except ValueError: # pragma: no cover text = response.text else: text = response.text syntax = rich.syntax.Syntax(text, lexer_name, theme="ansi_dark", word_wrap=True) console.print(syntax) else: console.print(f"<{len(response.content)} bytes of binary data>") _PCTRTT = typing.Tuple[typing.Tuple[str, str], ...] _PCTRTTT = typing.Tuple[_PCTRTT, ...] _PeerCertRetDictType = typing.Dict[str, typing.Union[str, _PCTRTTT, _PCTRTT]] def format_certificate(cert: _PeerCertRetDictType) -> str: # pragma: no cover lines = [] for key, value in cert.items(): if isinstance(value, (list, tuple)): lines.append(f"* {key}:") for item in value: if key in ("subject", "issuer"): for sub_item in item: lines.append(f"* {sub_item[0]}: {sub_item[1]!r}") elif isinstance(item, tuple) and len(item) == 2: lines.append(f"* {item[0]}: {item[1]!r}") else: lines.append(f"* {item!r}") else: lines.append(f"* {key}: {value!r}") return "\n".join(lines) def trace( name: str, info: typing.Mapping[str, typing.Any], verbose: bool = False ) -> None: console = rich.console.Console() if name == "connection.connect_tcp.started" and verbose: host = info["host"] console.print(f"* Connecting to {host!r}") elif name == "connection.connect_tcp.complete" and verbose: stream = info["return_value"] server_addr = stream.get_extra_info("server_addr") console.print(f"* Connected to {server_addr[0]!r} on port {server_addr[1]}") elif name == "connection.start_tls.complete" and verbose: # pragma: no cover stream = info["return_value"] ssl_object = stream.get_extra_info("ssl_object") version = ssl_object.version() cipher = ssl_object.cipher() server_cert = ssl_object.getpeercert() alpn = ssl_object.selected_alpn_protocol() console.print(f"* SSL established using {version!r} / {cipher[0]!r}") console.print(f"* Selected ALPN protocol: {alpn!r}") if server_cert: console.print("* Server certificate:") console.print(format_certificate(server_cert)) elif name == "http11.send_request_headers.started" and verbose: request = info["request"] print_request_headers(request, http2=False) elif name == "http2.send_request_headers.started" and verbose: # pragma: no cover request = info["request"] print_request_headers(request, http2=True) elif name == "http11.receive_response_headers.complete": http_version, status, reason_phrase, headers = info["return_value"] print_response_headers(http_version, status, reason_phrase, headers) elif name == "http2.receive_response_headers.complete": # pragma: no cover status, headers = info["return_value"] http_version = b"HTTP/2" reason_phrase = None print_response_headers(http_version, status, reason_phrase, headers) def download_response(response: Response, download: typing.BinaryIO) -> None: console = rich.console.Console() console.print() content_length = response.headers.get("Content-Length") with rich.progress.Progress( "[progress.description]{task.description}", "[progress.percentage]{task.percentage:>3.0f}%", rich.progress.BarColumn(bar_width=None), rich.progress.DownloadColumn(), rich.progress.TransferSpeedColumn(), ) as progress: description = f"Downloading [bold]{rich.markup.escape(download.name)}" download_task = progress.add_task( description, total=int(content_length or 0), start=content_length is not None, ) for chunk in response.iter_bytes(): download.write(chunk) progress.update(download_task, completed=response.num_bytes_downloaded) def validate_json( ctx: click.Context, param: click.Option | click.Parameter, value: typing.Any, ) -> typing.Any: if value is None: return None try: return json.loads(value) except json.JSONDecodeError: # pragma: no cover raise click.BadParameter("Not valid JSON") def validate_auth( ctx: click.Context, param: click.Option | click.Parameter, value: typing.Any, ) -> typing.Any: if value == (None, None): return None username, password = value if password == "-": # pragma: no cover password = click.prompt("Password", hide_input=True) return (username, password) def handle_help( ctx: click.Context, param: click.Option | click.Parameter, value: typing.Any, ) -> None: if not value or ctx.resilient_parsing: return print_help() ctx.exit() @click.command(add_help_option=False) @click.argument("url", type=str) @click.option( "--method", "-m", "method", type=str, help=( "Request method, such as GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD. " "[Default: GET, or POST if a request body is included]" ), ) @click.option( "--params", "-p", "params", type=(str, str), multiple=True, help="Query parameters to include in the request URL.", ) @click.option( "--content", "-c", "content", type=str, help="Byte content to include in the request body.", ) @click.option( "--data", "-d", "data", type=(str, str), multiple=True, help="Form data to include in the request body.", ) @click.option( "--files", "-f", "files", type=(str, click.File(mode="rb")), multiple=True, help="Form files to include in the request body.", ) @click.option( "--json", "-j", "json", type=str, callback=validate_json, help="JSON data to include in the request body.", ) @click.option( "--headers", "-h", "headers", type=(str, str), multiple=True, help="Include additional HTTP headers in the request.", ) @click.option( "--cookies", "cookies", type=(str, str), multiple=True, help="Cookies to include in the request.", ) @click.option( "--auth", "auth", type=(str, str), default=(None, None), callback=validate_auth, help=( "Username and password to include in the request. " "Specify '-' for the password to use a password prompt. " "Note that using --verbose/-v will expose the Authorization header, " "including the password encoding in a trivially reversible format." ), ) @click.option( "--proxy", "proxy", type=str, default=None, help="Send the request via a proxy. Should be the URL giving the proxy address.", ) @click.option( "--timeout", "timeout", type=float, default=5.0, help=( "Timeout value to use for network operations, such as establishing the " "connection, reading some data, etc... [Default: 5.0]" ), ) @click.option( "--follow-redirects", "follow_redirects", is_flag=True, default=False, help="Automatically follow redirects.", ) @click.option( "--no-verify", "verify", is_flag=True, default=True, help="Disable SSL verification.", ) @click.option( "--http2", "http2", type=bool, is_flag=True, default=False, help="Send the request using HTTP/2, if the remote server supports it.", ) @click.option( "--download", type=click.File("wb"), help="Save the response content as a file, rather than displaying it.", ) @click.option( "--verbose", "-v", type=bool, is_flag=True, default=False, help="Verbose. Show request as well as response.", ) @click.option( "--help", is_flag=True, is_eager=True, expose_value=False, callback=handle_help, help="Show this message and exit.", ) def main( url: str, method: str, params: list[tuple[str, str]], content: str, data: list[tuple[str, str]], files: list[tuple[str, click.File]], json: str, headers: list[tuple[str, str]], cookies: list[tuple[str, str]], auth: tuple[str, str] | None, proxy: str, timeout: float, follow_redirects: bool, verify: bool, http2: bool, download: typing.BinaryIO | None, verbose: bool, ) -> None: """ An HTTP command line client. Sends a request and displays the response. """ if not method: method = "POST" if content or data or files or json else "GET" try: with Client( proxy=proxy, timeout=timeout, verify=verify, http2=http2, ) as client: with client.stream( method, url, params=list(params), content=content, data=dict(data), files=files, # type: ignore json=json, headers=headers, cookies=dict(cookies), auth=auth, follow_redirects=follow_redirects, extensions={"trace": functools.partial(trace, verbose=verbose)}, ) as response: if download is not None: download_response(response, download) else: response.read() if response.content: print_response(response) except RequestError as exc: console = rich.console.Console() console.print(f"[red]{type(exc).__name__}[/red]: {exc}") sys.exit(1) sys.exit(0 if response.is_success else 1)