1
0
mirror of https://github.com/httpie/cli.git synced 2025-08-10 22:42:05 +02:00

[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
32 changed files with 2521 additions and 389 deletions

View File

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

View File

@@ -155,6 +155,7 @@ class HTTPieArgumentParser(BaseHTTPieArgumentParser):
namespace=None
) -> argparse.Namespace:
self.env = env
self.env.args = namespace = namespace or argparse.Namespace()
self.args, no_options = super().parse_known_args(args, namespace)
if self.args.debug:
self.args.traceback = True
@@ -557,19 +558,62 @@ class HTTPieArgumentParser(BaseHTTPieArgumentParser):
parsed_options = parse_format_options(options_group, defaults=parsed_options)
self.args.format_options = parsed_options
def print_manual(self):
from httpie.output.ui import man_pages
if man_pages.is_available(self.env.program_name):
man_pages.display_for(self.env, self.env.program_name)
return None
text = self.format_help()
with self.env.rich_console.pager():
self.env.rich_console.print(
text,
highlight=False
)
def print_help(self):
from httpie.output.ui import rich_help
for renderable in rich_help.to_help_message(self.spec):
self.env.rich_console.print(renderable)
def print_usage(self, file):
from rich.text import Text
from httpie.output.ui import rich_help
whitelist = set()
_, exception, _ = sys.exc_info()
if (
isinstance(exception, argparse.ArgumentError)
and len(exception.args) >= 1
and isinstance(exception.args[0], argparse.Action)
and exception.args[0].option_strings
):
# add_usage path is also taken when you pass an invalid option,
# e.g --style=invalid. If something like that happens, we want
# to include to action that caused to the invalid usage into
# the list of actions we are displaying.
whitelist.add(exception.args[0].option_strings[0])
usage_text = Text('usage', style='bold')
usage_text.append(':\n ')
usage_text.append(rich_help.to_usage(self.spec, whitelist=whitelist))
self.env.rich_error_console.print(usage_text)
def error(self, message):
"""Prints a usage message incorporating the message to stderr and
exits."""
self.print_usage(sys.stderr)
self.exit(
2,
self.env.rich_error_console.print(
dedent(
f'''
error:
[bold]error[/bold]:
{message}
for more information:
[bold]for more information[/bold]:
run '{self.prog} --help' or visit https://httpie.io/docs/cli
'''
'''.rstrip()
)
)
self.exit(2)

View File

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

View File

@@ -3,15 +3,16 @@ import textwrap
import typing
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Any, Optional, Dict, List, Type, TypeVar
from typing import Any, Optional, Dict, List, Tuple, Type, TypeVar
from httpie.cli.argparser import HTTPieArgumentParser
from httpie.cli.utils import LazyChoices
from httpie.cli.utils import Manual, LazyChoices
class Qualifiers(Enum):
OPTIONAL = auto()
ZERO_OR_MORE = auto()
ONE_OR_MORE = auto()
SUPPRESS = auto()
@@ -24,6 +25,27 @@ def map_qualifiers(
}
def drop_keys(
configuration: Dict[str, Any], key_blacklist: Tuple[str, ...]
):
return {
key: value
for key, value in configuration.items()
if key not in key_blacklist
}
def _get_first_line(source: str) -> str:
parts = []
for line in source.strip().splitlines():
line = line.strip()
parts.append(line)
if line.endswith("."):
break
return " ".join(parts)
PARSER_SPEC_VERSION = '0.0.1a0'
@@ -69,6 +91,7 @@ class Group:
def add_argument(self, *args, **kwargs):
argument = Argument(list(args), kwargs.copy())
argument.post_init()
self.arguments.append(argument)
return argument
@@ -85,14 +108,32 @@ class Argument(typing.NamedTuple):
aliases: List[str]
configuration: Dict[str, Any]
def serialize(self) -> Dict[str, Any]:
def post_init(self):
"""Run a bunch of post-init hooks."""
# If there is a short help, then create the longer version from it.
short_help = self.configuration.get('short_help')
if (
short_help
and 'help' not in self.configuration
and self.configuration.get('action') != 'lazy_choices'
):
self.configuration['help'] = f'\n{short_help}\n\n'
def serialize(self, *, isolation_mode: bool = False) -> Dict[str, Any]:
configuration = self.configuration.copy()
# Unpack the dynamically computed choices, since we
# will need to store the actual values somewhere.
action = configuration.pop('action', None)
short_help = configuration.pop('short_help', None)
nested_options = configuration.pop('nested_options', None)
if action == 'lazy_choices':
choices = LazyChoices(self.aliases, **{'dest': None, **configuration})
choices = LazyChoices(
self.aliases,
**{'dest': None, **configuration},
isolation_mode=isolation_mode
)
configuration['choices'] = list(choices.load())
configuration['help'] = choices.help
@@ -106,9 +147,13 @@ class Argument(typing.NamedTuple):
qualifiers = JSON_QUALIFIER_TO_OPTIONS[configuration.get('nargs', Qualifiers.SUPPRESS)]
result.update(qualifiers)
help_msg = configuration.get('help')
if help_msg and help_msg is not Qualifiers.SUPPRESS:
result['description'] = help_msg.strip()
description = configuration.get('help')
if description and description is not Qualifiers.SUPPRESS:
result['short_description'] = short_help
result['description'] = description
if nested_options:
result['nested_options'] = nested_options
python_type = configuration.get('type')
if python_type is not None:
@@ -123,10 +168,19 @@ class Argument(typing.NamedTuple):
key: value
for key, value in configuration.items()
if key in JSON_DIRECT_MIRROR_OPTIONS
if value is not Qualifiers.SUPPRESS
})
return result
@property
def is_positional(self):
return len(self.aliases) == 0
@property
def is_hidden(self):
return self.configuration.get('help') is Qualifiers.SUPPRESS
def __getattr__(self, attribute_name):
if attribute_name in self.configuration:
return self.configuration[attribute_name]
@@ -140,7 +194,9 @@ ARGPARSE_QUALIFIER_MAP = {
Qualifiers.OPTIONAL: argparse.OPTIONAL,
Qualifiers.SUPPRESS: argparse.SUPPRESS,
Qualifiers.ZERO_OR_MORE: argparse.ZERO_OR_MORE,
Qualifiers.ONE_OR_MORE: argparse.ONE_OR_MORE
}
ARGPARSE_IGNORE_KEYS = ('short_help', 'nested_options')
def to_argparse(
@@ -152,7 +208,9 @@ def to_argparse(
description=abstract_options.description,
epilog=abstract_options.epilog,
)
concrete_parser.spec = abstract_options
concrete_parser.register('action', 'lazy_choices', LazyChoices)
concrete_parser.register('action', 'manual', Manual)
for abstract_group in abstract_options.groups:
concrete_group = concrete_parser.add_argument_group(
@@ -164,9 +222,9 @@ def to_argparse(
for abstract_argument in abstract_group.arguments:
concrete_group.add_argument(
*abstract_argument.aliases,
**map_qualifiers(
**drop_keys(map_qualifiers(
abstract_argument.configuration, ARGPARSE_QUALIFIER_MAP
)
), ARGPARSE_IGNORE_KEYS)
)
return concrete_parser
@@ -181,9 +239,19 @@ JSON_DIRECT_MIRROR_OPTIONS = (
JSON_QUALIFIER_TO_OPTIONS = {
Qualifiers.OPTIONAL: {'is_optional': True},
Qualifiers.ZERO_OR_MORE: {'is_optional': True, 'is_variadic': True},
Qualifiers.ONE_OR_MORE: {'is_optional': False, 'is_variadic': True},
Qualifiers.SUPPRESS: {}
}
def to_data(abstract_options: ParserSpec) -> Dict[str, Any]:
return {'version': PARSER_SPEC_VERSION, 'spec': abstract_options.serialize()}
def parser_to_parser_spec(parser: argparse.ArgumentParser) -> ParserSpec:
"""Take an existing argparse parser, and create a spec from it."""
return ParserSpec(
program=parser.prog,
description=parser.description,
epilog=parser.epilog
)

View File

@@ -4,20 +4,43 @@ from typing import Any, Callable, Generic, Iterator, Iterable, Optional, TypeVar
T = TypeVar('T')
class Manual(argparse.Action):
def __init__(
self,
option_strings,
dest=argparse.SUPPRESS,
default=argparse.SUPPRESS,
help=None
):
super().__init__(
option_strings=option_strings,
dest=dest,
default=default,
nargs=0,
help=help
)
def __call__(self, parser, namespace, values, option_string=None):
parser.print_manual()
parser.exit()
class LazyChoices(argparse.Action, Generic[T]):
def __init__(
self,
*args,
getter: Callable[[], Iterable[T]],
help_formatter: Optional[Callable[[T], str]] = None,
help_formatter: Optional[Callable[[T, bool], str]] = None,
sort: bool = False,
cache: bool = True,
isolation_mode: bool = False,
**kwargs
) -> None:
self.getter = getter
self.help_formatter = help_formatter
self.sort = sort
self.cache = cache
self.isolation_mode = isolation_mode
self._help: Optional[str] = None
self._obj: Optional[Iterable[T]] = None
super().__init__(*args, **kwargs)
@@ -33,7 +56,10 @@ class LazyChoices(argparse.Action, Generic[T]):
@property
def help(self) -> str:
if self._help is None and self.help_formatter is not None:
self._help = self.help_formatter(self.load())
self._help = self.help_formatter(
self.load(),
isolation_mode=self.isolation_mode
)
return self._help
@help.setter

View File

@@ -1,9 +1,10 @@
import argparse
import sys
import os
import warnings
from contextlib import contextmanager
from pathlib import Path
from typing import Iterator, IO, Optional
from typing import Iterator, IO, Optional, TYPE_CHECKING
from enum import Enum
@@ -12,11 +13,15 @@ try:
except ImportError:
curses = None # Compiled w/o curses
from .compat import is_windows
from .compat import is_windows, cached_property
from .config import DEFAULT_CONFIG_DIR, Config, ConfigFileError
from .encoding import UTF8
from .utils import repr_dict
from httpie.output.ui import rich_palette as palette
if TYPE_CHECKING:
from rich.console import Console
class Levels(str, Enum):
@@ -40,6 +45,7 @@ class Environment:
is used by the test suite to simulate various scenarios.
"""
args = argparse.Namespace()
is_windows: bool = is_windows
config_dir: Path = DEFAULT_CONFIG_DIR
stdin: Optional[IO] = sys.stdin # `None` when closed fd (#791)
@@ -52,6 +58,10 @@ class Environment:
stderr_isatty: bool = stderr.isatty()
colors = 256
program_name: str = 'http'
# Whether to show progress bars / status spinners etc.
show_displays: bool = True
if not is_windows:
if curses:
try:
@@ -160,3 +170,49 @@ class Environment:
def apply_warnings_filter(self) -> None:
if self.quiet >= DISPLAY_THRESHOLDS[Levels.WARNING]:
warnings.simplefilter("ignore")
def _make_rich_console(
self,
file: IO[str],
force_terminal: bool
) -> 'Console':
from rich.console import Console
from rich.theme import Theme
from rich.style import Style
style = getattr(self.args, 'style', palette.AUTO_STYLE)
theme = {}
if style in palette.STYLE_SHADES:
shade = palette.STYLE_SHADES[style]
theme.update({
color: Style(
color=palette.get_color(
color,
shade,
palette=palette.RICH_THEME_PALETTE
),
bold=True
)
for color in palette.RICH_THEME_PALETTE
})
# Rich infers the rest of the knowledge (e.g encoding)
# dynamically by looking at the file/stderr.
return Console(
file=file,
force_terminal=force_terminal,
no_color=(self.colors == 0),
theme=Theme(theme)
)
# Rich recommends separting the actual console (stdout) from
# the error (stderr) console for better isolation between parts.
# https://rich.readthedocs.io/en/stable/console.html#error-console
@cached_property
def rich_console(self):
return self._make_rich_console(self.stdout, self.stdout_isatty)
@cached_property
def rich_error_console(self):
return self._make_rich_console(self.stderr, self.stderr_isatty)

View File

@@ -195,7 +195,7 @@ def program(args: argparse.Namespace, env: Environment) -> ExitStatus:
try:
if args.download:
args.follow = True # --download implies --follow.
downloader = Downloader(output_file=args.output_file, progress_file=env.stderr, resume=args.download_resume)
downloader = Downloader(env, output_file=args.output_file, resume=args.download_resume)
downloader.pre_request(args.headers)
messages = collect_messages(env, args=args,
request_body_read_callback=request_body_read_callback)

View File

@@ -5,10 +5,8 @@ Download mode implementation.
import mimetypes
import os
import re
import sys
import threading
from mailbox import Message
from time import sleep, monotonic
from time import monotonic
from typing import IO, Optional, Tuple
from urllib.parse import urlsplit
@@ -16,22 +14,11 @@ import requests
from .models import HTTPResponse, OutputOptions
from .output.streams import RawStream
from .utils import humanize_bytes
from .context import Environment
PARTIAL_CONTENT = 206
CLEAR_LINE = '\r\033[K'
PROGRESS = (
'{percentage: 6.2f} %'
' {downloaded: >10}'
' {speed: >10}/s'
' {eta: >8} ETA'
)
PROGRESS_NO_CONTENT_LENGTH = '{downloaded: >10} {speed: >10}/s'
SUMMARY = 'Done. {downloaded} in {time:0.5f}s ({speed}/s)\n'
SPINNER = '|/-\\'
class ContentRangeError(ValueError):
pass
@@ -176,9 +163,9 @@ class Downloader:
def __init__(
self,
env: Environment,
output_file: IO = None,
resume: bool = False,
progress_file: IO = sys.stderr
resume: bool = False
):
"""
:param resume: Should the download resume if partial download
@@ -191,14 +178,10 @@ class Downloader:
"""
self.finished = False
self.status = DownloadStatus()
self.status = DownloadStatus(env=env)
self._output_file = output_file
self._resume = resume
self._resumed_from = 0
self._progress_reporter = ProgressReporterThread(
status=self.status,
output=progress_file
)
def pre_request(self, request_headers: dict):
"""Called just before the HTTP request is sent.
@@ -261,11 +244,6 @@ class Downloader:
except OSError:
pass # stdout
self.status.started(
resumed_from=self._resumed_from,
total_size=total_size
)
output_options = OutputOptions.from_message(final_response, headers=False, body=True)
stream = RawStream(
msg=HTTPResponse(final_response),
@@ -273,11 +251,11 @@ class Downloader:
on_body_chunk_downloaded=self.chunk_downloaded,
)
self._progress_reporter.output.write(
f'Downloading {humanize_bytes(total_size) + " " if total_size is not None else ""}'
f'to "{self._output_file.name}"\n'
self.status.started(
output_file=self._output_file,
resumed_from=self._resumed_from,
total_size=total_size
)
self._progress_reporter.start()
return stream, self._output_file
@@ -287,7 +265,7 @@ class Downloader:
self.status.finished()
def failed(self):
self._progress_reporter.stop()
self.status.terminate()
@property
def interrupted(self) -> bool:
@@ -329,127 +307,71 @@ class Downloader:
class DownloadStatus:
"""Holds details about the download status."""
def __init__(self):
def __init__(self, env):
self.env = env
self.downloaded = 0
self.total_size = None
self.resumed_from = 0
self.time_started = None
self.time_finished = None
def started(self, resumed_from=0, total_size=None):
def started(self, output_file, resumed_from=0, total_size=None):
assert self.time_started is None
self.total_size = total_size
self.downloaded = self.resumed_from = resumed_from
self.time_started = monotonic()
self.start_display(output_file=output_file)
def start_display(self, output_file):
from httpie.output.ui.rich_progress import (
DummyDisplay,
StatusDisplay,
ProgressDisplay
)
message = f'Downloading to {output_file.name}'
if self.env.show_displays:
if self.total_size is None:
# Rich does not support progress bars without a total
# size given. Instead we use status objects.
self.display = StatusDisplay(self.env)
else:
self.display = ProgressDisplay(self.env)
else:
self.display = DummyDisplay(self.env)
self.display.start(
total=self.total_size,
at=self.downloaded,
description=message
)
def chunk_downloaded(self, size):
assert self.time_finished is None
self.downloaded += size
self.display.update(size)
@property
def has_finished(self):
return self.time_finished is not None
@property
def time_spent(self):
if (
self.time_started is not None
and self.time_finished is not None
):
return self.time_finished - self.time_started
else:
return None
def finished(self):
assert self.time_started is not None
assert self.time_finished is None
self.time_finished = monotonic()
if hasattr(self, 'display'):
self.display.stop(self.time_spent)
class ProgressReporterThread(threading.Thread):
"""
Reports download progress based on its status.
Uses threading to periodically update the status (speed, ETA, etc.).
"""
def __init__(
self,
status: DownloadStatus,
output: IO,
tick=.1,
update_interval=1
):
super().__init__()
self.status = status
self.output = output
self._tick = tick
self._update_interval = update_interval
self._spinner_pos = 0
self._status_line = ''
self._prev_bytes = 0
self._prev_time = monotonic()
self._should_stop = threading.Event()
def stop(self):
"""Stop reporting on next tick."""
self._should_stop.set()
def run(self):
while not self._should_stop.is_set():
if self.status.has_finished:
self.sum_up()
break
self.report_speed()
sleep(self._tick)
def report_speed(self):
now = monotonic()
if now - self._prev_time >= self._update_interval:
downloaded = self.status.downloaded
speed = ((downloaded - self._prev_bytes)
/ (now - self._prev_time))
if not self.status.total_size:
self._status_line = PROGRESS_NO_CONTENT_LENGTH.format(
downloaded=humanize_bytes(downloaded),
speed=humanize_bytes(speed),
)
else:
percentage = (downloaded / self.status.total_size * 100
if self.status.total_size
else 0)
if not speed:
eta = '-:--:--'
else:
s = int((self.status.total_size - downloaded) / speed)
h, s = divmod(s, 60 * 60)
m, s = divmod(s, 60)
eta = f'{h}:{m:0>2}:{s:0>2}'
self._status_line = PROGRESS.format(
percentage=percentage,
downloaded=humanize_bytes(downloaded),
speed=humanize_bytes(speed),
eta=eta,
)
self._prev_time = now
self._prev_bytes = downloaded
self.output.write(
f'{CLEAR_LINE} {SPINNER[self._spinner_pos]} {self._status_line}'
)
self.output.flush()
self._spinner_pos = (self._spinner_pos + 1) % len(SPINNER)
def sum_up(self):
actually_downloaded = (
self.status.downloaded - self.status.resumed_from)
time_taken = self.status.time_finished - self.status.time_started
speed = actually_downloaded / time_taken if time_taken else actually_downloaded
self.output.write(CLEAR_LINE)
self.output.write(SUMMARY.format(
downloaded=humanize_bytes(actually_downloaded),
total=(self.status.total_size
and humanize_bytes(self.status.total_size)),
speed=humanize_bytes(speed),
time=time_taken,
))
self.output.flush()
def terminate(self):
if hasattr(self, 'display'):
self.display.stop(self.time_spent)

View File

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

View File

@@ -17,12 +17,11 @@ from pygments.util import ClassNotFound
from ..lexers.json import EnhancedJsonLexer
from ..lexers.metadata import MetadataLexer
from ..ui.palette import SHADE_NAMES, get_color
from ..ui.palette import AUTO_STYLE, SHADE_NAMES, get_color
from ...context import Environment
from ...plugins import FormatterPlugin
AUTO_STYLE = 'auto' # Follows terminal ANSI color styles
DEFAULT_STYLE = AUTO_STYLE
SOLARIZED_STYLE = 'solarized' # Bundled here
@@ -33,7 +32,7 @@ BUNDLED_STYLES = {
def get_available_styles():
return BUNDLED_STYLES | set(pygments.styles.get_all_styles())
return sorted(BUNDLED_STYLES | set(pygments.styles.get_all_styles()))
class ColorFormatter(FormatterPlugin):

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

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