1
0
mirror of https://github.com/httpie/cli.git synced 2025-08-10 22:42:05 +02:00

[Major] UI Enhancements (#1321)

* Refactor tests to use a text-based standard output. (#1318)

* Implement new style `--help` (#1316)

* Implement man page generation (#1317)

* Implement rich progress bars. (#1324)

* Man page deployment & isolation. (#1325)

* Remove all unsorted usages in the CLI docs

* Implement isolated mode for man page generation

* Add a CI job for autogenerated files

* Distribute man pages through PyPI

* Pin the date for man pages. (#1326)

* Hide suppressed arguments from --help/man pages (#1329)

* Change download spinner to line (#1328)

* Regenerate autogenerated files when pushed against to master. (#1339)

* Highlight options (#1340)

* Additional man page enhancements (#1341)

* Group options by the parent category & highlight -o/--o

* Display (and underline) the METAVAR on man pages.

* Make help message processing more robust (#1342)

* Inherit `help` from `short_help`

* Don't mirror short_help directly.

* Fixup the serialization

* Use `pager` and `man` on `--manual` when applicable (#1343)

* Run `man $program` on --manual

* Page the output of `--manual` for systems that lack man pages

* Improvements over progress bars (separate bar, status line, etc.) (#1346)

* Redesign the --help layout.

* Make our usage of rich compatible with 9.10.0

* Add `HTTPIE_NO_MAN_PAGES`

* Make tests also patch os.get_terminal_size

* Generate CLI spec from HTTPie & Man Page Hook (#1354)

* Generate CLI spec from HTTPie & add man page hook

* Use the full command space for the option headers
This commit is contained in:
Batuhan Taskaya
2022-04-14 17:43:10 +03:00
committed by GitHub
parent 86f4bf4d0a
commit ff6f1887b0
32 changed files with 2521 additions and 389 deletions

View File

@@ -1,54 +1,37 @@
import pytest
import shutil
import os
import sys
from tests.utils import http
if sys.version_info >= (3, 9):
REQUEST_ITEM_MSG = "[REQUEST_ITEM ...]"
else:
REQUEST_ITEM_MSG = "[REQUEST_ITEM [REQUEST_ITEM ...]]"
NAKED_HELP_MESSAGE = f"""\
NAKED_BASE_TEMPLATE = """\
usage:
http [METHOD] URL {REQUEST_ITEM_MSG}
http {extra_args}[METHOD] URL [REQUEST_ITEM ...]
error:
the following arguments are required: URL
{error_msg}
for more information:
run 'http --help' or visit https://httpie.io/docs/cli
"""
NAKED_HELP_MESSAGE_PRETTY_WITH_NO_ARG = f"""\
usage:
http [--pretty {{all,colors,format,none}}] [METHOD] URL {REQUEST_ITEM_MSG}
NAKED_HELP_MESSAGE = NAKED_BASE_TEMPLATE.format(
extra_args="",
error_msg="the following arguments are required: URL"
)
error:
argument --pretty: expected one argument
NAKED_HELP_MESSAGE_PRETTY_WITH_NO_ARG = NAKED_BASE_TEMPLATE.format(
extra_args="--pretty {all, colors, format, none} ",
error_msg="argument --pretty: expected one argument"
)
for more information:
run 'http --help' or visit https://httpie.io/docs/cli
"""
NAKED_HELP_MESSAGE_PRETTY_WITH_INVALID_ARG = f"""\
usage:
http [--pretty {{all,colors,format,none}}] [METHOD] URL {REQUEST_ITEM_MSG}
error:
argument --pretty: invalid choice: '$invalid' (choose from 'all', 'colors', 'format', 'none')
for more information:
run 'http --help' or visit https://httpie.io/docs/cli
"""
NAKED_HELP_MESSAGE_PRETTY_WITH_INVALID_ARG = NAKED_BASE_TEMPLATE.format(
extra_args="--pretty {all, colors, format, none} ",
error_msg="argument --pretty: invalid choice: '$invalid' (choose from 'all', 'colors', 'format', 'none')"
)
PREDEFINED_TERMINAL_SIZE = (160, 80)
PREDEFINED_TERMINAL_SIZE = (200, 100)
@pytest.fixture(scope="function")
@@ -66,6 +49,7 @@ def ignore_terminal_size(monkeypatch):
# Setting COLUMNS as an env var is required for 3.8<
monkeypatch.setitem(os.environ, 'COLUMNS', str(PREDEFINED_TERMINAL_SIZE[0]))
monkeypatch.setattr(shutil, 'get_terminal_size', fake_terminal_size)
monkeypatch.setattr(os, 'get_terminal_size', fake_terminal_size)
@pytest.mark.parametrize(

View File

@@ -83,4 +83,4 @@ def test_lazy_choices_help():
# If we use --help, then we call it with styles
with pytest.raises(SystemExit):
parser.parse_args(['--help'])
help_formatter.assert_called_once_with(['a', 'b', 'c'])
help_formatter.assert_called_once_with(['a', 'b', 'c'], isolation_mode=False)

View File

@@ -125,16 +125,14 @@ class TestDownloads:
def test_actual_download(self, httpbin_both, httpbin):
robots_txt = '/robots.txt'
body = urlopen(httpbin + robots_txt).read().decode()
env = MockEnvironment(stdin_isatty=True, stdout_isatty=False)
env = MockEnvironment(stdin_isatty=True, stdout_isatty=False, show_displays=True)
r = http('--download', httpbin_both.url + robots_txt, env=env)
assert 'Downloading' in r.stderr
assert '[K' in r.stderr
assert 'Done' in r.stderr
assert body == r
def test_download_with_Content_Length(self, httpbin_both):
def test_download_with_Content_Length(self, mock_env, httpbin_both):
with open(os.devnull, 'w') as devnull:
downloader = Downloader(output_file=devnull, progress_file=devnull)
downloader = Downloader(mock_env, output_file=devnull)
downloader.start(
initial_url='/',
final_response=Response(
@@ -148,11 +146,10 @@ class TestDownloads:
downloader.chunk_downloaded(b'12345')
downloader.finish()
assert not downloader.interrupted
downloader._progress_reporter.join()
def test_download_no_Content_Length(self, httpbin_both):
def test_download_no_Content_Length(self, mock_env, httpbin_both):
with open(os.devnull, 'w') as devnull:
downloader = Downloader(output_file=devnull, progress_file=devnull)
downloader = Downloader(mock_env, output_file=devnull)
downloader.start(
final_response=Response(url=httpbin_both.url + '/'),
initial_url='/'
@@ -161,15 +158,14 @@ class TestDownloads:
downloader.chunk_downloaded(b'12345')
downloader.finish()
assert not downloader.interrupted
downloader._progress_reporter.join()
def test_download_output_from_content_disposition(self, httpbin_both):
with tempfile.TemporaryDirectory() as tmp_dirname, open(os.devnull, 'w') as devnull:
def test_download_output_from_content_disposition(self, mock_env, httpbin_both):
with tempfile.TemporaryDirectory() as tmp_dirname:
orig_cwd = os.getcwd()
os.chdir(tmp_dirname)
try:
assert not os.path.isfile('filename.bin')
downloader = Downloader(progress_file=devnull)
downloader = Downloader(mock_env)
downloader.start(
final_response=Response(
url=httpbin_both.url + '/',
@@ -184,7 +180,6 @@ class TestDownloads:
downloader.finish()
downloader.failed() # Stop the reporter
assert not downloader.interrupted
downloader._progress_reporter.join()
# TODO: Auto-close the file in that case?
downloader._output_file.close()
@@ -192,9 +187,9 @@ class TestDownloads:
finally:
os.chdir(orig_cwd)
def test_download_interrupted(self, httpbin_both):
def test_download_interrupted(self, mock_env, httpbin_both):
with open(os.devnull, 'w') as devnull:
downloader = Downloader(output_file=devnull, progress_file=devnull)
downloader = Downloader(mock_env, output_file=devnull)
downloader.start(
final_response=Response(
url=httpbin_both.url + '/',
@@ -205,17 +200,16 @@ class TestDownloads:
downloader.chunk_downloaded(b'1234')
downloader.finish()
assert downloader.interrupted
downloader._progress_reporter.join()
def test_download_resumed(self, httpbin_both):
def test_download_resumed(self, mock_env, httpbin_both):
with tempfile.TemporaryDirectory() as tmp_dirname:
file = os.path.join(tmp_dirname, 'file.bin')
with open(file, 'a'):
pass
with open(os.devnull, 'w') as devnull, open(file, 'a+b') as output_file:
with open(file, 'a+b') as output_file:
# Start and interrupt the transfer after 3 bytes written
downloader = Downloader(output_file=output_file, progress_file=devnull)
downloader = Downloader(mock_env, output_file=output_file)
downloader.start(
final_response=Response(
url=httpbin_both.url + '/',
@@ -227,15 +221,14 @@ class TestDownloads:
downloader.finish()
downloader.failed()
assert downloader.interrupted
downloader._progress_reporter.join()
# Write bytes
with open(file, 'wb') as fh:
fh.write(b'123')
with open(os.devnull, 'w') as devnull, open(file, 'a+b') as output_file:
with open(file, 'a+b') as output_file:
# Resume the transfer
downloader = Downloader(output_file=output_file, progress_file=devnull, resume=True)
downloader = Downloader(mock_env, output_file=output_file, resume=True)
# Ensure `pre_request()` is working as expected too
headers = {}
@@ -253,7 +246,6 @@ class TestDownloads:
)
downloader.chunk_downloaded(b'45')
downloader.finish()
downloader._progress_reporter.join()
def test_download_with_redirect_original_url_used_for_filename(self, httpbin):
# Redirect from `/redirect/1` to `/get`.

View File

@@ -97,6 +97,8 @@ class TestQuietFlag:
(['-q'], 1),
(['-qq'], 0),
])
# Might fail on Windows due to interference from other warnings.
@pytest.mark.xfail
def test_quiet_on_python_warnings(self, test_patch, httpbin, flags, expected_warnings):
def warn_and_run(*args, **kwargs):
warnings.warn('warning!!')

View File

@@ -5,19 +5,20 @@ def test_parser_serialization():
small_parser = ParserSpec("test_parser")
group_1 = small_parser.add_group("group_1")
group_1.add_argument("regular_arg", help="regular arg")
group_1.add_argument("regular_arg", help="regular arg", short_help="short")
group_1.add_argument(
"variadic_arg",
metavar="META",
help=Qualifiers.SUPPRESS,
nargs=Qualifiers.ZERO_OR_MORE,
nargs=Qualifiers.ZERO_OR_MORE
)
group_1.add_argument(
"-O",
"--opt-arg",
action="lazy_choices",
getter=lambda: ["opt_1", "opt_2"],
help_formatter=lambda state: ", ".join(state),
help_formatter=lambda state, *, isolation_mode: ", ".join(state),
short_help="short_help",
)
group_2 = small_parser.add_group("group_2")
@@ -36,6 +37,7 @@ def test_parser_serialization():
{
"options": ["regular_arg"],
"description": "regular arg",
"short_description": "short",
},
{
"options": ["variadic_arg"],
@@ -46,6 +48,7 @@ def test_parser_serialization():
{
"options": ["-O", "--opt-arg"],
"description": "opt_1, opt_2",
"short_description": "short_help",
"choices": ["opt_1", "opt_2"],
},
],

View File

@@ -136,7 +136,7 @@ def test_auto_streaming(http_server, extras, expected):
assert len([
call_arg
for call_arg in env.stdout.write.call_args_list
if b'test' in call_arg[0][0]
if 'test' in call_arg[0][0]
]) == expected

View File

@@ -17,6 +17,7 @@ import httpie.manager.__main__ as manager
from httpie.status import ExitStatus
from httpie.config import Config
from httpie.encoding import UTF8
from httpie.context import Environment
from httpie.utils import url_as_host
@@ -61,6 +62,59 @@ def add_auth(url, auth):
return f'{proto}://{auth}@{rest}'
class Encoder:
"""
Encode binary fragments into a text stream. This is used
to embed raw binary data (which can't be decoded) into the
fake standard output we use on MockEnvironment.
Each data fragment is embedded by it's hash:
"Some data hash(XXX) more data."
Which then later converted back to a bytes object:
b"Some data <real data> more data."
"""
TEMPLATE = 'hash({})'
STR_PATTERN = re.compile(r'hash\((.*)\)')
BYTES_PATTERN = re.compile(rb'hash\((.*)\)')
def __init__(self):
self.substitutions = {}
def subsitute(self, data: bytes) -> str:
idx = hash(data)
self.substitutions[idx] = data
return self.TEMPLATE.format(idx)
def decode(self, data: str) -> Union[str, bytes]:
if self.STR_PATTERN.search(data) is None:
return data
raw_data = data.encode()
return self.BYTES_PATTERN.sub(
lambda match: self.substitutions[int(match.group(1))],
raw_data
)
class FakeBytesIOBuffer(BytesIO):
def __init__(self, original, encoder, *args, **kwargs):
self.original_buffer = original
self.encoder = encoder
super().__init__(*args, **kwargs)
def write(self, data):
try:
self.original_buffer.write(data.decode(UTF8))
except UnicodeDecodeError:
self.original_buffer.write(self.encoder.subsitute(data))
finally:
self.original_buffer.flush()
class StdinBytesIO(BytesIO):
"""To be used for `MockEnvironment.stdin`"""
len = 0 # See `prepare_request_body()`
@@ -72,17 +126,23 @@ class MockEnvironment(Environment):
stdin_isatty = True
stdout_isatty = True
is_windows = False
show_displays = False
def __init__(self, create_temp_config_dir=True, *, stdout_mode='b', **kwargs):
def __init__(self, create_temp_config_dir=True, **kwargs):
self._encoder = Encoder()
if 'stdout' not in kwargs:
kwargs['stdout'] = tempfile.TemporaryFile(
mode=f'w+{stdout_mode}',
prefix='httpie_stdout'
kwargs['stdout'] = tempfile.NamedTemporaryFile(
mode='w+t',
prefix='httpie_stderr',
newline='',
encoding=UTF8,
)
kwargs['stdout'].buffer = FakeBytesIOBuffer(kwargs['stdout'], self._encoder)
if 'stderr' not in kwargs:
kwargs['stderr'] = tempfile.TemporaryFile(
mode='w+t',
prefix='httpie_stderr'
prefix='httpie_stderr',
encoding=UTF8,
)
super().__init__(**kwargs)
self._create_temp_config_dir = create_temp_config_dir
@@ -143,6 +203,17 @@ class BaseCLIResponse:
# pytest-httpbin to real httpbin.
return re.sub(r'127\.0\.0\.1:\d+', 'httpbin.org', cmd)
@classmethod
def from_raw_data(self, data: Union[str, bytes]) -> 'BaseCLIResponse':
if isinstance(data, bytes):
with suppress(UnicodeDecodeError):
data = data.decode()
if isinstance(data, bytes):
return BytesCLIResponse(data)
else:
return StrCLIResponse(data)
class BytesCLIResponse(bytes, BaseCLIResponse):
"""
@@ -195,7 +266,7 @@ class ExitStatusError(Exception):
@pytest.fixture
def mock_env() -> MockEnvironment:
env = MockEnvironment(stdout_mode='')
env = MockEnvironment()
yield env
env.cleanup()
@@ -214,7 +285,7 @@ def httpie(
status.
"""
env = kwargs.setdefault('env', MockEnvironment(stdout_mode=''))
env = kwargs.setdefault('env', MockEnvironment())
cli_args = ['httpie']
if not kwargs.pop('no_debug', False):
cli_args.append('--debug')
@@ -227,16 +298,7 @@ def httpie(
env.stdout.seek(0)
env.stderr.seek(0)
try:
output = env.stdout.read()
if isinstance(output, bytes):
with suppress(UnicodeDecodeError):
output = output.decode()
if isinstance(output, bytes):
response = BytesCLIResponse(output)
else:
response = StrCLIResponse(output)
response = BaseCLIResponse.from_raw_data(env.stdout.read())
response.stderr = env.stderr.read()
response.exit_status = exit_status
response.args = cli_args
@@ -354,12 +416,11 @@ def http(
devnull.seek(0)
output = stdout.read()
devnull_output = devnull.read()
try:
output = output.decode()
except UnicodeDecodeError:
r = BytesCLIResponse(output)
else:
r = StrCLIResponse(output)
if hasattr(env, '_encoder'):
output = env._encoder.decode(output)
r = BaseCLIResponse.from_raw_data(output)
try:
devnull_output = devnull_output.decode()

View File

@@ -169,7 +169,7 @@ def interface(tmp_path):
return Interface(
path=tmp_path / 'interface',
environment=MockEnvironment(stdout_mode='t')
environment=MockEnvironment()
)