You've already forked httpie-cli
mirror of
https://github.com/httpie/cli.git
synced 2025-08-10 22:42:05 +02:00
Fix incorrect separators and introduce assert_output_matches()
(close #1027)
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
import pytest
|
||||
|
||||
from httpie.compat import is_windows
|
||||
from tests.utils.matching import assert_output_matches, Expect
|
||||
from utils import HTTP_OK, MockEnvironment, http
|
||||
|
||||
|
||||
@@ -27,7 +28,6 @@ def test_output_devnull(httpbin):
|
||||
http('--output=/dev/null', httpbin + '/get')
|
||||
|
||||
|
||||
@pytest.mark.skip(reason='TODO: fix #1006')
|
||||
def test_verbose_redirected_stdout_separator(httpbin):
|
||||
"""
|
||||
|
||||
@@ -40,3 +40,10 @@ def test_verbose_redirected_stdout_separator(httpbin):
|
||||
env=MockEnvironment(stdout_isatty=False),
|
||||
)
|
||||
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
|
||||
"""Utilities for HTTPie test suite."""
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
import time
|
||||
import json
|
||||
import tempfile
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
from typing import Optional, Union, List
|
||||
|
||||
from httpie.status import ExitStatus
|
||||
from httpie.config import Config
|
||||
@@ -20,7 +22,7 @@ from httpie.core import main
|
||||
HTTPBIN_WITH_CHUNKED_SUPPORT = 'http://pie.dev'
|
||||
|
||||
|
||||
TESTS_ROOT = Path(__file__).parent
|
||||
TESTS_ROOT = Path(__file__).parent.parent
|
||||
CRLF = '\r\n'
|
||||
COLOR = '\x1b['
|
||||
HTTP_OK = '200 OK'
|
||||
@@ -49,7 +51,7 @@ class StdinBytesIO(BytesIO):
|
||||
|
||||
class MockEnvironment(Environment):
|
||||
"""Environment subclass with reasonable defaults for testing."""
|
||||
colors = 0
|
||||
colors = 0 # For easier debugging
|
||||
stdin_isatty = True,
|
||||
stdout_isatty = True
|
||||
is_windows = False
|
||||
@@ -113,6 +115,15 @@ class BaseCLIResponse:
|
||||
devnull: str = None
|
||||
json: dict = 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):
|
||||
@@ -284,10 +295,13 @@ def http(
|
||||
r.devnull = devnull_output
|
||||
r.stderr = stderr.read()
|
||||
r.exit_status = exit_status
|
||||
r.args = args
|
||||
r.complete_args = ' '.join(complete_args)
|
||||
|
||||
if r.exit_status != ExitStatus.SUCCESS:
|
||||
sys.stderr.write(r.stderr)
|
||||
|
||||
# print(f'\n\n$ {r.command}\n')
|
||||
return r
|
||||
|
||||
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]
|
||||
)
|
Reference in New Issue
Block a user