diff --git a/.travis.yml b/.travis.yml index 36b8a13e..b5b2acae 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,7 +4,7 @@ python: - 2.7 # TODO: Python 3 #- 3.2 -script: python tests.py +script: python tests/tests.py install: - pip install requests pygments - "if [[ $TRAVIS_PYTHON_VERSION == '2.6' ]]; then pip install argparse; fi" diff --git a/README.rst b/README.rst index 3331adea..9768edaf 100644 --- a/README.rst +++ b/README.rst @@ -40,15 +40,18 @@ Synopsis:: http [flags] METHOD URL [items] -There are three types of key-value pair items available: +There are four types of key-value pair items available: Headers Arbitrary HTTP headers. The ``:`` character is used to separate a header's name from its value, e.g., ``X-API-Token:123``. -Simple data items +Simple data fields Data items are included in the request body. Depending on the ``Content-Type``, they are automatically serialized as a JSON ``Object`` (default) or ``application/x-www-form-urlencoded`` (the ``-f`` flag). Data items use ``=`` as the separator, e.g., ``hello=world``. -Raw JSON items +File fields + Only available with ``-f`` / ``--form``. Use ``@`` as the separator, e.g., ``screenshot@/path/to/file.png``. + +Raw JSON fields This item type is needed when ``Content-Type`` is JSON and a field's value is a ``Boolean``, ``Number``, nested ``Object`` or an ``Array``, because simple data items are always serialized as ``String``. E.g. ``pies:=[1,2,3]``. Examples @@ -77,6 +80,17 @@ It can easily be changed to a 'form' request using the ``-f`` (or ``--form``) fl age=29&name=John&email=john%40example.org +It is also possible to send ``multipart/form-data`` requests, i.e., to simulate a file upload form submission. It is done using the ``--form`` / ``-f`` flag and passing one or more file fields:: + + http -f POST example.com/job-application email=John cv@~/Documents/cv.pdf + +The above will send a request equivalent to submitting the following form:: + +
+ A whole request body can be passed in via ``stdin`` instead:: echo '{"name": "John"}' | http PATCH example.com/person/1 X-API-Token:123 @@ -88,14 +102,6 @@ Flags ^^^^^ Most of the flags mirror the arguments understood by ``requests.request``. See ``http -h`` for more details:: - usage: http [-h] [--version] [--json | --form] [--traceback] - [--pretty | --ugly] - [--print OUTPUT_OPTIONS | --headers | --body] - [--style STYLE] [--auth AUTH] [--verify VERIFY] - [--proxy PROXY] [--allow-redirects] [--file PATH] - [--timeout TIMEOUT] - METHOD URL [items [items ...]] - HTTPie - cURL for humans. positional arguments: @@ -103,17 +109,20 @@ Most of the flags mirror the arguments understood by ``requests.request``. See ` PUT, DELETE, PATCH, ...). URL Protocol defaults to http:// if the URL does not include it. - items HTTP header (key:value), data field (key=value) or raw - JSON field (field:=value). + items HTTP header (header:value), data field (field=value), + raw JSON field (field:=value) or file field + (field@/path/to/file). optional arguments: -h, --help show this help message and exit --version show program's version number and exit --json, -j Serialize data items as a JSON object and set Content- Type to application/json, if not specified. - --form, -f Serialize data items as form values and set Content- - Type to application/x-www-form-urlencoded, if not - specified. + --form, -f Serialize fields as form values. The Content-Type is + set to application/x-www-form-urlencoded. The presence + of any file fields results into a multipart/form-data + request. Note that Content-Type is not automatically + set if explicitely specified. --traceback Print exception traceback should one occur. --pretty If stdout is a terminal, the response is prettified by default (colorized and indented if it is JSON). This @@ -126,10 +135,11 @@ Most of the flags mirror the arguments understood by ``requests.request``. See ` "h" stands for response headers and "b" for response body. Defaults to "hb" which means that the whole response (headers and body) is printed. - --headers, -t Print only the response headers. It's a shortcut for + --verbose, -v Print the whole request as well as response. Shortcut + for --print=HBhb. + --headers, -t Print only the response headers. Shortcut for --print=h. - --body, -b Print only the response body. It's a shortcut for - --print=b. + --body, -b Print only the response body. Shortcut for --print=b. --style STYLE, -s STYLE Output coloring style, one of autumn, borland, bw, colorful, default, emacs, friendly, fruity, manni, @@ -144,11 +154,9 @@ Most of the flags mirror the arguments understood by ``requests.request``. See ` http:foo.bar:3128). --allow-redirects Set this flag if full redirects are allowed (e.g. re- POST-ing of data at new ``Location``) - --file PATH File to multipart upload --timeout TIMEOUT Float describes the timeout of the request (Use socket.setdefaulttimeout() as fallback). - Contributors ------------ diff --git a/httpie/__main__.py b/httpie/__main__.py index 44f3c117..e06c47b1 100644 --- a/httpie/__main__.py +++ b/httpie/__main__.py @@ -100,26 +100,37 @@ def main(args=None, headers = CaseInsensitiveDict() headers['User-Agent'] = DEFAULT_UA data = OrderedDict() + files = OrderedDict() try: - cli.parse_items(items=args.items, headers=headers, data=data) + cli.parse_items(items=args.items, headers=headers, + data=data, files=files) except cli.ParseError as e: if args.traceback: raise parser.error(e.message) + if files and not args.form: + # We could just switch to --form automatically here, + # but I think it's better to make it explicit. + parser.error( + ' You need to set the --form / -f flag to' + ' to issue a multipart request. File fields: %s' + % ','.join(files.keys())) + if not stdin_isatty: if data: parser.error('Request body (stdin) and request ' 'data (key=value) cannot be mixed.') data = stdin.read() + # JSON/Form content type. if args.json or (not args.form and data): if stdin_isatty: data = json.dumps(data) - if 'Content-Type' not in headers and (data or args.json): + if not files and ('Content-Type' not in headers and (data or args.json)): headers['Content-Type'] = TYPE_JSON - elif 'Content-Type' not in headers: + elif not files and 'Content-Type' not in headers: headers['Content-Type'] = TYPE_FORM # Fire the request. @@ -133,7 +144,7 @@ def main(args=None, timeout=args.timeout, auth=(args.auth.key, args.auth.value) if args.auth else None, proxies=dict((p.key, p.value) for p in args.proxy), - files=dict((os.path.basename(f.name), f) for f in args.file), + files=files, allow_redirects=args.allow_redirects, ) except (KeyboardInterrupt, SystemExit): diff --git a/httpie/cli.py b/httpie/cli.py index c06d3500..70830f89 100644 --- a/httpie/cli.py +++ b/httpie/cli.py @@ -1,3 +1,4 @@ +import os import json import argparse from collections import namedtuple @@ -10,6 +11,7 @@ SEP_COMMON = ':' SEP_HEADERS = SEP_COMMON SEP_DATA = '=' SEP_DATA_RAW_JSON = ':=' +SEP_FILES = '@' PRETTIFY_STDOUT_TTY_ONLY = object() OUT_REQUEST_HEADERS = 'H' @@ -49,16 +51,28 @@ class KeyValueType(object): return KeyValue(key=key, value=value, sep=sep, orig=string) -def parse_items(items, data=None, headers=None): - """Parse `KeyValueType` `items` into `data` and `headers`.""" +def parse_items(items, data=None, headers=None, files=None): + """Parse `KeyValueType` `items` into `data`, `headers` and `files`.""" if headers is None: headers = {} if data is None: data = {} + if files is None: + files = {} for item in items: value = item.value + key = item.key if item.sep == SEP_HEADERS: target = headers + elif item.sep == SEP_FILES: + try: + value = open(os.path.expanduser(item.value), 'r') + except IOError as e: + raise ParseError( + 'Invalid argument %r. %s' % (item.orig, e)) + if not key: + key = os.path.basename(value.name) + target = files elif item.sep in [SEP_DATA, SEP_DATA_RAW_JSON]: if item.sep == SEP_DATA_RAW_JSON: try: @@ -69,12 +83,12 @@ def parse_items(items, data=None, headers=None): else: raise ParseError('%s is not valid item' % item.orig) - if item.key in target: + if key in target: ParseError('duplicate item %s (%s)' % (item.key, item.orig)) - target[item.key] = value + target[key] = value - return headers, data + return headers, data, files def _(text): @@ -111,9 +125,9 @@ group_type.add_argument( group_type.add_argument( '--form', '-f', action='store_true', help=_(''' - Serialize data items as form values and set - Content-Type to application/x-www-form-urlencoded, - if not specified. + Serialize fields as form values. The Content-Type is set to application/x-www-form-urlencoded. + The presence of any file fields results into a multipart/form-data request. + Note that Content-Type is not automatically set if explicitely specified. ''') ) @@ -225,11 +239,6 @@ parser.add_argument( (e.g. re-POST-ing of data at new ``Location``) ''') ) -parser.add_argument( - '--file', metavar='PATH', type=argparse.FileType(), - default=[], action='append', - help='File to multipart upload' -) parser.add_argument( '--timeout', type=float, help=_(''' @@ -258,9 +267,10 @@ parser.add_argument( ) parser.add_argument( 'items', nargs='*', - type=KeyValueType(SEP_COMMON, SEP_DATA, SEP_DATA_RAW_JSON), + type=KeyValueType(SEP_COMMON, SEP_DATA, SEP_DATA_RAW_JSON, SEP_FILES), help=_(''' - HTTP header (key:value), data field (key=value) - or raw JSON field (field:=value). + HTTP header (header:value), data field (field=value), + raw JSON field (field:=value) + or file field (field@/path/to/file). ''') ) diff --git a/tests/file.txt b/tests/file.txt new file mode 100644 index 00000000..fba7ccb9 --- /dev/null +++ b/tests/file.txt @@ -0,0 +1 @@ +__test_file_content__ diff --git a/tests.py b/tests/tests.py similarity index 79% rename from tests.py rename to tests/tests.py index 0cb2911a..f099aa8b 100644 --- a/tests.py +++ b/tests/tests.py @@ -1,7 +1,13 @@ +# coding:utf8 +import os import sys import unittest import argparse from StringIO import StringIO + + +TESTS_ROOT = os.path.dirname(__file__) +sys.path.insert(0, os.path.abspath(os.path.join(TESTS_ROOT, '..'))) from httpie import __main__ from httpie import cli @@ -82,7 +88,7 @@ class TestHTTPie(BaseTest): self.assertIn('"foo": "bar"', response) def test_form(self): - response = http('POST', '--form', 'http://httpbin.org/post', 'foo=bar') + response = http('--form', 'POST', 'http://httpbin.org/post', 'foo=bar') self.assertIn('"foo": "bar"', response) def test_headers(self): @@ -103,13 +109,27 @@ class TestPrettyFlag(BaseTest): self.assertNotIn(TERMINAL_COLOR_CHECK, r) def test_force_pretty(self): - r = http('GET', '--pretty', 'http://httpbin.org/get', stdout_isatty=False) + r = http('--pretty', 'GET', 'http://httpbin.org/get', stdout_isatty=False) self.assertIn(TERMINAL_COLOR_CHECK, r) def test_force_ugly(self): - r = http('GET', '--ugly', 'http://httpbin.org/get', stdout_isatty=True) + r = http('--ugly', 'GET', 'http://httpbin.org/get', stdout_isatty=True) self.assertNotIn(TERMINAL_COLOR_CHECK, r) +class TestFileUpload(BaseTest): + + def test_non_existent_file_raises_parse_error(self): + self.assertRaises(cli.ParseError, http, + '--form', '--traceback', + 'POST', 'http://httpbin.org/post', + 'foo@/__does_not_exist__') + + def test_upload_ok(self): + r = http('--form', 'POST', 'http://httpbin.org/post', + 'test-file@%s' % os.path.join(TESTS_ROOT, 'file.txt')) + self.assertIn('"test-file": "__test_file_content__', r) + + if __name__ == '__main__': unittest.main()