From ff6f1887b0c316b43a8ab4f040f99d1ea862268b Mon Sep 17 00:00:00 2001 From: Batuhan Taskaya Date: Thu, 14 Apr 2022 17:43:10 +0300 Subject: [PATCH] [Major] UI Enhancements (#1321) * Refactor tests to use a text-based standard output. (#1318) * Implement new style `--help` (#1316) * Implement man page generation (#1317) * Implement rich progress bars. (#1324) * Man page deployment & isolation. (#1325) * Remove all unsorted usages in the CLI docs * Implement isolated mode for man page generation * Add a CI job for autogenerated files * Distribute man pages through PyPI * Pin the date for man pages. (#1326) * Hide suppressed arguments from --help/man pages (#1329) * Change download spinner to line (#1328) * Regenerate autogenerated files when pushed against to master. (#1339) * Highlight options (#1340) * Additional man page enhancements (#1341) * Group options by the parent category & highlight -o/--o * Display (and underline) the METAVAR on man pages. * Make help message processing more robust (#1342) * Inherit `help` from `short_help` * Don't mirror short_help directly. * Fixup the serialization * Use `pager` and `man` on `--manual` when applicable (#1343) * Run `man $program` on --manual * Page the output of `--manual` for systems that lack man pages * Improvements over progress bars (separate bar, status line, etc.) (#1346) * Redesign the --help layout. * Make our usage of rich compatible with 9.10.0 * Add `HTTPIE_NO_MAN_PAGES` * Make tests also patch os.get_terminal_size * Generate CLI spec from HTTPie & Man Page Hook (#1354) * Generate CLI spec from HTTPie & add man page hook * Use the full command space for the option headers --- .github/workflows/autogenerated-files.yml | 28 + .github/workflows/docs-update-install.yml | 33 -- Makefile | 19 +- extras/man/http.1 | 600 ++++++++++++++++++++++ extras/man/httpie.1 | 91 ++++ extras/man/https.1 | 600 ++++++++++++++++++++++ extras/scripts/generate_man_pages.py | 156 ++++++ httpie/__init__.py | 1 + httpie/cli/argparser.py | 54 +- httpie/cli/definition.py | 256 +++++---- httpie/cli/options.py | 86 +++- httpie/cli/utils.py | 30 +- httpie/context.py | 60 ++- httpie/core.py | 2 +- httpie/downloads.py | 186 ++----- httpie/manager/cli.py | 21 +- httpie/output/formatters/colors.py | 5 +- httpie/output/ui/man_pages.py | 33 ++ httpie/output/ui/palette.py | 21 +- httpie/output/ui/rich_help.py | 217 ++++++++ httpie/output/ui/rich_palette.py | 23 + httpie/output/ui/rich_progress.py | 136 +++++ httpie/output/ui/rich_utils.py | 35 ++ setup.py | 5 + tests/test_cli_ui.py | 50 +- tests/test_cli_utils.py | 2 +- tests/test_downloads.py | 38 +- tests/test_output.py | 2 + tests/test_parser_schema.py | 9 +- tests/test_stream.py | 2 +- tests/utils/__init__.py | 107 +++- tests/utils/plugins_cli.py | 2 +- 32 files changed, 2521 insertions(+), 389 deletions(-) create mode 100644 .github/workflows/autogenerated-files.yml delete mode 100644 .github/workflows/docs-update-install.yml create mode 100644 extras/man/http.1 create mode 100644 extras/man/httpie.1 create mode 100644 extras/man/https.1 create mode 100644 extras/scripts/generate_man_pages.py create mode 100644 httpie/output/ui/man_pages.py create mode 100644 httpie/output/ui/rich_help.py create mode 100644 httpie/output/ui/rich_palette.py create mode 100644 httpie/output/ui/rich_progress.py create mode 100644 httpie/output/ui/rich_utils.py diff --git a/.github/workflows/autogenerated-files.yml b/.github/workflows/autogenerated-files.yml new file mode 100644 index 00000000..7a17ef0c --- /dev/null +++ b/.github/workflows/autogenerated-files.yml @@ -0,0 +1,28 @@ +name: Update Autogenerated Files + +on: + push: + branches: + - master + +jobs: + regen-autogenerated-files: + runs-on: ubuntu-latest + if: github.event.pull_request.head.repo.full_name == github.repository + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-python@v3 + with: + python-version: 3.9 + + - run: make regen-all + + - name: Create Pull Request + id: cpr + uses: peter-evans/create-pull-request@v3 + with: + commit-message: "[automated] Update auto-generated files" + title: "[automated] Update auto-generated files" + delete-branch: true + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/docs-update-install.yml b/.github/workflows/docs-update-install.yml deleted file mode 100644 index 2d3fbe09..00000000 --- a/.github/workflows/docs-update-install.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Update & Install Docs - -on: - push: - branches: - - master - paths: - - .github/workflows/docs-update-install.yml - - docs/installation/* - - # Allow to call the workflow manually - workflow_dispatch: - -jobs: - doc: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v3 - with: - python-version: 3.9 - - run: make install - - run: make doc-update-install - - uses: Automattic/action-commit-to-branch@master - with: - branch: master - commit_message: | - Auto-update install docs - - Via .github/workflows/docs-update-install.yml - - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Makefile b/Makefile index 0e5c1a12..207f02ff 100644 --- a/Makefile +++ b/Makefile @@ -147,11 +147,6 @@ doc-check: mdl --git-recurse --style docs/markdownlint.rb . -doc-update-install: - @echo $(H1)Updating installation instructions in the docs$(H1END) - $(VENV_PYTHON) docs/installation/generate.py - - ############################################################################### # Publishing to PyPi ############################################################################### @@ -211,3 +206,17 @@ brew-test: @echo $(H1)Auditing…$(H1END) brew audit --strict httpie + +############################################################################### +# Regeneration +############################################################################### + +regen-all: regen-man-pages regen-install-methods + +regen-man-pages: install + @echo $(H1)Regenerate man pages$(H1END) + $(VENV_PYTHON) extras/scripts/generate_man_pages.py + +regen-install-methods: + @echo $(H1)Updating installation instructions in the docs$(H1END) + $(VENV_PYTHON) docs/installation/generate.py diff --git a/extras/man/http.1 b/extras/man/http.1 new file mode 100644 index 00000000..1dd7996d --- /dev/null +++ b/extras/man/http.1 @@ -0,0 +1,600 @@ +.TH http 1 "2022-03-08" "HTTPie 3.1.1.dev0" "HTTPie Manual" +.SH NAME +http +.SH SYNOPSIS +http [METHOD] URL [REQUEST_ITEM ...] + +.SH DESCRIPTION +HTTPie: modern, user-friendly command-line HTTP client for the API era. +.SH Positional Arguments + +These arguments come after any flags and in the order they are listed here. +Only URL is required. + +.IP "\fB\,METHOD\/\fR" + + +The HTTP method to be used for the request (GET, POST, PUT, DELETE, ...). + +This argument can be omitted in which case HTTPie will use POST if there +is some data to be sent, otherwise GET: + + $ http example.org # => GET + $ http example.org hello=world # => POST + + + +.IP "\fB\,URL\/\fR" + + +The request URL. Scheme defaults to \'http://\' if the URL +does not include one. (You can override this with:\fB\,--default-scheme\/\fR=http/https) + +You can also use a shorthand for localhost + + $ http :3000 # => http://localhost:3000 + $ http :/foo # => http://localhost/foo + + + +.IP "\fB\,REQUEST_ITEM\/\fR" + + +Optional key-value pairs to be included in the request. The separator used +determines the type: + +\':\' HTTP headers: + + Referer:https://httpie.io Cookie:foo=bar User-Agent:bacon/1.0 + +\'==\' URL parameters to be appended to the request URI: + + search==httpie + +\'=\' Data fields to be serialized into a JSON object (with\fB\,--json\/\fR,\fB\,-j\/\fR) + or form data (with\fB\,--form\/\fR,\fB\,-f\/\fR): + + name=HTTPie language=Python description=\'CLI HTTP client\' + +\':=\' Non-string JSON data fields (only with\fB\,--json\/\fR,\fB\,-j\/\fR): + + awesome:=true amount:=42 colors:=\'["red", "green", "blue"]\' + +\'@\' Form file fields (only with\fB\,--form\/\fR or\fB\,--multipart\/\fR): + + cv@\~/Documents/CV.pdf + cv@\'\~/Documents/CV.pdf;type=application/pdf\' + +\'=@\' A data field like \'=\', but takes a file path and embeds its content: + + essay=@Documents/essay.txt + +\':=@\' A raw JSON field like \':=\', but takes a file path and embeds its content: + + package:=@./package.json + +You can use a backslash to escape a colliding separator in the field name: + + field-name-with\\:colon=value + + + +.PP +.SH Predefined Content Types +.IP "\fB\,--json\/\fR, \fB\,-j\/\fR" + + +(default) Data items from the command line are serialized as a JSON object. +The Content-Type and Accept headers are set to application/json +(if not specified). + + + +.IP "\fB\,--form\/\fR, \fB\,-f\/\fR" + + +Data items from the command line are serialized as form fields. + +The Content-Type is set to application/x-www-form-urlencoded (if not +specified). The presence of any file fields results in a +multipart/form-data request. + + + +.IP "\fB\,--multipart\/\fR" + + +Similar to\fB\,--form\/\fR, but always sends a multipart/form-data request (i.e., even without files). + + +.IP "\fB\,--boundary\/\fR" + + +Specify a custom boundary string for multipart/form-data requests. Only has effect only together with\fB\,--form\/\fR. + + +.IP "\fB\,--raw\/\fR" + + +This option allows you to pass raw request data without extra processing +(as opposed to the structured request items syntax): + + $ http\fB\,--raw\/\fR=\'data\' pie.dev/post + +You can achieve the same by piping the data via stdin: + + $ echo data | http pie.dev/post + +Or have HTTPie load the raw data from a file: + + $ http pie.dev/post @data.txt + + + + +.PP +.SH Content Processing Options +.IP "\fB\,--compress\/\fR, \fB\,-x\/\fR" + + +Content compressed (encoded) with Deflate algorithm. +The Content-Encoding header is set to deflate. + +Compression is skipped if it appears that compression ratio is +negative. Compression can be forced by repeating the argument. + + + +.PP +.SH Output Processing +.IP "\fB\,--pretty\/\fR" + + +Controls output processing. The value can be "none" to not prettify +the output (default for redirected output), "all" to apply both colors +and formatting (default for terminal output), "colors", or "format". + + + +.IP "\fB\,--style\/\fR, \fB\,-s\/\fR \fI\,STYLE\/\fR" + + +Output coloring style (default is "auto"). It can be one of: + + auto, pie, pie-dark, pie-light, solarized + + +For finding out all available styles in your system, try: + +$ http\fB\,--style\/\fR + +The "auto" style follows your terminal\'s ANSI color styles. +For non-auto styles to work properly, please make sure that the +$TERM environment variable is set to "xterm-256color" or similar +(e.g., via `export TERM=xterm-256color\' in your \~/.bashrc). + +.IP "\fB\,--unsorted\/\fR" + + +Disables all sorting while formatting output. It is a shortcut for: + + \fB\,--format-options\/\fR=headers.sort:false,json.sort_keys:false + + + +.IP "\fB\,--sorted\/\fR" + + +Re-enables all sorting options while formatting output. It is a shortcut for: + + \fB\,--format-options\/\fR=headers.sort:true,json.sort_keys:true + + + +.IP "\fB\,--response-charset\/\fR \fI\,ENCODING\/\fR" + + +Override the response encoding for terminal display purposes, e.g.: + + \fB\,--response-charset\/\fR=utf8 + \fB\,--response-charset\/\fR=big5 + + + +.IP "\fB\,--response-mime\/\fR \fI\,MIME_TYPE\/\fR" + + +Override the response mime type for coloring and formatting for the terminal, e.g.: + + \fB\,--response-mime\/\fR=application/json + \fB\,--response-mime\/\fR=text/xml + + + +.IP "\fB\,--format-options\/\fR" + + +Controls output formatting. Only relevant when formatting is enabled +through (explicit or implied)\fB\,--pretty\/\fR=all or\fB\,--pretty\/\fR=format. +The following are the default options: + + headers.sort:true + json.format:true + json.indent:4 + json.sort_keys:true + xml.format:true + xml.indent:2 + +You may use this option multiple times, as well as specify multiple +comma-separated options at the same time. For example, this modifies the +settings to disable the sorting of JSON keys, and sets the indent size to 2: + + \fB\,--format-options\/\fR json.sort_keys:false,json.indent:2 + +This is something you will typically put into your config file. + + + +.PP +.SH Output Options +.IP "\fB\,--print\/\fR, \fB\,-p\/\fR \fI\,WHAT\/\fR" + + +String specifying what the output should contain: + + \'H\' request headers + \'B\' request body + \'h\' response headers + \'b\' response body + \'m\' response metadata + +The default behaviour is \'hb\' (i.e., the response +headers and body is printed), if standard output is not redirected. +If the output is piped to another program or to a file, then only the +response body is printed by default. + + + +.IP "\fB\,--headers\/\fR, \fB\,-h\/\fR" + + +Print only the response headers. Shortcut for\fB\,--print\/\fR=h. + + + +.IP "\fB\,--meta\/\fR, \fB\,-m\/\fR" + + +Print only the response metadata. Shortcut for\fB\,--print\/\fR=m. + + + +.IP "\fB\,--body\/\fR, \fB\,-b\/\fR" + + +Print only the response body. Shortcut for\fB\,--print\/\fR=b. + + + +.IP "\fB\,--verbose\/\fR, \fB\,-v\/\fR" + + +Verbose output. For the level one (with single \fB\,-v\/\fR`/\fB\,--verbose\/\fR`), print +the whole request as well as the response. Also print any intermediary +requests/responses (such as redirects). For the second level and higher, +print these as well as the response metadata. + +Level one is a shortcut for:\fB\,--all\/\fR\fB\,--print\/\fR=BHbh +Level two is a shortcut for:\fB\,--all\/\fR\fB\,--print\/\fR=BHbhm + + +.IP "\fB\,--all\/\fR" + + +By default, only the final request/response is shown. Use this flag to show +any intermediary requests/responses as well. Intermediary requests include +followed redirects (with\fB\,--follow\/\fR), the first unauthorized request when +Digest auth is used \fB\,--auth\/\fR=digest), etc. + + + +.IP "\fB\,--history-print\/\fR, \fB\,-P\/\fR \fI\,WHAT\/\fR" + + +The same as\fB\,--print\/\fR,\fB\,-p\/\fR but applies only to intermediary requests/responses +(such as redirects) when their inclusion is enabled with\fB\,--all\/\fR. If this +options is not specified, then they are formatted the same way as the final +response. + + + +.IP "\fB\,--stream\/\fR, \fB\,-S\/\fR" + + +Always stream the response body by line, i.e., behave like `tail\fB\,-f\/\fR\'. + +Without\fB\,--stream\/\fR and with\fB\,--pretty\/\fR (either set or implied), +HTTPie fetches the whole response before it outputs the processed data. + +Set this option when you want to continuously display a prettified +long-lived response, such as one from the Twitter streaming API. + +It is useful also without\fB\,--pretty\/\fR: It ensures that the output is flushed +more often and in smaller chunks. + + + +.IP "\fB\,--output\/\fR, \fB\,-o\/\fR \fI\,FILE\/\fR" + + +Save output to FILE instead of stdout. If\fB\,--download\/\fR is also set, then only +the response body is saved to FILE. Other parts of the HTTP exchange are +printed to stderr. + + + +.IP "\fB\,--download\/\fR, \fB\,-d\/\fR" + + +Do not print the response body to stdout. Rather, download it and store it +in a file. The filename is guessed unless specified with\fB\,--output\/\fR +[filename]. This action is similar to the default behaviour of wget. + + + +.IP "\fB\,--continue\/\fR, \fB\,-c\/\fR" + + +Resume an interrupted download. Note that the\fB\,--output\/\fR option needs to be +specified as well. + + + +.IP "\fB\,--quiet\/\fR, \fB\,-q\/\fR" + + +Do not print to stdout or stderr, except for errors and warnings when provided once. +Provide twice to suppress warnings as well. +stdout is still redirected if\fB\,--output\/\fR is specified. +Flag doesn\'t affect behaviour of download beyond not printing to terminal. + + + +.PP +.SH Sessions +.IP "\fB\,--session\/\fR \fI\,SESSION_NAME_OR_PATH\/\fR" + + +Create, or reuse and update a session. Within a session, custom headers, +auth credential, as well as any cookies sent by the server persist between +requests. + +Session files are stored in: + + [HTTPIE_CONFIG_DIR]//.json. + +See the following page to find out your default HTTPIE_CONFIG_DIR: + + https://httpie.io/docs/cli/config-file-directory + + +.IP "\fB\,--session-read-only\/\fR \fI\,SESSION_NAME_OR_PATH\/\fR" + + +Create or read a session without updating it form the request/response +exchange. + + + +.PP +.SH Authentication +.IP "\fB\,--auth\/\fR, \fB\,-a\/\fR \fI\,USER[:PASS] | TOKEN\/\fR" + + +For username/password based authentication mechanisms (e.g +basic auth or digest auth) if only the username is provided +\fB\,-a\/\fR username), HTTPie will prompt for the password. + + + +.IP "\fB\,--auth-type\/\fR, \fB\,-A\/\fR" + + +The authentication mechanism to be used. Defaults to "basic". + +"basic": Basic HTTP auth + +"digest": Digest HTTP auth + +"bearer": Bearer HTTP Auth + +For finding out all available authentication types in your system, try: + +$ http\fB\,--auth-type\/\fR + +.IP "\fB\,--ignore-netrc\/\fR" + + +Ignore credentials from .netrc. + + +.PP +.SH Network +.IP "\fB\,--offline\/\fR" + + +Build the request and print it but don\'t actually send it. + + +.IP "\fB\,--proxy\/\fR \fI\,PROTOCOL:PROXY_URL\/\fR" + + +String mapping protocol to the URL of the proxy +(e.g. http:http://foo.bar:3128). You can specify multiple proxies with +different protocols. The environment variables $ALL_PROXY, $HTTP_PROXY, +and $HTTPS_proxy are supported as well. + + + +.IP "\fB\,--follow\/\fR, \fB\,-F\/\fR" + + +Follow 30x Location redirects. + + +.IP "\fB\,--max-redirects\/\fR" + + +By default, requests have a limit of 30 redirects (works with\fB\,--follow\/\fR). + + + +.IP "\fB\,--max-headers\/\fR" + + +The maximum number of response headers to be read before giving up (default 0, i.e., no limit). + + +.IP "\fB\,--timeout\/\fR \fI\,SECONDS\/\fR" + + +The connection timeout of the request in seconds. +The default value is 0, i.e., there is no timeout limit. +This is not a time limit on the entire response download; +rather, an error is reported if the server has not issued a response for +timeout seconds (more precisely, if no bytes have been received on +the underlying socket for timeout seconds). + + + +.IP "\fB\,--check-status\/\fR" + + +By default, HTTPie exits with 0 when no network or other fatal errors +occur. This flag instructs HTTPie to also check the HTTP status code and +exit with an error if the status indicates one. + +When the server replies with a 4xx (Client Error) or 5xx (Server Error) +status code, HTTPie exits with 4 or 5 respectively. If the response is a +3xx (Redirect) and\fB\,--follow\/\fR hasn\'t been set, then the exit status is 3. +Also an error message is written to stderr if stdout is redirected. + + + +.IP "\fB\,--path-as-is\/\fR" + + +Bypass dot segment (/../ or /./) URL squashing. + + +.IP "\fB\,--chunked\/\fR" + + +Enable streaming via chunked transfer encoding. The Transfer-Encoding header is set to chunked. + + +.PP +.SH SSL +.IP "\fB\,--verify\/\fR" + + +Set to "no" (or "false") to skip checking the host\'s SSL certificate. +Defaults to "yes" ("true"). You can also pass the path to a CA_BUNDLE file +for private certs. (Or you can set the REQUESTS_CA_BUNDLE environment +variable instead.) + + +.IP "\fB\,--ssl\/\fR" + + +The desired protocol version to use. This will default to +SSL v2.3 which will negotiate the highest protocol that both +the server and your installation of OpenSSL support. Available protocols +may vary depending on OpenSSL installation (only the supported ones +are shown here). + + + +.IP "\fB\,--ciphers\/\fR" + + + +A string in the OpenSSL cipher list format. By default, the following +is used: + +ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:DH+AESGCM:ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS + + + +.IP "\fB\,--cert\/\fR" + + +You can specify a local cert to use as client side SSL certificate. +This file may either contain both private key and certificate or you may +specify\fB\,--cert-key\/\fR separately. + + + +.IP "\fB\,--cert-key\/\fR" + + +The private key to use with SSL. Only needed if\fB\,--cert\/\fR is given and the +certificate file does not contain the private key. + + + +.IP "\fB\,--cert-key-pass\/\fR" + + +The passphrase to be used to with the given private key. Only needed if\fB\,--cert-key\/\fR +is given and the key file requires a passphrase. +If not provided, you\'ll be prompted interactively. + + +.PP +.SH Troubleshooting +.IP "\fB\,--ignore-stdin\/\fR, \fB\,-I\/\fR" + + +Do not attempt to read stdin + + +.IP "\fB\,--help\/\fR" + + +Show this help message and exit. + + +.IP "\fB\,--manual\/\fR" + + +Show the full manual. + + +.IP "\fB\,--version\/\fR" + + +Show version and exit. + + +.IP "\fB\,--traceback\/\fR" + + +Prints the exception traceback should one occur. + + +.IP "\fB\,--default-scheme\/\fR" + + +The default scheme to use if not specified in the URL. + + +.IP "\fB\,--debug\/\fR" + + +Prints the exception traceback should one occur, as well as other +information useful for debugging HTTPie itself and for reporting bugs. + + + +.PP \ No newline at end of file diff --git a/extras/man/httpie.1 b/extras/man/httpie.1 new file mode 100644 index 00000000..ad62f5a1 --- /dev/null +++ b/extras/man/httpie.1 @@ -0,0 +1,91 @@ +.TH httpie 1 "2022-03-08" "HTTPie 3.1.1.dev0" "HTTPie Manual" +.SH NAME +httpie +.SH SYNOPSIS +httpie HOSTNAME SESSION_NAME_OR_PATH TARGET TARGET TARGET TARGET TARGET TARGET + +.SH DESCRIPTION + +Managing interface for the HTTPie itself. + +Be aware that you might be looking for http/https commands for sending +HTTP requests. This command is only available for managing the HTTTPie +plugins and the configuration around it. + +.SH httpie cli export-args +Export available options for the CLI +.IP "\fB\,-f\/\fR, \fB\,--format\/\fR" + + + +.PP +.SH httpie cli sessions upgrade +Upgrade the given HTTPie session with the latest layout. A list of changes between different session versions can be found in the official documentation. +.IP "\fB\,HOSTNAME\/\fR" + +The host this session belongs. + +.IP "\fB\,SESSION_NAME_OR_PATH\/\fR" + +The name or the path for the session that will be upgraded. + +.IP "\fB\,--bind-cookies\/\fR" + +Bind domainless cookies to the host that session belongs. + +.PP +.SH httpie cli sessions upgrade-all +Upgrade all named sessions with the latest layout. A list of changes between different session versions can be found in the official documentation. +.IP "\fB\,--bind-cookies\/\fR" + +Bind domainless cookies to the host that session belongs. + +.PP +.SH httpie cli plugins install +Install the given targets from PyPI or from a local paths. +.IP "\fB\,TARGET\/\fR" + +targets to install + +.PP +.SH httpie cli plugins upgrade +Upgrade the given plugins +.IP "\fB\,TARGET\/\fR" + +targets to upgrade + +.PP +.SH httpie cli plugins uninstall +Uninstall the given HTTPie plugins. +.IP "\fB\,TARGET\/\fR" + +targets to install + +.PP +.SH httpie cli plugins list +List all installed HTTPie plugins. +.PP +.SH httpie plugins install +Install the given targets from PyPI or from a local paths. +.IP "\fB\,TARGET\/\fR" + +targets to install + +.PP +.SH httpie plugins upgrade +Upgrade the given plugins +.IP "\fB\,TARGET\/\fR" + +targets to upgrade + +.PP +.SH httpie plugins uninstall +Uninstall the given HTTPie plugins. +.IP "\fB\,TARGET\/\fR" + +targets to install + +.PP +.SH httpie plugins list +List all installed HTTPie plugins. +.PP \ No newline at end of file diff --git a/extras/man/https.1 b/extras/man/https.1 new file mode 100644 index 00000000..d0eef235 --- /dev/null +++ b/extras/man/https.1 @@ -0,0 +1,600 @@ +.TH https 1 "2022-03-08" "HTTPie 3.1.1.dev0" "HTTPie Manual" +.SH NAME +https +.SH SYNOPSIS +https [METHOD] URL [REQUEST_ITEM ...] + +.SH DESCRIPTION +HTTPie: modern, user-friendly command-line HTTP client for the API era. +.SH Positional Arguments + +These arguments come after any flags and in the order they are listed here. +Only URL is required. + +.IP "\fB\,METHOD\/\fR" + + +The HTTP method to be used for the request (GET, POST, PUT, DELETE, ...). + +This argument can be omitted in which case HTTPie will use POST if there +is some data to be sent, otherwise GET: + + $ http example.org # => GET + $ http example.org hello=world # => POST + + + +.IP "\fB\,URL\/\fR" + + +The request URL. Scheme defaults to \'http://\' if the URL +does not include one. (You can override this with:\fB\,--default-scheme\/\fR=http/https) + +You can also use a shorthand for localhost + + $ http :3000 # => http://localhost:3000 + $ http :/foo # => http://localhost/foo + + + +.IP "\fB\,REQUEST_ITEM\/\fR" + + +Optional key-value pairs to be included in the request. The separator used +determines the type: + +\':\' HTTP headers: + + Referer:https://httpie.io Cookie:foo=bar User-Agent:bacon/1.0 + +\'==\' URL parameters to be appended to the request URI: + + search==httpie + +\'=\' Data fields to be serialized into a JSON object (with\fB\,--json\/\fR,\fB\,-j\/\fR) + or form data (with\fB\,--form\/\fR,\fB\,-f\/\fR): + + name=HTTPie language=Python description=\'CLI HTTP client\' + +\':=\' Non-string JSON data fields (only with\fB\,--json\/\fR,\fB\,-j\/\fR): + + awesome:=true amount:=42 colors:=\'["red", "green", "blue"]\' + +\'@\' Form file fields (only with\fB\,--form\/\fR or\fB\,--multipart\/\fR): + + cv@\~/Documents/CV.pdf + cv@\'\~/Documents/CV.pdf;type=application/pdf\' + +\'=@\' A data field like \'=\', but takes a file path and embeds its content: + + essay=@Documents/essay.txt + +\':=@\' A raw JSON field like \':=\', but takes a file path and embeds its content: + + package:=@./package.json + +You can use a backslash to escape a colliding separator in the field name: + + field-name-with\\:colon=value + + + +.PP +.SH Predefined Content Types +.IP "\fB\,--json\/\fR, \fB\,-j\/\fR" + + +(default) Data items from the command line are serialized as a JSON object. +The Content-Type and Accept headers are set to application/json +(if not specified). + + + +.IP "\fB\,--form\/\fR, \fB\,-f\/\fR" + + +Data items from the command line are serialized as form fields. + +The Content-Type is set to application/x-www-form-urlencoded (if not +specified). The presence of any file fields results in a +multipart/form-data request. + + + +.IP "\fB\,--multipart\/\fR" + + +Similar to\fB\,--form\/\fR, but always sends a multipart/form-data request (i.e., even without files). + + +.IP "\fB\,--boundary\/\fR" + + +Specify a custom boundary string for multipart/form-data requests. Only has effect only together with\fB\,--form\/\fR. + + +.IP "\fB\,--raw\/\fR" + + +This option allows you to pass raw request data without extra processing +(as opposed to the structured request items syntax): + + $ http\fB\,--raw\/\fR=\'data\' pie.dev/post + +You can achieve the same by piping the data via stdin: + + $ echo data | http pie.dev/post + +Or have HTTPie load the raw data from a file: + + $ http pie.dev/post @data.txt + + + + +.PP +.SH Content Processing Options +.IP "\fB\,--compress\/\fR, \fB\,-x\/\fR" + + +Content compressed (encoded) with Deflate algorithm. +The Content-Encoding header is set to deflate. + +Compression is skipped if it appears that compression ratio is +negative. Compression can be forced by repeating the argument. + + + +.PP +.SH Output Processing +.IP "\fB\,--pretty\/\fR" + + +Controls output processing. The value can be "none" to not prettify +the output (default for redirected output), "all" to apply both colors +and formatting (default for terminal output), "colors", or "format". + + + +.IP "\fB\,--style\/\fR, \fB\,-s\/\fR \fI\,STYLE\/\fR" + + +Output coloring style (default is "auto"). It can be one of: + + auto, pie, pie-dark, pie-light, solarized + + +For finding out all available styles in your system, try: + +$ http\fB\,--style\/\fR + +The "auto" style follows your terminal\'s ANSI color styles. +For non-auto styles to work properly, please make sure that the +$TERM environment variable is set to "xterm-256color" or similar +(e.g., via `export TERM=xterm-256color\' in your \~/.bashrc). + +.IP "\fB\,--unsorted\/\fR" + + +Disables all sorting while formatting output. It is a shortcut for: + + \fB\,--format-options\/\fR=headers.sort:false,json.sort_keys:false + + + +.IP "\fB\,--sorted\/\fR" + + +Re-enables all sorting options while formatting output. It is a shortcut for: + + \fB\,--format-options\/\fR=headers.sort:true,json.sort_keys:true + + + +.IP "\fB\,--response-charset\/\fR \fI\,ENCODING\/\fR" + + +Override the response encoding for terminal display purposes, e.g.: + + \fB\,--response-charset\/\fR=utf8 + \fB\,--response-charset\/\fR=big5 + + + +.IP "\fB\,--response-mime\/\fR \fI\,MIME_TYPE\/\fR" + + +Override the response mime type for coloring and formatting for the terminal, e.g.: + + \fB\,--response-mime\/\fR=application/json + \fB\,--response-mime\/\fR=text/xml + + + +.IP "\fB\,--format-options\/\fR" + + +Controls output formatting. Only relevant when formatting is enabled +through (explicit or implied)\fB\,--pretty\/\fR=all or\fB\,--pretty\/\fR=format. +The following are the default options: + + headers.sort:true + json.format:true + json.indent:4 + json.sort_keys:true + xml.format:true + xml.indent:2 + +You may use this option multiple times, as well as specify multiple +comma-separated options at the same time. For example, this modifies the +settings to disable the sorting of JSON keys, and sets the indent size to 2: + + \fB\,--format-options\/\fR json.sort_keys:false,json.indent:2 + +This is something you will typically put into your config file. + + + +.PP +.SH Output Options +.IP "\fB\,--print\/\fR, \fB\,-p\/\fR \fI\,WHAT\/\fR" + + +String specifying what the output should contain: + + \'H\' request headers + \'B\' request body + \'h\' response headers + \'b\' response body + \'m\' response metadata + +The default behaviour is \'hb\' (i.e., the response +headers and body is printed), if standard output is not redirected. +If the output is piped to another program or to a file, then only the +response body is printed by default. + + + +.IP "\fB\,--headers\/\fR, \fB\,-h\/\fR" + + +Print only the response headers. Shortcut for\fB\,--print\/\fR=h. + + + +.IP "\fB\,--meta\/\fR, \fB\,-m\/\fR" + + +Print only the response metadata. Shortcut for\fB\,--print\/\fR=m. + + + +.IP "\fB\,--body\/\fR, \fB\,-b\/\fR" + + +Print only the response body. Shortcut for\fB\,--print\/\fR=b. + + + +.IP "\fB\,--verbose\/\fR, \fB\,-v\/\fR" + + +Verbose output. For the level one (with single \fB\,-v\/\fR`/\fB\,--verbose\/\fR`), print +the whole request as well as the response. Also print any intermediary +requests/responses (such as redirects). For the second level and higher, +print these as well as the response metadata. + +Level one is a shortcut for:\fB\,--all\/\fR\fB\,--print\/\fR=BHbh +Level two is a shortcut for:\fB\,--all\/\fR\fB\,--print\/\fR=BHbhm + + +.IP "\fB\,--all\/\fR" + + +By default, only the final request/response is shown. Use this flag to show +any intermediary requests/responses as well. Intermediary requests include +followed redirects (with\fB\,--follow\/\fR), the first unauthorized request when +Digest auth is used \fB\,--auth\/\fR=digest), etc. + + + +.IP "\fB\,--history-print\/\fR, \fB\,-P\/\fR \fI\,WHAT\/\fR" + + +The same as\fB\,--print\/\fR,\fB\,-p\/\fR but applies only to intermediary requests/responses +(such as redirects) when their inclusion is enabled with\fB\,--all\/\fR. If this +options is not specified, then they are formatted the same way as the final +response. + + + +.IP "\fB\,--stream\/\fR, \fB\,-S\/\fR" + + +Always stream the response body by line, i.e., behave like `tail\fB\,-f\/\fR\'. + +Without\fB\,--stream\/\fR and with\fB\,--pretty\/\fR (either set or implied), +HTTPie fetches the whole response before it outputs the processed data. + +Set this option when you want to continuously display a prettified +long-lived response, such as one from the Twitter streaming API. + +It is useful also without\fB\,--pretty\/\fR: It ensures that the output is flushed +more often and in smaller chunks. + + + +.IP "\fB\,--output\/\fR, \fB\,-o\/\fR \fI\,FILE\/\fR" + + +Save output to FILE instead of stdout. If\fB\,--download\/\fR is also set, then only +the response body is saved to FILE. Other parts of the HTTP exchange are +printed to stderr. + + + +.IP "\fB\,--download\/\fR, \fB\,-d\/\fR" + + +Do not print the response body to stdout. Rather, download it and store it +in a file. The filename is guessed unless specified with\fB\,--output\/\fR +[filename]. This action is similar to the default behaviour of wget. + + + +.IP "\fB\,--continue\/\fR, \fB\,-c\/\fR" + + +Resume an interrupted download. Note that the\fB\,--output\/\fR option needs to be +specified as well. + + + +.IP "\fB\,--quiet\/\fR, \fB\,-q\/\fR" + + +Do not print to stdout or stderr, except for errors and warnings when provided once. +Provide twice to suppress warnings as well. +stdout is still redirected if\fB\,--output\/\fR is specified. +Flag doesn\'t affect behaviour of download beyond not printing to terminal. + + + +.PP +.SH Sessions +.IP "\fB\,--session\/\fR \fI\,SESSION_NAME_OR_PATH\/\fR" + + +Create, or reuse and update a session. Within a session, custom headers, +auth credential, as well as any cookies sent by the server persist between +requests. + +Session files are stored in: + + [HTTPIE_CONFIG_DIR]//.json. + +See the following page to find out your default HTTPIE_CONFIG_DIR: + + https://httpie.io/docs/cli/config-file-directory + + +.IP "\fB\,--session-read-only\/\fR \fI\,SESSION_NAME_OR_PATH\/\fR" + + +Create or read a session without updating it form the request/response +exchange. + + + +.PP +.SH Authentication +.IP "\fB\,--auth\/\fR, \fB\,-a\/\fR \fI\,USER[:PASS] | TOKEN\/\fR" + + +For username/password based authentication mechanisms (e.g +basic auth or digest auth) if only the username is provided +\fB\,-a\/\fR username), HTTPie will prompt for the password. + + + +.IP "\fB\,--auth-type\/\fR, \fB\,-A\/\fR" + + +The authentication mechanism to be used. Defaults to "basic". + +"basic": Basic HTTP auth + +"digest": Digest HTTP auth + +"bearer": Bearer HTTP Auth + +For finding out all available authentication types in your system, try: + +$ http\fB\,--auth-type\/\fR + +.IP "\fB\,--ignore-netrc\/\fR" + + +Ignore credentials from .netrc. + + +.PP +.SH Network +.IP "\fB\,--offline\/\fR" + + +Build the request and print it but don\'t actually send it. + + +.IP "\fB\,--proxy\/\fR \fI\,PROTOCOL:PROXY_URL\/\fR" + + +String mapping protocol to the URL of the proxy +(e.g. http:http://foo.bar:3128). You can specify multiple proxies with +different protocols. The environment variables $ALL_PROXY, $HTTP_PROXY, +and $HTTPS_proxy are supported as well. + + + +.IP "\fB\,--follow\/\fR, \fB\,-F\/\fR" + + +Follow 30x Location redirects. + + +.IP "\fB\,--max-redirects\/\fR" + + +By default, requests have a limit of 30 redirects (works with\fB\,--follow\/\fR). + + + +.IP "\fB\,--max-headers\/\fR" + + +The maximum number of response headers to be read before giving up (default 0, i.e., no limit). + + +.IP "\fB\,--timeout\/\fR \fI\,SECONDS\/\fR" + + +The connection timeout of the request in seconds. +The default value is 0, i.e., there is no timeout limit. +This is not a time limit on the entire response download; +rather, an error is reported if the server has not issued a response for +timeout seconds (more precisely, if no bytes have been received on +the underlying socket for timeout seconds). + + + +.IP "\fB\,--check-status\/\fR" + + +By default, HTTPie exits with 0 when no network or other fatal errors +occur. This flag instructs HTTPie to also check the HTTP status code and +exit with an error if the status indicates one. + +When the server replies with a 4xx (Client Error) or 5xx (Server Error) +status code, HTTPie exits with 4 or 5 respectively. If the response is a +3xx (Redirect) and\fB\,--follow\/\fR hasn\'t been set, then the exit status is 3. +Also an error message is written to stderr if stdout is redirected. + + + +.IP "\fB\,--path-as-is\/\fR" + + +Bypass dot segment (/../ or /./) URL squashing. + + +.IP "\fB\,--chunked\/\fR" + + +Enable streaming via chunked transfer encoding. The Transfer-Encoding header is set to chunked. + + +.PP +.SH SSL +.IP "\fB\,--verify\/\fR" + + +Set to "no" (or "false") to skip checking the host\'s SSL certificate. +Defaults to "yes" ("true"). You can also pass the path to a CA_BUNDLE file +for private certs. (Or you can set the REQUESTS_CA_BUNDLE environment +variable instead.) + + +.IP "\fB\,--ssl\/\fR" + + +The desired protocol version to use. This will default to +SSL v2.3 which will negotiate the highest protocol that both +the server and your installation of OpenSSL support. Available protocols +may vary depending on OpenSSL installation (only the supported ones +are shown here). + + + +.IP "\fB\,--ciphers\/\fR" + + + +A string in the OpenSSL cipher list format. By default, the following +is used: + +ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:DH+AESGCM:ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS + + + +.IP "\fB\,--cert\/\fR" + + +You can specify a local cert to use as client side SSL certificate. +This file may either contain both private key and certificate or you may +specify\fB\,--cert-key\/\fR separately. + + + +.IP "\fB\,--cert-key\/\fR" + + +The private key to use with SSL. Only needed if\fB\,--cert\/\fR is given and the +certificate file does not contain the private key. + + + +.IP "\fB\,--cert-key-pass\/\fR" + + +The passphrase to be used to with the given private key. Only needed if\fB\,--cert-key\/\fR +is given and the key file requires a passphrase. +If not provided, you\'ll be prompted interactively. + + +.PP +.SH Troubleshooting +.IP "\fB\,--ignore-stdin\/\fR, \fB\,-I\/\fR" + + +Do not attempt to read stdin + + +.IP "\fB\,--help\/\fR" + + +Show this help message and exit. + + +.IP "\fB\,--manual\/\fR" + + +Show the full manual. + + +.IP "\fB\,--version\/\fR" + + +Show version and exit. + + +.IP "\fB\,--traceback\/\fR" + + +Prints the exception traceback should one occur. + + +.IP "\fB\,--default-scheme\/\fR" + + +The default scheme to use if not specified in the URL. + + +.IP "\fB\,--debug\/\fR" + + +Prints the exception traceback should one occur, as well as other +information useful for debugging HTTPie itself and for reporting bugs. + + + +.PP \ No newline at end of file diff --git a/extras/scripts/generate_man_pages.py b/extras/scripts/generate_man_pages.py new file mode 100644 index 00000000..96657515 --- /dev/null +++ b/extras/scripts/generate_man_pages.py @@ -0,0 +1,156 @@ +import re +from contextlib import contextmanager +from pathlib import Path +from typing import Optional, Iterator, Iterable + +import httpie +from httpie.cli.definition import options as core_options +from httpie.cli.options import ParserSpec +from httpie.manager.cli import options as manager_options +from httpie.output.ui.rich_help import OptionsHighlighter, to_usage +from httpie.output.ui.rich_utils import render_as_string +from httpie.utils import split + + +# Escape certain characters so they are rendered properly on +# all terminals. +ESCAPE_MAP = { + "'": "\\'", + '~': '\\~', + '’': "\\'", + '\\': '\\\\', +} +ESCAPE_MAP = {ord(key): value for key, value in ESCAPE_MAP.items()} + +EXTRAS_DIR = Path(__file__).parent.parent +MAN_PAGE_PATH = EXTRAS_DIR / 'man' + +OPTION_HIGHLIGHT_RE = re.compile( + OptionsHighlighter.highlights[0] +) + +class ManPageBuilder: + def __init__(self): + self.source = [] + + def title_line( + self, + full_name: str, + program_name: str, + program_version: str, + last_edit_date: str, + ) -> None: + self.source.append( + f'.TH {program_name} 1 "{last_edit_date}" ' + f'"{full_name} {program_version}" "{full_name} Manual"' + ) + + def set_name(self, program_name: str) -> None: + with self.section('NAME'): + self.write(program_name) + + def write(self, text: str, *, bold: bool = False) -> None: + if bold: + text = '.B ' + text + self.source.append(text) + + def separate(self) -> None: + self.source.append('.PP') + + def add_options(self, options: Iterable[str], *, metavar: Optional[str] = None) -> None: + text = ", ".join(map(self.boldify, options)) + if metavar: + text += f' {self.underline(metavar)}' + self.write(f'.IP "{text}"') + + def build(self) -> str: + return '\n'.join(self.source) + + @contextmanager + def section(self, section_name: str) -> Iterator[None]: + self.write(f'.SH {section_name}') + self.in_section = True + yield + self.in_section = False + + def underline(self, text: str) -> str: + return r'\fI\,{}\/\fR'.format(text) + + def boldify(self, text: str) -> str: + return r'\fB\,{}\/\fR'.format(text) + + +def _escape_and_dedent(text: str) -> str: + lines = [] + for should_act, line in enumerate(text.splitlines()): + # Only dedent after the first line. + if should_act: + if line.startswith(' '): + line = line[4:] + + lines.append(line) + return '\n'.join(lines).translate(ESCAPE_MAP) + + +def to_man_page(program_name: str, spec: ParserSpec) -> str: + builder = ManPageBuilder() + + builder.title_line( + full_name='HTTPie', + program_name=program_name, + program_version=httpie.__version__, + last_edit_date=httpie.__date__, + ) + builder.set_name(program_name) + + with builder.section('SYNOPSIS'): + builder.write(render_as_string(to_usage(spec, program_name=program_name))) + + with builder.section('DESCRIPTION'): + builder.write(spec.description) + + for index, group in enumerate(spec.groups, 1): + with builder.section(group.name): + if group.description: + builder.write(group.description) + + for argument in group.arguments: + if argument.is_hidden: + continue + + raw_arg = argument.serialize(isolation_mode=True) + + metavar = raw_arg.get('metavar') + if raw_arg.get('is_positional'): + # In case of positional arguments, metavar is always equal + # to the list of options (e.g `METHOD`). + metavar = None + builder.add_options(raw_arg['options'], metavar=metavar) + + description = _escape_and_dedent(raw_arg.get('description', '')) + description = OPTION_HIGHLIGHT_RE.sub( + lambda match: builder.boldify(match['option']), + description + ) + builder.write('\n' + description + '\n') + + builder.separate() + + + + return builder.build() + + +def main() -> None: + for program_name, spec in [ + ('http', core_options), + ('https', core_options), + ('httpie', manager_options), + ]: + with open((MAN_PAGE_PATH / program_name).with_suffix('.1'), 'w') as stream: + stream.write(to_man_page(program_name, spec)) + + + +if __name__ == '__main__': + main() diff --git a/httpie/__init__.py b/httpie/__init__.py index c8b2eb5e..7b865004 100644 --- a/httpie/__init__.py +++ b/httpie/__init__.py @@ -4,5 +4,6 @@ HTTPie: modern, user-friendly command-line HTTP client for the API era. """ __version__ = '3.1.1.dev0' +__date__ = '2022-03-08' __author__ = 'Jakub Roztocil' __licence__ = 'BSD' diff --git a/httpie/cli/argparser.py b/httpie/cli/argparser.py index a312b8ba..e388e465 100644 --- a/httpie/cli/argparser.py +++ b/httpie/cli/argparser.py @@ -155,6 +155,7 @@ class HTTPieArgumentParser(BaseHTTPieArgumentParser): namespace=None ) -> argparse.Namespace: self.env = env + self.env.args = namespace = namespace or argparse.Namespace() self.args, no_options = super().parse_known_args(args, namespace) if self.args.debug: self.args.traceback = True @@ -557,19 +558,62 @@ class HTTPieArgumentParser(BaseHTTPieArgumentParser): parsed_options = parse_format_options(options_group, defaults=parsed_options) self.args.format_options = parsed_options + def print_manual(self): + from httpie.output.ui import man_pages + + if man_pages.is_available(self.env.program_name): + man_pages.display_for(self.env, self.env.program_name) + return None + + text = self.format_help() + with self.env.rich_console.pager(): + self.env.rich_console.print( + text, + highlight=False + ) + + def print_help(self): + from httpie.output.ui import rich_help + + for renderable in rich_help.to_help_message(self.spec): + self.env.rich_console.print(renderable) + + def print_usage(self, file): + from rich.text import Text + from httpie.output.ui import rich_help + + whitelist = set() + _, exception, _ = sys.exc_info() + if ( + isinstance(exception, argparse.ArgumentError) + and len(exception.args) >= 1 + and isinstance(exception.args[0], argparse.Action) + and exception.args[0].option_strings + ): + # add_usage path is also taken when you pass an invalid option, + # e.g --style=invalid. If something like that happens, we want + # to include to action that caused to the invalid usage into + # the list of actions we are displaying. + whitelist.add(exception.args[0].option_strings[0]) + + usage_text = Text('usage', style='bold') + usage_text.append(':\n ') + usage_text.append(rich_help.to_usage(self.spec, whitelist=whitelist)) + self.env.rich_error_console.print(usage_text) + def error(self, message): """Prints a usage message incorporating the message to stderr and exits.""" self.print_usage(sys.stderr) - self.exit( - 2, + self.env.rich_error_console.print( dedent( f''' - error: + [bold]error[/bold]: {message} - for more information: + [bold]for more information[/bold]: run '{self.prog} --help' or visit https://httpie.io/docs/cli - ''' + '''.rstrip() ) ) + self.exit(2) diff --git a/httpie/cli/definition.py b/httpie/cli/definition.py index 5db5b390..79e66f7f 100644 --- a/httpie/cli/definition.py +++ b/httpie/cli/definition.py @@ -16,19 +16,23 @@ from httpie.cli.constants import (BASE_OUTPUT_OPTIONS, DEFAULT_FORMAT_OPTIONS, SORTED_FORMAT_OPTIONS_STRING, UNSORTED_FORMAT_OPTIONS_STRING, RequestType) from httpie.cli.options import ParserSpec, Qualifiers, to_argparse -from httpie.output.formatters.colors import (AUTO_STYLE, DEFAULT_STYLE, +from httpie.output.formatters.colors import (AUTO_STYLE, DEFAULT_STYLE, BUNDLED_STYLES, get_available_styles) from httpie.plugins.builtin import BuiltinAuthPlugin from httpie.plugins.registry import plugin_manager -from httpie.sessions import DEFAULT_SESSIONS_DIR from httpie.ssl_ import AVAILABLE_SSL_VERSION_ARG_MAPPING, DEFAULT_SSL_CIPHERS options = ParserSpec( 'http', description=f'{__doc__.strip()} ', epilog=""" + To learn more, you can try: + -> running 'http --manual' + -> visiting our full documentation at https://httpie.io/docs/cli + For every --OPTION there is also a --no-OPTION that reverts OPTION to its default value. + Suggestions and bug reports are greatly appreciated: https://github.com/httpie/httpie/issues """, @@ -52,6 +56,7 @@ positional_arguments.add_argument( metavar='METHOD', nargs=Qualifiers.OPTIONAL, default=None, + short_help='The HTTP method to be used for the request (GET, POST, PUT, DELETE, ...).', help=""" The HTTP method to be used for the request (GET, POST, PUT, DELETE, ...). @@ -66,9 +71,10 @@ positional_arguments.add_argument( positional_arguments.add_argument( dest='url', metavar='URL', + short_help='The request URL.', help=""" - The scheme defaults to 'http://' if the URL does not include one. - (You can override this with: --default-scheme=https) + The request URL. Scheme defaults to 'http://' if the URL + does not include one. (You can override this with: --default-scheme=http/https) You can also use a shorthand for localhost @@ -83,6 +89,17 @@ positional_arguments.add_argument( nargs=Qualifiers.ZERO_OR_MORE, default=None, type=KeyValueArgType(*SEPARATOR_GROUP_ALL_ITEMS), + short_help=( + 'HTTPie’s request items syntax for specifying HTTP headers, JSON/Form' + 'data, files, and URL parameters.' + ), + nested_options=[ + ('HTTP Headers', 'Name:Value', 'Arbitrary HTTP header, e.g X-API-Token:123'), + ('URL Parameters', 'name==value', 'Querystring parameter to the URL, e.g limit==50'), + ('Data Fields', 'field=value', 'Data fields to be serialized as JSON (default) or Form Data (with --form)'), + ('Raw JSON Fields', 'field:=json', 'Data field for real JSON types.'), + ('File upload Fields', 'field@/dir/file', 'Path field for uploading a file.'), + ], help=r""" Optional key-value pairs to be included in the request. The separator used determines the type: @@ -136,6 +153,7 @@ content_types.add_argument( action='store_const', const=RequestType.JSON, dest='request_type', + short_help='(default) Serialize data items from the command line as a JSON object.', help=""" (default) Data items from the command line are serialized as a JSON object. The Content-Type and Accept headers are set to application/json @@ -149,6 +167,7 @@ content_types.add_argument( action='store_const', const=RequestType.FORM, dest='request_type', + short_help='Serialize data items from the command line as form field data.', help=""" Data items from the command line are serialized as form fields. @@ -163,22 +182,21 @@ content_types.add_argument( action='store_const', const=RequestType.MULTIPART, dest='request_type', - help=""" - Similar to --form, but always sends a multipart/form-data - request (i.e., even without files). - - """, + short_help=( + 'Similar to --form, but always sends a multipart/form-data ' + 'request (i.e., even without files).' + ) ) content_types.add_argument( '--boundary', - help=""" - Specify a custom boundary string for multipart/form-data requests. - Only has effect only together with --form. - - """, + short_help=( + 'Specify a custom boundary string for multipart/form-data requests. ' + 'Only has effect only together with --form.' + ) ) content_types.add_argument( '--raw', + short_help='Pass raw request data without extra processing.', help=""" This option allows you to pass raw request data without extra processing (as opposed to the structured request items syntax): @@ -208,6 +226,7 @@ processing_options.add_argument( '-x', action='count', default=0, + short_help='Compress the content with Deflate algorithm.', help=""" Content compressed (encoded) with Deflate algorithm. The Content-Encoding header is set to deflate. @@ -223,22 +242,33 @@ processing_options.add_argument( ####################################################################### -def format_style_help(available_styles): - return """ +def format_style_help(available_styles, *, isolation_mode: bool = False): + text = """ Output coloring style (default is "{default}"). It can be one of: {available_styles} + """ + if isolation_mode: + text += '\n\n' + text += 'For finding out all available styles in your system, try:\n\n' + text += ' $ http --style\n' + text += textwrap.dedent(""" + The "{auto_style}" style follows your terminal's ANSI color styles. + For non-{auto_style} styles to work properly, please make sure that the + $TERM environment variable is set to "xterm-256color" or similar + (e.g., via `export TERM=xterm-256color' in your ~/.bashrc). + """) - The "{auto_style}" style follows your terminal's ANSI color styles. - For non-{auto_style} styles to work properly, please make sure that the - $TERM environment variable is set to "xterm-256color" or similar - (e.g., via `export TERM=xterm-256color' in your ~/.bashrc). - """.format( + if isolation_mode: + available_styles = sorted(BUNDLED_STYLES) + + available_styles_text = '\n'.join( + f' {line.strip()}' + for line in textwrap.wrap(', '.join(available_styles), 60) + ).strip() + return text.format( default=DEFAULT_STYLE, - available_styles='\n'.join( - f' {line.strip()}' - for line in textwrap.wrap(', '.join(available_styles), 60) - ).strip(), + available_styles=available_styles_text, auto_style=AUTO_STYLE, ) @@ -261,6 +291,7 @@ output_processing.add_argument( dest='prettify', default=PRETTY_STDOUT_TTY_ONLY, choices=sorted(PRETTY_MAP.keys()), + short_help='Control the processing of console outputs.', help=""" Controls output processing. The value can be "none" to not prettify the output (default for redirected output), "all" to apply both colors @@ -276,6 +307,7 @@ output_processing.add_argument( default=DEFAULT_STYLE, action='lazy_choices', getter=get_available_styles, + short_help=f'Output coloring style (default is "{DEFAULT_STYLE}").', help_formatter=format_style_help, ) @@ -291,6 +323,7 @@ output_processing.add_argument( output_processing.add_argument( '--unsorted', **_unsorted_kwargs, + short_help='Disables all sorting while formatting output.', help=f""" Disables all sorting while formatting output. It is a shortcut for: @@ -301,6 +334,7 @@ output_processing.add_argument( output_processing.add_argument( '--sorted', **_sorted_kwargs, + short_help='Re-enables all sorting options while formatting output.', help=f""" Re-enables all sorting options while formatting output. It is a shortcut for: @@ -312,6 +346,7 @@ output_processing.add_argument( '--response-charset', metavar='ENCODING', type=response_charset_type, + short_help='Override the response encoding for terminal display purposes.', help=""" Override the response encoding for terminal display purposes, e.g.: @@ -324,6 +359,7 @@ output_processing.add_argument( '--response-mime', metavar='MIME_TYPE', type=response_mime_type, + short_help='Override the response mime type for coloring and formatting for the terminal.', help=""" Override the response mime type for coloring and formatting for the terminal, e.g.: @@ -335,6 +371,7 @@ output_processing.add_argument( output_processing.add_argument( '--format-options', action='append', + short_help='Controls output formatting.', help=""" Controls output formatting. Only relevant when formatting is enabled through (explicit or implied) --pretty=all or --pretty=format. @@ -368,6 +405,7 @@ output_options.add_argument( '-p', dest='output_options', metavar='WHAT', + short_help='Options to specify what the console output should contain.', help=f""" String specifying what the output should contain: @@ -390,6 +428,7 @@ output_options.add_argument( dest='output_options', action='store_const', const=OUT_RESP_HEAD, + short_help='Print only the response headers.', help=f""" Print only the response headers. Shortcut for --print={OUT_RESP_HEAD}. @@ -401,6 +440,7 @@ output_options.add_argument( dest='output_options', action='store_const', const=OUT_RESP_META, + short_help='Print only the response metadata.', help=f""" Print only the response metadata. Shortcut for --print={OUT_RESP_META}. @@ -412,6 +452,7 @@ output_options.add_argument( dest='output_options', action='store_const', const=OUT_RESP_BODY, + short_help='Print only the response body.', help=f""" Print only the response body. Shortcut for --print={OUT_RESP_BODY}. @@ -424,20 +465,22 @@ output_options.add_argument( dest='verbose', action='count', default=0, + short_help='Make output more verbose.', help=f""" Verbose output. For the level one (with single `-v`/`--verbose`), print the whole request as well as the response. Also print any intermediary requests/responses (such as redirects). For the second level and higher, print these as well as the response metadata. - Level one is a shortcut for: --all --print={''.join(BASE_OUTPUT_OPTIONS)} - Level two is a shortcut for: --all --print={''.join(OUTPUT_OPTIONS)} + Level one is a shortcut for: --all --print={''.join(sorted(BASE_OUTPUT_OPTIONS))} + Level two is a shortcut for: --all --print={''.join(sorted(OUTPUT_OPTIONS))} """, ) output_options.add_argument( '--all', default=False, action='store_true', + short_help='Show any intermediary requests/responses.', help=""" By default, only the final request/response is shown. Use this flag to show any intermediary requests/responses as well. Intermediary requests include @@ -451,6 +494,7 @@ output_options.add_argument( '-P', dest='output_options_history', metavar='WHAT', + short_help='--print for intermediary requests/responses.', help=""" The same as --print, -p but applies only to intermediary requests/responses (such as redirects) when their inclusion is enabled with --all. If this @@ -464,6 +508,7 @@ output_options.add_argument( '-S', action='store_true', default=False, + short_help='Always stream the response body by line, i.e., behave like `tail -f`.', help=""" Always stream the response body by line, i.e., behave like `tail -f'. @@ -484,6 +529,7 @@ output_options.add_argument( type=FileType('a+b'), dest='output_file', metavar='FILE', + short_help='Save output to FILE instead of stdout.', help=""" Save output to FILE instead of stdout. If --download is also set, then only the response body is saved to FILE. Other parts of the HTTP exchange are @@ -497,6 +543,7 @@ output_options.add_argument( '-d', action='store_true', default=False, + short_help='Download the body to a file instead of printing it to stdout.', help=""" Do not print the response body to stdout. Rather, download it and store it in a file. The filename is guessed unless specified with --output @@ -510,6 +557,7 @@ output_options.add_argument( dest='download_resume', action='store_true', default=False, + short_help='Resume an interrupted download (--output needs to be specified).', help=""" Resume an interrupted download. Note that the --output option needs to be specified as well. @@ -521,6 +569,7 @@ output_options.add_argument( '-q', action='count', default=0, + short_help='Do not print to stdout or stderr, except for errors and warnings when provided once.', help=""" Do not print to stdout or stderr, except for errors and warnings when provided once. Provide twice to suppress warnings as well. @@ -544,21 +593,26 @@ sessions.add_argument( '--session', metavar='SESSION_NAME_OR_PATH', type=session_name_validator, - help=f""" + short_help='Create, or reuse and update a session.', + help=""" Create, or reuse and update a session. Within a session, custom headers, auth credential, as well as any cookies sent by the server persist between requests. Session files are stored in: - {DEFAULT_SESSIONS_DIR}//.json. + [HTTPIE_CONFIG_DIR]//.json. + See the following page to find out your default HTTPIE_CONFIG_DIR: + + https://httpie.io/docs/cli/config-file-directory """, ) sessions.add_argument( '--session-read-only', metavar='SESSION_NAME_OR_PATH', type=session_name_validator, + short_help='Create or read a session without updating it', help=""" Create or read a session without updating it form the request/response exchange. @@ -571,33 +625,46 @@ sessions.add_argument( ####################################################################### -def format_auth_help(auth_plugins_mapping): - auth_plugins = list(auth_plugins_mapping.values()) - return """ +def format_auth_help(auth_plugins_mapping, *, isolation_mode: bool = False): + text = """ The authentication mechanism to be used. Defaults to "{default}". - {types} + {auth_types} + """ - """.format( + auth_plugins = list(auth_plugins_mapping.values()) + if isolation_mode: + auth_plugins = [ + auth_plugin + for auth_plugin in auth_plugins + if issubclass(auth_plugin, BuiltinAuthPlugin) + ] + text += '\n' + text += 'For finding out all available authentication types in your system, try:\n\n' + text += ' $ http --auth-type' + + auth_types = '\n\n '.join( + '"{type}": {name}{package}{description}'.format( + type=plugin.auth_type, + name=plugin.name, + package=( + '' + if issubclass(plugin, BuiltinAuthPlugin) + else f' (provided by {plugin.package_name})' + ), + description=( + '' + if not plugin.description + else '\n ' + + ('\n '.join(textwrap.wrap(plugin.description))) + ), + ) + for plugin in auth_plugins + ) + + return text.format( default=auth_plugins[0].auth_type, - types='\n '.join( - '"{type}": {name}{package}{description}'.format( - type=plugin.auth_type, - name=plugin.name, - package=( - '' - if issubclass(plugin, BuiltinAuthPlugin) - else f' (provided by {plugin.package_name})' - ), - description=( - '' - if not plugin.description - else '\n ' - + ('\n '.join(textwrap.wrap(plugin.description))) - ), - ) - for plugin in auth_plugins - ), + auth_types=auth_types, ) @@ -608,6 +675,7 @@ authentication.add_argument( '-a', default=None, metavar='USER[:PASS] | TOKEN', + short_help='Credentials for the selected (-A) authentication method.', help=""" For username/password based authentication mechanisms (e.g basic auth or digest auth) if only the username is provided @@ -623,16 +691,14 @@ authentication.add_argument( getter=plugin_manager.get_auth_plugin_mapping, sort=True, cache=False, + short_help='The authentication mechanism to be used.', help_formatter=format_auth_help, ) authentication.add_argument( '--ignore-netrc', default=False, action='store_true', - help=""" - Ignore credentials from .netrc. - - """, + short_help='Ignore credentials from .netrc.' ) ####################################################################### @@ -645,9 +711,7 @@ network.add_argument( '--offline', default=False, action='store_true', - help=""" - Build the request and print it but don’t actually send it. - """, + short_help='Build the request and print it but don’t actually send it.' ) network.add_argument( '--proxy', @@ -655,6 +719,7 @@ network.add_argument( action='append', metavar='PROTOCOL:PROXY_URL', type=KeyValueArgType(SEPARATOR_PROXY), + short_help='String mapping of protocol to the URL of the proxy.', help=""" String mapping protocol to the URL of the proxy (e.g. http:http://foo.bar:3128). You can specify multiple proxies with @@ -668,16 +733,14 @@ network.add_argument( '-F', default=False, action='store_true', - help=""" - Follow 30x Location redirects. - - """, + short_help='Follow 30x Location redirects.' ) network.add_argument( '--max-redirects', type=int, default=30, + short_help='The maximum number of redirects that should be followed (with --follow).', help=""" By default, requests have a limit of 30 redirects (works with --follow). @@ -687,11 +750,10 @@ network.add_argument( '--max-headers', type=int, default=0, - help=""" - The maximum number of response headers to be read before giving up - (default 0, i.e., no limit). - - """, + short_help=( + 'The maximum number of response headers to be read before ' + 'giving up (default 0, i.e., no limit).' + ) ) network.add_argument( @@ -699,6 +761,7 @@ network.add_argument( type=float, default=0, metavar='SECONDS', + short_help='The connection timeout of the request in seconds.', help=""" The connection timeout of the request in seconds. The default value is 0, i.e., there is no timeout limit. @@ -713,6 +776,7 @@ network.add_argument( '--check-status', default=False, action='store_true', + short_help='Exit with an error status code if the server replies with an error.', help=""" By default, HTTPie exits with 0 when no network or other fatal errors occur. This flag instructs HTTPie to also check the HTTP status code and @@ -729,20 +793,16 @@ network.add_argument( '--path-as-is', default=False, action='store_true', - help=""" - Bypass dot segment (/../ or /./) URL squashing. - - """, + short_help='Bypass dot segment (/../ or /./) URL squashing.' ) network.add_argument( '--chunked', default=False, action='store_true', - help=""" - Enable streaming via chunked transfer encoding. - The Transfer-Encoding header is set to chunked. - - """, + short_help=( + 'Enable streaming via chunked transfer encoding. ' + 'The Transfer-Encoding header is set to chunked.' + ) ) ####################################################################### @@ -754,6 +814,7 @@ ssl = options.add_group('SSL') ssl.add_argument( '--verify', default='yes', + short_help='If "no", skip SSL verification. If a file path, use it as a CA bundle.', help=""" Set to "no" (or "false") to skip checking the host's SSL certificate. Defaults to "yes" ("true"). You can also pass the path to a CA_BUNDLE file @@ -765,6 +826,7 @@ ssl.add_argument( '--ssl', dest='ssl_version', choices=sorted(AVAILABLE_SSL_VERSION_ARG_MAPPING.keys()), + short_help='The desired protocol version to used.', help=""" The desired protocol version to use. This will default to SSL v2.3 which will negotiate the highest protocol that both @@ -776,6 +838,7 @@ ssl.add_argument( ) ssl.add_argument( '--ciphers', + short_help='A string in the OpenSSL cipher list format.', help=f""" A string in the OpenSSL cipher list format. By default, the following @@ -789,6 +852,7 @@ ssl.add_argument( '--cert', default=None, type=readable_file_arg, + short_help='Specifys a local cert to use as client side SSL certificate.', help=""" You can specify a local cert to use as client side SSL certificate. This file may either contain both private key and certificate or you may @@ -800,6 +864,7 @@ ssl.add_argument( '--cert-key', default=None, type=readable_file_arg, + short_help='The private key to use with SSL. Only needed if --cert is given.', help=""" The private key to use with SSL. Only needed if --cert is given and the certificate file does not contain the private key. @@ -811,11 +876,12 @@ ssl.add_argument( '--cert-key-pass', default=None, type=SSLCredentials, - help=''' + short_help='The passphrase to be used to with the given private key.', + help=""" The passphrase to be used to with the given private key. Only needed if --cert-key is given and the key file requires a passphrase. If not provided, you’ll be prompted interactively. - ''' + """ ) ####################################################################### @@ -828,50 +894,42 @@ troubleshooting.add_argument( '-I', action='store_true', default=False, - help=""" - Do not attempt to read stdin. - - """, + short_help='Do not attempt to read stdin' ) troubleshooting.add_argument( '--help', action='help', default=Qualifiers.SUPPRESS, - help=""" - Show this help message and exit. - - """, + short_help='Show this help message and exit.', +) +troubleshooting.add_argument( + '--manual', + action='manual', + default=Qualifiers.SUPPRESS, + short_help='Show the full manual.', ) troubleshooting.add_argument( '--version', action='version', version=__version__, - help=""" - Show version and exit. - - """, + short_help='Show version and exit.', ) troubleshooting.add_argument( '--traceback', action='store_true', default=False, - help=""" - Prints the exception traceback should one occur. - - """, + short_help='Prints the exception traceback should one occur.', ) troubleshooting.add_argument( '--default-scheme', default='http', - help=""" - The default scheme to use if not specified in the URL. - - """, + short_help='The default scheme to use if not specified in the URL.' ) troubleshooting.add_argument( '--debug', action='store_true', default=False, + short_help='Print useful diagnostic information for bug reports.', help=""" Prints the exception traceback should one occur, as well as other information useful for debugging HTTPie itself and for reporting bugs. diff --git a/httpie/cli/options.py b/httpie/cli/options.py index 45f66934..d2c240a9 100644 --- a/httpie/cli/options.py +++ b/httpie/cli/options.py @@ -3,15 +3,16 @@ import textwrap import typing from dataclasses import dataclass, field from enum import Enum, auto -from typing import Any, Optional, Dict, List, Type, TypeVar +from typing import Any, Optional, Dict, List, Tuple, Type, TypeVar from httpie.cli.argparser import HTTPieArgumentParser -from httpie.cli.utils import LazyChoices +from httpie.cli.utils import Manual, LazyChoices class Qualifiers(Enum): OPTIONAL = auto() ZERO_OR_MORE = auto() + ONE_OR_MORE = auto() SUPPRESS = auto() @@ -24,6 +25,27 @@ def map_qualifiers( } +def drop_keys( + configuration: Dict[str, Any], key_blacklist: Tuple[str, ...] +): + return { + key: value + for key, value in configuration.items() + if key not in key_blacklist + } + + +def _get_first_line(source: str) -> str: + parts = [] + for line in source.strip().splitlines(): + line = line.strip() + parts.append(line) + if line.endswith("."): + break + + return " ".join(parts) + + PARSER_SPEC_VERSION = '0.0.1a0' @@ -69,6 +91,7 @@ class Group: def add_argument(self, *args, **kwargs): argument = Argument(list(args), kwargs.copy()) + argument.post_init() self.arguments.append(argument) return argument @@ -85,14 +108,32 @@ class Argument(typing.NamedTuple): aliases: List[str] configuration: Dict[str, Any] - def serialize(self) -> Dict[str, Any]: + def post_init(self): + """Run a bunch of post-init hooks.""" + # If there is a short help, then create the longer version from it. + short_help = self.configuration.get('short_help') + if ( + short_help + and 'help' not in self.configuration + and self.configuration.get('action') != 'lazy_choices' + ): + self.configuration['help'] = f'\n{short_help}\n\n' + + def serialize(self, *, isolation_mode: bool = False) -> Dict[str, Any]: configuration = self.configuration.copy() # Unpack the dynamically computed choices, since we # will need to store the actual values somewhere. action = configuration.pop('action', None) + short_help = configuration.pop('short_help', None) + nested_options = configuration.pop('nested_options', None) + if action == 'lazy_choices': - choices = LazyChoices(self.aliases, **{'dest': None, **configuration}) + choices = LazyChoices( + self.aliases, + **{'dest': None, **configuration}, + isolation_mode=isolation_mode + ) configuration['choices'] = list(choices.load()) configuration['help'] = choices.help @@ -106,9 +147,13 @@ class Argument(typing.NamedTuple): qualifiers = JSON_QUALIFIER_TO_OPTIONS[configuration.get('nargs', Qualifiers.SUPPRESS)] result.update(qualifiers) - help_msg = configuration.get('help') - if help_msg and help_msg is not Qualifiers.SUPPRESS: - result['description'] = help_msg.strip() + description = configuration.get('help') + if description and description is not Qualifiers.SUPPRESS: + result['short_description'] = short_help + result['description'] = description + + if nested_options: + result['nested_options'] = nested_options python_type = configuration.get('type') if python_type is not None: @@ -123,10 +168,19 @@ class Argument(typing.NamedTuple): key: value for key, value in configuration.items() if key in JSON_DIRECT_MIRROR_OPTIONS + if value is not Qualifiers.SUPPRESS }) return result + @property + def is_positional(self): + return len(self.aliases) == 0 + + @property + def is_hidden(self): + return self.configuration.get('help') is Qualifiers.SUPPRESS + def __getattr__(self, attribute_name): if attribute_name in self.configuration: return self.configuration[attribute_name] @@ -140,7 +194,9 @@ ARGPARSE_QUALIFIER_MAP = { Qualifiers.OPTIONAL: argparse.OPTIONAL, Qualifiers.SUPPRESS: argparse.SUPPRESS, Qualifiers.ZERO_OR_MORE: argparse.ZERO_OR_MORE, + Qualifiers.ONE_OR_MORE: argparse.ONE_OR_MORE } +ARGPARSE_IGNORE_KEYS = ('short_help', 'nested_options') def to_argparse( @@ -152,7 +208,9 @@ def to_argparse( description=abstract_options.description, epilog=abstract_options.epilog, ) + concrete_parser.spec = abstract_options concrete_parser.register('action', 'lazy_choices', LazyChoices) + concrete_parser.register('action', 'manual', Manual) for abstract_group in abstract_options.groups: concrete_group = concrete_parser.add_argument_group( @@ -164,9 +222,9 @@ def to_argparse( for abstract_argument in abstract_group.arguments: concrete_group.add_argument( *abstract_argument.aliases, - **map_qualifiers( + **drop_keys(map_qualifiers( abstract_argument.configuration, ARGPARSE_QUALIFIER_MAP - ) + ), ARGPARSE_IGNORE_KEYS) ) return concrete_parser @@ -181,9 +239,19 @@ JSON_DIRECT_MIRROR_OPTIONS = ( JSON_QUALIFIER_TO_OPTIONS = { Qualifiers.OPTIONAL: {'is_optional': True}, Qualifiers.ZERO_OR_MORE: {'is_optional': True, 'is_variadic': True}, + Qualifiers.ONE_OR_MORE: {'is_optional': False, 'is_variadic': True}, Qualifiers.SUPPRESS: {} } def to_data(abstract_options: ParserSpec) -> Dict[str, Any]: return {'version': PARSER_SPEC_VERSION, 'spec': abstract_options.serialize()} + + +def parser_to_parser_spec(parser: argparse.ArgumentParser) -> ParserSpec: + """Take an existing argparse parser, and create a spec from it.""" + return ParserSpec( + program=parser.prog, + description=parser.description, + epilog=parser.epilog + ) diff --git a/httpie/cli/utils.py b/httpie/cli/utils.py index b2ffabdc..ad27da37 100644 --- a/httpie/cli/utils.py +++ b/httpie/cli/utils.py @@ -4,20 +4,43 @@ from typing import Any, Callable, Generic, Iterator, Iterable, Optional, TypeVar T = TypeVar('T') +class Manual(argparse.Action): + def __init__( + self, + option_strings, + dest=argparse.SUPPRESS, + default=argparse.SUPPRESS, + help=None + ): + super().__init__( + option_strings=option_strings, + dest=dest, + default=default, + nargs=0, + help=help + ) + + def __call__(self, parser, namespace, values, option_string=None): + parser.print_manual() + parser.exit() + + class LazyChoices(argparse.Action, Generic[T]): def __init__( self, *args, getter: Callable[[], Iterable[T]], - help_formatter: Optional[Callable[[T], str]] = None, + help_formatter: Optional[Callable[[T, bool], str]] = None, sort: bool = False, cache: bool = True, + isolation_mode: bool = False, **kwargs ) -> None: self.getter = getter self.help_formatter = help_formatter self.sort = sort self.cache = cache + self.isolation_mode = isolation_mode self._help: Optional[str] = None self._obj: Optional[Iterable[T]] = None super().__init__(*args, **kwargs) @@ -33,7 +56,10 @@ class LazyChoices(argparse.Action, Generic[T]): @property def help(self) -> str: if self._help is None and self.help_formatter is not None: - self._help = self.help_formatter(self.load()) + self._help = self.help_formatter( + self.load(), + isolation_mode=self.isolation_mode + ) return self._help @help.setter diff --git a/httpie/context.py b/httpie/context.py index 50a8f772..44aedd42 100644 --- a/httpie/context.py +++ b/httpie/context.py @@ -1,9 +1,10 @@ +import argparse import sys import os import warnings from contextlib import contextmanager from pathlib import Path -from typing import Iterator, IO, Optional +from typing import Iterator, IO, Optional, TYPE_CHECKING from enum import Enum @@ -12,11 +13,15 @@ try: except ImportError: curses = None # Compiled w/o curses -from .compat import is_windows +from .compat import is_windows, cached_property from .config import DEFAULT_CONFIG_DIR, Config, ConfigFileError from .encoding import UTF8 from .utils import repr_dict +from httpie.output.ui import rich_palette as palette + +if TYPE_CHECKING: + from rich.console import Console class Levels(str, Enum): @@ -40,6 +45,7 @@ class Environment: is used by the test suite to simulate various scenarios. """ + args = argparse.Namespace() is_windows: bool = is_windows config_dir: Path = DEFAULT_CONFIG_DIR stdin: Optional[IO] = sys.stdin # `None` when closed fd (#791) @@ -52,6 +58,10 @@ class Environment: stderr_isatty: bool = stderr.isatty() colors = 256 program_name: str = 'http' + + # Whether to show progress bars / status spinners etc. + show_displays: bool = True + if not is_windows: if curses: try: @@ -160,3 +170,49 @@ class Environment: def apply_warnings_filter(self) -> None: if self.quiet >= DISPLAY_THRESHOLDS[Levels.WARNING]: warnings.simplefilter("ignore") + + def _make_rich_console( + self, + file: IO[str], + force_terminal: bool + ) -> 'Console': + from rich.console import Console + from rich.theme import Theme + from rich.style import Style + + style = getattr(self.args, 'style', palette.AUTO_STYLE) + theme = {} + if style in palette.STYLE_SHADES: + shade = palette.STYLE_SHADES[style] + theme.update({ + color: Style( + color=palette.get_color( + color, + shade, + palette=palette.RICH_THEME_PALETTE + ), + bold=True + ) + for color in palette.RICH_THEME_PALETTE + }) + + # Rich infers the rest of the knowledge (e.g encoding) + # dynamically by looking at the file/stderr. + return Console( + file=file, + force_terminal=force_terminal, + no_color=(self.colors == 0), + theme=Theme(theme) + ) + + # Rich recommends separting the actual console (stdout) from + # the error (stderr) console for better isolation between parts. + # https://rich.readthedocs.io/en/stable/console.html#error-console + + @cached_property + def rich_console(self): + return self._make_rich_console(self.stdout, self.stdout_isatty) + + @cached_property + def rich_error_console(self): + return self._make_rich_console(self.stderr, self.stderr_isatty) diff --git a/httpie/core.py b/httpie/core.py index 98f47f34..71ecfa08 100644 --- a/httpie/core.py +++ b/httpie/core.py @@ -195,7 +195,7 @@ def program(args: argparse.Namespace, env: Environment) -> ExitStatus: try: if args.download: args.follow = True # --download implies --follow. - downloader = Downloader(output_file=args.output_file, progress_file=env.stderr, resume=args.download_resume) + downloader = Downloader(env, output_file=args.output_file, resume=args.download_resume) downloader.pre_request(args.headers) messages = collect_messages(env, args=args, request_body_read_callback=request_body_read_callback) diff --git a/httpie/downloads.py b/httpie/downloads.py index 40c5271b..557be87b 100644 --- a/httpie/downloads.py +++ b/httpie/downloads.py @@ -5,10 +5,8 @@ Download mode implementation. import mimetypes import os import re -import sys -import threading from mailbox import Message -from time import sleep, monotonic +from time import monotonic from typing import IO, Optional, Tuple from urllib.parse import urlsplit @@ -16,22 +14,11 @@ import requests from .models import HTTPResponse, OutputOptions from .output.streams import RawStream -from .utils import humanize_bytes +from .context import Environment PARTIAL_CONTENT = 206 -CLEAR_LINE = '\r\033[K' -PROGRESS = ( - '{percentage: 6.2f} %' - ' {downloaded: >10}' - ' {speed: >10}/s' - ' {eta: >8} ETA' -) -PROGRESS_NO_CONTENT_LENGTH = '{downloaded: >10} {speed: >10}/s' -SUMMARY = 'Done. {downloaded} in {time:0.5f}s ({speed}/s)\n' -SPINNER = '|/-\\' - class ContentRangeError(ValueError): pass @@ -176,9 +163,9 @@ class Downloader: def __init__( self, + env: Environment, output_file: IO = None, - resume: bool = False, - progress_file: IO = sys.stderr + resume: bool = False ): """ :param resume: Should the download resume if partial download @@ -191,14 +178,10 @@ class Downloader: """ self.finished = False - self.status = DownloadStatus() + self.status = DownloadStatus(env=env) self._output_file = output_file self._resume = resume self._resumed_from = 0 - self._progress_reporter = ProgressReporterThread( - status=self.status, - output=progress_file - ) def pre_request(self, request_headers: dict): """Called just before the HTTP request is sent. @@ -261,11 +244,6 @@ class Downloader: except OSError: pass # stdout - self.status.started( - resumed_from=self._resumed_from, - total_size=total_size - ) - output_options = OutputOptions.from_message(final_response, headers=False, body=True) stream = RawStream( msg=HTTPResponse(final_response), @@ -273,11 +251,11 @@ class Downloader: on_body_chunk_downloaded=self.chunk_downloaded, ) - self._progress_reporter.output.write( - f'Downloading {humanize_bytes(total_size) + " " if total_size is not None else ""}' - f'to "{self._output_file.name}"\n' + self.status.started( + output_file=self._output_file, + resumed_from=self._resumed_from, + total_size=total_size ) - self._progress_reporter.start() return stream, self._output_file @@ -287,7 +265,7 @@ class Downloader: self.status.finished() def failed(self): - self._progress_reporter.stop() + self.status.terminate() @property def interrupted(self) -> bool: @@ -329,127 +307,71 @@ class Downloader: class DownloadStatus: """Holds details about the download status.""" - def __init__(self): + def __init__(self, env): + self.env = env self.downloaded = 0 self.total_size = None self.resumed_from = 0 self.time_started = None self.time_finished = None - def started(self, resumed_from=0, total_size=None): + def started(self, output_file, resumed_from=0, total_size=None): assert self.time_started is None self.total_size = total_size self.downloaded = self.resumed_from = resumed_from self.time_started = monotonic() + self.start_display(output_file=output_file) + + def start_display(self, output_file): + from httpie.output.ui.rich_progress import ( + DummyDisplay, + StatusDisplay, + ProgressDisplay + ) + + message = f'Downloading to {output_file.name}' + if self.env.show_displays: + if self.total_size is None: + # Rich does not support progress bars without a total + # size given. Instead we use status objects. + self.display = StatusDisplay(self.env) + else: + self.display = ProgressDisplay(self.env) + else: + self.display = DummyDisplay(self.env) + + self.display.start( + total=self.total_size, + at=self.downloaded, + description=message + ) def chunk_downloaded(self, size): assert self.time_finished is None self.downloaded += size + self.display.update(size) @property def has_finished(self): return self.time_finished is not None + @property + def time_spent(self): + if ( + self.time_started is not None + and self.time_finished is not None + ): + return self.time_finished - self.time_started + else: + return None + def finished(self): assert self.time_started is not None assert self.time_finished is None self.time_finished = monotonic() + if hasattr(self, 'display'): + self.display.stop(self.time_spent) - -class ProgressReporterThread(threading.Thread): - """ - Reports download progress based on its status. - - Uses threading to periodically update the status (speed, ETA, etc.). - - """ - - def __init__( - self, - status: DownloadStatus, - output: IO, - tick=.1, - update_interval=1 - ): - super().__init__() - self.status = status - self.output = output - self._tick = tick - self._update_interval = update_interval - self._spinner_pos = 0 - self._status_line = '' - self._prev_bytes = 0 - self._prev_time = monotonic() - self._should_stop = threading.Event() - - def stop(self): - """Stop reporting on next tick.""" - self._should_stop.set() - - def run(self): - while not self._should_stop.is_set(): - if self.status.has_finished: - self.sum_up() - break - - self.report_speed() - sleep(self._tick) - - def report_speed(self): - now = monotonic() - if now - self._prev_time >= self._update_interval: - downloaded = self.status.downloaded - speed = ((downloaded - self._prev_bytes) - / (now - self._prev_time)) - - if not self.status.total_size: - self._status_line = PROGRESS_NO_CONTENT_LENGTH.format( - downloaded=humanize_bytes(downloaded), - speed=humanize_bytes(speed), - ) - else: - percentage = (downloaded / self.status.total_size * 100 - if self.status.total_size - else 0) - - if not speed: - eta = '-:--:--' - else: - s = int((self.status.total_size - downloaded) / speed) - h, s = divmod(s, 60 * 60) - m, s = divmod(s, 60) - eta = f'{h}:{m:0>2}:{s:0>2}' - - self._status_line = PROGRESS.format( - percentage=percentage, - downloaded=humanize_bytes(downloaded), - speed=humanize_bytes(speed), - eta=eta, - ) - - self._prev_time = now - self._prev_bytes = downloaded - - self.output.write( - f'{CLEAR_LINE} {SPINNER[self._spinner_pos]} {self._status_line}' - ) - self.output.flush() - - self._spinner_pos = (self._spinner_pos + 1) % len(SPINNER) - - def sum_up(self): - actually_downloaded = ( - self.status.downloaded - self.status.resumed_from) - time_taken = self.status.time_finished - self.status.time_started - speed = actually_downloaded / time_taken if time_taken else actually_downloaded - - self.output.write(CLEAR_LINE) - - self.output.write(SUMMARY.format( - downloaded=humanize_bytes(actually_downloaded), - total=(self.status.total_size - and humanize_bytes(self.status.total_size)), - speed=humanize_bytes(speed), - time=time_taken, - )) - self.output.flush() + def terminate(self): + if hasattr(self, 'display'): + self.display.stop(self.time_spent) diff --git a/httpie/manager/cli.py b/httpie/manager/cli.py index 2ae34fdb..c36ee813 100644 --- a/httpie/manager/cli.py +++ b/httpie/manager/cli.py @@ -1,5 +1,6 @@ from textwrap import dedent from httpie.cli.argparser import HTTPieManagerArgumentParser +from httpie.cli.options import Qualifiers, ARGPARSE_QUALIFIER_MAP, map_qualifiers, parser_to_parser_spec from httpie import __version__ CLI_SESSION_UPGRADE_FLAGS = [ @@ -58,7 +59,8 @@ COMMANDS['plugins'] = COMMANDS['cli']['plugins'] = { 'or from a local paths.', { 'dest': 'targets', - 'nargs': '+', + 'metavar': 'TARGET', + 'nargs': Qualifiers.ONE_OR_MORE, 'help': 'targets to install' } ], @@ -66,7 +68,8 @@ COMMANDS['plugins'] = COMMANDS['cli']['plugins'] = { 'Upgrade the given plugins', { 'dest': 'targets', - 'nargs': '+', + 'metavar': 'TARGET', + 'nargs': Qualifiers.ONE_OR_MORE, 'help': 'targets to upgrade' } ], @@ -74,7 +77,8 @@ COMMANDS['plugins'] = COMMANDS['cli']['plugins'] = { 'Uninstall the given HTTPie plugins.', { 'dest': 'targets', - 'nargs': '+', + 'metavar': 'TARGET', + 'nargs': Qualifiers.ONE_OR_MORE, 'help': 'targets to install' } ], @@ -94,7 +98,7 @@ def missing_subcommand(*args) -> str: return f'Please specify one of these: {subcommands}' -def generate_subparsers(root, parent_parser, definitions): +def generate_subparsers(root, parent_parser, definitions, spec): action_dest = '_'.join(parent_parser.prog.split()[1:] + ['action']) actions = parent_parser.add_subparsers( dest=action_dest @@ -107,13 +111,15 @@ def generate_subparsers(root, parent_parser, definitions): command_parser = actions.add_parser(command, description=descr) command_parser.root = root if is_subparser: - generate_subparsers(root, command_parser, properties) + generate_subparsers(root, command_parser, properties, spec) continue + group = spec.add_group(parent_parser.prog + ' ' + command, description=descr) for argument in properties: argument = argument.copy() flags = argument.pop('flags', []) - command_parser.add_argument(*flags, **argument) + command_parser.add_argument(*flags, **map_qualifiers(argument, ARGPARSE_QUALIFIER_MAP)) + group.add_argument(*flags, **argument) parser = HTTPieManagerArgumentParser( @@ -160,4 +166,5 @@ parser.add_argument( ''' ) -generate_subparsers(parser, parser, COMMANDS) +options = parser_to_parser_spec(parser) +generate_subparsers(parser, parser, COMMANDS, options) diff --git a/httpie/output/formatters/colors.py b/httpie/output/formatters/colors.py index c12c31c3..4bafe732 100644 --- a/httpie/output/formatters/colors.py +++ b/httpie/output/formatters/colors.py @@ -17,12 +17,11 @@ from pygments.util import ClassNotFound from ..lexers.json import EnhancedJsonLexer from ..lexers.metadata import MetadataLexer -from ..ui.palette import SHADE_NAMES, get_color +from ..ui.palette import AUTO_STYLE, SHADE_NAMES, get_color from ...context import Environment from ...plugins import FormatterPlugin -AUTO_STYLE = 'auto' # Follows terminal ANSI color styles DEFAULT_STYLE = AUTO_STYLE SOLARIZED_STYLE = 'solarized' # Bundled here @@ -33,7 +32,7 @@ BUNDLED_STYLES = { def get_available_styles(): - return BUNDLED_STYLES | set(pygments.styles.get_all_styles()) + return sorted(BUNDLED_STYLES | set(pygments.styles.get_all_styles())) class ColorFormatter(FormatterPlugin): diff --git a/httpie/output/ui/man_pages.py b/httpie/output/ui/man_pages.py new file mode 100644 index 00000000..5871e21d --- /dev/null +++ b/httpie/output/ui/man_pages.py @@ -0,0 +1,33 @@ +"""Logic for checking and displaying man pages.""" + +import subprocess +import os +from httpie.context import Environment + +MAN_COMMAND = 'man' +NO_MAN_PAGES = os.getenv('HTTPIE_NO_MAN_PAGES', False) + + +def is_available(program: str) -> bool: + """Check whether HTTPie's man pages are available in this system.""" + + if NO_MAN_PAGES or os.system == 'nt': + return False + + process = subprocess.run( + [MAN_COMMAND, program], + shell=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + return process.returncode == 0 + + +def display_for(env: Environment, program: str) -> None: + """Display the man page for the given command (http/https).""" + + subprocess.run( + [MAN_COMMAND, program], + stdout=env.stdout, + stderr=env.stderr + ) diff --git a/httpie/output/ui/palette.py b/httpie/output/ui/palette.py index 64bba703..87b962e4 100644 --- a/httpie/output/ui/palette.py +++ b/httpie/output/ui/palette.py @@ -1,5 +1,6 @@ -from typing import Optional +from typing import Dict, Optional +AUTO_STYLE = 'auto' # Follows terminal ANSI color styles STYLE_PIE = 'pie' STYLE_PIE_DARK = 'pie-dark' STYLE_PIE_LIGHT = 'pie-light' @@ -7,8 +8,6 @@ STYLE_PIE_LIGHT = 'pie-light' COLOR_PALETTE = { # Copy the brand palette - 'transparent': 'transparent', - 'current': 'currentColor', 'white': '#F5F5F0', 'black': '#1C1818', 'grey': { @@ -150,17 +149,27 @@ SHADE_NAMES = { '700': STYLE_PIE_LIGHT } +STYLE_SHADES = { + style: shade + for shade, style in SHADE_NAMES.items() +} + SHADES = [ '50', *map(str, range(100, 1000, 100)) ] -def get_color(color: str, shade: str) -> Optional[str]: - if color not in COLOR_PALETTE: +def get_color( + color: str, + shade: str, + *, + palette: Dict[str, Dict[str, str]] = COLOR_PALETTE +) -> Optional[str]: + if color not in palette: return None - color_code = COLOR_PALETTE[color] + color_code = palette[color] if isinstance(color_code, dict) and shade in color_code: return color_code[shade] else: diff --git a/httpie/output/ui/rich_help.py b/httpie/output/ui/rich_help.py new file mode 100644 index 00000000..7faa337a --- /dev/null +++ b/httpie/output/ui/rich_help.py @@ -0,0 +1,217 @@ +import re +import textwrap +from typing import AbstractSet, Iterable, Optional, Tuple + +from rich.console import RenderableType +from rich.highlighter import RegexHighlighter +from rich.padding import Padding +from rich.table import Table +from rich.text import Text + +from httpie.cli.constants import SEPARATOR_GROUP_ALL_ITEMS +from httpie.cli.options import Argument, ParserSpec, Qualifiers + +SEPARATORS = '|'.join(map(re.escape, SEPARATOR_GROUP_ALL_ITEMS)) + +STYLE_METAVAR = 'yellow' +STYLE_SWITCH = 'green' +STYLE_PROGRAM_NAME = 'bold green' +STYLE_USAGE_OPTIONAL = 'grey46' +STYLE_USAGE_REGULAR = 'white' +STYLE_USAGE_ERROR = 'red' +STYLE_USAGE_MISSING = 'yellow' + +MAX_CHOICE_CHARS = 80 + +LEFT_PADDING_2 = (0, 0, 0, 2) +LEFT_PADDING_4 = (0, 0, 0, 4) +LEFT_PADDING_5 = (0, 0, 0, 4) + +LEFT_INDENT_2 = (1, 0, 0, 2) +LEFT_INDENT_3 = (1, 0, 0, 3) +LEFT_INDENT_BOTTOM_3 = (0, 0, 1, 3) + + +class OptionsHighlighter(RegexHighlighter): + highlights = [ + r'(^|\W)(?P