Add --format-options to allow disabling sorting, etc.

#128
This commit is contained in:
Jakub Roztocil 2020-05-27 15:58:15 +02:00
parent 493e98c833
commit c2a0cef76e
11 changed files with 231 additions and 27 deletions

View File

@ -9,7 +9,8 @@ This project adheres to `Semantic Versioning <https://semver.org/>`_.
`2.2.0-dev`_ (unreleased) `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`_). * Added support for ``$XDG_CONFIG_HOME`` (`#920`_).
* Fixed built-in plugins-related circular imports (`#925`_). * Fixed built-in plugins-related circular imports (`#925`_).
@ -433,6 +434,7 @@ This project adheres to `Semantic Versioning <https://semver.org/>`_.
.. _2.2.0-dev: https://github.com/jakubroztocil/httpie/compare/2.1.0...master .. _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 .. _#488: https://github.com/jakubroztocil/httpie/issues/488
.. _#840: https://github.com/jakubroztocil/httpie/issues/840 .. _#840: https://github.com/jakubroztocil/httpie/issues/840
.. _#870: https://github.com/jakubroztocil/httpie/issues/870 .. _#870: https://github.com/jakubroztocil/httpie/issues/870

View File

@ -1395,6 +1395,20 @@ One of these options can be used to control output processing:
Default for redirected output. 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 Binary data
----------- -----------

View File

@ -2,9 +2,10 @@ import argparse
import getpass import getpass
import os import os
import sys 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 from httpie.sessions import VALID_SESSION_NAME_PATTERN
@ -181,3 +182,65 @@ def readable_file_arg(filename):
return filename return filename
except IOError as ex: except IOError as ex:
raise argparse.ArgumentTypeError(f'{filename}: {ex.args[1]}') 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,
)

View File

@ -83,6 +83,15 @@ PRETTY_MAP = {
} }
PRETTY_STDOUT_TTY_ONLY = object() PRETTY_STDOUT_TTY_ONLY = object()
DEFAULT_FORMAT_OPTIONS = [
'headers.sort=true',
'json.format=true',
'json.indent=4',
'json.sort_keys=true',
]
# Defaults # Defaults
OUTPUT_OPTIONS_DEFAULT = OUT_RESP_HEAD + OUT_RESP_BODY OUTPUT_OPTIONS_DEFAULT = OUT_RESP_HEAD + OUT_RESP_BODY
OUTPUT_OPTIONS_DEFAULT_STDOUT_REDIRECTED = OUT_RESP_BODY OUTPUT_OPTIONS_DEFAULT_STDOUT_REDIRECTED = OUT_RESP_BODY

View File

