diff --git a/README.rst b/README.rst index 23235235..768963f9 100644 --- a/README.rst +++ b/README.rst @@ -83,8 +83,8 @@ File fields (``field@/path/to/file``) ``screenshot@/path/to/file.png``. The presence of a file field results into a ``multipart/form-data`` request. -Query string parameters (``name=:value``) - Appends the given name/value pair as a query string parameter to the URL. +Query string parameters (``name==value``) + Appends the given name/value pair as a query string parameter to the URL. Examples @@ -127,11 +127,12 @@ The above will send the same request as if the following HTML form were submitte -Query string parameters can be added to any request:: +**Query string parameters** can be added to any request without having to quote +the ``&`` characters:: - http GET example.com/ search=:donuts + http GET example.com/ search==donuts in==fridge -Will GET the URL "example.com/?search=donuts". +Will ``GET` the URL ``http://example.com/?search=donuts&in=fridge``. A whole request body can be passed in via **``stdin``** instead, in which case it will be used with no further processing:: @@ -151,7 +152,7 @@ the second one does via ``stdin``:: http https://api.github.com/repos/jkbr/httpie | http httpbin.org/post -Note that when the output is redirected (like the examples above), HTTPie +Note that when the **output is redirected** (like the examples above), HTTPie applies a different set of defaults then for console output. Namely colors aren't used (can be forced with ``--pretty``) and only the response body gets printed (can be overwritten with ``--print``). @@ -165,7 +166,7 @@ request will send the verbatim contents of the file with http PUT httpbin.org/put @/data/file.xml -When using HTTPie from shell scripts you might want to use the +When using HTTPie from **shell scripts**, you might want to use the ``--check-status`` flag. It instructs HTTPie to exit with an error if the HTTP status is one of ``3xx``, ``4xx``, or ``5xx``. The exit status will be ``3`` (unless ``--allow-redirects`` is set), ``4``, or ``5`` @@ -213,7 +214,7 @@ See ``http -h`` for more details:: separator used. It can be an HTTP header (header:value), a data field to be used in the request body (field_name=value), a raw JSON data field - (field_name:=value), a query parameter (name=:value), + (field_name:=value), a query parameter (name=value), or a file field (field_name@/path/to/file). You can use a backslash to escape a colliding separator in the field name. @@ -333,7 +334,7 @@ Changelog the new default behaviour is to only print the response body. (It can still be overriden via the ``--print`` flag.) * Improved highlighing of HTTP headers. - * Added query string parameters (param=:value). + * Added query string parameters (param==value). * Added support for terminal colors under Windows. * `0.2.5 `_ (2012-07-17) * Unicode characters in prettified JSON now don't get escaped for diff --git a/httpie/cli.py b/httpie/cli.py index fb77b8ca..e6932cfb 100644 --- a/httpie/cli.py +++ b/httpie/cli.py @@ -146,7 +146,7 @@ parser.add_argument( # ``requests.request`` keyword arguments. parser.add_argument( - '--auth', '-a', type=cliparse.AuthCredentialsType(cliparse.SEP_COMMON), + '--auth', '-a', type=cliparse.AuthCredentialsArgType(cliparse.SEP_COMMON), help=_(''' username:password. If only the username is provided (-a username), @@ -174,7 +174,7 @@ parser.add_argument( ) parser.add_argument( '--proxy', default=[], action='append', - type=cliparse.KeyValueType(cliparse.SEP_COMMON), + type=cliparse.KeyValueArgType(cliparse.SEP_COMMON), help=_(''' String mapping protocol to the URL of the proxy (e.g. http:foo.bar:3128). @@ -221,7 +221,7 @@ parser.add_argument( parser.add_argument( 'items', nargs='*', metavar='ITEM', - type=cliparse.KeyValueType( + type=cliparse.KeyValueArgType( cliparse.SEP_COMMON, cliparse.SEP_QUERY, cliparse.SEP_DATA, @@ -233,7 +233,7 @@ parser.add_argument( separator used. It can be an HTTP header (header:value), a data field to be used in the request body (field_name=value), a raw JSON data field (field_name:=value), - a query parameter (name=:value), + a query parameter (name==value), or a file field (field_name@/path/to/file). You can use a backslash to escape a colliding separator in the field name. diff --git a/httpie/cliparse.py b/httpie/cliparse.py index ff8cf4e5..736b02e7 100644 --- a/httpie/cliparse.py +++ b/httpie/cliparse.py @@ -16,6 +16,7 @@ except ImportError: OrderedDict = dict from requests.structures import CaseInsensitiveDict +from requests.compat import str from . import __version__ @@ -25,7 +26,7 @@ SEP_HEADERS = SEP_COMMON SEP_DATA = '=' SEP_DATA_RAW_JSON = ':=' SEP_FILES = '@' -SEP_QUERY = '=:' +SEP_QUERY = '==' DATA_ITEM_SEPARATORS = [ SEP_DATA, SEP_DATA_RAW_JSON, @@ -61,7 +62,6 @@ class Parser(argparse.ArgumentParser): if not env.stdin_isatty: self._body_from_file(args, env.stdin) - if args.auth and not args.auth.has_password(): # stdin has already been read (if not a tty) so # it's save to prompt now. @@ -99,7 +99,7 @@ class Parser(argparse.ArgumentParser): # - Set `args.url` correctly. # - Parse the first item and move it to `args.items[0]`. - item = KeyValueType( + item = KeyValueArgType( SEP_COMMON, SEP_QUERY, SEP_DATA, @@ -119,20 +119,20 @@ class Parser(argparse.ArgumentParser): def _parse_items(self, args): """ Parse `args.items` into `args.headers`, - `args.data`, `args.queries`, and `args.files`. + `args.data`, `args.`, and `args.files`. """ args.headers = CaseInsensitiveDict() args.headers['User-Agent'] = DEFAULT_UA args.data = OrderedDict() args.files = OrderedDict() - args.queries = CaseInsensitiveDict() + args.params = OrderedDict() try: parse_items(items=args.items, headers=args.headers, data=args.data, files=args.files, - queries=args.queries) + params=args.params) except ParseError as e: if args.traceback: raise @@ -195,49 +195,91 @@ class KeyValue(object): return self.__dict__ == other.__dict__ -class KeyValueType(object): - """A type used with `argparse`.""" +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. + + """ key_value_class = KeyValue def __init__(self, *separators): self.separators = separators - self.escapes = ['\\\\' + sep for sep in separators] def __call__(self, string): - found = {} - found_escapes = [] - for esc in self.escapes: - found_escapes += [m.span() for m in re.finditer(esc, string)] - for sep in self.separators: - matches = re.finditer(sep, string) - for match in matches: - start, end = match.span() - inside_escape = False - for estart, eend in found_escapes: - if start >= estart and end <= eend: - inside_escape = True - break - if start in found and len(found[start]) > len(sep): - break - if not inside_escape: - found[start] = sep + """ + Parse `string` and return `self.key_value_class()` instance. - if not found: + The best of `self.separators` is determined (first found, longest). + Back slash escaped characters aren't considered as separators + (or parts thereof). Literal back slash characters have to be escaped + as well (r'\\'). + + """ + + class Escaped(str): + pass + + def tokenize(s): + """ + r'foo\=bar\\baz' + => ['foo', Escaped('='), 'bar', Escaped('\'), 'baz'] + + """ + tokens = [''] + esc = False + for c in s: + if esc: + tokens.extend([Escaped(c), '']) + esc = False + else: + if c == '\\': + esc = True + else: + tokens[-1] += c + return tokens + + tokens = tokenize(string) + + # Sorting by length ensures that the longest one will be + # chosen as it will overwrite any shorter ones starting + # at the same position in the `found` dictionary. + separators = sorted(self.separators, key=len) + + for i, token in enumerate(tokens): + + if isinstance(token, Escaped): + continue + + found = {} + for sep in separators: + pos = token.find(sep) + if pos != -1: + found[pos] = sep + + if found: + # Starting first, longest separator found. + sep = found[min(found.keys())] + + key, value = token.split(sep, 1) + + # Any preceding tokens are part of the key. + key = ''.join(tokens[:i]) + key + + # Any following tokens are part of the value. + value += ''.join(tokens[i + 1:]) + + break + + else: raise argparse.ArgumentTypeError( '"%s" is not a valid value' % string) - # split the string at the earliest non-escaped separator. - seploc = min(found.keys()) - sep = found[seploc] - key = string[:seploc] - value = string[seploc + len(sep):] - - # remove escape chars - for sepstr in self.separators: - key = key.replace('\\' + sepstr, sepstr) - value = value.replace('\\' + sepstr, sepstr) - return self.key_value_class(key=key, value=value, sep=sep, orig=string) + return self.key_value_class( + key=key, value=value, sep=sep, orig=string) class AuthCredentials(KeyValue): @@ -260,13 +302,13 @@ class AuthCredentials(KeyValue): sys.exit(0) -class AuthCredentialsType(KeyValueType): +class AuthCredentialsArgType(KeyValueArgType): key_value_class = AuthCredentials def __call__(self, string): try: - return super(AuthCredentialsType, self).__call__(string) + return super(AuthCredentialsArgType, self).__call__(string) except argparse.ArgumentTypeError: # No password provided, will prompt for it later. return self.key_value_class( @@ -277,10 +319,10 @@ class AuthCredentialsType(KeyValueType): ) -def parse_items(items, data=None, headers=None, files=None, queries=None): +def parse_items(items, data=None, headers=None, files=None, params=None): """ - Parse `KeyValueType` `items` into `data`, `headers`, `files`, - and `queries`. + Parse `KeyValue` `items` into `data`, `headers`, `files`, + and `params`. """ if headers is None: @@ -289,15 +331,15 @@ def parse_items(items, data=None, headers=None, files=None, queries=None): data = {} if files is None: files = {} - if queries is None: - queries = {} + if params is None: + params = {} for item in items: value = item.value key = item.key if item.sep == SEP_HEADERS: target = headers elif item.sep == SEP_QUERY: - target = queries + target = params elif item.sep == SEP_FILES: try: value = open(os.path.expanduser(item.value), 'r') @@ -322,4 +364,4 @@ def parse_items(items, data=None, headers=None, files=None, queries=None): target[key] = value - return headers, data, files, queries + return headers, data, files, params diff --git a/httpie/core.py b/httpie/core.py index b2b7c121..4cda53a8 100644 --- a/httpie/core.py +++ b/httpie/core.py @@ -53,7 +53,7 @@ def get_response(args): proxies=dict((p.key, p.value) for p in args.proxy), files=args.files, allow_redirects=args.allow_redirects, - params=args.queries, + params=args.params, ) except (KeyboardInterrupt, SystemExit): diff --git a/tests/tests.py b/tests/tests.py index ad397897..0ef67e73 100755 --- a/tests/tests.py +++ b/tests/tests.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python # coding=utf8 """ @@ -598,7 +599,7 @@ class ExitStatusTest(BaseTestCase): class ItemParsingTest(BaseTestCase): def setUp(self): - self.key_value_type = cliparse.KeyValueType( + self.key_value_type = cliparse.KeyValueArgType( cliparse.SEP_HEADERS, cliparse.SEP_QUERY, cliparse.SEP_DATA, @@ -613,7 +614,7 @@ class ItemParsingTest(BaseTestCase): lambda: self.key_value_type(item)) def test_escape(self): - headers, data, files, queries = cliparse.parse_items([ + headers, data, files, params = cliparse.parse_items([ # headers self.key_value_type('foo\\:bar:baz'), self.key_value_type('jack\\@jill:hill'), @@ -632,15 +633,15 @@ class ItemParsingTest(BaseTestCase): self.assertIn('bar@baz', files) def test_escape_longsep(self): - headers, data, files, queries = cliparse.parse_items([ + headers, data, files, params = cliparse.parse_items([ self.key_value_type('bob\\:==foo'), ]) - self.assertDictEqual(data, { - 'bob:=': 'foo', + self.assertDictEqual(params, { + 'bob:': 'foo', }) def test_valid_items(self): - headers, data, files, queries = cliparse.parse_items([ + headers, data, files, params = cliparse.parse_items([ self.key_value_type('string=value'), self.key_value_type('header:value'), self.key_value_type('list:=["a", 1, {}, false]'), @@ -649,7 +650,7 @@ class ItemParsingTest(BaseTestCase): self.key_value_type('ed='), self.key_value_type('bool:=true'), self.key_value_type('test-file@%s' % TEST_FILE_PATH), - self.key_value_type('query=:value'), + self.key_value_type('query==value'), ]) self.assertDictEqual(headers, { 'header': 'value', @@ -660,21 +661,13 @@ class ItemParsingTest(BaseTestCase): "string": "value", "bool": True, "list": ["a", 1, {}, False], - "obj": {"a": "b"} + "obj": {"a": "b"}, }) - self.assertDictEqual(queries, { + self.assertDictEqual(params, { 'query': 'value', }) self.assertIn('test-file', files) - def test_query_string(self): - headers, data, files, queries = cliparse.parse_items([ - self.key_value_type('query=:value'), - ]) - self.assertDictEqual(queries, { - 'query': 'value', - }) - class ArgumentParserTestCase(unittest.TestCase):