_main.py 15 KB

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