@ -8,20 +8,23 @@ from textwrap import dedent, wrap
from httpie import __doc__, __version__ from httpie import __doc__, __version__
from httpie.cli.argparser import HTTPieArgumentParser from httpie.cli.argparser import HTTPieArgumentParser
from httpie.cli.argtypes import ( 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 ( 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, OUT_RESP_BODY, OUT_RESP_HEAD, PRETTY_MAP, PRETTY_STDOUT_TTY_ONLY,
SEPARATOR_GROUP_ALL_ITEMS, SEPARATOR_PROXY, SEPARATOR_GROUP_ALL_ITEMS, SEPARATOR_PROXY,
) )
from httpie.output.formatters.colors import ( from httpie.output.formatters.colors import (
AUTO_STYLE, AVAILABLE_STYLES, DEFAULT_STYLE, AUTO_STYLE, AVAILABLE_STYLES, DEFAULT_STYLE,
) )
from httpie.plugins.registry import plugin_manager
from httpie.plugins.builtin import BuiltinAuthPlugin from httpie.plugins.builtin import BuiltinAuthPlugin
from httpie.plugins.registry import plugin_manager
from httpie.sessions import DEFAULT_SESSIONS_DIR 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( parser = HTTPieArgumentParser(
@ -206,7 +209,7 @@ output_processing.add_argument(
default=DEFAULT_STYLE, default=DEFAULT_STYLE,
choices=AVAILABLE_STYLES, choices=AVAILABLE_STYLES,
help=""" help="""
Output coloring style (default is "{default}"). One of: Output coloring style (default is "{default}"). It can be One of:
{available_styles} {available_styles}
@ -221,11 +224,38 @@ output_processing.add_argument(
available_styles='\n'.join( available_styles='\n'.join(
'{0}{1}'.format(8 * ' ', line.strip()) '{0}{1}'.format(8 * ' ', line.strip())
for line in wrap(', '.join(sorted(AVAILABLE_STYLES)), 60) for line in wrap(', '.join(sorted(AVAILABLE_STYLES)), 60)
).rstrip(), ).strip(),
auto_style=AUTO_STYLE, 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 # Output options
####################################################################### #######################################################################

View File

@ -3,6 +3,10 @@ from httpie.plugins import FormatterPlugin
class HeadersFormatter(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: def format_headers(self, headers: str) -> str:
""" """
Sorts headers by name while retaining relative Sorts headers by name while retaining relative

View File

@ -4,11 +4,12 @@ import json
from httpie.plugins import FormatterPlugin from httpie.plugins import FormatterPlugin
DEFAULT_INDENT = 4
class JSONFormatter(FormatterPlugin): 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: def format_body(self, body: str, mime: str) -> str:
maybe_json = [ maybe_json = [
'json', 'json',
@ -26,8 +27,8 @@ class JSONFormatter(FormatterPlugin):
# unicode escapes to improve readability. # unicode escapes to improve readability.
body = json.dumps( body = json.dumps(
obj=obj, obj=obj,
sort_keys=True, sort_keys=self.format_options['json']['sort_keys'],
ensure_ascii=False, ensure_ascii=False,
indent=DEFAULT_INDENT indent=self.format_options['json']['indent']
) )
return body return body

View File

@ -152,6 +152,7 @@ def get_stream_type_and_kwargs(
groups=args.prettify, groups=args.prettify,
color_scheme=args.style, color_scheme=args.style,
explicit_json=args.json, explicit_json=args.json,
format_options=args.format_options,
) )
} }
else: else:

View File

@ -119,6 +119,7 @@ class FormatterPlugin(BasePlugin):
""" """
self.enabled = True self.enabled = True
self.kwargs = kwargs self.kwargs = kwargs
self.format_options = kwargs['format_options']
def format_headers(self, headers: str) -> str: def format_headers(self, headers: str) -> str:
"""Return processed `headers` """Return processed `headers`

View File

@ -4,6 +4,7 @@
[tool:pytest] [tool:pytest]
# <https://docs.pytest.org/en/latest/customize.html> # <https://docs.pytest.org/en/latest/customize.html>
norecursedirs = tests/fixtures norecursedirs = tests/fixtures
addopts = --tb=native
[pycodestyle] [pycodestyle]

View File

@ -1,12 +1,15 @@
import argparse
import json
import os import os
from tempfile import gettempdir from tempfile import gettempdir
from urllib.request import urlopen from urllib.request import urlopen
import pytest import pytest
from utils import MockEnvironment, http, HTTP_OK, COLOR, CRLF from httpie.cli.argtypes import parse_format_options
from httpie.status import ExitStatus
from httpie.output.formatters.colors import get_lexer 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]) @pytest.mark.parametrize('stdout_isatty', [True, False])
@ -83,7 +86,7 @@ class TestColors:
class TestPrettyOptions: class TestPrettyOptions:
"""Test the --pretty flag handling.""" """Test the --pretty handling."""
def test_pretty_enabled_by_default(self, httpbin): def test_pretty_enabled_by_default(self, httpbin):
env = MockEnvironment(colors=256) env = MockEnvironment(colors=256)
@ -138,6 +141,7 @@ class TestLineEndings:
and as the headers/body separator. and as the headers/body separator.
""" """
def _validate_crlf(self, msg): def _validate_crlf(self, msg):
lines = iter(msg.splitlines(True)) lines = iter(msg.splitlines(True))
for header in lines: for header in lines:
@ -171,3 +175,77 @@ class TestLineEndings:
def test_CRLF_formatted_request(self, httpbin): def test_CRLF_formatted_request(self, httpbin):
r = http('--pretty=format', '--print=HB', 'GET', httpbin.url + '/get') r = http('--pretty=format', '--print=HB', 'GET', httpbin.url + '/get')
self._validate_crlf(r) 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)