diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 59c61b8c..a68baa71 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,7 +9,8 @@ This project adheres to `Semantic Versioning `_. `2.2.0-dev`_ (unreleased) ------------------------- -* Added support for ``--ciphers`` (`#870`_). +* Added ``--format-options`` to allow disabling sorting, etc. (`#128`_) +* Added ``--ciphers`` to allow configuring OpenSSL ciphers (`#870`_). * Added support for ``$XDG_CONFIG_HOME`` (`#920`_). * Fixed built-in plugins-related circular imports (`#925`_). @@ -433,6 +434,7 @@ This project adheres to `Semantic Versioning `_. .. _2.2.0-dev: https://github.com/jakubroztocil/httpie/compare/2.1.0...master +.. _#128: https://github.com/jakubroztocil/httpie/issues/128 .. _#488: https://github.com/jakubroztocil/httpie/issues/488 .. _#840: https://github.com/jakubroztocil/httpie/issues/840 .. _#870: https://github.com/jakubroztocil/httpie/issues/870 diff --git a/README.rst b/README.rst index 865352b1..572b1c42 100644 --- a/README.rst +++ b/README.rst @@ -1395,6 +1395,20 @@ One of these options can be used to control output processing: Default for redirected output. ==================== ======================================================== + +You can control the applied formatting via the ``--format-options`` option. +For example, this is how you would disable the default header and JSON key +sorting, and specify a custom JSON indent size: + + +.. code-block:: bash + + $ http --format-options headers.sort=false,json.sort_keys=false,json.indent=2 httpbin.org/get + +This is something you will typically store as one of the default options in your +`config`_ file. See ``http --help`` for all the available formatting options. + + Binary data ----------- diff --git a/httpie/cli/argtypes.py b/httpie/cli/argtypes.py index 20e83897..b3c47169 100644 --- a/httpie/cli/argtypes.py +++ b/httpie/cli/argtypes.py @@ -2,9 +2,10 @@ import argparse import getpass import os import sys -from typing import Union, List, Optional +from copy import deepcopy +from typing import List, Optional, Union -from httpie.cli.constants import SEPARATOR_CREDENTIALS +from httpie.cli.constants import DEFAULT_FORMAT_OPTIONS, SEPARATOR_CREDENTIALS from httpie.sessions import VALID_SESSION_NAME_PATTERN @@ -181,3 +182,65 @@ def readable_file_arg(filename): return filename except IOError as ex: raise argparse.ArgumentTypeError(f'{filename}: {ex.args[1]}') + + + +def parse_format_options(s: str, defaults: Optional[dict]) -> dict: + """ + Parse `s` and update `defaults` with the parsed values. + + >>> parse_format_options( + ... defaults={'json': {'indent': 4, 'sort_keys': True}}, + ... s='json.indent=2,json.sort_keys=False', + ... ) + {'json': {'indent': 2, 'sort_keys': False}} + + """ + value_map = { + 'true': True, + 'false': False, + } + options = deepcopy(defaults or {}) + for option in s.split(','): + try: + path, value = option.lower().split('=') + section, key = path.split('.') + except ValueError: + raise argparse.ArgumentTypeError( + f'--format-options: invalid option: {option!r}') + + if value in value_map: + parsed_value = value_map[value] + else: + if value.isnumeric(): + parsed_value = int(value) + else: + parsed_value = value + + if defaults is None: + options.setdefault(section, {}) + else: + try: + default_value = defaults[section][key] + except KeyError: + raise argparse.ArgumentTypeError( + f'--format-options: invalid key: {path!r} in {option!r}') + + default_type, parsed_type = type(default_value), type(parsed_value) + if parsed_type is not default_type: + raise argparse.ArgumentTypeError( + '--format-options: invalid value type:' + f' {value!r} in {option!r}' + f' (expected {default_type.__name__}' + f' got {parsed_type.__name__})' + ) + + options[section][key] = parsed_value + + return options + + +PARSED_DEFAULT_FORMAT_OPTIONS = parse_format_options( + s=','.join(DEFAULT_FORMAT_OPTIONS), + defaults=None, +) diff --git a/httpie/cli/constants.py b/httpie/cli/constants.py index 5865d5c5..c8d7b7c8 100644 --- a/httpie/cli/constants.py +++ b/httpie/cli/constants.py @@ -83,6 +83,15 @@ PRETTY_MAP = { } PRETTY_STDOUT_TTY_ONLY = object() + +DEFAULT_FORMAT_OPTIONS = [ + 'headers.sort=true', + 'json.format=true', + 'json.indent=4', + 'json.sort_keys=true', +] + + # Defaults OUTPUT_OPTIONS_DEFAULT = OUT_RESP_HEAD + OUT_RESP_BODY OUTPUT_OPTIONS_DEFAULT_STDOUT_REDIRECTED = OUT_RESP_BODY diff --git a/httpie/cli/definition.py b/httpie/cli/definition.py index 23b59106..9adffc57 100644 --- a/httpie/cli/definition.py +++ b/httpie/cli/definition.py @@ -8,20 +8,23 @@ from textwrap import dedent, wrap from httpie import __doc__, __version__ from httpie.cli.argparser import HTTPieArgumentParser from httpie.cli.argtypes import ( - KeyValueArgType, SessionNameValidator, readable_file_arg, + KeyValueArgType, PARSED_DEFAULT_FORMAT_OPTIONS, SessionNameValidator, + parse_format_options, + readable_file_arg, ) from httpie.cli.constants import ( - OUTPUT_OPTIONS, OUTPUT_OPTIONS_DEFAULT, OUT_REQ_BODY, OUT_REQ_HEAD, + DEFAULT_FORMAT_OPTIONS, OUTPUT_OPTIONS, + OUTPUT_OPTIONS_DEFAULT, OUT_REQ_BODY, OUT_REQ_HEAD, OUT_RESP_BODY, OUT_RESP_HEAD, PRETTY_MAP, PRETTY_STDOUT_TTY_ONLY, SEPARATOR_GROUP_ALL_ITEMS, SEPARATOR_PROXY, ) from httpie.output.formatters.colors import ( AUTO_STYLE, AVAILABLE_STYLES, DEFAULT_STYLE, ) -from httpie.plugins.registry import plugin_manager from httpie.plugins.builtin import BuiltinAuthPlugin +from httpie.plugins.registry import plugin_manager from httpie.sessions import DEFAULT_SESSIONS_DIR -from httpie.ssl import DEFAULT_SSL_CIPHERS, AVAILABLE_SSL_VERSION_ARG_MAPPING +from httpie.ssl import AVAILABLE_SSL_VERSION_ARG_MAPPING, DEFAULT_SSL_CIPHERS parser = HTTPieArgumentParser( @@ -206,9 +209,9 @@ output_processing.add_argument( default=DEFAULT_STYLE, choices=AVAILABLE_STYLES, help=""" - Output coloring style (default is "{default}"). One of: + Output coloring style (default is "{default}"). It can be One of: -{available_styles} + {available_styles} The "{auto_style}" style follows your terminal's ANSI color styles. @@ -221,11 +224,38 @@ output_processing.add_argument( available_styles='\n'.join( '{0}{1}'.format(8 * ' ', line.strip()) for line in wrap(', '.join(sorted(AVAILABLE_STYLES)), 60) - ).rstrip(), + ).strip(), auto_style=AUTO_STYLE, ) ) +output_processing.add_argument( + '--format-options', + type=lambda s: parse_format_options( + s=s, + defaults=PARSED_DEFAULT_FORMAT_OPTIONS + ), + default=PARSED_DEFAULT_FORMAT_OPTIONS, + help=""" + Controls output formatting. Only relevant when formatting is enabled + through (explicit or implied) --pretty=all or --pretty=format. + The following are the default options: + + {option_list} + + You can specify multiple comma-separated options. For example, this modifies + the settings to disable the sorting of JSON keys and headers: + + --format-options json.sort_keys=false,headers.sort=false + + This is something you will typically put into your config file. + + """.format( + option_list='\n'.join( + (8 * ' ') + option for option in DEFAULT_FORMAT_OPTIONS).strip() + ) +) + ####################################################################### # Output options ####################################################################### diff --git a/httpie/output/formatters/headers.py b/httpie/output/formatters/headers.py index 486f09f6..76b82c45 100644 --- a/httpie/output/formatters/headers.py +++ b/httpie/output/formatters/headers.py @@ -3,6 +3,10 @@ from httpie.plugins import FormatterPlugin class HeadersFormatter(FormatterPlugin): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.enabled = self.format_options['headers']['sort'] + def format_headers(self, headers: str) -> str: """ Sorts headers by name while retaining relative diff --git a/httpie/output/formatters/json.py b/httpie/output/formatters/json.py index d2eb52d3..3cfb9334 100644 --- a/httpie/output/formatters/json.py +++ b/httpie/output/formatters/json.py @@ -4,11 +4,12 @@ import json from httpie.plugins import FormatterPlugin -DEFAULT_INDENT = 4 - - class JSONFormatter(FormatterPlugin): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.enabled = self.format_options['json']['format'] + def format_body(self, body: str, mime: str) -> str: maybe_json = [ 'json', @@ -26,8 +27,8 @@ class JSONFormatter(FormatterPlugin): # unicode escapes to improve readability. body = json.dumps( obj=obj, - sort_keys=True, + sort_keys=self.format_options['json']['sort_keys'], ensure_ascii=False, - indent=DEFAULT_INDENT + indent=self.format_options['json']['indent'] ) return body diff --git a/httpie/output/writer.py b/httpie/output/writer.py index b9e219f9..ee5acf48 100644 --- a/httpie/output/writer.py +++ b/httpie/output/writer.py @@ -152,6 +152,7 @@ def get_stream_type_and_kwargs( groups=args.prettify, color_scheme=args.style, explicit_json=args.json, + format_options=args.format_options, ) } else: diff --git a/httpie/plugins/base.py b/httpie/plugins/base.py index e1845f79..80835da0 100644 --- a/httpie/plugins/base.py +++ b/httpie/plugins/base.py @@ -119,6 +119,7 @@ class FormatterPlugin(BasePlugin): """ self.enabled = True self.kwargs = kwargs + self.format_options = kwargs['format_options'] def format_headers(self, headers: str) -> str: """Return processed `headers` diff --git a/setup.cfg b/setup.cfg index 2b203042..7ae33761 100644 --- a/setup.cfg +++ b/setup.cfg @@ -4,6 +4,7 @@ [tool:pytest] # norecursedirs = tests/fixtures +addopts = --tb=native [pycodestyle] diff --git a/tests/test_output.py b/tests/test_output.py index cbbf8b73..3dcbf161 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -1,12 +1,15 @@ +import argparse +import json import os from tempfile import gettempdir from urllib.request import urlopen import pytest -from utils import MockEnvironment, http, HTTP_OK, COLOR, CRLF -from httpie.status import ExitStatus +from httpie.cli.argtypes import parse_format_options from httpie.output.formatters.colors import get_lexer +from httpie.status import ExitStatus +from utils import COLOR, CRLF, HTTP_OK, MockEnvironment, http @pytest.mark.parametrize('stdout_isatty', [True, False]) @@ -58,19 +61,19 @@ class TestColors: @pytest.mark.parametrize( argnames=['mime', 'explicit_json', 'body', 'expected_lexer_name'], argvalues=[ - ('application/json', False, None, 'JSON'), + ('application/json', False, None, 'JSON'), ('application/json+foo', False, None, 'JSON'), ('application/foo+json', False, None, 'JSON'), ('application/json-foo', False, None, 'JSON'), - ('application/x-json', False, None, 'JSON'), - ('foo/json', False, None, 'JSON'), - ('foo/json+bar', False, None, 'JSON'), - ('foo/bar+json', False, None, 'JSON'), - ('foo/json-foo', False, None, 'JSON'), - ('foo/x-json', False, None, 'JSON'), + ('application/x-json', False, None, 'JSON'), + ('foo/json', False, None, 'JSON'), + ('foo/json+bar', False, None, 'JSON'), + ('foo/bar+json', False, None, 'JSON'), + ('foo/json-foo', False, None, 'JSON'), + ('foo/x-json', False, None, 'JSON'), ('application/vnd.comverge.grid+hal+json', False, None, 'JSON'), - ('text/plain', True, '{}', 'JSON'), - ('text/plain', True, 'foo', 'Text only'), + ('text/plain', True, '{}', 'JSON'), + ('text/plain', True, 'foo', 'Text only'), ] ) def test_get_lexer(self, mime, explicit_json, body, expected_lexer_name): @@ -83,7 +86,7 @@ class TestColors: class TestPrettyOptions: - """Test the --pretty flag handling.""" + """Test the --pretty handling.""" def test_pretty_enabled_by_default(self, httpbin): env = MockEnvironment(colors=256) @@ -138,6 +141,7 @@ class TestLineEndings: and as the headers/body separator. """ + def _validate_crlf(self, msg): lines = iter(msg.splitlines(True)) for header in lines: @@ -171,3 +175,77 @@ class TestLineEndings: def test_CRLF_formatted_request(self, httpbin): r = http('--pretty=format', '--print=HB', 'GET', httpbin.url + '/get') self._validate_crlf(r) + + +class TestFormatOptions: + def test_header_formatting_options(self): + def get_headers(sort): + return http( + '--offline', '--print=H', + '--format-options', 'headers.sort=' + sort, + 'example.org', 'ZZZ:foo', 'XXX:foo', + ) + + r_sorted = get_headers('true') + r_unsorted = get_headers('false') + assert r_sorted != r_unsorted + assert f'XXX: foo{CRLF}ZZZ: foo' in r_sorted + assert f'ZZZ: foo{CRLF}XXX: foo' in r_unsorted + + @pytest.mark.parametrize( + argnames=['options', 'expected_json'], + argvalues=[ + # @formatter:off + ( + 'json.sort_keys=true,json.indent=4', + json.dumps({'a': 0, 'b': 0}, indent=4), + ), + ( + 'json.sort_keys=false,json.indent=2', + json.dumps({'b': 0, 'a': 0}, indent=2), + ), + ( + 'json.format=false', + json.dumps({'b': 0, 'a': 0}), + ), + # @formatter:on + ] + ) + def test_json_formatting_options(self, options: str, expected_json: str): + r = http( + '--offline', '--print=B', + '--format-options', options, + 'example.org', 'b:=0', 'a:=0', + ) + assert expected_json in r + + @pytest.mark.parametrize( + argnames=['defaults', 'options_string', 'expected'], + argvalues=[ + # @formatter:off + ({'foo': {'bar': 1}}, 'foo.bar=2', {'foo': {'bar': 2}}), + ({'foo': {'bar': True}}, 'foo.bar=false', {'foo': {'bar': False}}), + ({'foo': {'bar': 'a'}}, 'foo.bar=b', {'foo': {'bar': 'b'}}), + # @formatter:on + ] + ) + def test_parse_format_options(self, defaults, options_string, expected): + actual = parse_format_options(s=options_string, defaults=defaults) + assert expected == actual + + @pytest.mark.parametrize( + argnames=['options_string', 'expected_error'], + argvalues=[ + ('foo=2', 'invalid option'), + ('foo.baz=2', 'invalid key'), + ('foo.bar=false', 'expected int got bool'), + ] + ) + def test_parse_format_options_errors(self, options_string, expected_error): + defaults = { + 'foo': { + 'bar': 1 + } + } + with pytest.raises(argparse.ArgumentTypeError, match=expected_error): + parse_format_options(s=options_string, defaults=defaults)