Added file upload support

It is now possible to send multipart/form-data requests.

Note that the --file option used previously has been removed
because it didn't allow you specify the field name.

Example:

    http -f POST example.com field-name@/path/to/file
This commit is contained in:
Jakub Roztocil 2012-03-14 12:17:39 +01:00
parent 578acacdf3
commit b7e0473d6c
6 changed files with 95 additions and 45 deletions

View File

@ -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"

View File

@ -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::
<form enctype="multipart/form-data" method="post" action="http://example.com/job-application" >
<input type="text" name="email" />
<input type="file" name="cv" />
</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
------------

View File

@ -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):

View File

@ -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).
''')
)

1
tests/file.txt Normal file
View File

@ -0,0 +1 @@
__test_file_content__

View File

@ -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()