From 923a8b71bd68c56b2dc1c39e26b9605fe16be495 Mon Sep 17 00:00:00 2001 From: Jakub Roztocil Date: Mon, 30 Jul 2012 10:58:16 +0200 Subject: [PATCH] Revorked output Binary now works everywhere. Also added `--output FILE` for Windows. --- README.rst | 2 + httpie/cli.py | 28 +++++++-- httpie/core.py | 19 ++++-- httpie/input.py | 24 +++++++- httpie/models.py | 27 +++++---- httpie/output.py | 57 ++++++++++++------ tests/tests.py | 150 +++++++++++++++++++++++++++++++++-------------- 7 files changed, 223 insertions(+), 84 deletions(-) diff --git a/README.rst b/README.rst index e34925f6..9ce6351a 100644 --- a/README.rst +++ b/README.rst @@ -334,6 +334,8 @@ Changelog ========= * `0.2.7dev`_ + * Windows: Added ``--output FILE`` to store output into a file + (piping results into corrupted data on Windows). * Proper handling of binary requests and responses. * Fixed printing of ``multipart/form-data`` requests. * Renamed ``--traceback`` to ``--debug``. diff --git a/httpie/cli.py b/httpie/cli.py index 67b059f7..85054de0 100644 --- a/httpie/cli.py +++ b/httpie/cli.py @@ -3,6 +3,10 @@ NOTE: the CLI interface may change before reaching v1.0. """ +import argparse + +from requests.compat import is_windows + from . import __doc__ from . import __version__ from .output import AVAILABLE_STYLES @@ -50,12 +54,19 @@ group_type.add_argument( # Output options. ############################################# + parser.add_argument( - '--debug', action='store_true', default=False, - help=_(''' - Prints exception traceback should one occur and other - information useful for debugging HTTPie itself. - ''') + '--output', '-o', type=argparse.FileType('wb'), + metavar='FILE', + help= argparse.SUPPRESS if not is_windows else _( + ''' + Save output to FILE. + This option is a replacement for piping output to FILE, + which would on Windows result into corrupted data + being saved. + + ''' + ) ) prettify = parser.add_mutually_exclusive_group(required=False) @@ -200,6 +211,13 @@ parser.add_argument( (Use socket.setdefaulttimeout() as fallback). ''') ) +parser.add_argument( + '--debug', action='store_true', default=False, + help=_(''' + Prints exception traceback should one occur and other + information useful for debugging HTTPie itself. + ''') +) # Positional arguments. diff --git a/httpie/core.py b/httpie/core.py index 9bdb6cdb..95e21d0a 100644 --- a/httpie/core.py +++ b/httpie/core.py @@ -148,7 +148,15 @@ def main(args=sys.argv[1:], env=Environment()): Return exit status. """ + + if env.is_windows and not env.stdout_isatty: + env.stderr.write( + 'http: error: Output redirection is not supported on Windows.' + ' Please use `--output FILE\' instead.\n') + return 1 + args = parser.parse_args(args=args, env=env) + response = get_response(args, env) status = 0 @@ -159,12 +167,13 @@ def main(args=sys.argv[1:], env=Environment()): if status and not env.stdout_isatty: err = 'http error: %s %s\n' % ( response.raw.status, response.raw.reason) - env.stderr.write(err.encode('utf8')) + env.stderr.write(err) - output_bytes = get_output(args, env, response.request, response) + output = get_output(args, env, response.request, response) - # output_bytes = output.encode('utf8') - f = getattr(env.stdout, 'buffer', env.stdout) - f.write(output_bytes) + try: + env.stdout.buffer.write(output) + except AttributeError: + env.stdout.write(output) return status diff --git a/httpie/input.py b/httpie/input.py index c7ea7b2f..ba71ab10 100644 --- a/httpie/input.py +++ b/httpie/input.py @@ -89,8 +89,15 @@ class Parser(argparse.ArgumentParser): #noinspection PyMethodOverriding def parse_args(self, env, args=None, namespace=None): + + self.env = env + args = super(Parser, self).parse_args(args, namespace) + if args.output: + env.stdout = args.output + env.stdout_isatty = False + self._process_output_options(args, env) self._guess_method(args, env) self._parse_items(args) @@ -104,9 +111,24 @@ class Parser(argparse.ArgumentParser): if args.prettify == PRETTIFY_STDOUT_TTY_ONLY: args.prettify = env.stdout_isatty + elif args.prettify and env.is_windows: + self.error('Only terminal output can be prettified on Windows.') return args + def _print_message(self, message, file=None): + # Sneak in our stderr/stdout. + file = { + sys.stdout: self.env.stdout, + sys.stderr: self.env.stderr, + None: self.env.stderr + }.get(file, file) + + #if isinstance(message, str): + # message = message.encode('utf8') + + super(Parser, self)._print_message(message, file) + def _body_from_file(self, args, data): """There can only be one source of request data.""" if args.data: @@ -398,7 +420,7 @@ def parse_items(items, data=None, headers=None, files=None, params=None): target = params elif item.sep == SEP_FILES: try: - with open(os.path.expanduser(value), 'r') as f: + with open(os.path.expanduser(value)) as f: value = (os.path.basename(f.name), f.read()) except IOError as e: raise ParseError( diff --git a/httpie/models.py b/httpie/models.py index ec38d4c1..4b12d8c4 100644 --- a/httpie/models.py +++ b/httpie/models.py @@ -10,23 +10,18 @@ class Environment(object): and allows for mocking. """ + + #noinspection PyUnresolvedReferences + is_windows = is_windows + progname = os.path.basename(sys.argv[0]) if progname not in ['http', 'https']: progname = 'http' stdin_isatty = sys.stdin.isatty() stdin = sys.stdin - - if is_windows: - # `colorama` patches `sys.stdout` so its initialization - # needs to happen before the default environment is set. - import colorama - colorama.init() - del colorama - stdout_isatty = sys.stdout.isatty() stdout = sys.stdout - stderr = sys.stderr # Can be set to 0 to disable colors completely. @@ -35,6 +30,18 @@ class Environment(object): def __init__(self, **kwargs): self.__dict__.update(**kwargs) + def init_colors(self): + # We check for real Window here, not self.is_windows as + # it could be mocked. + if (is_windows and not self.__colors_initialized + and self.stdout == sys.stdout): + import colorama.initialise + self.stdout = colorama.initialise.wrap_stream( + self.stdout, autoreset=False, + convert=None, strip=None, wrap=True) + self.__colors_initialized = True + __colors_initialized = False + class HTTPMessage(object): """Model representing an HTTP message.""" @@ -49,7 +56,7 @@ class HTTPMessage(object): self.line = line # {Request,Status}-Line self.headers = headers self.body = body - self.encoding = encoding + self.encoding = encoding or 'utf8' self.content_type = content_type @classmethod diff --git a/httpie/output.py b/httpie/output.py index 6e5b6975..ec6be264 100644 --- a/httpie/output.py +++ b/httpie/output.py @@ -41,7 +41,26 @@ def format(msg, prettifier=None, with_headers=True, with_body=True, precision. """ - chunks = [] + + # Output encoding. + if env.stdout_isatty: + # Use encoding suitable for the terminal. Unsupported characters + # will be replaced in the output. + errors = 'replace' + output_encoding = getattr(env.stdout, 'encoding', None) + else: + # Preserve the message encoding. + errors = 'strict' + output_encoding = msg.encoding + if not output_encoding: + # Default to utf8 + output_encoding = 'utf8' + + if prettifier: + env.init_colors() + + #noinspection PyArgumentList + output = bytearray() if with_headers: headers = '\n'.join([msg.line, msg.headers]) @@ -49,37 +68,37 @@ def format(msg, prettifier=None, with_headers=True, with_body=True, if prettifier: headers = prettifier.process_headers(headers) - chunks.append(headers.strip().encode('utf8')) + output.extend( + headers.encode(output_encoding, errors).strip()) - if with_body and msg.body or env.stdout_isatty: - chunks.append(b'\n\n') + if with_body and msg.body: + output.extend(b'\n\n') if with_body and msg.body: body = msg.body - bin_suppressed = False - if prettifier or env.stdout_isatty: + if not (env.stdout_isatty or prettifier): + # Verbatim body even if it's binary. + pass + else: try: - body = msg.body.decode(msg.encoding or 'utf8') + body = body.decode(msg.encoding) except UnicodeDecodeError: - # Assume binary - bin_suppressed = True - body = BINARY_SUPPRESSED_NOTICE.encode('utf8') + # Suppress binary data. + body = BINARY_SUPPRESSED_NOTICE.encode(output_encoding) if not with_headers: - body = b'\n' + body + output.extend(b'\n') else: - body = body.encode('utf8') + if prettifier and msg.content_type: + body = prettifier.process_body( + body, msg.content_type).strip() - if not bin_suppressed and prettifier and msg.content_type: - body = (prettifier - .process_body(body.decode('utf8'), msg.content_type) - .strip() - .encode('utf8')) + body = body.encode(output_encoding, errors) - chunks.append(body) + output.extend(body) - return b''.join(chunks) + return bytes(output) class HTTPLexer(lexer.RegexLexer): diff --git a/tests/tests.py b/tests/tests.py index 2d372da8..d3d0cfe8 100755 --- a/tests/tests.py +++ b/tests/tests.py @@ -45,6 +45,7 @@ from httpie import input from httpie.models import Environment from httpie.core import main, get_output from httpie.output import BINARY_SUPPRESSED_NOTICE +from httpie.input import ParseError HTTPBIN_URL = os.environ.get('HTTPBIN_URL', @@ -52,7 +53,8 @@ HTTPBIN_URL = os.environ.get('HTTPBIN_URL', TEST_FILE_PATH = os.path.join(TESTS_ROOT, 'file.txt') TEST_FILE2_PATH = os.path.join(TESTS_ROOT, 'file2.txt') -TEST_FILE_CONTENT = open(TEST_FILE_PATH).read().strip() +with open(TEST_FILE_PATH) as f: + TEST_FILE_CONTENT = f.read().strip() TERMINAL_COLOR_PRESENCE_CHECK = '\x1b[' @@ -89,8 +91,8 @@ def http(*args, **kwargs): stdout_isatty=True, ) - stdout = kwargs['env'].stdout = tempfile.TemporaryFile() - stderr = kwargs['env'].stderr = tempfile.TemporaryFile() + stdout = kwargs['env'].stdout = tempfile.TemporaryFile('w+b') + stderr = kwargs['env'].stderr = tempfile.TemporaryFile('w+t') exit_status = main(args=['--debug'] + list(args), **kwargs) @@ -100,8 +102,10 @@ def http(*args, **kwargs): output = stdout.read() try: + #noinspection PyArgumentList r = StrResponse(output.decode('utf8')) except UnicodeDecodeError: + #noinspection PyArgumentList r = BytesResponse(output) else: if TERMINAL_COLOR_PRESENCE_CHECK not in r: @@ -120,7 +124,7 @@ def http(*args, **kwargs): except ValueError: pass - r.stderr = stderr.read().decode('utf8') + r.stderr = stderr.read() r.exit_status = exit_status stdout.close() @@ -133,10 +137,10 @@ class BaseTestCase(unittest.TestCase): if is_py26: def assertIn(self, member, container, msg=None): - self.assert_(member in container, msg) + self.assertTrue(member in container, msg) def assertNotIn(self, member, container, msg=None): - self.assert_(member not in container, msg) + self.assertTrue(member not in container, msg) def assertDictEqual(self, d1, d2, msg=None): self.assertEqual(set(d1.keys()), set(d2.keys()), msg) @@ -206,19 +210,20 @@ class HTTPieTest(BaseTestCase): def test_POST_stdin(self): - env = Environment( - stdin=open(TEST_FILE_PATH), - stdin_isatty=False, - stdout_isatty=True, - colors=0, - ) + with open(TEST_FILE_PATH) as f: + env = Environment( + stdin=f, + stdin_isatty=False, + stdout_isatty=True, + colors=0, + ) - r = http( - '--form', - 'POST', - httpbin('/post'), - env=env - ) + r = http( + '--form', + 'POST', + httpbin('/post'), + env=env + ) self.assertIn('HTTP/1.1 200', r) self.assertIn(TEST_FILE_CONTENT, r) @@ -434,17 +439,18 @@ class ImplicitHTTPMethodTest(BaseTestCase): self.assertIn('"foo": "bar"', r) def test_implicit_POST_stdin(self): - env = Environment( - stdin_isatty=False, - stdin=open(TEST_FILE_PATH), - stdout_isatty=True, - colors=0, - ) - r = http( - '--form', - httpbin('/post'), - env=env - ) + with open(TEST_FILE_PATH) as f: + env = Environment( + stdin_isatty=False, + stdin=f, + stdout_isatty=True, + colors=0, + ) + r = http( + '--form', + httpbin('/post'), + env=env + ) self.assertIn('HTTP/1.1 200', r) @@ -500,6 +506,7 @@ class VerboseFlagTest(BaseTestCase): 'test-header:__test__' ) self.assertIn('HTTP/1.1 200', r) + #noinspection PyUnresolvedReferences self.assertEqual(r.count('__test__'), 2) def test_verbose_form(self): @@ -524,18 +531,18 @@ class VerboseFlagTest(BaseTestCase): 'baz=bar' ) self.assertIn('HTTP/1.1 200', r) + #noinspection PyUnresolvedReferences self.assertEqual(r.count('"baz": "bar"'), 2) class MultipartFormDataFileUploadTest(BaseTestCase): def test_non_existent_file_raises_parse_error(self): - self.assertRaises(SystemExit, http, + self.assertRaises(ParseError, http, '--form', - '--traceback', 'POST', httpbin('/post'), - 'foo@/__does_not_exist__' + 'foo@/__does_not_exist__', ) def test_upload_ok(self): @@ -552,6 +559,7 @@ class MultipartFormDataFileUploadTest(BaseTestCase): self.assertIn('Content-Disposition: form-data; name="foo"', r) self.assertIn('Content-Disposition: form-data; name="test-file";' ' filename="%s"' % os.path.basename(TEST_FILE_PATH), r) + #noinspection PyUnresolvedReferences self.assertEqual(r.count(TEST_FILE_CONTENT), 2) self.assertIn('"foo": "bar"', r) @@ -620,19 +628,38 @@ class RequestBodyFromFilePathTest(BaseTestCase): self.assertIn('"Content-Type": "x-foo/bar"', r) def test_request_body_from_file_by_path_no_field_name_allowed(self): - self.assertRaises(SystemExit, lambda: http( - 'POST', - httpbin('/post'), - 'field-name@' + TEST_FILE_PATH) - ) + env = Environment(stdin_isatty=True) + try: + http( + 'POST', + httpbin('/post'), + 'field-name@' + TEST_FILE_PATH, + env=env + ) + except SystemExit: + env.stderr.seek(0) + stderr = env.stderr.read() + self.assertIn('perhaps you meant --form?', stderr) + else: + self.fail('validation did not work') def test_request_body_from_file_by_path_no_data_items_allowed(self): - self.assertRaises(SystemExit, lambda: http( - 'POST', - httpbin('/post'), - '@' + TEST_FILE_PATH, - 'foo=bar') - ) + env = Environment(stdin_isatty=True) + try: + http( + 'POST', + httpbin('/post'), + '@' + TEST_FILE_PATH, + 'foo=bar', + env=env + ) + except SystemExit: + env.stderr.seek(0) + self.assertIn( + 'cannot be mixed', + env.stderr.read()) + else: + self.fail('validation did not work') class AuthTest(BaseTestCase): @@ -727,7 +754,7 @@ class ExitStatusTest(BaseTestCase): self.assertIn('HTTP/1.1 401', r) self.assertEqual(r.exit_status, 4) # Also stderr should be empty since stdout isn't redirected. - self.assert_(not r.stderr) + self.assertTrue(not r.stderr) def test_5xx_check_status_exits_5(self): r = http( @@ -739,6 +766,41 @@ class ExitStatusTest(BaseTestCase): self.assertEqual(r.exit_status, 5) +class FakeWindowsTest(BaseTestCase): + + def test_stdout_redirect_not_supported_on_windows(self): + env = Environment(is_windows=True, stdout_isatty=False) + r = http( + 'GET', + httpbin('/get'), + env=env + ) + self.assertNotEqual(r.exit_status, 0) + self.assertIn('Windows', r.stderr) + self.assertIn('--output', r.stderr) + + def test_output_file_pretty_not_allowed_on_windows(self): + env = Environment( + is_windows=True, stdout_isatty=True, stdin_isatty=True) + + try: + http( + '--output', + os.path.join(tempfile.gettempdir(), '__httpie_test_output__'), + '--pretty', + 'GET', + httpbin('/get'), + env=env + ) + except SystemExit: + env.stderr.seek(0) + err = env.stderr.read() + self.assertIn( + 'Only terminal output can be prettified on Windows', err) + else: + self.fail('validation did not work') + + ################################################################# # CLI argument parsing related tests. #################################################################