cli.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. # -*- coding: utf-8 -*-
  2. """
  3. hyper/cli
  4. ~~~~~~~~~
  5. Command line interface for Hyper inspired by Httpie.
  6. """
  7. import json
  8. import locale
  9. import logging
  10. import sys
  11. from argparse import ArgumentParser, RawTextHelpFormatter
  12. from argparse import OPTIONAL, ZERO_OR_MORE
  13. from pprint import pformat
  14. from textwrap import dedent
  15. from hyper import HTTPConnection, HTTP20Connection
  16. from hyper import __version__
  17. from hyper.compat import is_py2, urlencode, urlsplit, write_to_stdout
  18. from hyper.common.util import to_host_port_tuple
  19. log = logging.getLogger('hyper')
  20. PREFERRED_ENCODING = locale.getpreferredencoding()
  21. # Various separators used in args
  22. SEP_HEADERS = ':'
  23. SEP_QUERY = '=='
  24. SEP_DATA = '='
  25. SEP_GROUP_ITEMS = [
  26. SEP_HEADERS,
  27. SEP_QUERY,
  28. SEP_DATA,
  29. ]
  30. class KeyValue(object):
  31. """Base key-value pair parsed from CLI."""
  32. def __init__(self, key, value, sep, orig):
  33. self.key = key
  34. self.value = value
  35. self.sep = sep
  36. self.orig = orig
  37. class KeyValueArgType(object):
  38. """A key-value pair argument type used with `argparse`.
  39. Parses a key-value arg and constructs a `KeyValue` instance.
  40. Used for headers, form data, and other key-value pair types.
  41. This class is inspired by httpie and implements simple tokenizer only.
  42. """
  43. def __init__(self, *separators):
  44. self.separators = separators
  45. def __call__(self, string):
  46. for sep in self.separators:
  47. splitted = string.split(sep, 1)
  48. if len(splitted) == 2:
  49. key, value = splitted
  50. return KeyValue(key, value, sep, string)
  51. def make_positional_argument(parser):
  52. parser.add_argument(
  53. 'method', metavar='METHOD', nargs=OPTIONAL, default='GET',
  54. help=dedent("""
  55. The HTTP method to be used for the request
  56. (GET, POST, PUT, DELETE, ...).
  57. """))
  58. parser.add_argument(
  59. '_url', metavar='URL',
  60. help=dedent("""
  61. The scheme defaults to 'https://' if the URL does not include one.
  62. """))
  63. parser.add_argument(
  64. 'items',
  65. metavar='REQUEST_ITEM',
  66. nargs=ZERO_OR_MORE,
  67. type=KeyValueArgType(*SEP_GROUP_ITEMS),
  68. help=dedent("""
  69. Optional key-value pairs to be included in the request.
  70. The separator used determines the type:
  71. ':' HTTP headers:
  72. Referer:http://httpie.org Cookie:foo=bar User-Agent:bacon/1.0
  73. '==' URL parameters to be appended to the request URI:
  74. search==hyper
  75. '=' Data fields to be serialized into a JSON object:
  76. name=Hyper language=Python description='CLI HTTP client'
  77. """))
  78. def make_troubleshooting_argument(parser):
  79. parser.add_argument(
  80. '--version', action='version', version=__version__,
  81. help='Show version and exit.')
  82. parser.add_argument(
  83. '--debug', action='store_true', default=False,
  84. help='Show debugging information (loglevel=DEBUG)')
  85. parser.add_argument(
  86. '--h2', action='store_true', default=False,
  87. help="Do HTTP/2 directly, skipping plaintext upgrade and ignoring "
  88. "NPN/ALPN."
  89. )
  90. def split_host_and_port(hostname):
  91. if ':' in hostname:
  92. return to_host_port_tuple(hostname, default_port=443)
  93. return hostname, None
  94. class UrlInfo(object):
  95. def __init__(self):
  96. self.fragment = None
  97. self.host = 'localhost'
  98. self.netloc = None
  99. self.path = '/'
  100. self.port = 443
  101. self.query = None
  102. self.scheme = 'https'
  103. self.secure = False
  104. def set_url_info(args):
  105. info = UrlInfo()
  106. _result = urlsplit(args._url)
  107. for attr in vars(info).keys():
  108. value = getattr(_result, attr, None)
  109. if value:
  110. setattr(info, attr, value)
  111. if info.scheme == 'http' and not _result.port:
  112. info.port = 80
  113. # Set the secure arg is the scheme is HTTPS, otherwise do unsecured.
  114. info.secure = info.scheme == 'https'
  115. if info.netloc:
  116. hostname, _ = split_host_and_port(info.netloc)
  117. info.host = hostname # ensure stripping port number
  118. else:
  119. if _result.path:
  120. _path = _result.path.split('/', 1)
  121. hostname, port = split_host_and_port(_path[0])
  122. info.host = hostname
  123. if info.path == _path[0]:
  124. info.path = '/'
  125. elif len(_path) == 2 and _path[1]:
  126. info.path = '/' + _path[1]
  127. if port is not None:
  128. info.port = port
  129. log.debug('Url Info: %s', vars(info))
  130. args.url = info
  131. def set_request_data(args):
  132. body, headers, params = {}, {}, {}
  133. for i in args.items:
  134. if i.sep == SEP_HEADERS:
  135. if i.key:
  136. headers[i.key] = i.value
  137. else:
  138. # when overriding a HTTP/2 special header there will be a
  139. # leading colon, which tricks the command line parser into
  140. # thinking the header is empty
  141. k, v = i.value.split(':', 1)
  142. headers[':' + k] = v
  143. elif i.sep == SEP_QUERY:
  144. params[i.key] = i.value
  145. elif i.sep == SEP_DATA:
  146. value = i.value
  147. if is_py2: # pragma: no cover
  148. value = value.decode(PREFERRED_ENCODING)
  149. body[i.key] = value
  150. if params:
  151. args.url.path += '?' + urlencode(params)
  152. if body:
  153. content_type = 'application/json'
  154. headers.setdefault('content-type', content_type)
  155. args.body = json.dumps(body)
  156. if args.method is None:
  157. args.method = 'POST' if args.body else 'GET'
  158. args.method = args.method.upper()
  159. args.headers = headers
  160. def parse_argument(argv=None):
  161. parser = ArgumentParser(formatter_class=RawTextHelpFormatter)
  162. parser.set_defaults(body=None, headers={})
  163. make_positional_argument(parser)
  164. make_troubleshooting_argument(parser)
  165. args = parser.parse_args(sys.argv[1:] if argv is None else argv)
  166. if args.debug:
  167. handler = logging.StreamHandler()
  168. handler.setLevel(logging.DEBUG)
  169. log.addHandler(handler)
  170. log.setLevel(logging.DEBUG)
  171. set_url_info(args)
  172. set_request_data(args)
  173. return args
  174. def get_content_type_and_charset(response):
  175. charset = 'utf-8'
  176. content_type = response.headers.get('content-type')
  177. if content_type is None:
  178. return 'unknown', charset
  179. content_type = content_type[0].decode('utf-8').lower()
  180. type_and_charset = content_type.split(';', 1)
  181. ctype = type_and_charset[0].strip()
  182. if len(type_and_charset) == 2:
  183. charset = type_and_charset[1].strip().split('=')[1]
  184. return ctype, charset
  185. def request(args):
  186. if not args.h2:
  187. conn = HTTPConnection(
  188. args.url.host, args.url.port, secure=args.url.secure
  189. )
  190. else: # pragma: no cover
  191. conn = HTTP20Connection(
  192. args.url.host,
  193. args.url.port,
  194. secure=args.url.secure,
  195. force_proto='h2'
  196. )
  197. conn.request(args.method, args.url.path, args.body, args.headers)
  198. response = conn.get_response()
  199. log.debug('Response Headers:\n%s', pformat(response.headers))
  200. ctype, charset = get_content_type_and_charset(response)
  201. data = response.read()
  202. return data
  203. def main(argv=None):
  204. args = parse_argument(argv)
  205. log.debug('Commandline Argument: %s', args)
  206. data = request(args)
  207. write_to_stdout(data)
  208. if __name__ == '__main__': # pragma: no cover
  209. main()