mirror of
https://github.com/httpie/cli.git
synced 2025-02-16 17:40:51 +01:00
Fix incorrect separators and introduce assert_output_matches()
(close #1027)
This commit is contained in:
parent
0401d7b31c
commit
0f1e098cc4
@ -11,6 +11,7 @@ This project adheres to `Semantic Versioning <https://semver.org/>`_.
|
|||||||
-------------------------
|
-------------------------
|
||||||
|
|
||||||
* Fixed upload with ``--session`` (`#1020`_).
|
* Fixed upload with ``--session`` (`#1020`_).
|
||||||
|
* Fixed a missing blank line between request and response (`#1006`_).
|
||||||
|
|
||||||
|
|
||||||
`2.3.0`_ (2020-10-25)
|
`2.3.0`_ (2020-10-25)
|
||||||
@ -485,4 +486,5 @@ This project adheres to `Semantic Versioning <https://semver.org/>`_.
|
|||||||
.. _#934: https://github.com/httpie/httpie/issues/934
|
.. _#934: https://github.com/httpie/httpie/issues/934
|
||||||
.. _#943: https://github.com/httpie/httpie/issues/943
|
.. _#943: https://github.com/httpie/httpie/issues/943
|
||||||
.. _#963: https://github.com/httpie/httpie/issues/963
|
.. _#963: https://github.com/httpie/httpie/issues/963
|
||||||
|
.. _#1006: https://github.com/httpie/httpie/issues/1006
|
||||||
.. _#1020: https://github.com/httpie/httpie/issues/1020
|
.. _#1020: https://github.com/httpie/httpie/issues/1020
|
||||||
|
145
httpie/core.py
145
httpie/core.py
@ -9,26 +9,17 @@ from pygments import __version__ as pygments_version
|
|||||||
from requests import __version__ as requests_version
|
from requests import __version__ as requests_version
|
||||||
|
|
||||||
from httpie import __version__ as httpie_version
|
from httpie import __version__ as httpie_version
|
||||||
from httpie.cli.constants import (
|
from httpie.cli.constants import OUT_REQ_BODY, OUT_REQ_HEAD, OUT_RESP_BODY, OUT_RESP_HEAD
|
||||||
OUT_REQ_BODY, OUT_REQ_HEAD, OUT_RESP_BODY,
|
|
||||||
OUT_RESP_HEAD,
|
|
||||||
)
|
|
||||||
from httpie.client import collect_messages
|
from httpie.client import collect_messages
|
||||||
from httpie.context import Environment
|
from httpie.context import Environment
|
||||||
from httpie.downloads import Downloader
|
from httpie.downloads import Downloader
|
||||||
from httpie.output.writer import (
|
from httpie.output.writer import write_message, write_stream, MESSAGE_SEPARATOR_BYTES
|
||||||
write_message,
|
|
||||||
write_stream,
|
|
||||||
)
|
|
||||||
from httpie.plugins.registry import plugin_manager
|
from httpie.plugins.registry import plugin_manager
|
||||||
from httpie.status import ExitStatus, http_status_to_exit_status
|
from httpie.status import ExitStatus, http_status_to_exit_status
|
||||||
|
|
||||||
|
|
||||||
# noinspection PyDefaultArgument
|
# noinspection PyDefaultArgument
|
||||||
def main(
|
def main(args: List[Union[str, bytes]] = sys.argv, env=Environment()) -> ExitStatus:
|
||||||
args: List[Union[str, bytes]] = sys.argv,
|
|
||||||
env=Environment(),
|
|
||||||
) -> ExitStatus:
|
|
||||||
"""
|
"""
|
||||||
The main function.
|
The main function.
|
||||||
|
|
||||||
@ -134,112 +125,82 @@ def get_output_options(
|
|||||||
}[type(message)]
|
}[type(message)]
|
||||||
|
|
||||||
|
|
||||||
def program(
|
def program(args: argparse.Namespace, env: Environment) -> ExitStatus:
|
||||||
args: argparse.Namespace,
|
|
||||||
env: Environment,
|
|
||||||
) -> ExitStatus:
|
|
||||||
"""
|
"""
|
||||||
The main program without error handling.
|
The main program without error handling.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
# TODO: Refactor and drastically simplify, especially so that the separator logic is elsewhere.
|
||||||
exit_status = ExitStatus.SUCCESS
|
exit_status = ExitStatus.SUCCESS
|
||||||
downloader = None
|
downloader = None
|
||||||
|
initial_request: Optional[requests.PreparedRequest] = None
|
||||||
|
final_response: Optional[requests.Response] = None
|
||||||
|
|
||||||
|
def separate():
|
||||||
|
getattr(env.stdout, 'buffer', env.stdout).write(MESSAGE_SEPARATOR_BYTES)
|
||||||
|
|
||||||
|
def request_body_read_callback(chunk: bytes):
|
||||||
|
should_pipe_to_stdout = bool(
|
||||||
|
# Request body output desired
|
||||||
|
OUT_REQ_BODY in args.output_options
|
||||||
|
# & not `.read()` already pre-request (e.g., for compression)
|
||||||
|
and initial_request
|
||||||
|
# & non-EOF chunk
|
||||||
|
and chunk
|
||||||
|
)
|
||||||
|
if should_pipe_to_stdout:
|
||||||
|
msg = requests.PreparedRequest()
|
||||||
|
msg.is_body_upload_chunk = True
|
||||||
|
msg.body = chunk
|
||||||
|
msg.headers = initial_request.headers
|
||||||
|
write_message(requests_message=msg, env=env, args=args, with_body=True, with_headers=False)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if args.download:
|
if args.download:
|
||||||
args.follow = True # --download implies --follow.
|
args.follow = True # --download implies --follow.
|
||||||
downloader = Downloader(
|
downloader = Downloader(output_file=args.output_file, progress_file=env.stderr, resume=args.download_resume)
|
||||||
output_file=args.output_file,
|
|
||||||
progress_file=env.stderr,
|
|
||||||
resume=args.download_resume
|
|
||||||
)
|
|
||||||
downloader.pre_request(args.headers)
|
downloader.pre_request(args.headers)
|
||||||
|
messages = collect_messages(args=args, config_dir=env.config.directory,
|
||||||
|
request_body_read_callback=request_body_read_callback)
|
||||||
|
force_separator = False
|
||||||
|
prev_with_body = False
|
||||||
|
|
||||||
needs_separator = False
|
# Process messages as they’re generated
|
||||||
|
|
||||||
def maybe_separate():
|
|
||||||
nonlocal needs_separator
|
|
||||||
if env.stdout_isatty and needs_separator:
|
|
||||||
needs_separator = False
|
|
||||||
getattr(env.stdout, 'buffer', env.stdout).write(b'\n\n')
|
|
||||||
|
|
||||||
initial_request: Optional[requests.PreparedRequest] = None
|
|
||||||
final_response: Optional[requests.Response] = None
|
|
||||||
|
|
||||||
def request_body_read_callback(chunk: bytes):
|
|
||||||
should_pipe_to_stdout = (
|
|
||||||
# Request body output desired
|
|
||||||
OUT_REQ_BODY in args.output_options
|
|
||||||
# & not `.read()` already pre-request (e.g., for compression)
|
|
||||||
and initial_request
|
|
||||||
# & non-EOF chunk
|
|
||||||
and chunk
|
|
||||||
)
|
|
||||||
if should_pipe_to_stdout:
|
|
||||||
msg = requests.PreparedRequest()
|
|
||||||
msg.is_body_upload_chunk = True
|
|
||||||
msg.body = chunk
|
|
||||||
msg.headers = initial_request.headers
|
|
||||||
write_message(
|
|
||||||
requests_message=msg,
|
|
||||||
env=env,
|
|
||||||
args=args,
|
|
||||||
with_body=True,
|
|
||||||
with_headers=False
|
|
||||||
)
|
|
||||||
|
|
||||||
messages = collect_messages(
|
|
||||||
args=args,
|
|
||||||
config_dir=env.config.directory,
|
|
||||||
request_body_read_callback=request_body_read_callback
|
|
||||||
)
|
|
||||||
for message in messages:
|
for message in messages:
|
||||||
maybe_separate()
|
|
||||||
is_request = isinstance(message, requests.PreparedRequest)
|
is_request = isinstance(message, requests.PreparedRequest)
|
||||||
with_headers, with_body = get_output_options(
|
with_headers, with_body = get_output_options(args=args, message=message)
|
||||||
args=args, message=message)
|
do_write_body = with_body
|
||||||
|
if prev_with_body and (with_headers or with_body) and (force_separator or not env.stdout_isatty):
|
||||||
|
# Separate after a previous message with body, if needed. See test_tokens.py.
|
||||||
|
separate()
|
||||||
|
force_separator = False
|
||||||
if is_request:
|
if is_request:
|
||||||
if not initial_request:
|
if not initial_request:
|
||||||
initial_request = message
|
initial_request = message
|
||||||
is_streamed_upload = not isinstance(
|
is_streamed_upload = not isinstance(message.body, (str, bytes))
|
||||||
message.body, (str, bytes))
|
|
||||||
if with_body:
|
if with_body:
|
||||||
with_body = not is_streamed_upload
|
do_write_body = not is_streamed_upload
|
||||||
needs_separator = is_streamed_upload
|
force_separator = is_streamed_upload and env.stdout_isatty
|
||||||
else:
|
else:
|
||||||
final_response = message
|
final_response = message
|
||||||
if args.check_status or downloader:
|
if args.check_status or downloader:
|
||||||
exit_status = http_status_to_exit_status(
|
exit_status = http_status_to_exit_status(http_status=message.status_code, follow=args.follow)
|
||||||
http_status=message.status_code,
|
if not env.stdout_isatty and exit_status != ExitStatus.SUCCESS:
|
||||||
follow=args.follow
|
env.log_error(f'HTTP {message.raw.status} {message.raw.reason}', level='warning')
|
||||||
)
|
write_message(requests_message=message, env=env, args=args, with_headers=with_headers,
|
||||||
if (not env.stdout_isatty
|
with_body=do_write_body)
|
||||||
and exit_status != ExitStatus.SUCCESS):
|
prev_with_body = with_body
|
||||||
env.log_error(
|
|
||||||
f'HTTP {message.raw.status} {message.raw.reason}',
|
|
||||||
level='warning'
|
|
||||||
)
|
|
||||||
write_message(
|
|
||||||
requests_message=message,
|
|
||||||
env=env,
|
|
||||||
args=args,
|
|
||||||
with_headers=with_headers,
|
|
||||||
with_body=with_body,
|
|
||||||
)
|
|
||||||
|
|
||||||
maybe_separate()
|
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
if force_separator:
|
||||||
|
separate()
|
||||||
if downloader and exit_status == ExitStatus.SUCCESS:
|
if downloader and exit_status == ExitStatus.SUCCESS:
|
||||||
# Last response body download.
|
# Last response body download.
|
||||||
download_stream, download_to = downloader.start(
|
download_stream, download_to = downloader.start(
|
||||||
initial_url=initial_request.url,
|
initial_url=initial_request.url,
|
||||||
final_response=final_response,
|
final_response=final_response,
|
||||||
)
|
)
|
||||||
write_stream(
|
write_stream(stream=download_stream, outfile=download_to, flush=False)
|
||||||
stream=download_stream,
|
|
||||||
outfile=download_to,
|
|
||||||
flush=False,
|
|
||||||
)
|
|
||||||
downloader.finish()
|
downloader.finish()
|
||||||
if downloader.interrupted:
|
if downloader.interrupted:
|
||||||
exit_status = ExitStatus.ERROR
|
exit_status = ExitStatus.ERROR
|
||||||
@ -253,9 +214,7 @@ def program(
|
|||||||
finally:
|
finally:
|
||||||
if downloader and not downloader.finished:
|
if downloader and not downloader.finished:
|
||||||
downloader.failed()
|
downloader.failed()
|
||||||
|
if not isinstance(args, list) and args.output_file and args.output_file_specified:
|
||||||
if (not isinstance(args, list) and args.output_file
|
|
||||||
and args.output_file_specified):
|
|
||||||
args.output_file.close()
|
args.output_file.close()
|
||||||
|
|
||||||
|
|
||||||
|
@ -12,6 +12,10 @@ from httpie.output.streams import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
MESSAGE_SEPARATOR = '\n\n'
|
||||||
|
MESSAGE_SEPARATOR_BYTES = MESSAGE_SEPARATOR.encode()
|
||||||
|
|
||||||
|
|
||||||
def write_message(
|
def write_message(
|
||||||
requests_message: Union[requests.PreparedRequest, requests.Response],
|
requests_message: Union[requests.PreparedRequest, requests.Response],
|
||||||
env: Environment,
|
env: Environment,
|
||||||
@ -111,7 +115,7 @@ def build_output_stream_for_message(
|
|||||||
and not getattr(requests_message, 'is_body_upload_chunk', False)):
|
and not getattr(requests_message, 'is_body_upload_chunk', False)):
|
||||||
# Ensure a blank line after the response body.
|
# Ensure a blank line after the response body.
|
||||||
# For terminal output only.
|
# For terminal output only.
|
||||||
yield b'\n\n'
|
yield MESSAGE_SEPARATOR_BYTES
|
||||||
|
|
||||||
|
|
||||||
def get_stream_type_and_kwargs(
|
def get_stream_type_and_kwargs(
|
||||||
|
@ -4,7 +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
|
addopts = --tb=native --doctest-modules
|
||||||
|
|
||||||
|
|
||||||
[pycodestyle]
|
[pycodestyle]
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from httpie.compat import is_windows
|
from httpie.compat import is_windows
|
||||||
|
from tests.utils.matching import assert_output_matches, Expect
|
||||||
from utils import HTTP_OK, MockEnvironment, http
|
from utils import HTTP_OK, MockEnvironment, http
|
||||||
|
|
||||||
|
|
||||||
@ -27,7 +28,6 @@ def test_output_devnull(httpbin):
|
|||||||
http('--output=/dev/null', httpbin + '/get')
|
http('--output=/dev/null', httpbin + '/get')
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skip(reason='TODO: fix #1006')
|
|
||||||
def test_verbose_redirected_stdout_separator(httpbin):
|
def test_verbose_redirected_stdout_separator(httpbin):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -40,3 +40,10 @@ def test_verbose_redirected_stdout_separator(httpbin):
|
|||||||
env=MockEnvironment(stdout_isatty=False),
|
env=MockEnvironment(stdout_isatty=False),
|
||||||
)
|
)
|
||||||
assert '}HTTP/' not in r
|
assert '}HTTP/' not in r
|
||||||
|
assert_output_matches(r, [
|
||||||
|
Expect.REQUEST_HEADERS,
|
||||||
|
Expect.BODY,
|
||||||
|
Expect.SEPARATOR,
|
||||||
|
Expect.RESPONSE_HEADERS,
|
||||||
|
Expect.BODY,
|
||||||
|
])
|
||||||
|
111
tests/test_tokens.py
Normal file
111
tests/test_tokens.py
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
"""
|
||||||
|
The ideas behind these test and the named templates is to ensure consistent output
|
||||||
|
across all supported different scenarios:
|
||||||
|
|
||||||
|
TODO: cover more scenarios
|
||||||
|
* terminal vs. redirect stdout
|
||||||
|
* different combinations of `--print=HBhb` (request/response headers/body)
|
||||||
|
* multipart requests
|
||||||
|
* streamed uploads
|
||||||
|
|
||||||
|
"""
|
||||||
|
from tests.utils.matching import assert_output_matches, Expect
|
||||||
|
from utils import http, HTTP_OK, MockEnvironment, HTTPBIN_WITH_CHUNKED_SUPPORT
|
||||||
|
|
||||||
|
|
||||||
|
RAW_REQUEST = [
|
||||||
|
Expect.REQUEST_HEADERS,
|
||||||
|
Expect.BODY,
|
||||||
|
]
|
||||||
|
RAW_RESPONSE = [
|
||||||
|
Expect.RESPONSE_HEADERS,
|
||||||
|
Expect.BODY,
|
||||||
|
]
|
||||||
|
RAW_EXCHANGE = [
|
||||||
|
*RAW_REQUEST,
|
||||||
|
Expect.SEPARATOR, # Good choice?
|
||||||
|
*RAW_RESPONSE,
|
||||||
|
]
|
||||||
|
RAW_BODY = [
|
||||||
|
Expect.BODY,
|
||||||
|
]
|
||||||
|
|
||||||
|
TERMINAL_REQUEST = [
|
||||||
|
*RAW_REQUEST,
|
||||||
|
Expect.SEPARATOR,
|
||||||
|
]
|
||||||
|
TERMINAL_RESPONSE = [
|
||||||
|
*RAW_RESPONSE,
|
||||||
|
Expect.SEPARATOR,
|
||||||
|
]
|
||||||
|
TERMINAL_EXCHANGE = [
|
||||||
|
*TERMINAL_REQUEST,
|
||||||
|
*TERMINAL_RESPONSE,
|
||||||
|
]
|
||||||
|
TERMINAL_BODY = [
|
||||||
|
RAW_BODY,
|
||||||
|
Expect.SEPARATOR
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_headers():
|
||||||
|
r = http('--print=H', '--offline', 'pie.dev')
|
||||||
|
assert_output_matches(r, [Expect.REQUEST_HEADERS])
|
||||||
|
|
||||||
|
|
||||||
|
def test_redirected_headers():
|
||||||
|
r = http('--print=H', '--offline', 'pie.dev', env=MockEnvironment(stdout_isatty=False))
|
||||||
|
assert_output_matches(r, [Expect.REQUEST_HEADERS])
|
||||||
|
|
||||||
|
|
||||||
|
def test_terminal_headers_and_body():
|
||||||
|
r = http('--print=HB', '--offline', 'pie.dev', 'AAA=BBB')
|
||||||
|
assert_output_matches(r, TERMINAL_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
|
def test_raw_headers_and_body():
|
||||||
|
r = http(
|
||||||
|
'--print=HB', '--offline', 'pie.dev', 'AAA=BBB',
|
||||||
|
env=MockEnvironment(stdout_isatty=False),
|
||||||
|
)
|
||||||
|
assert_output_matches(r, RAW_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
|
def test_raw_body():
|
||||||
|
r = http(
|
||||||
|
'--print=B', '--offline', 'pie.dev', 'AAA=BBB',
|
||||||
|
env=MockEnvironment(stdout_isatty=False),
|
||||||
|
)
|
||||||
|
assert_output_matches(r, RAW_BODY)
|
||||||
|
|
||||||
|
|
||||||
|
def test_raw_exchange(httpbin):
|
||||||
|
r = http('--verbose', httpbin + '/post', 'a=b', env=MockEnvironment(stdout_isatty=False))
|
||||||
|
assert HTTP_OK in r
|
||||||
|
assert_output_matches(r, RAW_EXCHANGE)
|
||||||
|
|
||||||
|
|
||||||
|
def test_terminal_exchange(httpbin):
|
||||||
|
r = http('--verbose', httpbin + '/post', 'a=b')
|
||||||
|
assert HTTP_OK in r
|
||||||
|
assert_output_matches(r, TERMINAL_EXCHANGE)
|
||||||
|
|
||||||
|
|
||||||
|
def test_headers_multipart_body_separator():
|
||||||
|
r = http('--print=HB', '--multipart', '--offline', 'pie.dev', 'AAA=BBB')
|
||||||
|
assert_output_matches(r, TERMINAL_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
|
def test_redirected_headers_multipart_no_separator():
|
||||||
|
r = http(
|
||||||
|
'--print=HB', '--multipart', '--offline', 'pie.dev', 'AAA=BBB',
|
||||||
|
env=MockEnvironment(stdout_isatty=False),
|
||||||
|
)
|
||||||
|
assert_output_matches(r, RAW_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
|
def test_verbose_chunked():
|
||||||
|
r = http('--verbose', '--chunked', HTTPBIN_WITH_CHUNKED_SUPPORT + '/post', 'hello=world')
|
||||||
|
assert HTTP_OK in r
|
||||||
|
assert 'Transfer-Encoding: chunked' in r
|
||||||
|
assert_output_matches(r, TERMINAL_EXCHANGE)
|
@ -1,12 +1,14 @@
|
|||||||
# coding=utf-8
|
# coding=utf-8
|
||||||
"""Utilities for HTTPie test suite."""
|
"""Utilities for HTTPie test suite."""
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import json
|
import json
|
||||||
import tempfile
|
import tempfile
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Union
|
from typing import Optional, Union, List
|
||||||
|
|
||||||
from httpie.status import ExitStatus
|
from httpie.status import ExitStatus
|
||||||
from httpie.config import Config
|
from httpie.config import Config
|
||||||
@ -20,7 +22,7 @@ from httpie.core import main
|
|||||||
HTTPBIN_WITH_CHUNKED_SUPPORT = 'http://pie.dev'
|
HTTPBIN_WITH_CHUNKED_SUPPORT = 'http://pie.dev'
|
||||||
|
|
||||||
|
|
||||||
TESTS_ROOT = Path(__file__).parent
|
TESTS_ROOT = Path(__file__).parent.parent
|
||||||
CRLF = '\r\n'
|
CRLF = '\r\n'
|
||||||
COLOR = '\x1b['
|
COLOR = '\x1b['
|
||||||
HTTP_OK = '200 OK'
|
HTTP_OK = '200 OK'
|
||||||
@ -49,7 +51,7 @@ class StdinBytesIO(BytesIO):
|
|||||||
|
|
||||||
class MockEnvironment(Environment):
|
class MockEnvironment(Environment):
|
||||||
"""Environment subclass with reasonable defaults for testing."""
|
"""Environment subclass with reasonable defaults for testing."""
|
||||||
colors = 0
|
colors = 0 # For easier debugging
|
||||||
stdin_isatty = True,
|
stdin_isatty = True,
|
||||||
stdout_isatty = True
|
stdout_isatty = True
|
||||||
is_windows = False
|
is_windows = False
|
||||||
@ -113,6 +115,15 @@ class BaseCLIResponse:
|
|||||||
devnull: str = None
|
devnull: str = None
|
||||||
json: dict = None
|
json: dict = None
|
||||||
exit_status: ExitStatus = None
|
exit_status: ExitStatus = None
|
||||||
|
command: str = None
|
||||||
|
args: List[str] = []
|
||||||
|
complete_args: List[str] = []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def command(self):
|
||||||
|
cmd = ' '.join(shlex.quote(arg) for arg in ['http', *self.args])
|
||||||
|
# pytest-httpbin to real httpbin.
|
||||||
|
return re.sub(r'127\.0\.0\.1:\d+', 'httpbin.org', cmd)
|
||||||
|
|
||||||
|
|
||||||
class BytesCLIResponse(bytes, BaseCLIResponse):
|
class BytesCLIResponse(bytes, BaseCLIResponse):
|
||||||
@ -284,10 +295,13 @@ def http(
|
|||||||
r.devnull = devnull_output
|
r.devnull = devnull_output
|
||||||
r.stderr = stderr.read()
|
r.stderr = stderr.read()
|
||||||
r.exit_status = exit_status
|
r.exit_status = exit_status
|
||||||
|
r.args = args
|
||||||
|
r.complete_args = ' '.join(complete_args)
|
||||||
|
|
||||||
if r.exit_status != ExitStatus.SUCCESS:
|
if r.exit_status != ExitStatus.SUCCESS:
|
||||||
sys.stderr.write(r.stderr)
|
sys.stderr.write(r.stderr)
|
||||||
|
|
||||||
|
# print(f'\n\n$ {r.command}\n')
|
||||||
return r
|
return r
|
||||||
|
|
||||||
finally:
|
finally:
|
32
tests/utils/matching/__init__.py
Normal file
32
tests/utils/matching/__init__.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.utils.matching.parsing import OutputMatchingError, expect_tokens, Expect
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'assert_output_matches',
|
||||||
|
'assert_output_does_not_match',
|
||||||
|
'Expect',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def assert_output_matches(output: str, tokens: Iterable[Expect]):
|
||||||
|
r"""
|
||||||
|
Check the command `output` for an exact full sequence of `tokens`.
|
||||||
|
|
||||||
|
>>> out = 'GET / HTTP/1.1\r\nAAA:BBB\r\n\r\nCCC\n\n'
|
||||||
|
>>> assert_output_matches(out, [Expect.REQUEST_HEADERS, Expect.BODY, Expect.SEPARATOR])
|
||||||
|
|
||||||
|
"""
|
||||||
|
# TODO: auto-remove ansi colors to allow for testing of colorized output as well.
|
||||||
|
expect_tokens(tokens=tokens, s=output)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_output_does_not_match(output: str, tokens: Iterable[Expect]):
|
||||||
|
r"""
|
||||||
|
>>> assert_output_does_not_match('\r\n', [Expect.BODY])
|
||||||
|
"""
|
||||||
|
with pytest.raises(OutputMatchingError):
|
||||||
|
assert_output_matches(output=output, tokens=tokens)
|
107
tests/utils/matching/parsing.py
Normal file
107
tests/utils/matching/parsing.py
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import re
|
||||||
|
from typing import Iterable
|
||||||
|
from enum import Enum, auto
|
||||||
|
|
||||||
|
from httpie.output.writer import MESSAGE_SEPARATOR
|
||||||
|
from tests.utils import CRLF
|
||||||
|
|
||||||
|
|
||||||
|
class Expect(Enum):
|
||||||
|
"""
|
||||||
|
Predefined token types we can expect in the output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
REQUEST_HEADERS = auto()
|
||||||
|
RESPONSE_HEADERS = auto()
|
||||||
|
BODY = auto()
|
||||||
|
SEPARATOR = auto()
|
||||||
|
|
||||||
|
|
||||||
|
SEPARATOR_RE = re.compile(f'^{MESSAGE_SEPARATOR}')
|
||||||
|
|
||||||
|
|
||||||
|
def make_headers_re(message_type: Expect):
|
||||||
|
assert message_type in {Expect.REQUEST_HEADERS, Expect.RESPONSE_HEADERS}
|
||||||
|
|
||||||
|
# language=RegExp
|
||||||
|
crlf = r'[\r][\n]'
|
||||||
|
non_crlf = rf'[^{CRLF}]'
|
||||||
|
|
||||||
|
# language=RegExp
|
||||||
|
http_version = r'HTTP/\d+\.\d+'
|
||||||
|
if message_type is Expect.REQUEST_HEADERS:
|
||||||
|
# POST /post HTTP/1.1
|
||||||
|
start_line_re = fr'{non_crlf}*{http_version}{crlf}'
|
||||||
|
else:
|
||||||
|
# HTTP/1.1 200 OK
|
||||||
|
start_line_re = fr'{http_version}{non_crlf}*{crlf}'
|
||||||
|
|
||||||
|
return re.compile(
|
||||||
|
fr'''
|
||||||
|
^
|
||||||
|
{start_line_re}
|
||||||
|
({non_crlf}+:{non_crlf}+{crlf})+
|
||||||
|
{crlf}
|
||||||
|
''',
|
||||||
|
flags=re.VERBOSE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
BODY_ENDINGS = [
|
||||||
|
MESSAGE_SEPARATOR,
|
||||||
|
CRLF, # Not really but useful for testing (just remember not to include it in a body).
|
||||||
|
]
|
||||||
|
TOKEN_REGEX_MAP = {
|
||||||
|
Expect.REQUEST_HEADERS: make_headers_re(Expect.REQUEST_HEADERS),
|
||||||
|
Expect.RESPONSE_HEADERS: make_headers_re(Expect.RESPONSE_HEADERS),
|
||||||
|
Expect.SEPARATOR: SEPARATOR_RE,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class OutputMatchingError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def expect_tokens(tokens: Iterable[Expect], s: str):
|
||||||
|
for token in tokens:
|
||||||
|
s = expect_token(token, s)
|
||||||
|
if s:
|
||||||
|
raise OutputMatchingError(f'Unmatched remaining output for {tokens} in {s!r}')
|
||||||
|
|
||||||
|
|
||||||
|
def expect_token(token: Expect, s: str) -> str:
|
||||||
|
if token is Expect.BODY:
|
||||||
|
s = expect_body(s)
|
||||||
|
else:
|
||||||
|
s = expect_regex(token, s)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def expect_regex(token: Expect, s: str) -> str:
|
||||||
|
match = TOKEN_REGEX_MAP[token].match(s)
|
||||||
|
if not match:
|
||||||
|
raise OutputMatchingError(f'No match for {token} in {s!r}')
|
||||||
|
return s[match.end():]
|
||||||
|
|
||||||
|
|
||||||
|
def expect_body(s: str) -> str:
|
||||||
|
"""
|
||||||
|
We require some text, and continue to read until we find an ending or until the end of the string.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if 'content-disposition:' in s.lower():
|
||||||
|
# Multipart body heuristic.
|
||||||
|
final_boundary_re = re.compile('\r\n--[^-]+?--\r\n')
|
||||||
|
match = final_boundary_re.search(s)
|
||||||
|
if match:
|
||||||
|
return s[match.end():]
|
||||||
|
|
||||||
|
endings = [s.index(sep) for sep in BODY_ENDINGS if sep in s]
|
||||||
|
if not endings:
|
||||||
|
s = '' # Only body
|
||||||
|
else:
|
||||||
|
end = min(endings)
|
||||||
|
if end == 0:
|
||||||
|
raise OutputMatchingError(f'Empty body: {s!r}')
|
||||||
|
s = s[end:]
|
||||||
|
return s
|
190
tests/utils/matching/test_matching.py
Normal file
190
tests/utils/matching/test_matching.py
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
"""
|
||||||
|
Here we test our output parsing and matching implementation, not HTTPie itself.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from httpie.output.writer import MESSAGE_SEPARATOR
|
||||||
|
from tests.utils import CRLF
|
||||||
|
from tests.utils.matching import assert_output_does_not_match, assert_output_matches, Expect
|
||||||
|
|
||||||
|
|
||||||
|
def test_assert_output_matches_headers_incomplete():
|
||||||
|
assert_output_does_not_match(f'HTTP/1.1{CRLF}', [Expect.RESPONSE_HEADERS])
|
||||||
|
|
||||||
|
|
||||||
|
def test_assert_output_matches_headers_unterminated():
|
||||||
|
assert_output_does_not_match(
|
||||||
|
(
|
||||||
|
f'HTTP/1.1{CRLF}'
|
||||||
|
f'AAA:BBB'
|
||||||
|
f'{CRLF}'
|
||||||
|
),
|
||||||
|
[Expect.RESPONSE_HEADERS],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_assert_output_matches_response_headers():
|
||||||
|
assert_output_matches(
|
||||||
|
(
|
||||||
|
f'HTTP/1.1 200 OK{CRLF}'
|
||||||
|
f'AAA:BBB{CRLF}'
|
||||||
|
f'{CRLF}'
|
||||||
|
),
|
||||||
|
[Expect.RESPONSE_HEADERS],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_assert_output_matches_request_headers():
|
||||||
|
assert_output_matches(
|
||||||
|
(
|
||||||
|
f'GET / HTTP/1.1{CRLF}'
|
||||||
|
f'AAA:BBB{CRLF}'
|
||||||
|
f'{CRLF}'
|
||||||
|
),
|
||||||
|
[Expect.REQUEST_HEADERS],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_assert_output_matches_headers_and_separator():
|
||||||
|
assert_output_matches(
|
||||||
|
(
|
||||||
|
f'HTTP/1.1{CRLF}'
|
||||||
|
f'AAA:BBB{CRLF}'
|
||||||
|
f'{CRLF}'
|
||||||
|
f'{MESSAGE_SEPARATOR}'
|
||||||
|
),
|
||||||
|
[Expect.RESPONSE_HEADERS, Expect.SEPARATOR],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_assert_output_matches_body_unmatched_crlf():
|
||||||
|
assert_output_does_not_match(f'AAA{CRLF}', [Expect.BODY])
|
||||||
|
|
||||||
|
|
||||||
|
def test_assert_output_matches_body_unmatched_separator():
|
||||||
|
assert_output_does_not_match(f'AAA{MESSAGE_SEPARATOR}', [Expect.BODY])
|
||||||
|
|
||||||
|
|
||||||
|
def test_assert_output_matches_body_and_separator():
|
||||||
|
assert_output_matches(f'AAA{MESSAGE_SEPARATOR}', [Expect.BODY, Expect.SEPARATOR])
|
||||||
|
|
||||||
|
|
||||||
|
def test_assert_output_matches_body_r():
|
||||||
|
assert_output_matches(f'AAA\r', [Expect.BODY])
|
||||||
|
|
||||||
|
|
||||||
|
def test_assert_output_matches_body_n():
|
||||||
|
assert_output_matches(f'AAA\n', [Expect.BODY])
|
||||||
|
|
||||||
|
|
||||||
|
def test_assert_output_matches_body_r_body():
|
||||||
|
assert_output_matches(f'AAA\rBBB', [Expect.BODY])
|
||||||
|
|
||||||
|
|
||||||
|
def test_assert_output_matches_body_n_body():
|
||||||
|
assert_output_matches(f'AAA\nBBB', [Expect.BODY])
|
||||||
|
|
||||||
|
|
||||||
|
def test_assert_output_matches_headers_and_body():
|
||||||
|
assert_output_matches(
|
||||||
|
(
|
||||||
|
f'HTTP/1.1{CRLF}'
|
||||||
|
f'AAA:BBB{CRLF}'
|
||||||
|
f'{CRLF}'
|
||||||
|
f'CCC'
|
||||||
|
),
|
||||||
|
[Expect.RESPONSE_HEADERS, Expect.BODY]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_assert_output_matches_headers_with_body_and_separator():
|
||||||
|
assert_output_matches(
|
||||||
|
(
|
||||||
|
f'HTTP/1.1 {CRLF}'
|
||||||
|
f'AAA:BBB{CRLF}{CRLF}'
|
||||||
|
f'CCC{MESSAGE_SEPARATOR}'
|
||||||
|
),
|
||||||
|
[Expect.RESPONSE_HEADERS, Expect.BODY, Expect.SEPARATOR]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_assert_output_matches_multiple_messages():
|
||||||
|
assert_output_matches(
|
||||||
|
(
|
||||||
|
f'POST / HTTP/1.1{CRLF}'
|
||||||
|
f'AAA:BBB{CRLF}'
|
||||||
|
f'{CRLF}'
|
||||||
|
|
||||||
|
f'CCC'
|
||||||
|
f'{MESSAGE_SEPARATOR}'
|
||||||
|
|
||||||
|
f'HTTP/1.1 200 OK{CRLF}'
|
||||||
|
f'EEE:FFF{CRLF}'
|
||||||
|
f'{CRLF}'
|
||||||
|
|
||||||
|
f'GGG'
|
||||||
|
f'{MESSAGE_SEPARATOR}'
|
||||||
|
), [
|
||||||
|
Expect.REQUEST_HEADERS,
|
||||||
|
Expect.BODY,
|
||||||
|
Expect.SEPARATOR,
|
||||||
|
Expect.RESPONSE_HEADERS,
|
||||||
|
Expect.BODY,
|
||||||
|
Expect.SEPARATOR,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_assert_output_matches_multipart_body():
|
||||||
|
output = (
|
||||||
|
'POST / HTTP/1.1\r\n'
|
||||||
|
'User-Agent: HTTPie/2.4.0-dev\r\n'
|
||||||
|
'Accept-Encoding: gzip, deflate\r\n'
|
||||||
|
'Accept: */*\r\n'
|
||||||
|
'Connection: keep-alive\r\n'
|
||||||
|
'Content-Type: multipart/form-data; boundary=1e22169de43e4a2e8d9e41c0a1c93cc5\r\n'
|
||||||
|
'Content-Length: 212\r\n'
|
||||||
|
'Host: pie.dev\r\n'
|
||||||
|
'\r\n'
|
||||||
|
'--1e22169de43e4a2e8d9e41c0a1c93cc5\r\n'
|
||||||
|
'Content-Disposition: form-data; name="AAA"\r\n'
|
||||||
|
'\r\n'
|
||||||
|
'BBB\r\n'
|
||||||
|
'--1e22169de43e4a2e8d9e41c0a1c93cc5\r\n'
|
||||||
|
'Content-Disposition: form-data; name="CCC"\r\n'
|
||||||
|
'\r\n'
|
||||||
|
'DDD\r\n'
|
||||||
|
'--1e22169de43e4a2e8d9e41c0a1c93cc5--\r\n'
|
||||||
|
)
|
||||||
|
assert_output_matches(output, [Expect.REQUEST_HEADERS, Expect.BODY])
|
||||||
|
|
||||||
|
|
||||||
|
def test_assert_output_matches_multipart_body_with_separator():
|
||||||
|
output = (
|
||||||
|
'POST / HTTP/1.1\r\n'
|
||||||
|
'User-Agent: HTTPie/2.4.0-dev\r\n'
|
||||||
|
'Accept-Encoding: gzip, deflate\r\n'
|
||||||
|
'Accept: */*\r\n'
|
||||||
|
'Connection: keep-alive\r\n'
|
||||||
|
'Content-Type: multipart/form-data; boundary=1e22169de43e4a2e8d9e41c0a1c93cc5\r\n'
|
||||||
|
'Content-Length: 212\r\n'
|
||||||
|
'Host: pie.dev\r\n'
|
||||||
|
'\r\n'
|
||||||
|
'--1e22169de43e4a2e8d9e41c0a1c93cc5\r\n'
|
||||||
|
'Content-Disposition: form-data; name="AAA"\r\n'
|
||||||
|
'\r\n'
|
||||||
|
'BBB\r\n'
|
||||||
|
'--1e22169de43e4a2e8d9e41c0a1c93cc5\r\n'
|
||||||
|
'Content-Disposition: form-data; name="CCC"\r\n'
|
||||||
|
'\r\n'
|
||||||
|
'DDD\r\n'
|
||||||
|
'--1e22169de43e4a2e8d9e41c0a1c93cc5--\r\n'
|
||||||
|
f'{MESSAGE_SEPARATOR}'
|
||||||
|
)
|
||||||
|
assert_output_matches(output, [Expect.REQUEST_HEADERS, Expect.BODY, Expect.SEPARATOR])
|
||||||
|
|
||||||
|
|
||||||
|
def test_assert_output_matches_multiple_separators():
|
||||||
|
assert_output_matches(
|
||||||
|
MESSAGE_SEPARATOR + MESSAGE_SEPARATOR + 'AAA' + MESSAGE_SEPARATOR + MESSAGE_SEPARATOR,
|
||||||
|
[Expect.SEPARATOR, Expect.SEPARATOR, Expect.BODY, Expect.SEPARATOR, Expect.SEPARATOR]
|
||||||
|
)
|
Loading…
Reference in New Issue
Block a user