diff --git a/README.rst b/README.rst index 1141fa42..d5815432 100644 --- a/README.rst +++ b/README.rst @@ -1277,6 +1277,7 @@ Changelog * Added `CONTRIBUTING`_. * Fixed ``User-Agent`` overwriting when used within a session. * Fixed handling of empty passwords in URL credentials. + * Fixed multiple file uploads with the same form field name. * To make it easier to deal with Windows paths in request items, ``\`` now only escapes special characters (the ones that are used as key-value separators). diff --git a/httpie/config.py b/httpie/config.py index e80aa89f..fa1f789a 100644 --- a/httpie/config.py +++ b/httpie/config.py @@ -48,7 +48,7 @@ class BaseConfigDict(dict): except ValueError as e: raise ValueError( 'Invalid %s JSON: %s [%s]' % - (type(self).__name__, e.message, self.path) + (type(self).__name__, str(e), self.path) ) self.update(data) except IOError as e: diff --git a/httpie/context.py b/httpie/context.py index ed7ff13a..f9af3629 100644 --- a/httpie/context.py +++ b/httpie/context.py @@ -25,13 +25,16 @@ class Environment(object): stdout_encoding = None stderr = sys.stderr stderr_isatty = stderr.isatty() + colors = 256 if not is_windows: import curses - curses.setupterm() - colors = curses.tigetnum('colors') + try: + curses.setupterm() + colors = curses.tigetnum('colors') + except curses.error: + pass del curses else: - colors = 256 # noinspection PyUnresolvedReferences import colorama.initialise stdout = colorama.initialise.wrap_stream( diff --git a/httpie/input.py b/httpie/input.py index 111775ac..fc4967f1 100644 --- a/httpie/input.py +++ b/httpie/input.py @@ -309,21 +309,20 @@ class Parser(ArgumentParser): and `args.files`. """ - self.args.headers = CaseInsensitiveDict() - self.args.data = ParamDict() if self.args.form else OrderedDict() - self.args.files = OrderedDict() - self.args.params = ParamDict() - try: - parse_items(items=self.args.items, - headers=self.args.headers, - data=self.args.data, - files=self.args.files, - params=self.args.params) + items = parse_items( + items=self.args.items, + data_class=ParamsDict if self.args.form else OrderedDict + ) except ParseError as e: if self.args.traceback: raise self.error(e.args[0]) + else: + self.args.headers = items.headers + self.args.data = items.data + self.args.files = items.files + self.args.params = items.params if self.args.files and not self.args.form: # `http url @/path/to/file` @@ -555,7 +554,7 @@ class AuthCredentialsArgType(KeyValueArgType): ) -class ParamDict(OrderedDict): +class RequestItemsDict(OrderedDict): """Multi-value dict for URL parameters and form data.""" #noinspection PyMethodOverriding @@ -567,31 +566,46 @@ class ParamDict(OrderedDict): data and URL params. """ + assert not isinstance(value, list) if key not in self: - super(ParamDict, self).__setitem__(key, value) + super(RequestItemsDict, self).__setitem__(key, value) else: if not isinstance(self[key], list): - super(ParamDict, self).__setitem__(key, [self[key]]) + super(RequestItemsDict, self).__setitem__(key, [self[key]]) self[key].append(value) +class ParamsDict(RequestItemsDict): + pass + + +class DataDict(RequestItemsDict): + + def items(self): + for key, values in super(RequestItemsDict, self).items(): + if not isinstance(values, list): + values = [values] + for value in values: + yield key, value + + RequestItems = namedtuple('RequestItems', ['headers', 'data', 'files', 'params']) -def parse_items(items, data=None, headers=None, files=None, params=None): +def parse_items(items, + headers_class=CaseInsensitiveDict, + data_class=OrderedDict, + files_class=DataDict, + params_class=ParamsDict): """Parse `KeyValue` `items` into `data`, `headers`, `files`, and `params`. """ - if headers is None: - headers = CaseInsensitiveDict() - if data is None: - data = OrderedDict() - if files is None: - files = OrderedDict() - if params is None: - params = ParamDict() + headers = [] + data = [] + files = [] + params = [] for item in items: value = item.value @@ -634,9 +648,12 @@ def parse_items(items, data=None, headers=None, files=None, params=None): else: raise TypeError(item) - target[item.key] = value + target.append((item.key, value)) - return RequestItems(headers, data, files, params) + return RequestItems(headers_class(headers), + data_class(data), + files_class(files), + params_class(params)) def readable_file_arg(filename): diff --git a/tests/test_cli.py b/tests/test_cli.py index d7e0c01e..f5754edc 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,11 +2,12 @@ import json # noinspection PyCompatibility import argparse +import os import pytest from httpie import input -from httpie.input import KeyValue, KeyValueArgType +from httpie.input import KeyValue, KeyValueArgType, DataDict from httpie import ExitStatus from httpie.cli import parser from utils import TestEnvironment, http, HTTP_OK @@ -105,6 +106,25 @@ class TestItemParsing: assert (items.files['file'][1].read().strip().decode('utf8') == FILE_CONTENT) + def test_multiple_file_fields_with_same_field_name(self): + items = input.parse_items([ + self.key_value('file_field@' + FILE_PATH_ARG), + self.key_value('file_field@' + FILE_PATH_ARG), + ]) + assert len(items.files['file_field']) == 2 + + def test_multiple_text_fields_with_same_field_name(self): + items = input.parse_items( + [self.key_value('text_field=a'), + self.key_value('text_field=b')], + data_class=DataDict + ) + assert items.data['text_field'] == ['a', 'b'] + assert list(items.data.items()) == [ + ('text_field', 'a'), + ('text_field', 'b'), + ] + class TestQuerystring: def test_query_string_params_in_url(self, httpbin): @@ -134,7 +154,7 @@ class TestQuerystring: assert '"url": "%s"' % url in r -class TestCLIParser: +class TestURLshorthand: def test_expand_localhost_shorthand(self): args = parser.parse_args(args=[':'], env=TestEnvironment()) assert args.url == 'http://localhost' diff --git a/tests/test_uploads.py b/tests/test_uploads.py index f725c62d..e97bdafb 100644 --- a/tests/test_uploads.py +++ b/tests/test_uploads.py @@ -24,6 +24,17 @@ class TestMultipartFormDataFileUpload: assert FILE_CONTENT in r assert '"foo": "bar"' in r + def test_upload_multiple_fields_with_the_same_name(self, httpbin): + r = http('--form', '--verbose', 'POST', httpbin.url + '/post', + 'test-file@%s' % FILE_PATH_ARG, + 'test-file@%s' % FILE_PATH_ARG) + assert HTTP_OK in r + assert r.count('Content-Disposition: form-data; name="test-file";' + ' filename="%s"' % os.path.basename(FILE_PATH)) == 2 + # Should be 4, but is 3 because httpbin + # doesn't seem to support filed field lists + assert r.count(FILE_CONTENT) in [3, 4] + class TestRequestBodyFromFilePath: """ diff --git a/tox.ini b/tox.ini index 9a1911df..1b6ee19b 100644 --- a/tox.ini +++ b/tox.ini @@ -16,3 +16,6 @@ deps = commands = py.test --verbose --doctest-modules --basetemp={envtmpdir} {posargs:./tests ./httpie} + +[pytest] +addopts = --tb=native