Add support for streamed uploads, --chunked, finish --multipart, etc.

Close #201
Close #753
Close #684
Close #903
Related: #452
This commit is contained in:
Jakub Roztocil 2020-09-28 12:16:57 +02:00
parent b7754f92ce
commit 6925d930da
22 changed files with 498 additions and 328 deletions

View File

@ -8,14 +8,17 @@ This project adheres to `Semantic Versioning <https://semver.org/>`_.
`2.3.0-dev`_ (unreleased)
-------------------------
* Added support for multipart upload streaming (`#684`_).
* Added support for body-from-file upload streaming (``http httpbin.org/post @file``).
* Added ``--chunked`` to allow chunked transfer encoding.
* Added ``--multipart`` to allow ``multipart/form-data`` encoding for non-file ``--form`` requests as well.
* Added ``--boundary`` to allow a custom boundary string for ``multipart/form-data`` requests.
* Added support for combining cookies specified on the CLI and in a session file (`#932`_).
* Added out of the box SOCKS support with no extra installation (`#904`_).
* Added ``--quiet, -q`` flag to enforce silent behaviour.
* Added ``--multipart`` to allow ``multipart/form-data`` encoding for non-file ``--form`` requests as well.
* Added ``--boundary`` to allow a custom boundary string for ``multipart/form-data`` requests.
* Removed Tox testing entirely (`#943`_).
* Fixed the handling of invalid ``expires`` dates in ``Set-Cookie`` headers (`#963`_).
* Removed Tox testing entirely (`#943`_).
`2.2.0`_ (2020-06-18)

View File

