mirror of
https://github.com/httpie/cli.git
synced 2024-11-25 09:13:25 +01:00
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:
parent
578acacdf3
commit
b7e0473d6c
@ -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"
|
||||
|
50
README.rst
50
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::
|
||||
|
||||
<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
|
||||
------------
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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
1
tests/file.txt
Normal file
@ -0,0 +1 @@
|
||||
__test_file_content__
|
@ -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()
|
Loading…
Reference in New Issue
Block a user