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)