@ -16,7 +16,7 @@ They use simple and natural syntax and provide formatted and colorized output.
.. class:: no-web no-pdf
.. image:: https://raw.githubusercontent.com/jakubroztocil/httpie/master/httpie.gif
.. image:: https://raw.githubusercontent.com/httpie/httpie/master/httpie.gif
:alt: HTTPie in action
:width: 100%
:align: center
@ -35,7 +35,7 @@ where you can select your corresponding HTTPie version as well as run examples d
browser using a `termible.io <https://termible.io?utm_source=httpie-readme>`_ embedded terminal.
If you are reading this on GitHub, then this text covers the current *development* version.
You are invited to submit fixes and improvements to the the docs by editing
`README.rst <https://github.com/jakubroztocil/httpie/blob/master/README.rst>`_.
`README.rst <https://github.com/httpie/httpie/blob/master/README.rst>`_.
Main features
@ -58,7 +58,7 @@ Main features
.. class:: no-web
.. image:: https://raw.githubusercontent.com/jakubroztocil/httpie/master/httpie.png
.. image:: https://raw.githubusercontent.com/httpie/httpie/master/httpie.png
:alt: HTTPie compared to cURL
:width: 100%
:align: center
@ -167,11 +167,11 @@ Otherwise with ``pip``:
.. code-block:: bash
$ pip install --upgrade https://github.com/jakubroztocil/httpie/archive/master.tar.gz
$ pip install --upgrade https://github.com/httpie/httpie/archive/master.tar.gz
Verify that now we have the
`current development version identifier <https://github.com/jakubroztocil/httpie/blob/master/httpie/__init__.py#L6>`_
`current development version identifier <https://github.com/httpie/httpie/blob/master/httpie/__init__.py#L6>`_
with the ``-dev`` suffix, for example:
.. code-block:: bash
@ -234,12 +234,12 @@ Build and print a request without sending it using `offline mode`_:
Use `GitHub API`_ to post a comment on an
`issue <https://github.com/jakubroztocil/httpie/issues/83>`_
`issue <https://github.com/httpie/httpie/issues/83>`_
with `authentication`_:
.. code-block:: bash
$ http -a USERNAME POST https://api.github.com/repos/jakubroztocil/httpie/issues/83/comments body='HTTPie is awesome! :heart:'
$ http -a USERNAME POST https://api.github.com/repos/httpie/httpie/issues/83/comments body='HTTPie is awesome! :heart:'
Upload a file using `redirected input`_:
@ -472,8 +472,9 @@ their type is distinguished only by the separator used:
| | The ``==`` separator is used. |
+-----------------------+-----------------------------------------------------+
| Data Fields | Request data fields to be serialized as a JSON |
| ``field=value``, | object (default), or to be form-encoded |
| ``field=@file.txt`` | (``--form, -f``). |
| ``field=value``, | object (default), to be form-encoded |
| ``field=@file.txt`` | (with ``--form, -f``), or to be serialized as |
| | ``multipart/form-data`` (with ``--multipart``). |
+-----------------------+-----------------------------------------------------+
| Raw JSON fields | Useful when sending JSON and one or |
| ``field:=json``, | more fields need to be a ``Boolean``, ``Number``, |
@ -481,10 +482,12 @@ their type is distinguished only by the separator used:
| | ``meals:='["ham","spam"]'`` or ``pies:=[1,2,3]`` |
| | (note the quotes). |
+-----------------------+-----------------------------------------------------+
| Form File Fields | Only available with ``--form, -f``. |
| ``field@/dir/file`` | For example ``screenshot@~/Pictures/img.png``. |
| | The presence of a file field results |
| | in a ``multipart/form-data`` request. |
| Fields upload fields | Only available with ``--form, -f`` and |
| ``field@/dir/file`` | ``--multipart``. |
| ``field@file;type`` | For example ``screenshot@~/Pictures/img.png``, or |
| | ``'cv@cv.txt;text/markdown'``. |
| | With ``--form``, the presence of a file field |
| | results in a ``--multipart`` request. |
+-----------------------+-----------------------------------------------------+
@ -714,12 +717,10 @@ To perform a ``multipart/form-data`` request even without any files, use
world
--c31279ab254f40aeb06df32b433cbccb--
Larger multipart uploads are always streamed to avoid memory issues.
Additionally, the display of the request body on the terminal is suppressed
for larger uploads.
File uploads are always streamed to avoid memory issues with large files.
By default, HTTPie uses a random unique string as the boundary but you can use
``--boundary`` to specify a custom string instead:
By default, HTTPie uses a random unique string as the multipart boundary
but you can use ``--boundary`` to specify a custom string instead:
.. code-block:: bash
@ -739,8 +740,8 @@ By default, HTTPie uses a random unique string as the boundary but you can use
--xoxo--
If you specify a custom ``Content-Type`` header without including the boundary
bit, HTTPie will add the boundary value (specified or generated) to the header
automatically:
bit, HTTPie will add the boundary value (explicitly specified or auto-generated)
to the header automatically:
.. code-block:: bash
@ -1347,9 +1348,14 @@ Redirected Input
================
The universal method for passing request data is through redirected ``stdin``
(standard input)—piping. Such data is buffered and then with no further
processing used as the request body. There are multiple useful ways to use
piping:
(standard input)—piping.
By default, ``stdin`` data is buffered and then with no further processing
used as the request body. If you provide ``Content-Length``, then the request
body is streamed without buffering. You can also use ``--chunked`` to enable
streaming via `chunked transfer encoding`_.
There are multiple useful ways to use piping:
Redirect from a file:
@ -1383,7 +1389,7 @@ You can even pipe web services together using HTTPie:
.. code-block:: bash
$ http GET https://api.github.com/repos/jakubroztocil/httpie | http POST httpbin.org/post
$ http GET https://api.github.com/repos/httpie/httpie | http POST httpbin.org/post
You can use ``cat`` to enter multiline data on the terminal:
@ -1438,6 +1444,33 @@ verbatim contents of that XML file with ``Content-Type: application/xml``:
$ http PUT httpbin.org/put @files/data.xml
File uploads are always streamed to avoid memory issues with large files.
Chunked transfer encoding
=========================
For any request, you can use the ``--chunked`` flag to instruct HTTPie to use
``Transfer-Encoding: chunked``:
.. code-block:: bash
$ http --chunked PUT httpbin.org/put hello=world
.. code-block:: bash
$ http --chunked --multipart PUT httpbin.org/put hello=world foo@files/data.xml
.. code-block:: bash
$ http --chunked httpbin.org/post @files/data.xml
.. code-block:: bash
$ cat files/data.xml | http --chunked httpbin.org/post
Terminal output
===============
@ -1585,7 +1618,7 @@ is being saved to a file.
.. code-block:: bash
$ http --download https://github.com/jakubroztocil/httpie/archive/master.tar.gz
$ http --download https://github.com/httpie/httpie/archive/master.tar.gz
.. code-block:: http
@ -1627,7 +1660,7 @@ headers and progress are still shown in the terminal:
.. code-block:: bash
$ http -d https://github.com/jakubroztocil/httpie/archive/master.tar.gz | tar zxf -
$ http -d https://github.com/httpie/httpie/archive/master.tar.gz | tar zxf -
@ -2108,27 +2141,27 @@ Alternatives
Contributing
------------
See `CONTRIBUTING.rst <https://github.com/jakubroztocil/httpie/blob/master/CONTRIBUTING.rst>`_.
See `CONTRIBUTING.rst <https://github.com/httpie/httpie/blob/master/CONTRIBUTING.rst>`_.
Change log
----------
See `CHANGELOG <https://github.com/jakubroztocil/httpie/blob/master/CHANGELOG.rst>`_.
See `CHANGELOG <https://github.com/httpie/httpie/blob/master/CHANGELOG.rst>`_.
Artwork
-------
* `Logo <https://github.com/claudiatd/httpie-artwork>`_ by `Cláudia Delgado <https://github.com/claudiatd>`_.
* `Animation <https://raw.githubusercontent.com/jakubroztocil/httpie/master/httpie.gif>`_ by `Allen Smith <https://github.com/loranallensmith>`_ of GitHub.
* `Animation <https://raw.githubusercontent.com/httpie/httpie/master/httpie.gif>`_ by `Allen Smith <https://github.com/loranallensmith>`_ of GitHub.
Licence
-------
BSD-3-Clause: `LICENSE <https://github.com/jakubroztocil/httpie/blob/master/LICENSE>`_.
BSD-3-Clause: `LICENSE <https://github.com/httpie/httpie/blob/master/LICENSE>`_.
@ -2141,7 +2174,7 @@ have contributed.
.. _pip: https://pip.pypa.io/en/stable/installing/
.. _GitHub API: https://developer.github.com/v3/issues/comments/#create-a-comment
.. _these fine people: https://github.com/jakubroztocil/httpie/contributors
.. _these fine people: https://github.com/httpie/httpie/contributors
.. _Jakub Roztocil: https://roztocil.co
.. _@jakubroztocil: https://twitter.com/jakubroztocil
@ -2154,12 +2187,12 @@ have contributed.
:target: https://pypi.python.org/pypi/httpie
:alt: Latest version released on PyPi
.. |coverage| image:: https://img.shields.io/codecov/c/github/jakubroztocil/httpie?style=flat-square
:target: https://codecov.io/gh/jakubroztocil/httpie
.. |coverage| image:: https://img.shields.io/codecov/c/github/httpie/httpie?style=flat-square
:target: https://codecov.io/gh/httpie/httpie
:alt: Test coverage
.. |build| image:: https://github.com/jakubroztocil/httpie/workflows/Build/badge.svg
:target: https://github.com/jakubroztocil/httpie/actions
.. |build| image:: https://github.com/httpie/httpie/workflows/Build/badge.svg
:target: https://github.com/httpie/httpie/actions
:alt: Build status of the master branch on Mac/Linux/Windows
.. |gitter| image:: https://img.shields.io/gitter/room/jkbrzt/httpie.svg?style=flat-square

View File

