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)
-------------------------
* 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 <https://semver.org/>`_.
.. _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

View File

@ -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
-----------

View File

@ -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,
)

View File

@ -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

View File

@ -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
#######################################################################

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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`

View File

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

View File

@ -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)