httpie-cli/tests/utils/__init__.py

312 lines
8.9 KiB
Python
Raw Normal View History

2016-03-01 16:22:50 +01:00
"""Utilities for HTTPie test suite."""
import re
import shlex
2014-04-24 14:07:31 +02:00
import sys
import time
import json
import tempfile
from io import BytesIO
from pathlib import Path
from typing import Optional, Union, List
2014-04-24 14:07:31 +02:00
2019-09-16 13:26:18 +02:00
from httpie.status import ExitStatus
from httpie.config import Config
from httpie.context import Environment
2014-04-24 14:07:31 +02:00
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_DOMAIN = 'pie.dev'
HTTPBIN_WITH_CHUNKED_SUPPORT = 'http://' + HTTPBIN_WITH_CHUNKED_SUPPORT_DOMAIN
TESTS_ROOT = Path(__file__).parent.parent
2014-04-24 14:07:31 +02:00
CRLF = '\r\n'
2014-04-24 15:48:01 +02:00
COLOR = '\x1b['
HTTP_OK = '200 OK'
# noinspection GrazieInspection
2014-04-24 15:48:01 +02:00
HTTP_OK_COLOR = (
2014-04-24 14:07:31 +02:00
'HTTP\x1b[39m\x1b[38;5;245m/\x1b[39m\x1b'
'[38;5;37m1.1\x1b[39m\x1b[38;5;245m \x1b[39m\x1b[38;5;37m200'
'\x1b[39m\x1b[38;5;245m \x1b[39m\x1b[38;5;136mOK'
)
def mk_config_dir() -> Path:
2016-03-01 16:22:50 +01:00
dirname = tempfile.mkdtemp(prefix='httpie_config_')
return Path(dirname)
2014-04-24 15:48:01 +02:00
2014-04-25 13:10:01 +02:00
def add_auth(url, auth):
proto, rest = url.split('://', 1)
return f'{proto}://{auth}@{rest}'
2014-04-24 14:07:31 +02:00
class StdinBytesIO(BytesIO):
"""To be used for `MockEnvironment.stdin`"""
len = 0 # See `prepare_request_body()`
class MockEnvironment(Environment):
2016-03-01 16:22:50 +01:00
"""Environment subclass with reasonable defaults for testing."""
colors = 0 # For easier debugging
stdin_isatty = True
2014-04-24 14:07:31 +02:00
stdout_isatty = True
is_windows = False
2014-04-24 15:48:01 +02:00
def __init__(self, create_temp_config_dir=True, **kwargs):
2014-04-24 14:07:31 +02:00
if 'stdout' not in kwargs:
2016-03-01 16:11:06 +01:00
kwargs['stdout'] = tempfile.TemporaryFile(
mode='w+b',
prefix='httpie_stdout'
)
2014-04-24 14:07:31 +02:00
if 'stderr' not in kwargs:
2016-03-01 16:11:06 +01:00
kwargs['stderr'] = tempfile.TemporaryFile(
mode='w+t',
prefix='httpie_stderr'
)
super().__init__(**kwargs)
self._create_temp_config_dir = create_temp_config_dir
2016-03-01 16:11:06 +01:00
self._delete_config_dir = False
self._temp_dir = Path(tempfile.gettempdir())
2016-03-01 16:11:06 +01:00
@property
def config(self) -> Config:
if (self._create_temp_config_dir
and self._temp_dir not in self.config_dir.parents):
self.create_temp_config_dir()
return super().config
2016-03-01 16:11:06 +01:00
def create_temp_config_dir(self):
self.config_dir = mk_config_dir()
self._delete_config_dir = True
2016-03-01 16:11:06 +01:00
def cleanup(self):
self.stdout.close()
self.stderr.close()
2016-03-01 16:11:06 +01:00
if self._delete_config_dir:
assert self._temp_dir in self.config_dir.parents
2016-03-01 16:11:06 +01:00
from shutil import rmtree
rmtree(self.config_dir, ignore_errors=True)
2014-04-24 14:07:31 +02:00
def __del__(self):
# noinspection PyBroadException
2016-03-01 16:11:06 +01:00
try:
self.cleanup()
except Exception:
pass
2014-04-24 14:07:31 +02:00
class BaseCLIResponse:
2016-03-01 16:22:50 +01:00
"""
Represents the result of simulated `$ http' invocation via `http()`.
2016-03-01 16:22:50 +01:00
Holds and provides access to:
- stdout output: print(self)
- stderr output: print(self.stderr)
2020-07-15 00:21:57 +02:00
- devnull output: print(self.devnull)
2016-03-01 16:22:50 +01:00
- exit_status output: print(self.exit_status)
"""
stderr: str = None
2020-07-15 00:21:57 +02:00
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)
2016-03-01 16:22:50 +01:00
class BytesCLIResponse(bytes, BaseCLIResponse):
"""
Used as a fallback when a StrCLIResponse cannot be used.
E.g. when the output contains binary data or when it is colorized.
`.json` will always be None.
"""
class StrCLIResponse(str, BaseCLIResponse):
@property
def json(self) -> Optional[dict]:
2016-03-01 16:22:50 +01:00
"""
Return deserialized the request or response JSON body,
if one (and only one) included in the output and is parsable.
2016-03-01 16:22:50 +01:00
"""
if not hasattr(self, '_json'):
self._json = None
# De-serialize JSON body if possible.
if COLOR in self:
# Colorized output cannot be parsed.
pass
elif self.strip().startswith('{'):
# Looks like JSON body.
self._json = json.loads(self)
elif self.count('Content-Type:') == 1:
# Looks like a HTTP message,
# try to extract JSON from its body.
2016-03-01 16:22:50 +01:00
try:
j = self.strip()[self.strip().rindex('\r\n\r\n'):]
except ValueError:
pass
else:
try:
# noinspection PyAttributeOutsideInit
2016-03-01 16:22:50 +01:00
self._json = json.loads(j)
except ValueError:
pass
return self._json
2016-03-02 06:31:23 +01:00
class ExitStatusError(Exception):
2016-03-02 06:16:41 +01:00
pass
def http(
*args,
program_name='http',
tolerate_error_exit_status=False,
**kwargs,
) -> Union[StrCLIResponse, BytesCLIResponse]:
2016-03-01 16:11:06 +01:00
# noinspection PyUnresolvedReferences
2014-04-24 14:07:31 +02:00
"""
2016-03-01 16:13:45 +01:00
Run HTTPie and capture stderr/out and exit status.
2020-07-15 00:21:57 +02:00
Content writtent to devnull will be captured only if
env.devnull is set manually.
2014-04-25 12:18:35 +02:00
2016-03-01 16:13:45 +01:00
Invoke `httpie.core.main()` with `args` and `kwargs`,
and return a `CLIResponse` subclass instance.
2016-03-01 16:13:45 +01:00
The return value is either a `StrCLIResponse`, or `BytesCLIResponse`
2020-07-15 00:21:57 +02:00
if unable to decode the output. Devnull is string when possible,
bytes otherwise.
2014-04-24 14:07:31 +02:00
2016-03-01 16:13:45 +01:00
The response has the following attributes:
2014-04-24 14:07:31 +02:00
2016-03-01 16:13:45 +01:00
`stdout` is represented by the instance itself (print r)
`stderr`: text written to stderr
2020-07-15 00:21:57 +02:00
`devnull` text written to devnull.
2016-03-01 16:13:45 +01:00
`exit_status`: the exit status
`json`: decoded JSON (if possible) or `None`
2014-04-24 14:07:31 +02:00
2016-03-01 16:13:45 +01:00
Exceptions are propagated.
If you pass ``tolerate_error_exit_status=True``, then error exit statuses
2016-03-01 16:13:45 +01:00
won't result into an exception.
2016-03-01 16:13:45 +01:00
Example:
2014-04-24 14:07:31 +02:00
2020-12-24 21:34:30 +01:00
$ http --auth=user:password GET pie.dev/basic-auth/user/password
2016-03-01 16:13:45 +01:00
>>> httpbin = getfixture('httpbin')
>>> r = http('-a', 'user:pw', httpbin.url + '/basic-auth/user/pw')
>>> type(r) == StrCLIResponse
True
>>> r.exit_status is ExitStatus.SUCCESS
True
2016-03-01 16:13:45 +01:00
>>> r.stderr
''
>>> 'HTTP/1.1 200 OK' in r
True
>>> r.json == {'authenticated': True, 'user': 'user'}
True
2016-03-01 16:13:45 +01:00
"""
2014-04-24 14:07:31 +02:00
env = kwargs.get('env')
if not env:
env = kwargs['env'] = MockEnvironment()
2014-04-24 14:07:31 +02:00
stdout = env.stdout
stderr = env.stderr
devnull = env.devnull
2014-04-24 14:07:31 +02:00
2014-04-25 12:42:50 +02:00
args = list(args)
args_with_config_defaults = args + env.config.default_options
add_to_args = []
if '--debug' not in args_with_config_defaults:
if (not tolerate_error_exit_status
and '--traceback' not in args_with_config_defaults):
add_to_args.append('--traceback')
if not any('--timeout' in arg for arg in args_with_config_defaults):
add_to_args.append('--timeout=3')
complete_args = [program_name, *add_to_args, *args]
# print(' '.join(complete_args))
2014-04-25 12:42:50 +02:00
def dump_stderr():
stderr.seek(0)
sys.stderr.write(stderr.read())
2014-04-24 18:32:15 +02:00
try:
2014-04-24 14:07:31 +02:00
try:
exit_status = main(args=complete_args, **kwargs)
2014-04-24 14:07:31 +02:00
if '--download' in args:
# Let the progress reporter thread finish.
time.sleep(.5)
except SystemExit:
if tolerate_error_exit_status:
2016-03-02 06:16:41 +01:00
exit_status = ExitStatus.ERROR
else:
dump_stderr()
raise
2014-04-24 14:07:31 +02:00
except Exception:
stderr.seek(0)
2014-04-24 14:07:31 +02:00
sys.stderr.write(stderr.read())
raise
else:
if (not tolerate_error_exit_status
and exit_status != ExitStatus.SUCCESS):
dump_stderr()
2016-03-02 06:31:23 +01:00
raise ExitStatusError(
2016-03-02 06:16:41 +01:00
'httpie.core.main() unexpectedly returned'
f' a non-zero exit status: {exit_status}'
2016-03-02 06:16:41 +01:00
)
2014-04-24 14:07:31 +02:00
stdout.seek(0)
stderr.seek(0)
devnull.seek(0)
2014-04-24 14:07:31 +02:00
output = stdout.read()
devnull_output = devnull.read()
2014-04-24 14:07:31 +02:00
try:
2014-04-25 12:42:50 +02:00
output = output.decode('utf8')
2014-04-24 14:07:31 +02:00
except UnicodeDecodeError:
r = BytesCLIResponse(output)
2014-04-25 12:42:50 +02:00
else:
r = StrCLIResponse(output)
2020-07-15 00:21:57 +02:00
try:
devnull_output = devnull_output.decode('utf8')
except Exception:
pass
r.devnull = devnull_output
2014-04-24 14:07:31 +02:00
r.stderr = stderr.read()
r.exit_status = exit_status
r.args = args
r.complete_args = ' '.join(complete_args)
2014-04-24 14:07:31 +02:00
if r.exit_status != ExitStatus.SUCCESS:
sys.stderr.write(r.stderr)
# print(f'\n\n$ {r.command}\n')
2014-04-24 14:07:31 +02:00
return r
finally:
devnull.close()
2014-04-24 14:07:31 +02:00
stdout.close()
stderr.close()
2016-03-01 16:11:06 +01:00
env.cleanup()