diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 69a627bb..d8411953 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -11,6 +11,7 @@ This project adheres to `Semantic Versioning `_.
-------------------------
* Fixed upload with ``--session`` (`#1020`_).
+* Fixed a missing blank line between request and response (`#1006`_).
`2.3.0`_ (2020-10-25)
@@ -485,4 +486,5 @@ This project adheres to `Semantic Versioning `_.
.. _#934: https://github.com/httpie/httpie/issues/934
.. _#943: https://github.com/httpie/httpie/issues/943
.. _#963: https://github.com/httpie/httpie/issues/963
+.. _#1006: https://github.com/httpie/httpie/issues/1006
.. _#1020: https://github.com/httpie/httpie/issues/1020
diff --git a/httpie/core.py b/httpie/core.py
index 43b4255c..3f46603e 100644
--- a/httpie/core.py
+++ b/httpie/core.py
@@ -9,26 +9,17 @@ from pygments import __version__ as pygments_version
from requests import __version__ as requests_version
from httpie import __version__ as httpie_version
-from httpie.cli.constants import (
- OUT_REQ_BODY, OUT_REQ_HEAD, OUT_RESP_BODY,
- OUT_RESP_HEAD,
-)
+from httpie.cli.constants import OUT_REQ_BODY, OUT_REQ_HEAD, OUT_RESP_BODY, OUT_RESP_HEAD
from httpie.client import collect_messages
from httpie.context import Environment
from httpie.downloads import Downloader
-from httpie.output.writer import (
- write_message,
- write_stream,
-)
+from httpie.output.writer import write_message, write_stream, MESSAGE_SEPARATOR_BYTES
from httpie.plugins.registry import plugin_manager
from httpie.status import ExitStatus, http_status_to_exit_status
# noinspection PyDefaultArgument
-def main(
- args: List[Union[str, bytes]] = sys.argv,
- env=Environment(),
-) -> ExitStatus:
+def main(args: List[Union[str, bytes]] = sys.argv, env=Environment()) -> ExitStatus:
"""
The main function.
@@ -134,112 +125,82 @@ def get_output_options(
}[type(message)]
-def program(
- args: argparse.Namespace,
- env: Environment,
-) -> ExitStatus:
+def program(args: argparse.Namespace, env: Environment) -> ExitStatus:
"""
The main program without error handling.
"""
+ # TODO: Refactor and drastically simplify, especially so that the separator logic is elsewhere.
exit_status = ExitStatus.SUCCESS
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:
if args.download:
args.follow = True # --download implies --follow.
- downloader = Downloader(
- output_file=args.output_file,
- progress_file=env.stderr,
- resume=args.download_resume
- )
+ downloader = Downloader(output_file=args.output_file, progress_file=env.stderr, resume=args.download_resume)
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
-
- 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
- )
+ # Process messages as they’re generated
for message in messages:
- maybe_separate()
is_request = isinstance(message, requests.PreparedRequest)
- with_headers, with_body = get_output_options(
- args=args, message=message)
+ with_headers, with_body = get_output_options(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 not initial_request:
initial_request = message
- is_streamed_upload = not isinstance(
- message.body, (str, bytes))
+ is_streamed_upload = not isinstance(message.body, (str, bytes))
if with_body:
- with_body = not is_streamed_upload
- needs_separator = is_streamed_upload
+ do_write_body = not is_streamed_upload
+ force_separator = is_streamed_upload and env.stdout_isatty
else:
final_response = message
if args.check_status or downloader:
- exit_status = http_status_to_exit_status(
- http_status=message.status_code,
- follow=args.follow
- )
- if (not env.stdout_isatty
- and exit_status != ExitStatus.SUCCESS):
- 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()
+ exit_status = http_status_to_exit_status(http_status=message.status_code, follow=args.follow)
+ if not env.stdout_isatty and exit_status != ExitStatus.SUCCESS:
+ 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=do_write_body)
+ prev_with_body = with_body
+ # Cleanup
+ if force_separator:
+ separate()
if downloader and exit_status == ExitStatus.SUCCESS:
# Last response body download.
download_stream, download_to = downloader.start(
initial_url=initial_request.url,
final_response=final_response,
)
- write_stream(
- stream=download_stream,
- outfile=download_to,
- flush=False,
- )
+ write_stream(stream=download_stream, outfile=download_to, flush=False)
downloader.finish()
if downloader.interrupted:
exit_status = ExitStatus.ERROR
@@ -253,9 +214,7 @@ def program(
finally:
if downloader and not downloader.finished:
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()
diff --git a/httpie/output/writer.py b/httpie/output/writer.py
index d8a34aa3..2e63b6ae 100644
--- a/httpie/output/writer.py
+++ b/httpie/output/writer.py
@@ -12,6 +12,10 @@ from httpie.output.streams import (
)
+MESSAGE_SEPARATOR = '\n\n'
+MESSAGE_SEPARATOR_BYTES = MESSAGE_SEPARATOR.encode()
+
+
def write_message(
requests_message: Union[requests.PreparedRequest, requests.Response],
env: Environment,
@@ -111,7 +115,7 @@ def build_output_stream_for_message(
and not getattr(requests_message, 'is_body_upload_chunk', False)):
# Ensure a blank line after the response body.
# For terminal output only.
- yield b'\n\n'
+ yield MESSAGE_SEPARATOR_BYTES
def get_stream_type_and_kwargs(
diff --git a/setup.cfg b/setup.cfg
index e672f486..cf654a6b 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -4,7 +4,7 @@
[tool:pytest]
#
norecursedirs = tests/fixtures
-addopts = --tb=native
+addopts = --tb=native --doctest-modules
[pycodestyle]
diff --git a/tests/test_regressions.py b/tests/test_regressions.py
index c6c0c3db..118b1023 100644
--- a/tests/test_regressions.py
+++ b/tests/test_regressions.py
@@ -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,
+ ])
diff --git a/tests/test_tokens.py b/tests/test_tokens.py
new file mode 100644
index 00000000..60af01e5
--- /dev/null
+++ b/tests/test_tokens.py
@@ -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)
diff --git a/tests/utils.py b/tests/utils/__init__.py
similarity index 93%
rename from tests/utils.py
rename to tests/utils/__init__.py
index 254c5264..575f1e2d 100644
--- a/tests/utils.py
+++ b/tests/utils/__init__.py
@@ -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:
diff --git a/tests/utils/matching/__init__.py b/tests/utils/matching/__init__.py
new file mode 100644
index 00000000..6afeaf14
--- /dev/null
+++ b/tests/utils/matching/__init__.py
@@ -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)
diff --git a/tests/utils/matching/parsing.py b/tests/utils/matching/parsing.py
new file mode 100644
index 00000000..4d573aa1
--- /dev/null
+++ b/tests/utils/matching/parsing.py
@@ -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
diff --git a/tests/utils/matching/test_matching.py b/tests/utils/matching/test_matching.py
new file mode 100644
index 00000000..0cc09858
--- /dev/null
+++ b/tests/utils/matching/test_matching.py
@@ -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]
+ )