[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
This commit is contained in:
Batuhan Taskaya 2022-04-14 17:43:10 +03:00 committed by GitHub
parent 86f4bf4d0a
commit ff6f1887b0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 2521 additions and 389 deletions

View File

@ -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 }}

View File

@ -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 }}

View File

@ -147,11 +147,6 @@ doc-check:
mdl --git-recurse --style docs/markdownlint.rb . 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 # Publishing to PyPi
############################################################################### ###############################################################################
@ -211,3 +206,17 @@ brew-test:
@echo $(H1)Auditing…$(H1END) @echo $(H1)Auditing…$(H1END)
brew audit --strict httpie 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

600
extras/man/http.1 Normal file
View File

@ -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. <https://httpie.io>
.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]/<HOST>/<SESSION_NAME>.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

91
extras/man/httpie.1 Normal file
View File

@ -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. <https://httpie.io/docs#manager>
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

600
extras/man/https.1 Normal file
View File

@ -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. <https://httpie.io>
.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]/<HOST>/<SESSION_NAME>.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

View File

@ -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()

View File

@ -4,5 +4,6 @@ HTTPie: modern, user-friendly command-line HTTP client for the API era.
""" """
__version__ = '3.1.1.dev0' __version__ = '3.1.1.dev0'
__date__ = '2022-03-08'
__author__ = 'Jakub Roztocil' __author__ = 'Jakub Roztocil'
__licence__ = 'BSD' __licence__ = 'BSD'

View File

@ -155,6 +155,7 @@ class HTTPieArgumentParser(BaseHTTPieArgumentParser):
namespace=None namespace=None
) -> argparse.Namespace: ) -> argparse.Namespace:
self.env = env self.env = env
self.env.args = namespace = namespace or argparse.Namespace()
self.args, no_options = super().parse_known_args(args, namespace) self.args, no_options = super().parse_known_args(args, namespace)
if self.args.debug: if self.args.debug:
self.args.traceback = True self.args.traceback = True
@ -557,19 +558,62 @@ class HTTPieArgumentParser(BaseHTTPieArgumentParser):
parsed_options = parse_format_options(options_group, defaults=parsed_options) parsed_options = parse_format_options(options_group, defaults=parsed_options)
self.args.format_options = 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): def error(self, message):
"""Prints a usage message incorporating the message to stderr and """Prints a usage message incorporating the message to stderr and
exits.""" exits."""
self.print_usage(sys.stderr) self.print_usage(sys.stderr)
self.exit( self.env.rich_error_console.print(
2,
dedent( dedent(
f''' f'''
error: [bold]error[/bold]:
{message} {message}
for more information: [bold]for more information[/bold]:
run '{self.prog} --help' or visit https://httpie.io/docs/cli run '{self.prog} --help' or visit https://httpie.io/docs/cli
''' '''.rstrip()
) )
) )
self.exit(2)

View File