@ -17,7 +17,7 @@ from httpie.cli.argtypes import (
from httpie.cli.constants import (
HTTP_GET, HTTP_POST, OUTPUT_OPTIONS, OUTPUT_OPTIONS_DEFAULT,
OUTPUT_OPTIONS_DEFAULT_OFFLINE, OUTPUT_OPTIONS_DEFAULT_STDOUT_REDIRECTED,
OUT_RESP_BODY, PRETTY_MAP, PRETTY_STDOUT_TTY_ONLY, RequestContentType,
OUT_RESP_BODY, PRETTY_MAP, PRETTY_STDOUT_TTY_ONLY, RequestType,
SEPARATOR_CREDENTIALS,
SEPARATOR_GROUP_ALL_ITEMS, SEPARATOR_GROUP_DATA_ITEMS, URL_SCHEME_RE,
)
@ -83,7 +83,7 @@ class HTTPieArgumentParser(argparse.ArgumentParser):
)
# Arguments processing and environment setup.
self._apply_no_options(no_options)
self._process_request_content_type()
self._process_request_type()
self._process_download_options()
self._setup_standard_streams()
self._process_output_options()
@ -95,15 +95,23 @@ class HTTPieArgumentParser(argparse.ArgumentParser):
self._body_from_file(self.env.stdin)
self._process_url()
self._process_auth()
if self.args.compress:
# TODO: allow --compress with --chunked / --multipart
if self.args.chunked:
self.error('cannot combine --compress and --chunked')
if self.args.multipart:
self.error('cannot combine --compress and --multipart')
return self.args
def _process_request_content_type(self):
rct = self.args.request_content_type
self.args.json = rct is RequestContentType.JSON
self.args.multipart = rct is RequestContentType.MULTIPART
self.args.form = rct in {
RequestContentType.FORM,
RequestContentType.MULTIPART,
def _process_request_type(self):
request_type = self.args.request_type
self.args.json = request_type is RequestType.JSON
self.args.multipart = request_type is RequestType.MULTIPART
self.args.form = request_type in {
RequestType.FORM,
RequestType.MULTIPART,
}
def _process_url(self):
@ -285,7 +293,7 @@ class HTTPieArgumentParser(argparse.ArgumentParser):
'data (key=value) cannot be mixed. Pass '
'--ignore-stdin to let key/value take priority. '
'See https://httpie.org/doc#scripting for details.')
self.args.data = getattr(fd, 'buffer', fd).read()
self.args.data = getattr(fd, 'buffer', fd)
def _guess_method(self):
"""Set `args.method` if not specified to either POST or GET
@ -346,6 +354,7 @@ class HTTPieArgumentParser(argparse.ArgumentParser):
self.args.data = request_items.data
self.args.files = request_items.files
self.args.params = request_items.params
self.args.multipart_data = request_items.multipart_data
if self.args.files and not self.args.form:
# `http url @/path/to/file`

View File

@ -242,9 +242,3 @@ PARSED_DEFAULT_FORMAT_OPTIONS = parse_format_options(
s=','.join(DEFAULT_FORMAT_OPTIONS),
defaults=None,
)
class UnsortedAction(argparse.Action):
def __call__(self, *args, **kwargs):
return 1

View File

@ -41,6 +41,12 @@ SEPARATOR_GROUP_DATA_ITEMS = frozenset({
SEPARATOR_DATA_EMBED_RAW_JSON_FILE
})
SEPARATORS_GROUP_MULTIPART = frozenset({
SEPARATOR_DATA_STRING,
SEPARATOR_DATA_EMBED_FILE_CONTENTS,
SEPARATOR_FILE_UPLOAD,
})
# Separators for items whose value is a filename to be embedded
SEPARATOR_GROUP_DATA_EMBED_ITEMS = frozenset({
SEPARATOR_DATA_EMBED_FILE_CONTENTS,
@ -108,7 +114,7 @@ OUTPUT_OPTIONS_DEFAULT_STDOUT_REDIRECTED = OUT_RESP_BODY
OUTPUT_OPTIONS_DEFAULT_OFFLINE = OUT_REQ_HEAD + OUT_REQ_BODY
class RequestContentType(enum.Enum):
class RequestType(enum.Enum):
FORM = enum.auto()
MULTIPART = enum.auto()
JSON = enum.auto()

View File

@ -15,7 +15,7 @@ from httpie.cli.constants import (
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,
RequestContentType, SEPARATOR_GROUP_ALL_ITEMS, SEPARATOR_PROXY,
RequestType, SEPARATOR_GROUP_ALL_ITEMS, SEPARATOR_PROXY,
SORTED_FORMAT_OPTIONS_STRING,
UNSORTED_FORMAT_OPTIONS_STRING,
)
@ -111,7 +111,7 @@ positional.add_argument(
awesome:=true amount:=42 colors:='["red", "green", "blue"]'
'@' Form file fields (only with --form, -f):
'@' Form file fields (only with --form or --multipart):
cv@~/Documents/CV.pdf
cv@'~/Documents/CV.pdf;type=application/pdf'
@ -143,8 +143,8 @@ content_type = parser.add_argument_group(
content_type.add_argument(
'--json', '-j',
action='store_const',
const=RequestContentType.JSON,
dest='request_content_type',
const=RequestType.JSON,
dest='request_type',
help='''
(default) Data items from the command line are serialized as a JSON object.
The Content-Type and Accept headers are set to application/json
@ -155,8 +155,8 @@ content_type.add_argument(
content_type.add_argument(
'--form', '-f',
action='store_const',
const=RequestContentType.FORM,
dest='request_content_type',
const=RequestType.FORM,
dest='request_type',
help='''
Data items from the command line are serialized as form fields.
@ -169,8 +169,8 @@ content_type.add_argument(
content_type.add_argument(
'--multipart',
action='store_const',
const=RequestContentType.MULTIPART,
dest='request_content_type',
const=RequestType.MULTIPART,
dest='request_type',
help='''
Similar to --form, but always sends a multipart/form-data
request (i.e., even without files).
@ -400,7 +400,7 @@ output_options.add_argument(
action='store_true',
default=False,
help='''
Always stream the output by line, i.e., behave like `tail -f'.
Always stream the response body by line, i.e., behave like `tail -f'.
Without --stream and with --pretty (either set or implied),
HTTPie fetches the whole response before it outputs the processed data.

View File

@ -34,20 +34,25 @@ class MultiValueOrderedDict(OrderedDict):
super().__setitem__(key, [self[key]])
self[key].append(value)
class RequestQueryParamsDict(MultiValueOrderedDict):
pass
class RequestDataDict(MultiValueOrderedDict):
def items(self):
for key, values in super(MultiValueOrderedDict, self).items():
for key, values in super().items():
if not isinstance(values, list):
values = [values]
for value in values:
yield key, value
class RequestQueryParamsDict(MultiValueOrderedDict):
pass
class RequestDataDict(MultiValueOrderedDict):
pass
class MultipartRequestDataDict(MultiValueOrderedDict):
pass
class RequestFilesDict(RequestDataDict):
pass

View File

@ -4,13 +4,15 @@ from typing import Callable, Dict, IO, List, Optional, Tuple, Union
from httpie.cli.argtypes import KeyValueArg
from httpie.cli.constants import (
SEPARATOR_DATA_EMBED_FILE_CONTENTS, SEPARATOR_DATA_EMBED_RAW_JSON_FILE,
SEPARATORS_GROUP_MULTIPART, SEPARATOR_DATA_EMBED_FILE_CONTENTS,
SEPARATOR_DATA_EMBED_RAW_JSON_FILE,
SEPARATOR_DATA_RAW_JSON, SEPARATOR_DATA_STRING, SEPARATOR_FILE_UPLOAD,
SEPARATOR_FILE_UPLOAD_TYPE, SEPARATOR_HEADER, SEPARATOR_HEADER_EMPTY,
SEPARATOR_QUERY_PARAM,
)
from httpie.cli.dicts import (
RequestDataDict, RequestFilesDict, RequestHeadersDict, RequestJSONDataDict,
MultipartRequestDataDict, RequestDataDict, RequestFilesDict,
RequestHeadersDict, RequestJSONDataDict,
RequestQueryParamsDict,
)
from httpie.cli.exceptions import ParseError
@ -24,6 +26,8 @@ class RequestItems:
self.data = RequestDataDict() if as_form else RequestJSONDataDict()
self.files = RequestFilesDict()
self.params = RequestQueryParamsDict()
# To preserve the order of fields in file upload multipart requests.
self.multipart_data = MultipartRequestDataDict()
@classmethod
def from_args(
@ -69,7 +73,11 @@ class RequestItems:
for arg in request_item_args:
processor_func, target_dict = rules[arg.sep]
target_dict[arg.key] = processor_func(arg)
value = processor_func(arg)
target_dict[arg.key] = value
if arg.sep in SEPARATORS_GROUP_MULTIPART:
instance.multipart_data[arg.key] = value
return instance

View File

@ -2,7 +2,6 @@ import argparse
import http.client
import json
import sys
import zlib
from contextlib import contextmanager
from pathlib import Path
from typing import Callable, Iterable, Union
@ -17,7 +16,7 @@ from httpie.plugins.registry import plugin_manager
from httpie.sessions import get_httpie_session
from httpie.ssl import AVAILABLE_SSL_VERSION_ARG_MAPPING, HTTPieHTTPSAdapter
from httpie.uploads import (
wrap_request_data,
compress_request, prepare_request_body,
get_multipart_data_and_content_type,
)
from httpie.utils import get_expired_cookies, repr_dict
@ -34,7 +33,7 @@ DEFAULT_UA = f'HTTPie/{__version__}'
def collect_messages(
args: argparse.Namespace,
config_dir: Path,
body_chunk_sent_callback: Callable[[bytes], None]=None,
request_body_read_callback: Callable[[bytes], None] = None,
) -> Iterable[Union[requests.PreparedRequest, requests.Response]]:
httpie_session = None
httpie_session_headers = None
@ -50,7 +49,7 @@ def collect_messages(
request_kwargs = make_request_kwargs(
args=args,
base_headers=httpie_session_headers,
callback=body_chunk_sent_callback
request_body_read_callback=request_body_read_callback
)
send_kwargs = make_send_kwargs(args)
send_kwargs_mergeable_from_env = make_send_kwargs_mergeable_from_env(args)
@ -85,7 +84,10 @@ def collect_messages(
prepped_url=prepared_request.url,
)
if args.compress and prepared_request.body:
compress_body(prepared_request, always=args.compress > 1)
compress_request(
request=prepared_request,
always=args.compress > 1,
)
response_count = 0
expired_cookies = []
while prepared_request:
@ -142,23 +144,6 @@ def max_headers(limit):
http.client._MAXHEADERS = orig
def compress_body(request: requests.PreparedRequest, always: bool):
deflater = zlib.compressobj()
if isinstance(request.body, str):
body_bytes = request.body.encode()
elif hasattr(request.body, 'read'):
body_bytes = request.body.read()
else:
body_bytes = request.body
deflated_data = deflater.compress(body_bytes)
deflated_data += deflater.flush()
is_economical = len(deflated_data) < len(body_bytes)
if is_economical or always:
request.body = deflated_data
request.headers['Content-Encoding'] = 'deflate'
request.headers['Content-Length'] = str(len(deflated_data))
def build_requests_session(
verify: bool,
ssl_version: str = None,
@ -258,7 +243,7 @@ def make_send_kwargs_mergeable_from_env(args: argparse.Namespace) -> dict:
def make_request_kwargs(
args: argparse.Namespace,
base_headers: RequestHeadersDict = None,
callback=lambda chunk: chunk
request_body_read_callback=lambda chunk: chunk
) -> dict:
"""
Translate our `args` into `requests.Request` keyword arguments.
@ -285,21 +270,23 @@ def make_request_kwargs(
if (args.form and files) or args.multipart:
data, headers['Content-Type'] = get_multipart_data_and_content_type(
data=data,
files=files,
data=args.multipart_data,
boundary=args.boundary,
content_type=args.headers.get('Content-Type'),
)
files = None
kwargs = {
'method': args.method.lower(),
'url': args.url,
'headers': headers,
'data': wrap_request_data(data, callback=callback),
'data': prepare_request_body(
body=data,
body_read_callback=request_body_read_callback,
chunked=args.chunked,
content_length_header_value=headers.get('Content-Length')
),
'auth': args.auth,
'params': args.params,
'files': files,
'params': args.params.items(),
}
return kwargs

View File

@ -2,7 +2,7 @@ import argparse
import os
import platform
import sys
from typing import List, Union
from typing import List, Optional, Tuple, Union
import requests
from pygments import __version__ as pygments_version
@ -16,7 +16,10 @@ from httpie.cli.constants import (
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,
)
from httpie.plugins.registry import plugin_manager
from httpie.status import ExitStatus, http_status_to_exit_status
@ -118,16 +121,16 @@ def main(
def get_output_options(
args: argparse.Namespace,
message: Union[requests.PreparedRequest, requests.Response]
) -> dict:
) -> Tuple[bool, bool]:
return {
requests.PreparedRequest: {
'with_headers': OUT_REQ_HEAD in args.output_options,
'with_body': OUT_REQ_BODY in args.output_options,
},
requests.Response: {
'with_headers': OUT_RESP_HEAD in args.output_options,
'with_body': OUT_RESP_BODY in args.output_options,
},
requests.PreparedRequest: (
OUT_REQ_HEAD in args.output_options,
OUT_REQ_BODY in args.output_options,
),
requests.Response: (
OUT_RESP_HEAD in args.output_options,
OUT_RESP_BODY in args.output_options,
),
}[type(message)]
@ -152,39 +155,57 @@ def program(
)
downloader.pre_request(args.headers)
initial_request = None
final_response = None
needs_separator = False
def upload_callback(chunk):
print('GOT', chunk)
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,
body_chunk_sent_callback=upload_callback
request_body_read_callback=request_body_read_callback
)
for message in messages:
maybe_separate()
is_request = isinstance(message, requests.PreparedRequest)
output_options = get_output_options(args=args, message=message)
if not is_request or not output_options['with_body']:
write_message(
requests_message=message,
env=env,
args=args,
**output_options,
)
with_headers, with_body = get_output_options(
args=args, message=message)
if is_request:
if not initial_request:
initial_request = message
if 0and not args.offline:
output_options['with_body'] = False
write_message(
requests_message=message,
env=env,
args=args,
**output_options,
)
is_streamed_upload = not args.offline and not isinstance(
message.body, (str, bytes))
if with_body:
with_body = not is_streamed_upload
needs_separator = is_streamed_upload
else:
final_response = message
if args.check_status or downloader:
@ -198,6 +219,15 @@ def program(
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()
if downloader and exit_status == ExitStatus.SUCCESS:
# Last response body download.

View File

@ -86,7 +86,7 @@ class ColorFormatter(FormatterPlugin):
lexer=lexer,
formatter=self.formatter,
)
return body.strip()
return body
def get_lexer_for_body(
self, mime: str,

View File

@ -4,7 +4,6 @@ from typing import Callable, Iterable, Union
from httpie.context import Environment
from httpie.models import HTTPMessage
from httpie.output.processing import Conversion, Formatting
from requests_toolbelt import MultipartEncoder
BINARY_SUPPRESSED_NOTICE = (
@ -15,21 +14,6 @@ BINARY_SUPPRESSED_NOTICE = (
)
LARGE_UPLOAD_SUPPRESSED_NOTICE = (
b'\n'
b'+--------------------------------------------------------+\n'
b'| NOTE: large form upload data not shown in the terminal |\n'
b'+--------------------------------------------------------+'
)
def might_suppress(chunk):
if isinstance(chunk, MultipartEncoder):
raise LargeUploadSuppressedError()
# if isinstance(io.IOBase):
# pass
class DataSuppressedError(Exception):
message = None
@ -40,13 +24,6 @@ class BinarySuppressedError(DataSuppressedError):
message = BINARY_SUPPRESSED_NOTICE
class LargeUploadSuppressedError(DataSuppressedError):
"""An error indicating that the body is binary and won't be written,
e.g., for terminal output)."""
message = LARGE_UPLOAD_SUPPRESSED_NOTICE
class BaseStream:
"""Base HTTP message output stream class."""
@ -132,8 +109,6 @@ class EncodedStream(BaseStream):
def iter_body(self) -> Iterable[bytes]:
for line, lf in self.msg.iter_lines(self.CHUNK_SIZE):
if isinstance(line, MultipartEncoder):
raise LargeUploadSuppressedError()
if b'\0' in line:
raise BinarySuppressedError()
yield line.decode(self.msg.encoding) \
@ -212,8 +187,6 @@ class BufferedPrettyStream(PrettyStream):
body = bytearray()
for chunk in self.msg.iter_body(self.CHUNK_SIZE):
if isinstance(chunk, MultipartEncoder):
raise LargeUploadSuppressedError()
if not converter and b'\0' in chunk:
converter = self.conversion.get_converter(self.mime)
if not converter:

View File

@ -107,7 +107,8 @@ def build_output_stream_for_message(
with_body=with_body,
**stream_kwargs,
)
if env.stdout_isatty and with_body:
if (env.stdout_isatty and with_body
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'

View File

@ -1,68 +1,129 @@
from typing import Tuple, Union
import zlib
from typing import Callable, IO, Iterable, Tuple, Union
from urllib.parse import urlencode
import requests
from requests.utils import super_len
from requests_toolbelt import MultipartEncoder
from httpie.cli.dicts import RequestDataDict, RequestFilesDict
from httpie.cli.dicts import MultipartRequestDataDict, RequestDataDict
# Multipart uploads smaller than this size gets buffered (otherwise streamed).
# NOTE: Unbuffered upload requests cannot be displayed on the terminal.
MULTIPART_UPLOAD_BUFFER = 1024 * 1000
class ChunkedUploadStream:
def __init__(self, stream: Iterable, callback: Callable):
self.callback = callback
self.stream = stream
def __iter__(self) -> Iterable[Union[str, bytes]]:
for chunk in self.stream:
self.callback(chunk)
yield chunk
class ChunkedMultipartUploadStream:
chunk_size = 100 * 1024
def __init__(self, encoder: MultipartEncoder):
self.encoder = encoder
def __iter__(self) -> Iterable[Union[str, bytes]]:
while True:
chunk = self.encoder.read(self.chunk_size)
if not chunk:
break
yield chunk
def prepare_request_body(
body: Union[str, bytes, IO, MultipartEncoder, RequestDataDict],
body_read_callback: Callable[[bytes], bytes],
content_length_header_value: int = None,
chunked=False,
) -> Union[str, bytes, IO, MultipartEncoder, ChunkedUploadStream]:
if isinstance(body, RequestDataDict):
body = urlencode(body, doseq=True)
if not hasattr(body, 'read'):
if chunked:
body = ChunkedUploadStream(
# Pass the entire body as one chunk.
stream=(chunk.encode() for chunk in [body]),
callback=body_read_callback,
)
else:
# File-like object.
if not super_len(body):
# Zero-length -> assume stdin.
if content_length_header_value is None and not chunked:
#
# Read the whole stdin to determine `Content-Length`.
#
# TODO: Instead of opt-in --chunked, consider making
# `Transfer-Encoding: chunked` for STDIN opt-out via
# something like --no-chunked.
# This would be backwards-incompatible so wait until v3.0.0.
#
body = body.read()
else:
orig_read = body.read
def new_read(*args):
chunk = orig_read(*args)
body_read_callback(chunk)
return chunk
body.read = new_read
if chunked:
if isinstance(body, MultipartEncoder):
body = ChunkedMultipartUploadStream(
encoder=body,
)
else:
body = ChunkedUploadStream(
stream=body,
callback=body_read_callback,
)
return body
def get_multipart_data_and_content_type(
data: RequestDataDict,
files: RequestFilesDict,
data: MultipartRequestDataDict,
boundary: str = None,
content_type: str = None,
) -> Tuple[Union[MultipartEncoder, bytes], str]:
fields = list(data.items()) + list(files.items())
) -> Tuple[MultipartEncoder, str]:
encoder = MultipartEncoder(
fields=fields,
fields=data.items(),
boundary=boundary,
)
if content_type:
content_type = content_type.strip() # maybe auto-strip all headers somewhere
content_type = content_type.strip()
if 'boundary=' not in content_type:
content_type = f'{content_type}; boundary={encoder.boundary_value}'
else:
content_type = encoder.content_type
data = encoder.to_string() if 0 and encoder.len < MULTIPART_UPLOAD_BUFFER else encoder
data = encoder
return data, content_type
class Stdin:
def __init__(self, stdin, callback):
self.callback = callback
self.stdin = stdin
def __iter__(self):
for chunk in self.stdin:
print("__iter__() =>", chunk)
self.callback(chunk)
yield chunk
@classmethod
def is_stdin(cls, obj):
return super_len(obj) == 0
def wrap_request_data(data, callback=lambda chunk: print('chunk', chunk)):
if hasattr(data, 'read'):
if Stdin.is_stdin(data):
data = Stdin(data, callback=callback)
def compress_request(
request: requests.PreparedRequest,
always: bool,
):
deflater = zlib.compressobj()
if isinstance(request.body, str):
body_bytes = request.body.encode()
elif hasattr(request.body, 'read'):
body_bytes = request.body.read()
else:
orig_read = data.read
def new_read(*args):
val = orig_read(*args)
print('read() =>', val)
callback(callback)
return val
data.read = new_read
return data
body_bytes = request.body
deflated_data = deflater.compress(body_bytes)
deflated_data += deflater.flush()
is_economical = len(deflated_data) < len(body_bytes)
if is_economical or always:
request.body = deflated_data
request.headers['Content-Encoding'] = 'deflate'
request.headers['Content-Length'] = str(len(deflated_data))

View File

@ -1,6 +1,5 @@
"""Test data"""
from os import path
import codecs
from pathlib import Path
def patharg(path):
@ -9,32 +8,24 @@ def patharg(path):
even in Windows paths.
"""
return path.replace('\\', '\\\\\\')
return str(path).replace('\\', '\\\\\\')
FIXTURES_ROOT = path.join(path.abspath(path.dirname(__file__)))
FILE_PATH = path.join(FIXTURES_ROOT, 'test.txt')
JSON_FILE_PATH = path.join(FIXTURES_ROOT, 'test.json')
BIN_FILE_PATH = path.join(FIXTURES_ROOT, 'test.bin')
FIXTURES_ROOT = Path(__file__).parent
FILE_PATH = FIXTURES_ROOT / 'test.txt'
JSON_FILE_PATH = FIXTURES_ROOT / 'test.json'
BIN_FILE_PATH = FIXTURES_ROOT / 'test.bin'
FILE_PATH_ARG = patharg(FILE_PATH)
BIN_FILE_PATH_ARG = patharg(BIN_FILE_PATH)
JSON_FILE_PATH_ARG = patharg(JSON_FILE_PATH)
with codecs.open(FILE_PATH, encoding='utf8') as f:
# Strip because we don't want new lines in the data so that we can
# easily count occurrences also when embedded in JSON (where the new
# line would be escaped).
FILE_CONTENT = f.read().strip()
FILE_CONTENT = FILE_PATH.read_text().strip()
with codecs.open(JSON_FILE_PATH, encoding='utf8') as f:
JSON_FILE_CONTENT = f.read()
with open(BIN_FILE_PATH, 'rb') as f:
BIN_FILE_CONTENT = f.read()
JSON_FILE_CONTENT = JSON_FILE_PATH.read_text()
BIN_FILE_CONTENT = BIN_FILE_PATH.read_bytes()
UNICODE = FILE_CONTENT

View File

@ -15,7 +15,7 @@ from httpie.cli import constants
from httpie.cli.definition import parser
from httpie.cli.argtypes import KeyValueArg, KeyValueArgType
from httpie.cli.requestitems import RequestItems
from utils import HTTP_OK, MockEnvironment, http
from utils import HTTP_OK, MockEnvironment, StdinBytesIO, http
class TestItemParsing:
@ -312,10 +312,11 @@ class TestNoOptions:
class TestStdin:
def test_ignore_stdin(self, httpbin):
with open(FILE_PATH) as f:
env = MockEnvironment(stdin=f, stdin_isatty=False)
r = http('--ignore-stdin', '--verbose', httpbin.url + '/get',
env=env)
env = MockEnvironment(
stdin=StdinBytesIO(FILE_PATH.read_bytes()),
stdin_isatty=False,
)
r = http('--ignore-stdin', '--verbose', httpbin.url + '/get', env=env)
assert HTTP_OK in r
assert 'GET /get HTTP' in r, "Don't default to POST."
assert FILE_CONTENT not in r, "Don't send stdin data."

View File

@ -12,7 +12,8 @@ import base64
import zlib
from fixtures import FILE_PATH, FILE_CONTENT
from utils import http, HTTP_OK, MockEnvironment
from httpie.status import ExitStatus
from utils import StdinBytesIO, http, HTTP_OK, MockEnvironment
def assert_decompressed_equal(base64_compressed_data, expected_str):
@ -27,6 +28,20 @@ def assert_decompressed_equal(base64_compressed_data, expected_str):
assert actual_str == expected_str
def test_cannot_combine_compress_with_chunked(httpbin):
r = http('--compress', '--chunked', httpbin.url + '/get',
tolerate_error_exit_status=True)
assert r.exit_status == ExitStatus.ERROR
assert 'cannot combine --compress and --chunked' in r.stderr
def test_cannot_combine_compress_with_multipart(httpbin):
r = http('--compress', '--multipart', httpbin.url + '/get',
tolerate_error_exit_status=True)
assert r.exit_status == ExitStatus.ERROR
assert 'cannot combine --compress and --multipart' in r.stderr
def test_compress_skip_negative_ratio(httpbin_both):
r = http(
'--compress',
@ -78,8 +93,10 @@ def test_compress_form(httpbin_both):
def test_compress_stdin(httpbin_both):
with open(FILE_PATH) as f:
env = MockEnvironment(stdin=f, stdin_isatty=False)
env = MockEnvironment(
stdin=StdinBytesIO(FILE_PATH.read_bytes()),
stdin_isatty=False,
)
r = http(
'--compress',
'--compress',
@ -100,7 +117,7 @@ def test_compress_file(httpbin_both):
'--compress',
'PUT',
httpbin_both + '/put',
'file@' + FILE_PATH,
f'file@{FILE_PATH}',
)
assert HTTP_OK in r
assert r.json['headers']['Content-Encoding'] == 'deflate'

View File

@ -2,6 +2,8 @@
Tests for the provided defaults regarding HTTP method, and --json vs. --form.
"""
from io import BytesIO
from httpie.client import JSON_ACCEPT
from utils import MockEnvironment, http, HTTP_OK
from fixtures import FILE_PATH
@ -44,8 +46,10 @@ class TestImplicitHTTPMethod:
assert r.json['form'] == {'foo': 'bar'}
def test_implicit_POST_stdin(self, httpbin):
with open(FILE_PATH) as f:
env = MockEnvironment(stdin_isatty=False, stdin=f)
env = MockEnvironment(
stdin_isatty=False,
stdin=BytesIO(FILE_PATH.read_bytes())
)
r = http('--form', httpbin.url + '/post', env=env)
assert HTTP_OK in r

View File

@ -9,7 +9,7 @@ import httpie.__main__
from httpie.context import Environment
from httpie.status import ExitStatus
from httpie.cli.exceptions import ParseError
from utils import MockEnvironment, http, HTTP_OK
from utils import MockEnvironment, StdinBytesIO, http, HTTP_OK
from fixtures import FILE_PATH, FILE_CONTENT
import httpie
@ -104,15 +104,17 @@ def test_POST_form_multiple_values(httpbin_both):
def test_POST_stdin(httpbin_both):
with open(FILE_PATH) as f:
env = MockEnvironment(stdin=f, stdin_isatty=False)
env = MockEnvironment(
stdin=StdinBytesIO(FILE_PATH.read_bytes()),
stdin_isatty=False,
)
r = http('--form', 'POST', httpbin_both + '/post', env=env)
assert HTTP_OK in r
assert FILE_CONTENT in r
def test_POST_file(httpbin_both):
r = http('--form', 'POST', httpbin_both + '/post', 'file@' + FILE_PATH)
r = http('--form', 'POST', httpbin_both + '/post', f'file@{FILE_PATH}')
assert HTTP_OK in r
assert FILE_CONTENT in r
@ -127,10 +129,10 @@ def test_form_POST_file_redirected_stdin(httpbin):
'--form',
'POST',
httpbin + '/post',
'file@' + FILE_PATH,
f'file@{FILE_PATH}',
tolerate_error_exit_status=True,
env=MockEnvironment(
stdin=f,
stdin=StdinBytesIO(FILE_PATH.read_bytes()),
stdin_isatty=False,
),
)

View File

@ -2,7 +2,7 @@ import pytest
from httpie.compat import is_windows
from httpie.output.streams import BINARY_SUPPRESSED_NOTICE
from utils import http, MockEnvironment
from utils import StdinBytesIO, http, MockEnvironment
from fixtures import BIN_FILE_CONTENT, BIN_FILE_PATH
@ -13,10 +13,12 @@ from fixtures import BIN_FILE_CONTENT, BIN_FILE_PATH
reason='Pretty redirect not supported under Windows')
def test_pretty_redirected_stream(httpbin):
"""Test that --stream works with prettified redirected output."""
with open(BIN_FILE_PATH, 'rb') as f:
env = MockEnvironment(colors=256, stdin=f,
env = MockEnvironment(
colors=256,
stdin=StdinBytesIO(BIN_FILE_PATH.read_bytes()),
stdin_isatty=False,
stdout_isatty=False)
stdout_isatty=False,
)
r = http('--verbose', '--pretty=all', '--stream', 'GET',
httpbin.url + '/get', env=env)
assert BINARY_SUPPRESSED_NOTICE.decode() in r
@ -25,8 +27,10 @@ def test_pretty_redirected_stream(httpbin):
def test_encoded_stream(httpbin):
"""Test that --stream works with non-prettified
redirected terminal output."""
with open(BIN_FILE_PATH, 'rb') as f:
env = MockEnvironment(stdin=f, stdin_isatty=False)
env = MockEnvironment(
stdin=StdinBytesIO(BIN_FILE_PATH.read_bytes()),
stdin_isatty=False,
)
r = http('--pretty=none', '--stream', '--verbose', 'GET',
httpbin.url + '/get', env=env)
assert BINARY_SUPPRESSED_NOTICE.decode() in r
@ -35,10 +39,11 @@ def test_encoded_stream(httpbin):
def test_redirected_stream(httpbin):
"""Test that --stream works with non-prettified
redirected terminal output."""
with open(BIN_FILE_PATH, 'rb') as f:
env = MockEnvironment(stdout_isatty=False,
env = MockEnvironment(
stdout_isatty=False,
stdin_isatty=False,
stdin=f)
stdin=StdinBytesIO(BIN_FILE_PATH.read_bytes()),
)
r = http('--pretty=none', '--stream', '--verbose', 'GET',
httpbin.url + '/get', env=env)
assert BIN_FILE_CONTENT in r

