_main.py 15 KB

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