From bd0b18489b277563d6a9edfd9821cfa7170326cc Mon Sep 17 00:00:00 2001 From: Batuhan Taskaya Date: Thu, 14 Apr 2022 22:35:09 +0300 Subject: [PATCH] Automate ZSH completion. --- extras/completion/completion.zsh | 147 ++++++++++++++++ extras/completion/templates/completion.zsh.j2 | 89 ++++++++++ extras/scripts/generate_completion.py | 166 ++++++++++++++++++ httpie/cli/definition.py | 13 +- httpie/cli/options.py | 16 ++ httpie/output/ui/rich_help.py | 2 +- 6 files changed, 427 insertions(+), 6 deletions(-) create mode 100644 extras/completion/completion.zsh create mode 100755 extras/completion/templates/completion.zsh.j2 create mode 100644 extras/scripts/generate_completion.py diff --git a/extras/completion/completion.zsh b/extras/completion/completion.zsh new file mode 100644 index 00000000..57f85cd0 --- /dev/null +++ b/extras/completion/completion.zsh @@ -0,0 +1,147 @@ +# compdef http +# Copyright (c) 2015 Github zsh-users +# Based on the initial work of http://github.com/zsh-users + + +_httpie_params () { + local ret=1 expl + + if (( CURRENT == NORMARG )) && [[ $words[NORMARG] != *:* ]]; then + # URL + _httpie_urls && ret=0 + elif (( CURRENT > NORMARG )); then + # regular param, if we already have a url + # ignore all prefix stuff + compset -P '(#b)([^:@=]#)' + local name=$match[1] + + if false; then + false; + elif compset -P ':'; then + _message "$name HTTP Headers" + + elif compset -P '=='; then + _message "$name URL Parameters" + + elif compset -P '='; then + _message "$name Data Fields" + + elif compset -P ':='; then + _message "$name Raw JSON Fields" + + elif compset -P '@'; then + _files + + else + typeset -a ops + ops=( + "\::Arbitrary HTTP header, e.g X-API-Token:123" + "==:Querystring parameter to the URL, e.g limit==50" + "=:Data fields to be serialized as JSON (default) or Form Data (with --form)" + "\:=:Data field for real JSON types." + "@:Path field for uploading a file." + ) + _describe -t httpparams 'parameter types' ops -Q -S '' + fi + ret=0 + fi + + # first arg may be a request method + (( CURRENT == NORMARG )) && + _wanted http_method expl 'Request Method' \ + compadd GET POST PUT DELETE HEAD OPTIONS PATCH TRACE CONNECT && ret=0 + + return $ret + +} + +_httpie_urls() { + local ret=1 + + if ! [[ -prefix [-+.a-z0-9]#:// ]]; then + local expl + compset -S '[^:/]*' && compstate[to_end]='' + _wanted url-schemas expl 'URL schema' compadd -S '' http:// https:// && ret=0 + else + _urls && ret=0 + fi + + return $ret +} + +_httpie_printflags () { + local ret=1 + + # not sure why this is necessary, but it will complete "-pH" style without it + [[ $IPREFIX == "-p" ]] && IPREFIX+=" " + + compset -P '(#b)([a-zA-Z]#)' + + local -a flags + [[ $match[1] != *H* ]] && flags+=( "H:request headers" ) + [[ $match[1] != *B* ]] && flags+=( "B:request body" ) + [[ $match[1] != *h* ]] && flags+=( "h:response headers" ) + [[ $match[1] != *b* ]] && flags+=( "b:response body" ) + [[ $match[1] != *m* ]] && flags+=( "b:response meta" ) + + _describe -t printflags "print flags" flags -S '' && ret=0 + + return $ret +} + +integer NORMARG + +_arguments -n -C -s \ + {--json,-j}'[(default) Serialize data items from the command line as a JSON object.]' \ + {--form,-f}'[Serialize data items from the command line as form field data.]' \ + '--multipart[Similar to --form, but always sends a multipart/form-data request (i.e., even without files).]' \ + '--boundary=[Specify a custom boundary string for multipart/form-data requests. Only has effect only together with --form.]' \ + '--raw=[Pass raw request data without extra processing.]' \ + {--compress,-x}'[Compress the content with Deflate algorithm.]' \ + '--pretty=[Control the processing of console outputs.]:PRETTY:(all colors format none)' \ + {--style,-s}'=[Output coloring style (default is "auto").]:STYLE:' \ + '--unsorted[Disables all sorting while formatting output.]' \ + '--sorted[Re-enables all sorting options while formatting output.]' \ + '--response-charset=[Override the response encoding for terminal display purposes.]:ENCODING:' \ + '--response-mime=[Override the response mime type for coloring and formatting for the terminal.]:MIME_TYPE:' \ + '--format-options=[Controls output formatting.]' \ + {--print,-p}'=[Options to specify what the console output should contain.]:WHAT:' \ + {--headers,-h}'[Print only the response headers.]' \ + {--meta,-m}'[Print only the response metadata.]' \ + {--body,-b}'[Print only the response body.]' \ + {--verbose,-v}'[Make output more verbose.]' \ + '--all[Show any intermediary requests/responses.]' \ + {--history-print,-P}'=[--print for intermediary requests/responses.]:WHAT:' \ + {--stream,-S}'[Always stream the response body by line, i.e., behave like `tail -f`.]' \ + {--output,-o}'=[Save output to FILE instead of stdout.]:FILE:' \ + {--download,-d}'[Download the body to a file instead of printing it to stdout.]' \ + {--continue,-c}'[Resume an interrupted download (--output needs to be specified).]' \ + {--quiet,-q}'[Do not print to stdout or stderr, except for errors and warnings when provided once.]' \ + '--session=[Create, or reuse and update a session.]:SESSION_NAME_OR_PATH:' \ + '--session-read-only=[Create or read a session without updating it]:SESSION_NAME_OR_PATH:' \ + {--auth,-a}'=[Credentials for the selected (-A) authentication method.]:USER[\:PASS] | TOKEN:' \ + {--auth-type,-A}'=[The authentication mechanism to be used.]' \ + '--ignore-netrc[Ignore credentials from .netrc.]' \ + '--offline[Build the request and print it but don’t actually send it.]' \ + '--proxy=[String mapping of protocol to the URL of the proxy.]:PROTOCOL\:PROXY_URL:' \ + {--follow,-F}'[Follow 30x Location redirects.]' \ + '--max-redirects=[The maximum number of redirects that should be followed (with --follow).]' \ + '--max-headers=[The maximum number of response headers to be read before giving up (default 0, i.e., no limit).]' \ + '--timeout=[The connection timeout of the request in seconds.]:SECONDS:' \ + '--check-status[Exit with an error status code if the server replies with an error.]' \ + '--path-as-is[Bypass dot segment (/../ or /./) URL squashing.]' \ + '--chunked[Enable streaming via chunked transfer encoding. The Transfer-Encoding header is set to chunked.]' \ + '--verify=[If "no", skip SSL verification. If a file path, use it as a CA bundle.]' \ + '--ssl=[The desired protocol version to used.]:SSL:(ssl2.3 tls1 tls1.1 tls1.2)' \ + '--ciphers=[A string in the OpenSSL cipher list format.]' \ + '--cert=[Specifys a local cert to use as client side SSL certificate.]' \ + '--cert-key=[The private key to use with SSL. Only needed if --cert is given.]' \ + '--cert-key-pass=[The passphrase to be used to with the given private key.]' \ + {--ignore-stdin,-I}'[Do not attempt to read stdin]' \ + '--help[Show this help message and exit.]' \ + '--manual[Show the full manual.]' \ + '--version[Show version and exit.]' \ + '--traceback[Prints the exception traceback should one occur.]' \ + '--default-scheme=[The default scheme to use if not specified in the URL.]' \ + '--debug[Print useful diagnostic information for bug reports.]' \ + '*:args:_httpie_params' && return 0 \ No newline at end of file diff --git a/extras/completion/templates/completion.zsh.j2 b/extras/completion/templates/completion.zsh.j2 new file mode 100755 index 00000000..b78291c9 --- /dev/null +++ b/extras/completion/templates/completion.zsh.j2 @@ -0,0 +1,89 @@ +# compdef http +# Copyright (c) 2015 Github zsh-users +# Based on the initial work of http://github.com/zsh-users + + +_httpie_params () { + local ret=1 expl + + if (( CURRENT == NORMARG )) && [[ $words[NORMARG] != *:* ]]; then + # URL + _httpie_urls && ret=0 + elif (( CURRENT > NORMARG )); then + # regular param, if we already have a url + # ignore all prefix stuff + compset -P '(#b)([^:@=]#)' + local name=$match[1] + + if false; then + false; + {% for option_name, _, operator, desc in request_items.nested_options -%} + elif compset -P '{{ operator }}'; then + {% if is_file_based_operator(operator) -%} + _files + {% else -%} + _message "$name {{ option_name }}" + {% endif %} + {% endfor -%} + else + typeset -a ops + ops=( + {% for option_name, _, operator, desc in request_items.nested_options -%} + "{{ escape_zsh(operator) }}:{{ desc }}" + {% endfor -%} + ) + _describe -t httpparams 'parameter types' ops -Q -S '' + fi + ret=0 + fi + + # first arg may be a request method + (( CURRENT == NORMARG )) && + _wanted http_method expl 'Request Method' \ + compadd {% for method in methods -%} {{ method }} {% endfor -%} && ret=0 + + return $ret + +} + +_httpie_urls() { + local ret=1 + + if ! [[ -prefix [-+.a-z0-9]#:// ]]; then + local expl + compset -S '[^:/]*' && compstate[to_end]='' + _wanted url-schemas expl 'URL schema' compadd -S '' http:// https:// && ret=0 + else + _urls && ret=0 + fi + + return $ret +} + +_httpie_printflags () { + local ret=1 + + # not sure why this is necessary, but it will complete "-pH" style without it + [[ $IPREFIX == "-p" ]] && IPREFIX+=" " + + compset -P '(#b)([a-zA-Z]#)' + + local -a flags + [[ $match[1] != *H* ]] && flags+=( "H:request headers" ) + [[ $match[1] != *B* ]] && flags+=( "B:request body" ) + [[ $match[1] != *h* ]] && flags+=( "h:response headers" ) + [[ $match[1] != *b* ]] && flags+=( "b:response body" ) + [[ $match[1] != *m* ]] && flags+=( "b:response meta" ) + + _describe -t printflags "print flags" flags -S '' && ret=0 + + return $ret +} + +integer NORMARG + +_arguments -n -C -s \ + {% for argument in arguments -%} + {{ serialize_argument_to_zsh(argument) }} \ + {% endfor -%} + '*:args:_httpie_params' && return 0 diff --git a/extras/scripts/generate_completion.py b/extras/scripts/generate_completion.py new file mode 100644 index 00000000..ba19b962 --- /dev/null +++ b/extras/scripts/generate_completion.py @@ -0,0 +1,166 @@ +from atexit import register +import functools +import string +import textwrap + +from jinja2 import Template +from pathlib import Path +from typing import Any, Dict, Callable, TypeVar + +from httpie.cli.constants import SEPARATOR_FILE_UPLOAD +from httpie.cli.definition import options +from httpie.cli.options import Argument, ParserSpec + +T = TypeVar("T") + +EXTRAS_DIR = Path(__file__).parent.parent +COMPLETION_DIR = EXTRAS_DIR / "completion" +TEMPLATES_DIR = COMPLETION_DIR / "templates" + +COMPLETION_TEMPLATE_BASE = TEMPLATES_DIR / "completion" +COMPLETION_SCRIPT_BASE = COMPLETION_DIR / "completion" + +COMMON_HTTP_METHODS = [ + "GET", + "POST", + "PUT", + "DELETE", + "HEAD", + "OPTIONS", + "PATCH", + "TRACE", + "CONNECT", +] + + +def use_template(shell_type): + def decorator(func): + @functools.wraps(func) + def wrapper(spec): + template_file = COMPLETION_TEMPLATE_BASE.with_suffix( + f".{shell_type}.j2" + ) + compiletion_script_file = COMPLETION_SCRIPT_BASE.with_suffix( + f".{shell_type}" + ) + + jinja_template = Template(template_file.read_text()) + jinja_template.globals.update(prepare_objects(spec)) + extra_variables = func(spec) + compiletion_script_file.write_text( + jinja_template.render(**extra_variables) + ) + + return wrapper + + return decorator + + +BASE_FUNCTIONS = {} + + +def prepare_objects(spec: ParserSpec) -> Dict[str, Any]: + global_objects = { + **BASE_FUNCTIONS, + } + global_objects["request_items"] = find_argument_by_target_name( + spec, "REQUEST_ITEM" + ) + global_objects["arguments"] = [ + argument + for group in spec.groups + for argument in group.arguments + if not argument.is_hidden + if not argument.is_positional + ] + + return global_objects + + +def register_function(func: T) -> T: + BASE_FUNCTIONS[func.__name__] = func + return func + + +@register_function +def is_file_based_operator(operator: str) -> bool: + return operator in {SEPARATOR_FILE_UPLOAD} + + +def escape_zsh(text: str) -> str: + return text.replace(":", "\\:") + + +def serialize_argument_to_zsh(argument): + # The argument format is the followig: + # $prefix'$alias$has_value[$short_desc]:$metavar$:($choice_1 $choice_2)' + + prefix = "" + declaration = [] + has_choices = "choices" in argument.configuration + + # The format for the argument declaration canges depending on the + # the number of aliases. For a single $alias, we'll embed it directly + # in the declaration string, but for multiple of them, we'll use a + # $prefix. + if len(argument.aliases) > 1: + prefix = "{" + ",".join(argument.aliases) + "}" + else: + declaration.append(argument.aliases[0]) + + if not argument.is_flag: + declaration.append("=") + + declaration.append("[" + argument.short_help + "]") + + if "metavar" in argument.configuration: + metavar = argument.metavar + elif has_choices: + # Choices always require a metavar, so even if we don't have one + # we can generate it from the argument aliases. + metavar = ( + max(argument.aliases, key=len) + .lstrip("-") + .replace("-", "_") + .upper() + ) + else: + metavar = None + + if metavar: + # Strip out any whitespace, and escape any characters that would + # conflict with the shell. + metavar = escape_zsh(metavar.strip(" ")) + declaration.append(f":{metavar}:") + + if has_choices: + declaration.append("(" + " ".join(argument.choices) + ")") + + return prefix + f"'{''.join(declaration)}'" + + +def find_argument_by_target_name(spec: ParserSpec, name: str) -> Argument: + for group in spec.groups: + for argument in group.arguments: + if argument.aliases: + targets = argument.aliases + else: + targets = [argument.metavar] + + if name in targets: + return argument + + raise ValueError(f"Could not find argument with name {name}") + + +@use_template("zsh") +def zsh_completer(spec: ParserSpec) -> Dict[str, Any]: + return { + "escape_zsh": escape_zsh, + "serialize_argument_to_zsh": serialize_argument_to_zsh, + "methods": COMMON_HTTP_METHODS, + } + + +if __name__ == "__main__": + zsh_completer(options) diff --git a/httpie/cli/definition.py b/httpie/cli/definition.py index 0e5f91ed..ca12e3fd 100644 --- a/httpie/cli/definition.py +++ b/httpie/cli/definition.py @@ -13,6 +13,9 @@ from httpie.cli.constants import (BASE_OUTPUT_OPTIONS, DEFAULT_FORMAT_OPTIONS, OUTPUT_OPTIONS_DEFAULT, PRETTY_MAP, PRETTY_STDOUT_TTY_ONLY, SEPARATOR_GROUP_ALL_ITEMS, SEPARATOR_PROXY, + SEPARATOR_HEADER, SEPARATOR_QUERY_PARAM, + SEPARATOR_DATA_STRING, SEPARATOR_DATA_RAW_JSON, + SEPARATOR_FILE_UPLOAD, SORTED_FORMAT_OPTIONS_STRING, UNSORTED_FORMAT_OPTIONS_STRING, RequestType) from httpie.cli.options import ParserSpec, Qualifiers, to_argparse @@ -91,11 +94,11 @@ positional_arguments.add_argument( '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.'), + ('HTTP Headers', 'Name:Value', SEPARATOR_HEADER, 'Arbitrary HTTP header, e.g X-API-Token:123'), + ('URL Parameters', 'name==value', SEPARATOR_QUERY_PARAM, 'Querystring parameter to the URL, e.g limit==50'), + ('Data Fields', 'field=value', SEPARATOR_DATA_STRING, 'Data fields to be serialized as JSON (default) or Form Data (with --form)'), + ('Raw JSON Fields', 'field:=json', SEPARATOR_DATA_RAW_JSON, 'Data field for real JSON types.'), + ('File upload Fields', 'field@/dir/file', SEPARATOR_FILE_UPLOAD, 'Path field for uploading a file.'), ], help=r""" Optional key-value pairs to be included in the request. The separator used diff --git a/httpie/cli/options.py b/httpie/cli/options.py index c06a8ee6..6434961d 100644 --- a/httpie/cli/options.py +++ b/httpie/cli/options.py @@ -172,6 +172,11 @@ class Argument(typing.NamedTuple): def is_hidden(self): return self.configuration.get('help') is Qualifiers.SUPPRESS + @property + def is_flag(self): + action = getattr(self, 'action', None) + return action in ARGPARSE_FLAG_ACTIONS + def __getattr__(self, attribute_name): if attribute_name in self.configuration: return self.configuration[attribute_name] @@ -188,6 +193,17 @@ ARGPARSE_QUALIFIER_MAP = { Qualifiers.ONE_OR_MORE: argparse.ONE_OR_MORE } ARGPARSE_IGNORE_KEYS = ('short_help', 'nested_options') +ARGPARSE_FLAG_ACTIONS = [ + "store_true", + "store_false", + "count", + "version", + "help", + "debug", + "manual", + "append_const", + "store_const" +] def to_argparse( diff --git a/httpie/output/ui/rich_help.py b/httpie/output/ui/rich_help.py index 2b19f3e3..cb0d25f9 100644 --- a/httpie/output/ui/rich_help.py +++ b/httpie/output/ui/rich_help.py @@ -193,7 +193,7 @@ def to_help_message( value, dec, ) - for key, value, dec in argument.nested_options + for key, value, _, dec in argument.nested_options ] )