View File

@ -1,16 +1,57 @@
import os
from unittest import mock
import pytest
from httpie.cli.exceptions import ParseError
from httpie.client import FORM_CONTENT_TYPE
from httpie.output.streams import LARGE_UPLOAD_SUPPRESSED_NOTICE
from httpie.status import ExitStatus
from utils import MockEnvironment, http, HTTP_OK
from utils import (
HTTPBIN_WITH_CHUNKED_SUPPORT, MockEnvironment, StdinBytesIO, http,
HTTP_OK,
)
from fixtures import FILE_PATH_ARG, FILE_PATH, FILE_CONTENT
def test_chunked_json():
r = http(
'--verbose',
'--chunked',
HTTPBIN_WITH_CHUNKED_SUPPORT + '/post',
'hello=world',
)
assert HTTP_OK in r
assert 'Transfer-Encoding: chunked' in r
assert r.count('hello') == 3
def test_chunked_form():
r = http(
'--verbose',
'--chunked',
'--form',
HTTPBIN_WITH_CHUNKED_SUPPORT + '/post',
'hello=world',
)
assert HTTP_OK in r
assert 'Transfer-Encoding: chunked' in r
assert r.count('hello') == 2
def test_chunked_stdin():
r = http(
'--verbose',
'--chunked',
HTTPBIN_WITH_CHUNKED_SUPPORT + '/post',
env=MockEnvironment(
stdin=StdinBytesIO(FILE_PATH.read_bytes()),
stdin_isatty=False,
)
)
assert HTTP_OK in r
assert 'Transfer-Encoding: chunked' in r
assert r.count(FILE_CONTENT) == 2
class TestMultipartFormDataFileUpload:
def test_non_existent_file_raises_parse_error(self, httpbin):
@ -55,19 +96,6 @@ class TestMultipartFormDataFileUpload:
assert r.count(FILE_CONTENT) == 2
assert 'Content-Type: image/vnd.microsoft.icon' in r
@mock.patch('httpie.uploads.MULTIPART_UPLOAD_BUFFER', 0)
def test_large_upload_display_suppressed(self, httpbin):
r = http(
'--form',
'--verbose',
httpbin.url + '/post',
f'test-file@{FILE_PATH_ARG}',
'foo=bar',
)
assert HTTP_OK in r
assert r.count(FILE_CONTENT) == 1
assert LARGE_UPLOAD_SUPPRESSED_NOTICE.decode() in r
def test_form_no_files_urlencoded(self, httpbin):
r = http(
'--form',
@ -91,33 +119,6 @@ class TestMultipartFormDataFileUpload:
assert FORM_CONTENT_TYPE not in r
assert 'multipart/form-data' in r
def test_multipart_too_large_for_terminal(self, httpbin):
with mock.patch('httpie.uploads.MULTIPART_UPLOAD_BUFFER', 0):
r = http(
'--verbose',
'--multipart',
httpbin.url + '/post',
'AAAA=AAA',
'BBB=BBB',
)
assert HTTP_OK in r
assert FORM_CONTENT_TYPE not in r
assert 'multipart/form-data' in r
def test_multipart_too_large_for_terminal_non_pretty(self, httpbin):
with mock.patch('httpie.uploads.MULTIPART_UPLOAD_BUFFER', 0):
r = http(
'--verbose',
'--multipart',
'--pretty=none',
httpbin.url + '/post',
'AAAA=AAA',
'BBB=BBB',
)
assert HTTP_OK in r
assert FORM_CONTENT_TYPE not in r
assert 'multipart/form-data' in r
def test_form_multipart_custom_boundary(self, httpbin):
boundary = 'HTTPIE_FTW'
r = http(
@ -164,6 +165,20 @@ class TestMultipartFormDataFileUpload:
assert f'multipart/magic; boundary={boundary_in_header}' in r
assert r.count(boundary_in_body) == 3
def test_multipart_chunked(self, httpbin):
r = http(
'--verbose',
'--multipart',
'--chunked',
# '--offline',
HTTPBIN_WITH_CHUNKED_SUPPORT + '/post',
'AAA=AAA',
)
assert 'Transfer-Encoding: chunked' in r
assert 'multipart/form-data' in r
assert 'name="AAA"' in r # in request
assert '"AAA": "AAA"', r # in response
class TestRequestBodyFromFilePath:
"""
@ -172,12 +187,26 @@ class TestRequestBodyFromFilePath:
"""
def test_request_body_from_file_by_path(self, httpbin):
r = http('--verbose',
'POST', httpbin.url + '/post', '@' + FILE_PATH_ARG)
r = http(
'--verbose',
'POST', httpbin.url + '/post',
'@' + FILE_PATH_ARG,
)
assert HTTP_OK in r
assert FILE_CONTENT in r, r
assert r.count(FILE_CONTENT) == 2
assert '"Content-Type": "text/plain"' in r
def test_request_body_from_file_by_path_chunked(self, httpbin):
r = http(
'--verbose', '--chunked',
HTTPBIN_WITH_CHUNKED_SUPPORT + '/post',
'@' + FILE_PATH_ARG,
)
assert HTTP_OK in r
assert 'Transfer-Encoding: chunked' in r
assert '"Content-Type": "text/plain"' in r
assert r.count(FILE_CONTENT) == 2
def test_request_body_from_file_by_path_with_explicit_content_type(
self, httpbin):
r = http('--verbose',

View File

@ -1,10 +1,10 @@
# coding=utf-8
"""Utilities for HTTPie test suite."""
import os
import sys
import time
import json
import tempfile
from io import BytesIO
from pathlib import Path
from typing import Optional, Union
@ -14,6 +14,12 @@ from httpie.context import Environment
from httpie.core import main
# pytest-httpbin currently does not support chunked requests:
# <https://github.com/kevin1024/pytest-httpbin/issues/33>
# <https://github.com/kevin1024/pytest-httpbin/issues/28>
HTTPBIN_WITH_CHUNKED_SUPPORT = 'http://httpbin.org'
TESTS_ROOT = Path(__file__).parent
CRLF = '\r\n'
COLOR = '\x1b['
@ -36,6 +42,11 @@ def add_auth(url, auth):
return proto + '://' + auth + '@' + rest
class StdinBytesIO(BytesIO):
"""To be used for `MockEnvironment.stdin`"""
len = 0 # See `prepare_request_body()`
class MockEnvironment(Environment):
"""Environment subclass with reasonable defaults for testing."""
colors = 0