265 lines
7.3 KiB
Python
265 lines
7.3 KiB
Python
|
# -*- coding: utf-8 -*-
|
||
|
"""
|
||
|
hyper/cli
|
||
|
~~~~~~~~~
|
||
|
|
||
|
Command line interface for Hyper inspired by Httpie.
|
||
|
"""
|
||
|
import json
|
||
|
import locale
|
||
|
import logging
|
||
|
import sys
|
||
|
from argparse import ArgumentParser, RawTextHelpFormatter
|
||
|
from argparse import OPTIONAL, ZERO_OR_MORE
|
||
|
from pprint import pformat
|
||
|
from textwrap import dedent
|
||
|
|
||
|
from hyper import HTTPConnection, HTTP20Connection
|
||
|
from hyper import __version__
|
||
|
from hyper.compat import is_py2, urlencode, urlsplit, write_to_stdout
|
||
|
from hyper.common.util import to_host_port_tuple
|
||
|
|
||
|
|
||
|
log = logging.getLogger('hyper')
|
||
|
|
||
|
PREFERRED_ENCODING = locale.getpreferredencoding()
|
||
|
|
||
|
# Various separators used in args
|
||
|
SEP_HEADERS = ':'
|
||
|
SEP_QUERY = '=='
|
||
|
SEP_DATA = '='
|
||
|
|
||
|
SEP_GROUP_ITEMS = [
|
||
|
SEP_HEADERS,
|
||
|
SEP_QUERY,
|
||
|
SEP_DATA,
|
||
|
]
|
||
|
|
||
|
|
||
|
class KeyValue(object):
|
||
|
"""Base key-value pair parsed from CLI."""
|
||
|
|
||
|
def __init__(self, key, value, sep, orig):
|
||
|
self.key = key
|
||
|
self.value = value
|
||
|
self.sep = sep
|
||
|
self.orig = orig
|
||
|
|
||
|
|
||
|
class KeyValueArgType(object):
|
||
|
"""A key-value pair argument type used with `argparse`.
|
||
|
|
||
|
Parses a key-value arg and constructs a `KeyValue` instance.
|
||
|
Used for headers, form data, and other key-value pair types.
|
||
|
This class is inspired by httpie and implements simple tokenizer only.
|
||
|
"""
|
||
|
def __init__(self, *separators):
|
||
|
self.separators = separators
|
||
|
|
||
|
def __call__(self, string):
|
||
|
for sep in self.separators:
|
||
|
splitted = string.split(sep, 1)
|
||
|
if len(splitted) == 2:
|
||
|
key, value = splitted
|
||
|
return KeyValue(key, value, sep, string)
|
||
|
|
||
|
|
||
|
def make_positional_argument(parser):
|
||
|
parser.add_argument(
|
||
|
'method', metavar='METHOD', nargs=OPTIONAL, default='GET',
|
||
|
help=dedent("""
|
||
|
The HTTP method to be used for the request
|
||
|
(GET, POST, PUT, DELETE, ...).
|
||
|
"""))
|
||
|
parser.add_argument(
|
||
|
'_url', metavar='URL',
|
||
|
help=dedent("""
|
||
|
The scheme defaults to 'https://' if the URL does not include one.
|
||
|
"""))
|
||
|
parser.add_argument(
|
||
|
'items',
|
||
|
metavar='REQUEST_ITEM',
|
||
|
nargs=ZERO_OR_MORE,
|
||
|
type=KeyValueArgType(*SEP_GROUP_ITEMS),
|
||
|
help=dedent("""
|
||
|
Optional key-value pairs to be included in the request.
|
||
|
The separator used determines the type:
|
||
|
|
||
|
':' HTTP headers:
|
||
|
|
||
|
Referer:http://httpie.org Cookie:foo=bar User-Agent:bacon/1.0
|
||
|
|
||
|
'==' URL parameters to be appended to the request URI:
|
||
|
|
||
|
search==hyper
|
||
|
|
||
|
'=' Data fields to be serialized into a JSON object:
|
||
|
|
||
|
name=Hyper language=Python description='CLI HTTP client'
|
||
|
"""))
|
||
|
|
||
|
|
||
|
def make_troubleshooting_argument(parser):
|
||
|
parser.add_argument(
|
||
|
'--version', action='version', version=__version__,
|
||
|
help='Show version and exit.')
|
||
|
parser.add_argument(
|
||
|
'--debug', action='store_true', default=False,
|
||
|
help='Show debugging information (loglevel=DEBUG)')
|
||
|
parser.add_argument(
|
||
|
'--h2', action='store_true', default=False,
|
||
|
help="Do HTTP/2 directly, skipping plaintext upgrade and ignoring "
|
||
|
"NPN/ALPN."
|
||
|
)
|
||
|
|
||
|
|
||
|
def split_host_and_port(hostname):
|
||
|
if ':' in hostname:
|
||
|
return to_host_port_tuple(hostname, default_port=443)
|
||
|
return hostname, None
|
||
|
|
||
|
|
||
|
class UrlInfo(object):
|
||
|
def __init__(self):
|
||
|
self.fragment = None
|
||
|
self.host = 'localhost'
|
||
|
self.netloc = None
|
||
|
self.path = '/'
|
||
|
self.port = 443
|
||
|
self.query = None
|
||
|
self.scheme = 'https'
|
||
|
self.secure = False
|
||
|
|
||
|
|
||
|
def set_url_info(args):
|
||
|
info = UrlInfo()
|
||
|
_result = urlsplit(args._url)
|
||
|
for attr in vars(info).keys():
|
||
|
value = getattr(_result, attr, None)
|
||
|
if value:
|
||
|
setattr(info, attr, value)
|
||
|
|
||
|
if info.scheme == 'http' and not _result.port:
|
||
|
info.port = 80
|
||
|
|
||
|
# Set the secure arg is the scheme is HTTPS, otherwise do unsecured.
|
||
|
info.secure = info.scheme == 'https'
|
||
|
|
||
|
if info.netloc:
|
||
|
hostname, _ = split_host_and_port(info.netloc)
|
||
|
info.host = hostname # ensure stripping port number
|
||
|
else:
|
||
|
if _result.path:
|
||
|
_path = _result.path.split('/', 1)
|
||
|
hostname, port = split_host_and_port(_path[0])
|
||
|
info.host = hostname
|
||
|
if info.path == _path[0]:
|
||
|
info.path = '/'
|
||
|
elif len(_path) == 2 and _path[1]:
|
||
|
info.path = '/' + _path[1]
|
||
|
if port is not None:
|
||
|
info.port = port
|
||
|
|
||
|
log.debug('Url Info: %s', vars(info))
|
||
|
args.url = info
|
||
|
|
||
|
|
||
|
def set_request_data(args):
|
||
|
body, headers, params = {}, {}, {}
|
||
|
for i in args.items:
|
||
|
if i.sep == SEP_HEADERS:
|
||
|
if i.key:
|
||
|
headers[i.key] = i.value
|
||
|
else:
|
||
|
# when overriding a HTTP/2 special header there will be a
|
||
|
# leading colon, which tricks the command line parser into
|
||
|
# thinking the header is empty
|
||
|
k, v = i.value.split(':', 1)
|
||
|
headers[':' + k] = v
|
||
|
elif i.sep == SEP_QUERY:
|
||
|
params[i.key] = i.value
|
||
|
elif i.sep == SEP_DATA:
|
||
|
value = i.value
|
||
|
if is_py2: # pragma: no cover
|
||
|
value = value.decode(PREFERRED_ENCODING)
|
||
|
body[i.key] = value
|
||
|
|
||
|
if params:
|
||
|
args.url.path += '?' + urlencode(params)
|
||
|
|
||
|
if body:
|
||
|
content_type = 'application/json'
|
||
|
headers.setdefault('content-type', content_type)
|
||
|
args.body = json.dumps(body)
|
||
|
|
||
|
if args.method is None:
|
||
|
args.method = 'POST' if args.body else 'GET'
|
||
|
|
||
|
args.method = args.method.upper()
|
||
|
args.headers = headers
|
||
|
|
||
|
|
||
|
def parse_argument(argv=None):
|
||
|
parser = ArgumentParser(formatter_class=RawTextHelpFormatter)
|
||
|
parser.set_defaults(body=None, headers={})
|
||
|
make_positional_argument(parser)
|
||
|
make_troubleshooting_argument(parser)
|
||
|
args = parser.parse_args(sys.argv[1:] if argv is None else argv)
|
||
|
|
||
|
if args.debug:
|
||
|
handler = logging.StreamHandler()
|
||
|
handler.setLevel(logging.DEBUG)
|
||
|
log.addHandler(handler)
|
||
|
log.setLevel(logging.DEBUG)
|
||
|
|
||
|
set_url_info(args)
|
||
|
set_request_data(args)
|
||
|
return args
|
||
|
|
||
|
|
||
|
def get_content_type_and_charset(response):
|
||
|
charset = 'utf-8'
|
||
|
content_type = response.headers.get('content-type')
|
||
|
if content_type is None:
|
||
|
return 'unknown', charset
|
||
|
|
||
|
content_type = content_type[0].decode('utf-8').lower()
|
||
|
type_and_charset = content_type.split(';', 1)
|
||
|
ctype = type_and_charset[0].strip()
|
||
|
if len(type_and_charset) == 2:
|
||
|
charset = type_and_charset[1].strip().split('=')[1]
|
||
|
|
||
|
return ctype, charset
|
||
|
|
||
|
|
||
|
def request(args):
|
||
|
if not args.h2:
|
||
|
conn = HTTPConnection(
|
||
|
args.url.host, args.url.port, secure=args.url.secure
|
||
|
)
|
||
|
else: # pragma: no cover
|
||
|
conn = HTTP20Connection(
|
||
|
args.url.host,
|
||
|
args.url.port,
|
||
|
secure=args.url.secure,
|
||
|
force_proto='h2'
|
||
|
)
|
||
|
|
||
|
conn.request(args.method, args.url.path, args.body, args.headers)
|
||
|
response = conn.get_response()
|
||
|
log.debug('Response Headers:\n%s', pformat(response.headers))
|
||
|
ctype, charset = get_content_type_and_charset(response)
|
||
|
data = response.read()
|
||
|
return data
|
||
|
|
||
|
|
||
|
def main(argv=None):
|
||
|
args = parse_argument(argv)
|
||
|
log.debug('Commandline Argument: %s', args)
|
||
|
data = request(args)
|
||
|
write_to_stdout(data)
|
||
|
|
||
|
|
||
|
if __name__ == '__main__': # pragma: no cover
|
||
|
main()
|