@ -16,19 +16,23 @@ from httpie.cli.constants import (BASE_OUTPUT_OPTIONS, DEFAULT_FORMAT_OPTIONS,
SORTED_FORMAT_OPTIONS_STRING, SORTED_FORMAT_OPTIONS_STRING,
UNSORTED_FORMAT_OPTIONS_STRING, RequestType) UNSORTED_FORMAT_OPTIONS_STRING, RequestType)
from httpie.cli.options import ParserSpec, Qualifiers, to_argparse 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) get_available_styles)
from httpie.plugins.builtin import BuiltinAuthPlugin from httpie.plugins.builtin import BuiltinAuthPlugin
from httpie.plugins.registry import plugin_manager 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 from httpie.ssl_ import AVAILABLE_SSL_VERSION_ARG_MAPPING, DEFAULT_SSL_CIPHERS
options = ParserSpec( options = ParserSpec(
'http', 'http',
description=f'{__doc__.strip()} <https://httpie.io>', description=f'{__doc__.strip()} <https://httpie.io>',
epilog=""" 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 For every --OPTION there is also a --no-OPTION that reverts OPTION
to its default value. to its default value.
Suggestions and bug reports are greatly appreciated: Suggestions and bug reports are greatly appreciated:
https://github.com/httpie/httpie/issues https://github.com/httpie/httpie/issues
""", """,
@ -52,6 +56,7 @@ positional_arguments.add_argument(
metavar='METHOD', metavar='METHOD',
nargs=Qualifiers.OPTIONAL, nargs=Qualifiers.OPTIONAL,
default=None, default=None,
short_help='The HTTP method to be used for the request (GET, POST, PUT, DELETE, ...).',
help=""" help="""
The HTTP method to be used for the request (GET, POST, PUT, DELETE, ...). 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( positional_arguments.add_argument(
dest='url', dest='url',
metavar='URL', metavar='URL',
short_help='The request URL.',
help=""" help="""
The scheme defaults to 'http://' if the URL does not include one. The request URL. Scheme defaults to 'http://' if the URL
(You can override this with: --default-scheme=https) does not include one. (You can override this with: --default-scheme=http/https)
You can also use a shorthand for localhost You can also use a shorthand for localhost
@ -83,6 +89,17 @@ positional_arguments.add_argument(
nargs=Qualifiers.ZERO_OR_MORE, nargs=Qualifiers.ZERO_OR_MORE,
default=None, default=None,
type=KeyValueArgType(*SEPARATOR_GROUP_ALL_ITEMS), type=KeyValueArgType(*SEPARATOR_GROUP_ALL_ITEMS),
short_help=(
'HTTPies 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""" help=r"""
Optional key-value pairs to be included in the request. The separator used Optional key-value pairs to be included in the request. The separator used
determines the type: determines the type:
@ -136,6 +153,7 @@ content_types.add_argument(
action='store_const', action='store_const',
const=RequestType.JSON, const=RequestType.JSON,
dest='request_type', dest='request_type',
short_help='(default) Serialize data items from the command line as a JSON object.',
help=""" help="""
(default) Data items from the command line are serialized as a JSON object. (default) Data items from the command line are serialized as a JSON object.
The Content-Type and Accept headers are set to application/json The Content-Type and Accept headers are set to application/json
@ -149,6 +167,7 @@ content_types.add_argument(
action='store_const', action='store_const',
const=RequestType.FORM, const=RequestType.FORM,
dest='request_type', dest='request_type',
short_help='Serialize data items from the command line as form field data.',
help=""" help="""
Data items from the command line are serialized as form fields. Data items from the command line are serialized as form fields.
@ -163,22 +182,21 @@ content_types.add_argument(
action='store_const', action='store_const',
const=RequestType.MULTIPART, const=RequestType.MULTIPART,
dest='request_type', dest='request_type',
help=""" short_help=(
Similar to --form, but always sends a multipart/form-data 'Similar to --form, but always sends a multipart/form-data '
request (i.e., even without files). 'request (i.e., even without files).'
)
""",
) )
content_types.add_argument( content_types.add_argument(
'--boundary', '--boundary',
help=""" short_help=(
Specify a custom boundary string for multipart/form-data requests. 'Specify a custom boundary string for multipart/form-data requests. '
Only has effect only together with --form. 'Only has effect only together with --form.'
)
""",
) )
content_types.add_argument( content_types.add_argument(
'--raw', '--raw',
short_help='Pass raw request data without extra processing.',
help=""" help="""
This option allows you to pass raw request data without extra processing This option allows you to pass raw request data without extra processing
(as opposed to the structured request items syntax): (as opposed to the structured request items syntax):
@ -208,6 +226,7 @@ processing_options.add_argument(
'-x', '-x',
action='count', action='count',
default=0, default=0,
short_help='Compress the content with Deflate algorithm.',
help=""" help="""
Content compressed (encoded) with Deflate algorithm. Content compressed (encoded) with Deflate algorithm.
The Content-Encoding header is set to deflate. The Content-Encoding header is set to deflate.
@ -223,22 +242,33 @@ processing_options.add_argument(
####################################################################### #######################################################################
def format_style_help(available_styles): def format_style_help(available_styles, *, isolation_mode: bool = False):
return """ text = """
Output coloring style (default is "{default}"). It can be one of: Output coloring style (default is "{default}"). It can be one of:
{available_styles} {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. The "{auto_style}" style follows your terminal's ANSI color styles.
For non-{auto_style} styles to work properly, please make sure that the For non-{auto_style} styles to work properly, please make sure that the
$TERM environment variable is set to "xterm-256color" or similar $TERM environment variable is set to "xterm-256color" or similar
(e.g., via `export TERM=xterm-256color' in your ~/.bashrc). (e.g., via `export TERM=xterm-256color' in your ~/.bashrc).
""".format( """)
default=DEFAULT_STYLE,
available_styles='\n'.join( if isolation_mode:
available_styles = sorted(BUNDLED_STYLES)
available_styles_text = '\n'.join(
f' {line.strip()}' f' {line.strip()}'
for line in textwrap.wrap(', '.join(available_styles), 60) for line in textwrap.wrap(', '.join(available_styles), 60)
).strip(), ).strip()
return text.format(
default=DEFAULT_STYLE,
available_styles=available_styles_text,
auto_style=AUTO_STYLE, auto_style=AUTO_STYLE,
) )
@ -261,6 +291,7 @@ output_processing.add_argument(
dest='prettify', dest='prettify',
default=PRETTY_STDOUT_TTY_ONLY, default=PRETTY_STDOUT_TTY_ONLY,
choices=sorted(PRETTY_MAP.keys()), choices=sorted(PRETTY_MAP.keys()),
short_help='Control the processing of console outputs.',
help=""" help="""
Controls output processing. The value can be "none" to not prettify Controls output processing. The value can be "none" to not prettify
the output (default for redirected output), "all" to apply both colors the output (default for redirected output), "all" to apply both colors
@ -276,6 +307,7 @@ output_processing.add_argument(
default=DEFAULT_STYLE, default=DEFAULT_STYLE,
action='lazy_choices', action='lazy_choices',
getter=get_available_styles, getter=get_available_styles,
short_help=f'Output coloring style (default is "{DEFAULT_STYLE}").',
help_formatter=format_style_help, help_formatter=format_style_help,
) )
@ -291,6 +323,7 @@ output_processing.add_argument(
output_processing.add_argument( output_processing.add_argument(
'--unsorted', '--unsorted',
**_unsorted_kwargs, **_unsorted_kwargs,
short_help='Disables all sorting while formatting output.',
help=f""" help=f"""
Disables all sorting while formatting output. It is a shortcut for: Disables all sorting while formatting output. It is a shortcut for:
@ -301,6 +334,7 @@ output_processing.add_argument(
output_processing.add_argument( output_processing.add_argument(
'--sorted', '--sorted',
**_sorted_kwargs, **_sorted_kwargs,
short_help='Re-enables all sorting options while formatting output.',
help=f""" help=f"""
Re-enables all sorting options while formatting output. It is a shortcut for: Re-enables all sorting options while formatting output. It is a shortcut for:
@ -312,6 +346,7 @@ output_processing.add_argument(
'--response-charset', '--response-charset',
metavar='ENCODING', metavar='ENCODING',
type=response_charset_type, type=response_charset_type,
short_help='Override the response encoding for terminal display purposes.',
help=""" help="""
Override the response encoding for terminal display purposes, e.g.: Override the response encoding for terminal display purposes, e.g.:
@ -324,6 +359,7 @@ output_processing.add_argument(
'--response-mime', '--response-mime',
metavar='MIME_TYPE', metavar='MIME_TYPE',
type=response_mime_type, type=response_mime_type,
short_help='Override the response mime type for coloring and formatting for the terminal.',
help=""" help="""
Override the response mime type for coloring and formatting for the terminal, e.g.: 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( output_processing.add_argument(
'--format-options', '--format-options',
action='append', action='append',
short_help='Controls output formatting.',
help=""" help="""
Controls output formatting. Only relevant when formatting is enabled Controls output formatting. Only relevant when formatting is enabled
through (explicit or implied) --pretty=all or --pretty=format. through (explicit or implied) --pretty=all or --pretty=format.
@ -368,6 +405,7 @@ output_options.add_argument(
'-p', '-p',
dest='output_options', dest='output_options',
metavar='WHAT', metavar='WHAT',
short_help='Options to specify what the console output should contain.',
help=f""" help=f"""
String specifying what the output should contain: String specifying what the output should contain:
@ -390,6 +428,7 @@ output_options.add_argument(
dest='output_options', dest='output_options',
action='store_const', action='store_const',
const=OUT_RESP_HEAD, const=OUT_RESP_HEAD,
short_help='Print only the response headers.',
help=f""" help=f"""
Print only the response headers. Shortcut for --print={OUT_RESP_HEAD}. Print only the response headers. Shortcut for --print={OUT_RESP_HEAD}.
@ -401,6 +440,7 @@ output_options.add_argument(
dest='output_options', dest='output_options',
action='store_const', action='store_const',
const=OUT_RESP_META, const=OUT_RESP_META,
short_help='Print only the response metadata.',
help=f""" help=f"""
Print only the response metadata. Shortcut for --print={OUT_RESP_META}. Print only the response metadata. Shortcut for --print={OUT_RESP_META}.
@ -412,6 +452,7 @@ output_options.add_argument(
dest='output_options', dest='output_options',
action='store_const', action='store_const',
const=OUT_RESP_BODY, const=OUT_RESP_BODY,
short_help='Print only the response body.',
help=f""" help=f"""
Print only the response body. Shortcut for --print={OUT_RESP_BODY}. Print only the response body. Shortcut for --print={OUT_RESP_BODY}.
@ -424,20 +465,22 @@ output_options.add_argument(
dest='verbose', dest='verbose',
action='count', action='count',
default=0, default=0,
short_help='Make output more verbose.',
help=f""" help=f"""
Verbose output. For the level one (with single `-v`/`--verbose`), print Verbose output. For the level one (with single `-v`/`--verbose`), print
the whole request as well as the response. Also print any intermediary the whole request as well as the response. Also print any intermediary
requests/responses (such as redirects). For the second level and higher, requests/responses (such as redirects). For the second level and higher,
print these as well as the response metadata. print these as well as the response metadata.
Level one is a shortcut for: --all --print={''.join(BASE_OUTPUT_OPTIONS)} Level one is a shortcut for: --all --print={''.join(sorted(BASE_OUTPUT_OPTIONS))}
Level two is a shortcut for: --all --print={''.join(OUTPUT_OPTIONS)} Level two is a shortcut for: --all --print={''.join(sorted(OUTPUT_OPTIONS))}
""", """,
) )
output_options.add_argument( output_options.add_argument(
'--all', '--all',
default=False, default=False,
action='store_true', action='store_true',
short_help='Show any intermediary requests/responses.',
help=""" help="""
By default, only the final request/response is shown. Use this flag to show By default, only the final request/response is shown. Use this flag to show
any intermediary requests/responses as well. Intermediary requests include any intermediary requests/responses as well. Intermediary requests include
@ -451,6 +494,7 @@ output_options.add_argument(
'-P', '-P',
dest='output_options_history', dest='output_options_history',
metavar='WHAT', metavar='WHAT',
short_help='--print for intermediary requests/responses.',
help=""" help="""
The same as --print, -p but applies only to intermediary requests/responses The same as --print, -p but applies only to intermediary requests/responses
(such as redirects) when their inclusion is enabled with --all. If this (such as redirects) when their inclusion is enabled with --all. If this
@ -464,6 +508,7 @@ output_options.add_argument(
'-S', '-S',
action='store_true', action='store_true',
default=False, default=False,
short_help='Always stream the response body by line, i.e., behave like `tail -f`.',
help=""" help="""
Always stream the response body by line, i.e., behave like `tail -f'. 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'), type=FileType('a+b'),
dest='output_file', dest='output_file',
metavar='FILE', metavar='FILE',
short_help='Save output to FILE instead of stdout.',
help=""" help="""
Save output to FILE instead of stdout. If --download is also set, then only 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 the response body is saved to FILE. Other parts of the HTTP exchange are
@ -497,6 +543,7 @@ output_options.add_argument(
'-d', '-d',
action='store_true', action='store_true',
default=False, default=False,
short_help='Download the body to a file instead of printing it to stdout.',
help=""" help="""
Do not print the response body to stdout. Rather, download it and store it 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 in a file. The filename is guessed unless specified with --output
@ -510,6 +557,7 @@ output_options.add_argument(
dest='download_resume', dest='download_resume',
action='store_true', action='store_true',
default=False, default=False,
short_help='Resume an interrupted download (--output needs to be specified).',
help=""" help="""
Resume an interrupted download. Note that the --output option needs to be Resume an interrupted download. Note that the --output option needs to be
specified as well. specified as well.
@ -521,6 +569,7 @@ output_options.add_argument(
'-q', '-q',
action='count', action='count',
default=0, default=0,
short_help='Do not print to stdout or stderr, except for errors and warnings when provided once.',
help=""" help="""
Do not print to stdout or stderr, except for errors and warnings when provided once. Do not print to stdout or stderr, except for errors and warnings when provided once.
Provide twice to suppress warnings as well. Provide twice to suppress warnings as well.
@ -544,21 +593,26 @@ sessions.add_argument(
'--session', '--session',
metavar='SESSION_NAME_OR_PATH', metavar='SESSION_NAME_OR_PATH',
type=session_name_validator, 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, 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 auth credential, as well as any cookies sent by the server persist between
requests. requests.
Session files are stored in: Session files are stored in:
{DEFAULT_SESSIONS_DIR}/<HOST>/<SESSION_NAME>.json. [HTTPIE_CONFIG_DIR]/<HOST>/<SESSION_NAME>.json.
See the following page to find out your default HTTPIE_CONFIG_DIR:
https://httpie.io/docs/cli/config-file-directory
""", """,
) )
sessions.add_argument( sessions.add_argument(
'--session-read-only', '--session-read-only',
metavar='SESSION_NAME_OR_PATH', metavar='SESSION_NAME_OR_PATH',
type=session_name_validator, type=session_name_validator,
short_help='Create or read a session without updating it',
help=""" help="""
Create or read a session without updating it form the request/response Create or read a session without updating it form the request/response
exchange. exchange.
@ -571,16 +625,25 @@ sessions.add_argument(
####################################################################### #######################################################################
def format_auth_help(auth_plugins_mapping): def format_auth_help(auth_plugins_mapping, *, isolation_mode: bool = False):
auth_plugins = list(auth_plugins_mapping.values()) text = """
return """
The authentication mechanism to be used. Defaults to "{default}". The authentication mechanism to be used. Defaults to "{default}".
{types} {auth_types}
"""
""".format( auth_plugins = list(auth_plugins_mapping.values())
default=auth_plugins[0].auth_type, if isolation_mode:
types='\n '.join( 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}": {name}{package}{description}'.format(
type=plugin.auth_type, type=plugin.auth_type,
name=plugin.name, name=plugin.name,
@ -597,7 +660,11 @@ def format_auth_help(auth_plugins_mapping):
), ),
) )
for plugin in auth_plugins for plugin in auth_plugins
), )
return text.format(
default=auth_plugins[0].auth_type,
auth_types=auth_types,
) )
@ -608,6 +675,7 @@ authentication.add_argument(
'-a', '-a',
default=None, default=None,
metavar='USER[:PASS] | TOKEN', metavar='USER[:PASS] | TOKEN',
short_help='Credentials for the selected (-A) authentication method.',
help=""" help="""
For username/password based authentication mechanisms (e.g For username/password based authentication mechanisms (e.g
basic auth or digest auth) if only the username is provided 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, getter=plugin_manager.get_auth_plugin_mapping,
sort=True, sort=True,
cache=False, cache=False,
short_help='The authentication mechanism to be used.',
help_formatter=format_auth_help, help_formatter=format_auth_help,
) )
authentication.add_argument( authentication.add_argument(
'--ignore-netrc', '--ignore-netrc',
default=False, default=False,
action='store_true', action='store_true',
help=""" short_help='Ignore credentials from .netrc.'
Ignore credentials from .netrc.
""",
) )
####################################################################### #######################################################################
@ -645,9 +711,7 @@ network.add_argument(
'--offline', '--offline',
default=False, default=False,
action='store_true', action='store_true',
help=""" short_help='Build the request and print it but dont actually send it.'
Build the request and print it but dont actually send it.
""",
) )
network.add_argument( network.add_argument(
'--proxy', '--proxy',
@ -655,6 +719,7 @@ network.add_argument(
action='append', action='append',
metavar='PROTOCOL:PROXY_URL', metavar='PROTOCOL:PROXY_URL',
type=KeyValueArgType(SEPARATOR_PROXY), type=KeyValueArgType(SEPARATOR_PROXY),
short_help='String mapping of protocol to the URL of the proxy.',
help=""" help="""
String mapping protocol to the URL of the proxy String mapping protocol to the URL of the proxy
(e.g. http:http://foo.bar:3128). You can specify multiple proxies with (e.g. http:http://foo.bar:3128). You can specify multiple proxies with
@ -668,16 +733,14 @@ network.add_argument(
'-F', '-F',
default=False, default=False,
action='store_true', action='store_true',
help=""" short_help='Follow 30x Location redirects.'
Follow 30x Location redirects.
""",
) )
network.add_argument( network.add_argument(
'--max-redirects', '--max-redirects',
type=int, type=int,
default=30, default=30,
short_help='The maximum number of redirects that should be followed (with --follow).',
help=""" help="""
By default, requests have a limit of 30 redirects (works with --follow). By default, requests have a limit of 30 redirects (works with --follow).
@ -687,11 +750,10 @@ network.add_argument(
'--max-headers', '--max-headers',
type=int, type=int,
default=0, default=0,
help=""" short_help=(
The maximum number of response headers to be read before giving up 'The maximum number of response headers to be read before '
(default 0, i.e., no limit). 'giving up (default 0, i.e., no limit).'
)
""",
) )
network.add_argument( network.add_argument(
@ -699,6 +761,7 @@ network.add_argument(
type=float, type=float,
default=0, default=0,
metavar='SECONDS', metavar='SECONDS',
short_help='The connection timeout of the request in seconds.',
help=""" help="""
The connection timeout of the request in seconds. The connection timeout of the request in seconds.
The default value is 0, i.e., there is no timeout limit. The default value is 0, i.e., there is no timeout limit.
@ -713,6 +776,7 @@ network.add_argument(
'--check-status', '--check-status',
default=False, default=False,
action='store_true', action='store_true',
short_help='Exit with an error status code if the server replies with an error.',
help=""" help="""
By default, HTTPie exits with 0 when no network or other fatal errors 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 occur. This flag instructs HTTPie to also check the HTTP status code and
@ -729,20 +793,16 @@ network.add_argument(
'--path-as-is', '--path-as-is',
default=False, default=False,
action='store_true', action='store_true',
help=""" short_help='Bypass dot segment (/../ or /./) URL squashing.'
Bypass dot segment (/../ or /./) URL squashing.
""",
) )
network.add_argument( network.add_argument(
'--chunked', '--chunked',
default=False, default=False,
action='store_true', action='store_true',
help=""" short_help=(
Enable streaming via chunked transfer encoding. 'Enable streaming via chunked transfer encoding. '
The Transfer-Encoding header is set to chunked. 'The Transfer-Encoding header is set to chunked.'
)
""",
) )
####################################################################### #######################################################################
@ -754,6 +814,7 @@ ssl = options.add_group('SSL')
ssl.add_argument( ssl.add_argument(
'--verify', '--verify',
default='yes', default='yes',
short_help='If "no", skip SSL verification. If a file path, use it as a CA bundle.',
help=""" help="""
Set to "no" (or "false") to skip checking the host's SSL certificate. 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 Defaults to "yes" ("true"). You can also pass the path to a CA_BUNDLE file
@ -765,6 +826,7 @@ ssl.add_argument(
'--ssl', '--ssl',
dest='ssl_version', dest='ssl_version',
choices=sorted(AVAILABLE_SSL_VERSION_ARG_MAPPING.keys()), choices=sorted(AVAILABLE_SSL_VERSION_ARG_MAPPING.keys()),
short_help='The desired protocol version to used.',
help=""" help="""
The desired protocol version to use. This will default to The desired protocol version to use. This will default to
SSL v2.3 which will negotiate the highest protocol that both SSL v2.3 which will negotiate the highest protocol that both
@ -776,6 +838,7 @@ ssl.add_argument(
) )
ssl.add_argument( ssl.add_argument(
'--ciphers', '--ciphers',
short_help='A string in the OpenSSL cipher list format.',
help=f""" help=f"""
A string in the OpenSSL cipher list format. By default, the following A string in the OpenSSL cipher list format. By default, the following
@ -789,6 +852,7 @@ ssl.add_argument(
'--cert', '--cert',
default=None, default=None,
type=readable_file_arg, type=readable_file_arg,
short_help='Specifys a local cert to use as client side SSL certificate.',
help=""" help="""
You can specify a local cert to use as client side SSL certificate. 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 This file may either contain both private key and certificate or you may
@ -800,6 +864,7 @@ ssl.add_argument(
'--cert-key', '--cert-key',
default=None, default=None,
type=readable_file_arg, type=readable_file_arg,
short_help='The private key to use with SSL. Only needed if --cert is given.',
help=""" help="""
The private key to use with SSL. Only needed if --cert is given and the The private key to use with SSL. Only needed if --cert is given and the
certificate file does not contain the private key. certificate file does not contain the private key.
@ -811,11 +876,12 @@ ssl.add_argument(
'--cert-key-pass', '--cert-key-pass',
default=None, default=None,
type=SSLCredentials, 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 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. is given and the key file requires a passphrase.
If not provided, youll be prompted interactively. If not provided, youll be prompted interactively.
''' """
) )
####################################################################### #######################################################################
@ -828,50 +894,42 @@ troubleshooting.add_argument(
'-I', '-I',
action='store_true', action='store_true',
default=False, default=False,
help=""" short_help='Do not attempt to read stdin'
Do not attempt to read stdin.
""",
) )
troubleshooting.add_argument( troubleshooting.add_argument(
'--help', '--help',
action='help', action='help',
default=Qualifiers.SUPPRESS, default=Qualifiers.SUPPRESS,
help=""" short_help='Show this help message and exit.',
Show this help message and exit. )
troubleshooting.add_argument(
""", '--manual',
action='manual',
default=Qualifiers.SUPPRESS,
short_help='Show the full manual.',
) )
troubleshooting.add_argument( troubleshooting.add_argument(
'--version', '--version',
action='version', action='version',
version=__version__, version=__version__,
help=""" short_help='Show version and exit.',
Show version and exit.
""",
) )
troubleshooting.add_argument( troubleshooting.add_argument(
'--traceback', '--traceback',
action='store_true', action='store_true',
default=False, default=False,
help=""" short_help='Prints the exception traceback should one occur.',
Prints the exception traceback should one occur.
""",
) )
troubleshooting.add_argument( troubleshooting.add_argument(
'--default-scheme', '--default-scheme',
default='http', default='http',
help=""" short_help='The default scheme to use if not specified in the URL.'
The default scheme to use if not specified in the URL.
""",
) )
troubleshooting.add_argument( troubleshooting.add_argument(
'--debug', '--debug',
action='store_true', action='store_true',
default=False, default=False,
short_help='Print useful diagnostic information for bug reports.',
help=""" help="""
Prints the exception traceback should one occur, as well as other Prints the exception traceback should one occur, as well as other
information useful for debugging HTTPie itself and for reporting bugs. information useful for debugging HTTPie itself and for reporting bugs.

View File

@ -3,15 +3,16 @@ import textwrap
import typing import typing
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum, auto 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.argparser import HTTPieArgumentParser
from httpie.cli.utils import LazyChoices from httpie.cli.utils import Manual, LazyChoices
class Qualifiers(Enum): class Qualifiers(Enum):
OPTIONAL = auto() OPTIONAL = auto()
ZERO_OR_MORE = auto() ZERO_OR_MORE = auto()
ONE_OR_MORE = auto()
SUPPRESS = 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' PARSER_SPEC_VERSION = '0.0.1a0'
@ -69,6 +91,7 @@ class Group:
def add_argument(self, *args, **kwargs): def add_argument(self, *args, **kwargs):
argument = Argument(list(args), kwargs.copy()) argument = Argument(list(args), kwargs.copy())
argument.post_init()
self.arguments.append(argument) self.arguments.append(argument)
return argument return argument
@ -85,14 +108,32 @@ class Argument(typing.NamedTuple):
aliases: List[str] aliases: List[str]
configuration: Dict[str, Any] 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() configuration = self.configuration.copy()
# Unpack the dynamically computed choices, since we # Unpack the dynamically computed choices, since we
# will need to store the actual values somewhere. # will need to store the actual values somewhere.
action = configuration.pop('action', None) action = configuration.pop('action', None)
short_help = configuration.pop('short_help', None)
nested_options = configuration.pop('nested_options', None)
if action == 'lazy_choices': 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['choices'] = list(choices.load())
configuration['help'] = choices.help configuration['help'] = choices.help
@ -106,9 +147,13 @@ class Argument(typing.NamedTuple):
qualifiers = JSON_QUALIFIER_TO_OPTIONS[configuration.get('nargs', Qualifiers.SUPPRESS)] qualifiers = JSON_QUALIFIER_TO_OPTIONS[configuration.get('nargs', Qualifiers.SUPPRESS)]
result.update(qualifiers) result.update(qualifiers)
help_msg = configuration.get('help') description = configuration.get('help')
if help_msg and help_msg is not Qualifiers.SUPPRESS: if description and description is not Qualifiers.SUPPRESS:
result['description'] = help_msg.strip() result['short_description'] = short_help
result['description'] = description
if nested_options:
result['nested_options'] = nested_options
python_type = configuration.get('type') python_type = configuration.get('type')
if python_type is not None: if python_type is not None:
@ -123,10 +168,19 @@ class Argument(typing.NamedTuple):
key: value key: value
for key, value in configuration.items() for key, value in configuration.items()
if key in JSON_DIRECT_MIRROR_OPTIONS if key in JSON_DIRECT_MIRROR_OPTIONS
if value is not Qualifiers.SUPPRESS
}) })
return result 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): def __getattr__(self, attribute_name):
if attribute_name in self.configuration: if attribute_name in self.configuration:
return self.configuration[attribute_name] return self.configuration[attribute_name]
@ -140,7 +194,9 @@ ARGPARSE_QUALIFIER_MAP = {
Qualifiers.OPTIONAL: argparse.OPTIONAL, Qualifiers.OPTIONAL: argparse.OPTIONAL,
Qualifiers.SUPPRESS: argparse.SUPPRESS, Qualifiers.SUPPRESS: argparse.SUPPRESS,
Qualifiers.ZERO_OR_MORE: argparse.ZERO_OR_MORE, 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( def to_argparse(
@ -152,7 +208,9 @@ def to_argparse(
description=abstract_options.description, description=abstract_options.description,
epilog=abstract_options.epilog, epilog=abstract_options.epilog,
) )
concrete_parser.spec = abstract_options
concrete_parser.register('action', 'lazy_choices', LazyChoices) concrete_parser.register('action', 'lazy_choices', LazyChoices)
concrete_parser.register('action', 'manual', Manual)
for abstract_group in abstract_options.groups: for abstract_group in abstract_options.groups:
concrete_group = concrete_parser.add_argument_group( concrete_group = concrete_parser.add_argument_group(
@ -164,9 +222,9 @@ def to_argparse(
for abstract_argument in abstract_group.arguments: for abstract_argument in abstract_group.arguments:
concrete_group.add_argument( concrete_group.add_argument(
*abstract_argument.aliases, *abstract_argument.aliases,
**map_qualifiers( **drop_keys(map_qualifiers(
abstract_argument.configuration, ARGPARSE_QUALIFIER_MAP abstract_argument.configuration, ARGPARSE_QUALIFIER_MAP
) ), ARGPARSE_IGNORE_KEYS)
) )
return concrete_parser return concrete_parser
@ -181,9 +239,19 @@ JSON_DIRECT_MIRROR_OPTIONS = (
JSON_QUALIFIER_TO_OPTIONS = { JSON_QUALIFIER_TO_OPTIONS = {
Qualifiers.OPTIONAL: {'is_optional': True}, Qualifiers.OPTIONAL: {'is_optional': True},
Qualifiers.ZERO_OR_MORE: {'is_optional': True, 'is_variadic': True}, Qualifiers.ZERO_OR_MORE: {'is_optional': True, 'is_variadic': True},
Qualifiers.ONE_OR_MORE: {'is_optional': False, 'is_variadic': True},
Qualifiers.SUPPRESS: {} Qualifiers.SUPPRESS: {}
} }
def to_data(abstract_options: ParserSpec) -> Dict[str, Any]: def to_data(abstract_options: ParserSpec) -> Dict[str, Any]:
return {'version': PARSER_SPEC_VERSION, 'spec': abstract_options.serialize()} 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
)

View File

@ -4,20 +4,43 @@ from typing import Any, Callable, Generic, Iterator, Iterable, Optional, TypeVar
T = TypeVar('T') 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]): class LazyChoices(argparse.Action, Generic[T]):
def __init__( def __init__(
self, self,
*args, *args,
getter: Callable[[], Iterable[T]], getter: Callable[[], Iterable[T]],
help_formatter: Optional[Callable[[T], str]] = None, help_formatter: Optional[Callable[[T, bool], str]] = None,
sort: bool = False, sort: bool = False,
cache: bool = True, cache: bool = True,
isolation_mode: bool = False,
**kwargs **kwargs
) -> None: ) -> None:
self.getter = getter self.getter = getter
self.help_formatter = help_formatter self.help_formatter = help_formatter
self.sort = sort self.sort = sort
self.cache = cache self.cache = cache
self.isolation_mode = isolation_mode
self._help: Optional[str] = None self._help: Optional[str] = None
self._obj: Optional[Iterable[T]] = None self._obj: Optional[Iterable[T]] = None
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -33,7 +56,10 @@ class LazyChoices(argparse.Action, Generic[T]):
@property @property
def help(self) -> str: def help(self) -> str:
if self._help is None and self.help_formatter is not None: 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 return self._help
@help.setter @help.setter

View File

@ -1,9 +1,10 @@
import argparse
import sys import sys
import os import os
import warnings import warnings
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from typing import Iterator, IO, Optional from typing import Iterator, IO, Optional, TYPE_CHECKING
from enum import Enum from enum import Enum
@ -12,11 +13,15 @@ try:
except ImportError: except ImportError:
curses = None # Compiled w/o curses 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 .config import DEFAULT_CONFIG_DIR, Config, ConfigFileError
from .encoding import UTF8 from .encoding import UTF8
from .utils import repr_dict 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): class Levels(str, Enum):
@ -40,6 +45,7 @@ class Environment:
is used by the test suite to simulate various scenarios. is used by the test suite to simulate various scenarios.
""" """
args = argparse.Namespace()
is_windows: bool = is_windows is_windows: bool = is_windows
config_dir: Path = DEFAULT_CONFIG_DIR config_dir: Path = DEFAULT_CONFIG_DIR
stdin: Optional[IO] = sys.stdin # `None` when closed fd (#791) stdin: Optional[IO] = sys.stdin # `None` when closed fd (#791)
@ -52,6 +58,10 @@ class Environment:
stderr_isatty: bool = stderr.isatty() stderr_isatty: bool = stderr.isatty()
colors = 256 colors = 256
program_name: str = 'http' program_name: str = 'http'
# Whether to show progress bars / status spinners etc.
show_displays: bool = True
if not is_windows: if not is_windows:
if curses: if curses:
try: try:
@ -160,3 +170,49 @@ class Environment:
def apply_warnings_filter(self) -> None: def apply_warnings_filter(self) -> None:
if self.quiet >= DISPLAY_THRESHOLDS[Levels.WARNING]: if self.quiet >= DISPLAY_THRESHOLDS[Levels.WARNING]:
warnings.simplefilter("ignore") 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)

View File

@ -195,7 +195,7 @@ def program(args: argparse.Namespace, env: Environment) -> ExitStatus:
try: try:
if args.download: if args.download:
args.follow = True # --download implies --follow. 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) downloader.pre_request(args.headers)
messages = collect_messages(env, args=args, messages = collect_messages(env, args=args,
request_body_read_callback=request_body_read_callback) request_body_read_callback=request_body_read_callback)

View File

@ -5,10 +5,8 @@ Download mode implementation.
import mimetypes import mimetypes
import os import os
import re import re
import sys
import threading
from mailbox import Message from mailbox import Message
from time import sleep, monotonic from time import monotonic
from typing import IO, Optional, Tuple from typing import IO, Optional, Tuple
from urllib.parse import urlsplit from urllib.parse import urlsplit
@ -16,22 +14,11 @@ import requests
from .models import HTTPResponse, OutputOptions from .models import HTTPResponse, OutputOptions
from .output.streams import RawStream from .output.streams import RawStream
from .utils import humanize_bytes from .context import Environment
PARTIAL_CONTENT = 206 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): class ContentRangeError(ValueError):
pass pass
@ -176,9 +163,9 @@ class Downloader:
def __init__( def __init__(
self, self,
env: Environment,
output_file: IO = None, output_file: IO = None,
resume: bool = False, resume: bool = False
progress_file: IO = sys.stderr
): ):
""" """
:param resume: Should the download resume if partial download :param resume: Should the download resume if partial download
@ -191,14 +178,10 @@ class Downloader:
""" """
self.finished = False self.finished = False
self.status = DownloadStatus() self.status = DownloadStatus(env=env)
self._output_file = output_file self._output_file = output_file
self._resume = resume self._resume = resume
self._resumed_from = 0 self._resumed_from = 0
self._progress_reporter = ProgressReporterThread(
status=self.status,
output=progress_file
)
def pre_request(self, request_headers: dict): def pre_request(self, request_headers: dict):
"""Called just before the HTTP request is sent. """Called just before the HTTP request is sent.
@ -261,11 +244,6 @@ class Downloader:
except OSError: except OSError:
pass # stdout 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) output_options = OutputOptions.from_message(final_response, headers=False, body=True)
stream = RawStream( stream = RawStream(
msg=HTTPResponse(final_response), msg=HTTPResponse(final_response),
@ -273,11 +251,11 @@ class Downloader:
on_body_chunk_downloaded=self.chunk_downloaded, on_body_chunk_downloaded=self.chunk_downloaded,
) )
self._progress_reporter.output.write( self.status.started(
f'Downloading {humanize_bytes(total_size) + " " if total_size is not None else ""}' output_file=self._output_file,
f'to "{self._output_file.name}"\n' resumed_from=self._resumed_from,
total_size=total_size
) )
self._progress_reporter.start()
return stream, self._output_file return stream, self._output_file
@ -287,7 +265,7 @@ class Downloader:
self.status.finished() self.status.finished()
def failed(self): def failed(self):
self._progress_reporter.stop() self.status.terminate()
@property @property
def interrupted(self) -> bool: def interrupted(self) -> bool:
@ -329,127 +307,71 @@ class Downloader:
class DownloadStatus: class DownloadStatus:
"""Holds details about the download status.""" """Holds details about the download status."""
def __init__(self): def __init__(self, env):
self.env = env
self.downloaded = 0 self.downloaded = 0
self.total_size = None self.total_size = None
self.resumed_from = 0 self.resumed_from = 0
self.time_started = None self.time_started = None
self.time_finished = 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 assert self.time_started is None
self.total_size = total_size self.total_size = total_size
self.downloaded = self.resumed_from = resumed_from self.downloaded = self.resumed_from = resumed_from
self.time_started = monotonic() 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): def chunk_downloaded(self, size):
assert self.time_finished is None assert self.time_finished is None
self.downloaded += size self.downloaded += size
self.display.update(size)
@property @property
def has_finished(self): def has_finished(self):
return self.time_finished is not None 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): def finished(self):
assert self.time_started is not None assert self.time_started is not None
assert self.time_finished is None assert self.time_finished is None
self.time_finished = monotonic() self.time_finished = monotonic()
if hasattr(self, 'display'):
self.display.stop(self.time_spent)
def terminate(self):
class ProgressReporterThread(threading.Thread): if hasattr(self, 'display'):
""" self.display.stop(self.time_spent)
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()

View File

@ -1,5 +1,6 @@
from textwrap import dedent from textwrap import dedent
from httpie.cli.argparser import HTTPieManagerArgumentParser 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__ from httpie import __version__
CLI_SESSION_UPGRADE_FLAGS = [ CLI_SESSION_UPGRADE_FLAGS = [
@ -58,7 +59,8 @@ COMMANDS['plugins'] = COMMANDS['cli']['plugins'] = {
'or from a local paths.', 'or from a local paths.',
{ {
'dest': 'targets', 'dest': 'targets',
'nargs': '+', 'metavar': 'TARGET',
'nargs': Qualifiers.ONE_OR_MORE,
'help': 'targets to install' 'help': 'targets to install'
} }
], ],
@ -66,7 +68,8 @@ COMMANDS['plugins'] = COMMANDS['cli']['plugins'] = {
'Upgrade the given plugins', 'Upgrade the given plugins',
{ {
'dest': 'targets', 'dest': 'targets',
'nargs': '+', 'metavar': 'TARGET',
'nargs': Qualifiers.ONE_OR_MORE,
'help': 'targets to upgrade' 'help': 'targets to upgrade'
} }
], ],
@ -74,7 +77,8 @@ COMMANDS['plugins'] = COMMANDS['cli']['plugins'] = {
'Uninstall the given HTTPie plugins.', 'Uninstall the given HTTPie plugins.',
{ {
'dest': 'targets', 'dest': 'targets',
'nargs': '+', 'metavar': 'TARGET',
'nargs': Qualifiers.ONE_OR_MORE,
'help': 'targets to install' 'help': 'targets to install'
} }
], ],
@ -94,7 +98,7 @@ def missing_subcommand(*args) -> str:
return f'Please specify one of these: {subcommands}' 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']) action_dest = '_'.join(parent_parser.prog.split()[1:] + ['action'])
actions = parent_parser.add_subparsers( actions = parent_parser.add_subparsers(
dest=action_dest dest=action_dest
@ -107,13 +111,15 @@ def generate_subparsers(root, parent_parser, definitions):
command_parser = actions.add_parser(command, description=descr) command_parser = actions.add_parser(command, description=descr)
command_parser.root = root command_parser.root = root
if is_subparser: if is_subparser:
generate_subparsers(root, command_parser, properties) generate_subparsers(root, command_parser, properties, spec)
continue continue
group = spec.add_group(parent_parser.prog + ' ' + command, description=descr)
for argument in properties: for argument in properties:
argument = argument.copy() argument = argument.copy()
flags = argument.pop('flags', []) 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( 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)

View File

@ -17,12 +17,11 @@ from pygments.util import ClassNotFound
from ..lexers.json import EnhancedJsonLexer from ..lexers.json import EnhancedJsonLexer
from ..lexers.metadata import MetadataLexer 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 ...context import Environment
from ...plugins import FormatterPlugin from ...plugins import FormatterPlugin
AUTO_STYLE = 'auto' # Follows terminal ANSI color styles
DEFAULT_STYLE = AUTO_STYLE DEFAULT_STYLE = AUTO_STYLE
SOLARIZED_STYLE = 'solarized' # Bundled here SOLARIZED_STYLE = 'solarized' # Bundled here
@ -33,7 +32,7 @@ BUNDLED_STYLES = {
def get_available_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): class ColorFormatter(FormatterPlugin):

View File

@ -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
)

View File

@ -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 = 'pie'
STYLE_PIE_DARK = 'pie-dark' STYLE_PIE_DARK = 'pie-dark'
STYLE_PIE_LIGHT = 'pie-light' STYLE_PIE_LIGHT = 'pie-light'
@ -7,8 +8,6 @@ STYLE_PIE_LIGHT = 'pie-light'
COLOR_PALETTE = { COLOR_PALETTE = {
# Copy the brand palette # Copy the brand palette
'transparent': 'transparent',
'current': 'currentColor',
'white': '#F5F5F0', 'white': '#F5F5F0',
'black': '#1C1818', 'black': '#1C1818',
'grey': { 'grey': {
@ -150,17 +149,27 @@ SHADE_NAMES = {
'700': STYLE_PIE_LIGHT '700': STYLE_PIE_LIGHT
} }
STYLE_SHADES = {
style: shade
for shade, style in SHADE_NAMES.items()
}
SHADES = [ SHADES = [
'50', '50',
*map(str, range(100, 1000, 100)) *map(str, range(100, 1000, 100))
] ]
def get_color(color: str, shade: str) -> Optional[str]: def get_color(
if color not in COLOR_PALETTE: color: str,
shade: str,
*,
palette: Dict[str, Dict[str, str]] = COLOR_PALETTE
) -> Optional[str]:
if color not in palette:
return None return None
color_code = COLOR_PALETTE[color] color_code = palette[color]
if isinstance(color_code, dict) and shade in color_code: if isinstance(color_code, dict) and shade in color_code:
return color_code[shade] return color_code[shade]
else: else:

View File

@ -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<option>\-{1,2}[\w|-]+)(?![a-zA-Z0-9])',
r'(?P<bold>HTTPie)',
]
options_highlighter = OptionsHighlighter()
def unpack_argument(
argument: Argument,
) -> Tuple[Text, Text]:
opt1 = opt2 = ''
style = None
if argument.aliases:
if len(argument.aliases) >= 2:
opt2, opt1 = argument.aliases
else:
(opt1,) = argument.aliases
else:
opt1 = argument.metavar
style = STYLE_USAGE_REGULAR
return Text(opt1, style=style), Text(opt2)
def to_usage(
spec: ParserSpec,
*,
program_name: Optional[str] = None,
whitelist: AbstractSet[str] = frozenset()
) -> RenderableType:
shown_arguments = [
argument
for group in spec.groups
for argument in group.arguments
if (not argument.aliases or whitelist.intersection(argument.aliases))
]
# Sort the shown_arguments so that --dash options are
# shown first
shown_arguments.sort(key=lambda argument: argument.aliases, reverse=True)
text = Text(program_name or spec.program, style='bold')
for argument in shown_arguments:
text.append(' ')
is_whitelisted = whitelist.intersection(argument.aliases)
if argument.aliases:
name = '/'.join(sorted(argument.aliases, key=len))
else:
name = argument.metavar
nargs = argument.configuration.get('nargs')
if nargs is Qualifiers.OPTIONAL:
text.append('[' + name + ']', style=STYLE_USAGE_OPTIONAL)
elif nargs is Qualifiers.ZERO_OR_MORE:
text.append(
'[' + name + ' ...]',
style=STYLE_USAGE_OPTIONAL,
)
else:
text.append(
name,
style=STYLE_USAGE_ERROR
if is_whitelisted
else STYLE_USAGE_REGULAR,
)
raw_form = argument.serialize()
if raw_form.get('choices'):
text.append(' ')
text.append(
'{' + ', '.join(raw_form['choices']) + '}',
style=STYLE_USAGE_MISSING,
)
return text
# This part is loosely based on the rich-click's help message
# generation.
def to_help_message(
spec: ParserSpec,
) -> Iterable[RenderableType]:
yield Padding(
options_highlighter(spec.description),
LEFT_INDENT_2,
)
yield Padding(
Text('Usage', style=STYLE_SWITCH),
LEFT_INDENT_2,
)
yield Padding(to_usage(spec), LEFT_INDENT_3)
group_rows = {}
for group in spec.groups:
options_rows = []
for argument in group.arguments:
if argument.is_hidden:
continue
opt1, opt2 = unpack_argument(argument)
if opt2:
opt1.append('/')
opt1.append(opt2)
# Column for a metavar, if we have one
metavar = Text(style=STYLE_METAVAR)
metavar.append(argument.configuration.get('metavar', ''))
if opt1 == metavar:
metavar = Text('')
raw_form = argument.serialize()
desc = raw_form.get('short_description', '')
if raw_form.get('choices'):
desc += ' (choices: '
desc += textwrap.shorten(
', '.join(raw_form.get('choices')),
MAX_CHOICE_CHARS,
)
desc += ')'
rows = [
Padding(
options_highlighter(opt1),
LEFT_PADDING_2,
),
metavar,
options_highlighter(desc),
]
options_rows.append(rows)
if argument.configuration.get('nested_options'):
options_rows.extend(
[
(
Padding(
Text(
key,
style=STYLE_USAGE_OPTIONAL,
),
LEFT_PADDING_4,
),
value,
dec,
)
for key, value, dec in argument.nested_options
]
)
group_rows[group.name] = options_rows
options_table = Table(highlight=False, box=None, show_header=False)
for group_name, options_rows in group_rows.items():
options_table.add_row(Text(), Text(), Text())
options_table.add_row(
Text(group_name, style=STYLE_SWITCH),
Text(),
Text(),
)
options_table.add_row(Text(), Text(), Text())
for row in options_rows:
options_table.add_row(*row)
yield Padding(
Text('Options', style=STYLE_SWITCH),
LEFT_INDENT_2,
)
yield Padding(options_table, LEFT_PADDING_2)
yield Padding(
Text('More Information', style=STYLE_SWITCH),
LEFT_INDENT_2,
)
yield Padding(
spec.epilog.rstrip('\n'),
LEFT_INDENT_BOTTOM_3,
)

View File

@ -0,0 +1,23 @@
from httpie.output.ui.palette import * # noqa
# Rich-specific color code declarations
# https://github.com/Textualize/rich/blob/fcd684dd3a482977cab620e71ccaebb94bf13ac9/rich/default_styles.py#L5
CUSTOM_STYLES = {
'progress.description': 'white',
'progress.data.speed': 'green',
'progress.percentage': 'aqua',
'progress.download': 'aqua',
'progress.remaining': 'orange',
'bar.complete': 'purple',
'bar.finished': 'green',
'bar.pulse': 'purple',
'option': 'pink'
}
RICH_THEME_PALETTE = COLOR_PALETTE.copy() # noqa
RICH_THEME_PALETTE.update(
{
custom_style: RICH_THEME_PALETTE[color]
for custom_style, color in CUSTOM_STYLES.items()
}
)

View File

@ -0,0 +1,136 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional
from httpie.context import Environment
if TYPE_CHECKING:
from rich.console import Console
@dataclass
class BaseDisplay:
env: Environment
def start(
self, *, total: Optional[float], at: float, description: str
) -> None:
...
def update(self, steps: float) -> None:
...
def stop(self, time_spent: float) -> None:
...
@property
def console(self) -> 'Console':
"""Returns the default console to be used with displays (stderr)."""
return self.env.rich_error_console
def _print_summary(
self,
is_finished: bool,
observed_steps: int,
time_spent: float
):
from rich import filesize
if is_finished:
verb = 'Done'
else:
verb = 'Interrupted'
total_size = filesize.decimal(observed_steps)
avg_speed = filesize.decimal(observed_steps / time_spent)
minutes, seconds = divmod(time_spent, 60)
hours, minutes = divmod(int(minutes), 60)
if hours:
total_time = f'{hours:d}:{minutes:02d}:{seconds:0.5f}'
else:
total_time = f'{minutes:02d}:{seconds:0.5f}'
self.console.print(f'[progress.description]{verb}. {total_size} in {total_time} ({avg_speed}/s)')
class DummyDisplay(BaseDisplay):
"""
A dummy display object to be used when the progress bars,
spinners etc. are disabled globally (or during tests).
"""
class StatusDisplay(BaseDisplay):
def start(
self, *, total: Optional[float], at: float, description: str
) -> None:
self.observed = at
self.description = f'[progress.description]{description}[/progress.description]'
self.status = self.console.status(self.description, spinner='line')
self.status.start()
def update(self, steps: float) -> None:
from rich import filesize
self.observed += steps
observed_amount, observed_unit = filesize.decimal(self.observed).split()
self.status.update(status=f'{self.description} [progress.download]{observed_amount}/? {observed_unit}[/progress.download]')
def stop(self, time_spent: float) -> None:
self.status.stop()
self.console.print(self.description)
if time_spent:
self._print_summary(
is_finished=True,
observed_steps=self.observed,
time_spent=time_spent
)
class ProgressDisplay(BaseDisplay):
def start(
self, *, total: Optional[float], at: float, description: str
) -> None:
from rich.progress import (
Progress,
BarColumn,
DownloadColumn,
TimeRemainingColumn,
TransferSpeedColumn,
)
assert total is not None
self.console.print(f'[progress.description]{description}')
self.progress_bar = Progress(
'[',
BarColumn(),
']',
'[progress.percentage]{task.percentage:>3.0f}%',
'(',
DownloadColumn(),
')',
TimeRemainingColumn(),
TransferSpeedColumn(),
console=self.console,
transient=True
)
self.progress_bar.start()
self.transfer_task = self.progress_bar.add_task(
description, completed=at, total=total
)
def update(self, steps: float) -> None:
self.progress_bar.advance(self.transfer_task, steps)
def stop(self, time_spent: Optional[float]) -> None:
self.progress_bar.stop()
if time_spent:
[task] = self.progress_bar.tasks
self._print_summary(
is_finished=task.finished,
observed_steps=task.completed,
time_spent=time_spent
)

View File

@ -0,0 +1,35 @@
import os
from typing import Iterator
from contextlib import contextmanager
from rich.console import Console, RenderableType
from rich.highlighter import Highlighter
def render_as_string(renderable: RenderableType) -> str:
"""Render any `rich` object in a fake console and
return a *style-less* version of it as a string."""
with open(os.devnull, "w") as null_stream:
fake_console = Console(
file=null_stream,
record=True
)
fake_console.print(renderable)
return fake_console.export_text()
@contextmanager
def enable_highlighter(
console: Console,
highlighter: Highlighter,
) -> Iterator[Console]:
"""Enable a higlighter temporarily."""
original_highlighter = console.highlighter
try:
console.highlighter = highlighter
yield console
finally:
console.highlighter = original_highlighter

View File

@ -38,6 +38,7 @@ install_requires = [
'multidict>=4.7.0', 'multidict>=4.7.0',
'setuptools', 'setuptools',
'importlib-metadata>=1.4.0; python_version < "3.8"', 'importlib-metadata>=1.4.0; python_version < "3.8"',
'rich>=9.10.0'
] ]
install_requires_win_only = [ install_requires_win_only = [
'colorama>=0.2.4', 'colorama>=0.2.4',
@ -111,4 +112,8 @@ setup(
'Documentation': 'https://httpie.io/docs', 'Documentation': 'https://httpie.io/docs',
'Online Demo': 'https://httpie.io/run', 'Online Demo': 'https://httpie.io/run',
}, },
data_files=[
('share/man/man1', ['extras/man/http.1']),
('share/man/man1', ['extras/man/https.1']),
]
) )

View File

@ -1,54 +1,37 @@
import pytest import pytest
import shutil import shutil
import os import os
import sys
from tests.utils import http from tests.utils import http
NAKED_BASE_TEMPLATE = """\
if sys.version_info >= (3, 9):
REQUEST_ITEM_MSG = "[REQUEST_ITEM ...]"
else:
REQUEST_ITEM_MSG = "[REQUEST_ITEM [REQUEST_ITEM ...]]"
NAKED_HELP_MESSAGE = f"""\
usage: usage:
http [METHOD] URL {REQUEST_ITEM_MSG} http {extra_args}[METHOD] URL [REQUEST_ITEM ...]
error: error:
the following arguments are required: URL {error_msg}
for more information: for more information:
run 'http --help' or visit https://httpie.io/docs/cli run 'http --help' or visit https://httpie.io/docs/cli
""" """
NAKED_HELP_MESSAGE_PRETTY_WITH_NO_ARG = f"""\ NAKED_HELP_MESSAGE = NAKED_BASE_TEMPLATE.format(
usage: extra_args="",
http [--pretty {{all,colors,format,none}}] [METHOD] URL {REQUEST_ITEM_MSG} error_msg="the following arguments are required: URL"
)
error: NAKED_HELP_MESSAGE_PRETTY_WITH_NO_ARG = NAKED_BASE_TEMPLATE.format(
argument --pretty: expected one argument extra_args="--pretty {all, colors, format, none} ",
error_msg="argument --pretty: expected one argument"
)
for more information: NAKED_HELP_MESSAGE_PRETTY_WITH_INVALID_ARG = NAKED_BASE_TEMPLATE.format(
run 'http --help' or visit https://httpie.io/docs/cli extra_args="--pretty {all, colors, format, none} ",
error_msg="argument --pretty: invalid choice: '$invalid' (choose from 'all', 'colors', 'format', 'none')"
""" )
NAKED_HELP_MESSAGE_PRETTY_WITH_INVALID_ARG = f"""\
usage:
http [--pretty {{all,colors,format,none}}] [METHOD] URL {REQUEST_ITEM_MSG}
error:
argument --pretty: invalid choice: '$invalid' (choose from 'all', 'colors', 'format', 'none')
for more information:
run 'http --help' or visit https://httpie.io/docs/cli
"""
PREDEFINED_TERMINAL_SIZE = (160, 80) PREDEFINED_TERMINAL_SIZE = (200, 100)
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
@ -66,6 +49,7 @@ def ignore_terminal_size(monkeypatch):
# Setting COLUMNS as an env var is required for 3.8< # Setting COLUMNS as an env var is required for 3.8<
monkeypatch.setitem(os.environ, 'COLUMNS', str(PREDEFINED_TERMINAL_SIZE[0])) monkeypatch.setitem(os.environ, 'COLUMNS', str(PREDEFINED_TERMINAL_SIZE[0]))
monkeypatch.setattr(shutil, 'get_terminal_size', fake_terminal_size) monkeypatch.setattr(shutil, 'get_terminal_size', fake_terminal_size)
monkeypatch.setattr(os, 'get_terminal_size', fake_terminal_size)
@pytest.mark.parametrize( @pytest.mark.parametrize(

View File

@ -83,4 +83,4 @@ def test_lazy_choices_help():
# If we use --help, then we call it with styles # If we use --help, then we call it with styles
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
parser.parse_args(['--help']) parser.parse_args(['--help'])
help_formatter.assert_called_once_with(['a', 'b', 'c']) help_formatter.assert_called_once_with(['a', 'b', 'c'], isolation_mode=False)

View File

@ -125,16 +125,14 @@ class TestDownloads:
def test_actual_download(self, httpbin_both, httpbin): def test_actual_download(self, httpbin_both, httpbin):
robots_txt = '/robots.txt' robots_txt = '/robots.txt'
body = urlopen(httpbin + robots_txt).read().decode() body = urlopen(httpbin + robots_txt).read().decode()
env = MockEnvironment(stdin_isatty=True, stdout_isatty=False) env = MockEnvironment(stdin_isatty=True, stdout_isatty=False, show_displays=True)
r = http('--download', httpbin_both.url + robots_txt, env=env) r = http('--download', httpbin_both.url + robots_txt, env=env)
assert 'Downloading' in r.stderr assert 'Downloading' in r.stderr
assert '[K' in r.stderr
assert 'Done' in r.stderr
assert body == r assert body == r
def test_download_with_Content_Length(self, httpbin_both): def test_download_with_Content_Length(self, mock_env, httpbin_both):
with open(os.devnull, 'w') as devnull: with open(os.devnull, 'w') as devnull:
downloader = Downloader(output_file=devnull, progress_file=devnull) downloader = Downloader(mock_env, output_file=devnull)
downloader.start( downloader.start(
initial_url='/', initial_url='/',
final_response=Response( final_response=Response(
@ -148,11 +146,10 @@ class TestDownloads:
downloader.chunk_downloaded(b'12345') downloader.chunk_downloaded(b'12345')
downloader.finish() downloader.finish()
assert not downloader.interrupted assert not downloader.interrupted
downloader._progress_reporter.join()
def test_download_no_Content_Length(self, httpbin_both): def test_download_no_Content_Length(self, mock_env, httpbin_both):
with open(os.devnull, 'w') as devnull: with open(os.devnull, 'w') as devnull:
downloader = Downloader(output_file=devnull, progress_file=devnull) downloader = Downloader(mock_env, output_file=devnull)
downloader.start( downloader.start(
final_response=Response(url=httpbin_both.url + '/'), final_response=Response(url=httpbin_both.url + '/'),
initial_url='/' initial_url='/'
@ -161,15 +158,14 @@ class TestDownloads:
downloader.chunk_downloaded(b'12345') downloader.chunk_downloaded(b'12345')
downloader.finish() downloader.finish()
assert not downloader.interrupted assert not downloader.interrupted
downloader._progress_reporter.join()
def test_download_output_from_content_disposition(self, httpbin_both): def test_download_output_from_content_disposition(self, mock_env, httpbin_both):
with tempfile.TemporaryDirectory() as tmp_dirname, open(os.devnull, 'w') as devnull: with tempfile.TemporaryDirectory() as tmp_dirname:
orig_cwd = os.getcwd() orig_cwd = os.getcwd()
os.chdir(tmp_dirname) os.chdir(tmp_dirname)
try: try:
assert not os.path.isfile('filename.bin') assert not os.path.isfile('filename.bin')
downloader = Downloader(progress_file=devnull) downloader = Downloader(mock_env)
downloader.start( downloader.start(
final_response=Response( final_response=Response(
url=httpbin_both.url + '/', url=httpbin_both.url + '/',
@ -184,7 +180,6 @@ class TestDownloads:
downloader.finish() downloader.finish()
downloader.failed() # Stop the reporter downloader.failed() # Stop the reporter
assert not downloader.interrupted assert not downloader.interrupted
downloader._progress_reporter.join()
# TODO: Auto-close the file in that case? # TODO: Auto-close the file in that case?
downloader._output_file.close() downloader._output_file.close()
@ -192,9 +187,9 @@ class TestDownloads:
finally: finally:
os.chdir(orig_cwd) os.chdir(orig_cwd)
def test_download_interrupted(self, httpbin_both): def test_download_interrupted(self, mock_env, httpbin_both):
with open(os.devnull, 'w') as devnull: with open(os.devnull, 'w') as devnull:
downloader = Downloader(output_file=devnull, progress_file=devnull) downloader = Downloader(mock_env, output_file=devnull)
downloader.start( downloader.start(
final_response=Response( final_response=Response(
url=httpbin_both.url + '/', url=httpbin_both.url + '/',
@ -205,17 +200,16 @@ class TestDownloads:
downloader.chunk_downloaded(b'1234') downloader.chunk_downloaded(b'1234')
downloader.finish() downloader.finish()
assert downloader.interrupted assert downloader.interrupted
downloader._progress_reporter.join()
def test_download_resumed(self, httpbin_both): def test_download_resumed(self, mock_env, httpbin_both):
with tempfile.TemporaryDirectory() as tmp_dirname: with tempfile.TemporaryDirectory() as tmp_dirname:
file = os.path.join(tmp_dirname, 'file.bin') file = os.path.join(tmp_dirname, 'file.bin')
with open(file, 'a'): with open(file, 'a'):
pass pass
with open(os.devnull, 'w') as devnull, open(file, 'a+b') as output_file: with open(file, 'a+b') as output_file:
# Start and interrupt the transfer after 3 bytes written # Start and interrupt the transfer after 3 bytes written
downloader = Downloader(output_file=output_file, progress_file=devnull) downloader = Downloader(mock_env, output_file=output_file)
downloader.start( downloader.start(
final_response=Response( final_response=Response(
url=httpbin_both.url + '/', url=httpbin_both.url + '/',
@ -227,15 +221,14 @@ class TestDownloads:
downloader.finish() downloader.finish()
downloader.failed() downloader.failed()
assert downloader.interrupted assert downloader.interrupted
downloader._progress_reporter.join()
# Write bytes # Write bytes
with open(file, 'wb') as fh: with open(file, 'wb') as fh:
fh.write(b'123') fh.write(b'123')
with open(os.devnull, 'w') as devnull, open(file, 'a+b') as output_file: with open(file, 'a+b') as output_file:
# Resume the transfer # Resume the transfer
downloader = Downloader(output_file=output_file, progress_file=devnull, resume=True) downloader = Downloader(mock_env, output_file=output_file, resume=True)
# Ensure `pre_request()` is working as expected too # Ensure `pre_request()` is working as expected too
headers = {} headers = {}
@ -253,7 +246,6 @@ class TestDownloads:
) )
downloader.chunk_downloaded(b'45') downloader.chunk_downloaded(b'45')
downloader.finish() downloader.finish()
downloader._progress_reporter.join()
def test_download_with_redirect_original_url_used_for_filename(self, httpbin): def test_download_with_redirect_original_url_used_for_filename(self, httpbin):
# Redirect from `/redirect/1` to `/get`. # Redirect from `/redirect/1` to `/get`.

View File

@ -97,6 +97,8 @@ class TestQuietFlag:
(['-q'], 1), (['-q'], 1),
(['-qq'], 0), (['-qq'], 0),
]) ])
# Might fail on Windows due to interference from other warnings.
@pytest.mark.xfail
def test_quiet_on_python_warnings(self, test_patch, httpbin, flags, expected_warnings): def test_quiet_on_python_warnings(self, test_patch, httpbin, flags, expected_warnings):
def warn_and_run(*args, **kwargs): def warn_and_run(*args, **kwargs):
warnings.warn('warning!!') warnings.warn('warning!!')

View File

@ -5,19 +5,20 @@ def test_parser_serialization():
small_parser = ParserSpec("test_parser") small_parser = ParserSpec("test_parser")
group_1 = small_parser.add_group("group_1") group_1 = small_parser.add_group("group_1")
group_1.add_argument("regular_arg", help="regular arg") group_1.add_argument("regular_arg", help="regular arg", short_help="short")
group_1.add_argument( group_1.add_argument(
"variadic_arg", "variadic_arg",
metavar="META", metavar="META",
help=Qualifiers.SUPPRESS, help=Qualifiers.SUPPRESS,
nargs=Qualifiers.ZERO_OR_MORE, nargs=Qualifiers.ZERO_OR_MORE
) )
group_1.add_argument( group_1.add_argument(
"-O", "-O",
"--opt-arg", "--opt-arg",
action="lazy_choices", action="lazy_choices",
getter=lambda: ["opt_1", "opt_2"], getter=lambda: ["opt_1", "opt_2"],
help_formatter=lambda state: ", ".join(state), help_formatter=lambda state, *, isolation_mode: ", ".join(state),
short_help="short_help",
) )
group_2 = small_parser.add_group("group_2") group_2 = small_parser.add_group("group_2")
@ -36,6 +37,7 @@ def test_parser_serialization():
{ {
"options": ["regular_arg"], "options": ["regular_arg"],
"description": "regular arg", "description": "regular arg",
"short_description": "short",
}, },
{ {
"options": ["variadic_arg"], "options": ["variadic_arg"],
@ -46,6 +48,7 @@ def test_parser_serialization():
{ {
"options": ["-O", "--opt-arg"], "options": ["-O", "--opt-arg"],
"description": "opt_1, opt_2", "description": "opt_1, opt_2",
"short_description": "short_help",
"choices": ["opt_1", "opt_2"], "choices": ["opt_1", "opt_2"],
}, },
], ],

View File

@ -136,7 +136,7 @@ def test_auto_streaming(http_server, extras, expected):
assert len([ assert len([
call_arg call_arg
for call_arg in env.stdout.write.call_args_list for call_arg in env.stdout.write.call_args_list
if b'test' in call_arg[0][0] if 'test' in call_arg[0][0]
]) == expected ]) == expected

View File

@ -17,6 +17,7 @@ import httpie.manager.__main__ as manager
from httpie.status import ExitStatus from httpie.status import ExitStatus
from httpie.config import Config from httpie.config import Config
from httpie.encoding import UTF8
from httpie.context import Environment from httpie.context import Environment
from httpie.utils import url_as_host from httpie.utils import url_as_host
@ -61,6 +62,59 @@ def add_auth(url, auth):
return f'{proto}://{auth}@{rest}' return f'{proto}://{auth}@{rest}'
class Encoder:
"""
Encode binary fragments into a text stream. This is used
to embed raw binary data (which can't be decoded) into the
fake standard output we use on MockEnvironment.
Each data fragment is embedded by it's hash:
"Some data hash(XXX) more data."
Which then later converted back to a bytes object:
b"Some data <real data> more data."
"""
TEMPLATE = 'hash({})'
STR_PATTERN = re.compile(r'hash\((.*)\)')
BYTES_PATTERN = re.compile(rb'hash\((.*)\)')
def __init__(self):
self.substitutions = {}
def subsitute(self, data: bytes) -> str:
idx = hash(data)
self.substitutions[idx] = data
return self.TEMPLATE.format(idx)
def decode(self, data: str) -> Union[str, bytes]:
if self.STR_PATTERN.search(data) is None:
return data
raw_data = data.encode()
return self.BYTES_PATTERN.sub(
lambda match: self.substitutions[int(match.group(1))],
raw_data
)
class FakeBytesIOBuffer(BytesIO):
def __init__(self, original, encoder, *args, **kwargs):
self.original_buffer = original
self.encoder = encoder
super().__init__(*args, **kwargs)
def write(self, data):
try:
self.original_buffer.write(data.decode(UTF8))
except UnicodeDecodeError:
self.original_buffer.write(self.encoder.subsitute(data))
finally:
self.original_buffer.flush()
class StdinBytesIO(BytesIO): class StdinBytesIO(BytesIO):
"""To be used for `MockEnvironment.stdin`""" """To be used for `MockEnvironment.stdin`"""
len = 0 # See `prepare_request_body()` len = 0 # See `prepare_request_body()`
@ -72,17 +126,23 @@ class MockEnvironment(Environment):
stdin_isatty = True stdin_isatty = True
stdout_isatty = True stdout_isatty = True
is_windows = False is_windows = False
show_displays = False
def __init__(self, create_temp_config_dir=True, *, stdout_mode='b', **kwargs): def __init__(self, create_temp_config_dir=True, **kwargs):
self._encoder = Encoder()
if 'stdout' not in kwargs: if 'stdout' not in kwargs:
kwargs['stdout'] = tempfile.TemporaryFile( kwargs['stdout'] = tempfile.NamedTemporaryFile(
mode=f'w+{stdout_mode}', mode='w+t',
prefix='httpie_stdout' prefix='httpie_stderr',
newline='',
encoding=UTF8,
) )
kwargs['stdout'].buffer = FakeBytesIOBuffer(kwargs['stdout'], self._encoder)
if 'stderr' not in kwargs: if 'stderr' not in kwargs:
kwargs['stderr'] = tempfile.TemporaryFile( kwargs['stderr'] = tempfile.TemporaryFile(
mode='w+t', mode='w+t',
prefix='httpie_stderr' prefix='httpie_stderr',
encoding=UTF8,
) )
super().__init__(**kwargs) super().__init__(**kwargs)
self._create_temp_config_dir = create_temp_config_dir self._create_temp_config_dir = create_temp_config_dir
@ -143,6 +203,17 @@ class BaseCLIResponse:
# pytest-httpbin to real httpbin. # pytest-httpbin to real httpbin.
return re.sub(r'127\.0\.0\.1:\d+', 'httpbin.org', cmd) return re.sub(r'127\.0\.0\.1:\d+', 'httpbin.org', cmd)
@classmethod
def from_raw_data(self, data: Union[str, bytes]) -> 'BaseCLIResponse':
if isinstance(data, bytes):
with suppress(UnicodeDecodeError):
data = data.decode()
if isinstance(data, bytes):
return BytesCLIResponse(data)
else:
return StrCLIResponse(data)
class BytesCLIResponse(bytes, BaseCLIResponse): class BytesCLIResponse(bytes, BaseCLIResponse):
""" """
@ -195,7 +266,7 @@ class ExitStatusError(Exception):
@pytest.fixture @pytest.fixture
def mock_env() -> MockEnvironment: def mock_env() -> MockEnvironment:
env = MockEnvironment(stdout_mode='') env = MockEnvironment()
yield env yield env
env.cleanup() env.cleanup()
@ -214,7 +285,7 @@ def httpie(
status. status.
""" """
env = kwargs.setdefault('env', MockEnvironment(stdout_mode='')) env = kwargs.setdefault('env', MockEnvironment())
cli_args = ['httpie'] cli_args = ['httpie']
if not kwargs.pop('no_debug', False): if not kwargs.pop('no_debug', False):
cli_args.append('--debug') cli_args.append('--debug')
@ -227,16 +298,7 @@ def httpie(
env.stdout.seek(0) env.stdout.seek(0)
env.stderr.seek(0) env.stderr.seek(0)
try: try:
output = env.stdout.read() response = BaseCLIResponse.from_raw_data(env.stdout.read())
if isinstance(output, bytes):
with suppress(UnicodeDecodeError):
output = output.decode()
if isinstance(output, bytes):
response = BytesCLIResponse(output)
else:
response = StrCLIResponse(output)
response.stderr = env.stderr.read() response.stderr = env.stderr.read()
response.exit_status = exit_status response.exit_status = exit_status
response.args = cli_args response.args = cli_args
@ -354,12 +416,11 @@ def http(
devnull.seek(0) devnull.seek(0)
output = stdout.read() output = stdout.read()
devnull_output = devnull.read() devnull_output = devnull.read()
try:
output = output.decode() if hasattr(env, '_encoder'):
except UnicodeDecodeError: output = env._encoder.decode(output)
r = BytesCLIResponse(output)
else: r = BaseCLIResponse.from_raw_data(output)
r = StrCLIResponse(output)
try: try:
devnull_output = devnull_output.decode() devnull_output = devnull_output.decode()

View File

@ -169,7 +169,7 @@ def interface(tmp_path):
return Interface( return Interface(
path=tmp_path / 'interface', path=tmp_path / 'interface',
environment=MockEnvironment(stdout_mode='t') environment=MockEnvironment()
) )