python-click/click/testing.py

375 lines
13 KiB
Python
Raw Normal View History

2014-10-16 20:40:34 +02:00
import os
import sys
import shutil
import tempfile
import contextlib
2018-09-06 20:55:10 +02:00
import shlex
2014-10-16 20:40:34 +02:00
2018-09-06 20:55:10 +02:00
from ._compat import iteritems, PY2, string_types
2014-10-16 20:40:34 +02:00
# If someone wants to vendor click, we want to ensure the
# correct package is discovered. Ideally we could use a
# relative import here but unfortunately Python does not
# support that.
clickpkg = sys.modules[__name__.rsplit('.', 1)[0]]
if PY2:
from cStringIO import StringIO
else:
import io
from ._compat import _find_binary_reader
class EchoingStdin(object):
def __init__(self, input, output):
self._input = input
self._output = output
def __getattr__(self, x):
return getattr(self._input, x)
def _echo(self, rv):
self._output.write(rv)
return rv
def read(self, n=-1):
return self._echo(self._input.read(n))
def readline(self, n=-1):
return self._echo(self._input.readline(n))
def readlines(self):
return [self._echo(x) for x in self._input.readlines()]
def __iter__(self):
return iter(self._echo(x) for x in self._input)
def __repr__(self):
return repr(self._input)
def make_input_stream(input, charset):
# Is already an input stream.
if hasattr(input, 'read'):
if PY2:
return input
rv = _find_binary_reader(input)
if rv is not None:
return rv
raise TypeError('Could not find binary reader for input stream.')
if input is None:
input = b''
elif not isinstance(input, bytes):
input = input.encode(charset)
if PY2:
return StringIO(input)
return io.BytesIO(input)
class Result(object):
"""Holds the captured result of an invoked CLI script."""
2018-09-06 20:55:10 +02:00
def __init__(self, runner, stdout_bytes, stderr_bytes, exit_code,
exception, exc_info=None):
2014-10-16 20:40:34 +02:00
#: The runner that created the result
self.runner = runner
2018-09-06 20:55:10 +02:00
#: The standard output as bytes.
self.stdout_bytes = stdout_bytes
#: The standard error as bytes, or False(y) if not available
self.stderr_bytes = stderr_bytes
2014-10-16 20:40:34 +02:00
#: The exit code as integer.
self.exit_code = exit_code
2018-09-06 20:55:10 +02:00
#: The exception that happened if one did.
2014-10-16 20:40:34 +02:00
self.exception = exception
#: The traceback
self.exc_info = exc_info
@property
def output(self):
2018-09-06 20:55:10 +02:00
"""The (standard) output as unicode string."""
return self.stdout
@property
def stdout(self):
"""The standard output as unicode string."""
return self.stdout_bytes.decode(self.runner.charset, 'replace') \
.replace('\r\n', '\n')
@property
def stderr(self):
"""The standard error as unicode string."""
if not self.stderr_bytes:
raise ValueError("stderr not separately captured")
return self.stderr_bytes.decode(self.runner.charset, 'replace') \
2014-10-16 20:40:34 +02:00
.replace('\r\n', '\n')
2018-09-06 20:55:10 +02:00
2014-10-16 20:40:34 +02:00
def __repr__(self):
2018-09-06 20:55:10 +02:00
return '<%s %s>' % (
type(self).__name__,
2014-10-16 20:40:34 +02:00
self.exception and repr(self.exception) or 'okay',
)
class CliRunner(object):
"""The CLI runner provides functionality to invoke a Click command line
script for unittesting purposes in a isolated environment. This only
works in single-threaded systems without any concurrency as it changes the
global interpreter state.
:param charset: the character set for the input and output data. This is
UTF-8 by default and should not be changed currently as
the reporting to Click only works in Python 2 properly.
:param env: a dictionary with environment variables for overriding.
:param echo_stdin: if this is set to `True`, then reading from stdin writes
to stdout. This is useful for showing examples in
some circumstances. Note that regular prompts
will automatically echo the input.
2018-09-06 20:55:10 +02:00
:param mix_stderr: if this is set to `False`, then stdout and stderr are
preserved as independent streams. This is useful for
Unix-philosophy apps that have predictable stdout and
noisy stderr, such that each may be measured
independently
2014-10-16 20:40:34 +02:00
"""
2018-09-06 20:55:10 +02:00
def __init__(self, charset=None, env=None, echo_stdin=False,
mix_stderr=True):
2014-10-16 20:40:34 +02:00
if charset is None:
charset = 'utf-8'
self.charset = charset
self.env = env or {}
self.echo_stdin = echo_stdin
2018-09-06 20:55:10 +02:00
self.mix_stderr = mix_stderr
2014-10-16 20:40:34 +02:00
def get_default_prog_name(self, cli):
"""Given a command object it will return the default program name
for it. The default is the `name` attribute or ``"root"`` if not
set.
"""
return cli.name or 'root'
def make_env(self, overrides=None):
"""Returns the environment overrides for invoking a script."""
rv = dict(self.env)
if overrides:
rv.update(overrides)
return rv
@contextlib.contextmanager
2015-07-16 14:26:14 +02:00
def isolation(self, input=None, env=None, color=False):
2014-10-16 20:40:34 +02:00
"""A context manager that sets up the isolation for invoking of a
command line tool. This sets up stdin with the given input data
and `os.environ` with the overrides from the given dictionary.
This also rebinds some internals in Click to be mocked (like the
prompt functionality).
This is automatically done in the :meth:`invoke` method.
2015-07-16 14:26:14 +02:00
.. versionadded:: 4.0
The ``color`` parameter was added.
2014-10-16 20:40:34 +02:00
:param input: the input stream to put into sys.stdin.
:param env: the environment overrides as dictionary.
2015-07-16 14:26:14 +02:00
:param color: whether the output should contain color codes. The
application can still override this explicitly.
2014-10-16 20:40:34 +02:00
"""
input = make_input_stream(input, self.charset)
old_stdin = sys.stdin
old_stdout = sys.stdout
old_stderr = sys.stderr
2015-12-04 16:51:02 +01:00
old_forced_width = clickpkg.formatting.FORCED_WIDTH
clickpkg.formatting.FORCED_WIDTH = 80
2014-10-16 20:40:34 +02:00
env = self.make_env(env)
if PY2:
2018-09-06 20:55:10 +02:00
bytes_output = StringIO()
2014-10-16 20:40:34 +02:00
if self.echo_stdin:
input = EchoingStdin(input, bytes_output)
2018-09-06 20:55:10 +02:00
sys.stdout = bytes_output
if not self.mix_stderr:
bytes_error = StringIO()
sys.stderr = bytes_error
2014-10-16 20:40:34 +02:00
else:
bytes_output = io.BytesIO()
if self.echo_stdin:
input = EchoingStdin(input, bytes_output)
input = io.TextIOWrapper(input, encoding=self.charset)
2018-09-06 20:55:10 +02:00
sys.stdout = io.TextIOWrapper(
2014-10-16 20:40:34 +02:00
bytes_output, encoding=self.charset)
2018-09-06 20:55:10 +02:00
if not self.mix_stderr:
bytes_error = io.BytesIO()
sys.stderr = io.TextIOWrapper(
bytes_error, encoding=self.charset)
if self.mix_stderr:
sys.stderr = sys.stdout
2014-10-16 20:40:34 +02:00
sys.stdin = input
def visible_input(prompt=None):
sys.stdout.write(prompt or '')
val = input.readline().rstrip('\r\n')
sys.stdout.write(val + '\n')
sys.stdout.flush()
return val
def hidden_input(prompt=None):
sys.stdout.write((prompt or '') + '\n')
sys.stdout.flush()
return input.readline().rstrip('\r\n')
def _getchar(echo):
char = sys.stdin.read(1)
if echo:
sys.stdout.write(char)
sys.stdout.flush()
return char
2015-07-16 14:26:14 +02:00
default_color = color
2018-09-06 20:55:10 +02:00
2015-07-16 14:26:14 +02:00
def should_strip_ansi(stream=None, color=None):
if color is None:
return not default_color
return not color
2014-10-16 20:40:34 +02:00
old_visible_prompt_func = clickpkg.termui.visible_prompt_func
old_hidden_prompt_func = clickpkg.termui.hidden_prompt_func
old__getchar_func = clickpkg.termui._getchar
2015-07-16 14:26:14 +02:00
old_should_strip_ansi = clickpkg.utils.should_strip_ansi
2014-10-16 20:40:34 +02:00
clickpkg.termui.visible_prompt_func = visible_input
clickpkg.termui.hidden_prompt_func = hidden_input
clickpkg.termui._getchar = _getchar
2015-07-16 14:26:14 +02:00
clickpkg.utils.should_strip_ansi = should_strip_ansi
2014-10-16 20:40:34 +02:00
old_env = {}
try:
for key, value in iteritems(env):
2017-07-19 20:06:01 +02:00
old_env[key] = os.environ.get(key)
2014-10-16 20:40:34 +02:00
if value is None:
try:
del os.environ[key]
except Exception:
pass
else:
os.environ[key] = value
2018-09-06 20:55:10 +02:00
yield (bytes_output, not self.mix_stderr and bytes_error)
2014-10-16 20:40:34 +02:00
finally:
for key, value in iteritems(old_env):
if value is None:
try:
del os.environ[key]
except Exception:
pass
else:
os.environ[key] = value
sys.stdout = old_stdout
sys.stderr = old_stderr
sys.stdin = old_stdin
clickpkg.termui.visible_prompt_func = old_visible_prompt_func
clickpkg.termui.hidden_prompt_func = old_hidden_prompt_func
clickpkg.termui._getchar = old__getchar_func
2015-07-16 14:26:14 +02:00
clickpkg.utils.should_strip_ansi = old_should_strip_ansi
2015-12-04 16:51:02 +01:00
clickpkg.formatting.FORCED_WIDTH = old_forced_width
2014-10-16 20:40:34 +02:00
def invoke(self, cli, args=None, input=None, env=None,
2018-09-06 20:55:10 +02:00
catch_exceptions=True, color=False, mix_stderr=False, **extra):
2014-10-16 20:40:34 +02:00
"""Invokes a command in an isolated environment. The arguments are
forwarded directly to the command line script, the `extra` keyword
arguments are passed to the :meth:`~clickpkg.Command.main` function of
the command.
This returns a :class:`Result` object.
.. versionadded:: 3.0
The ``catch_exceptions`` parameter was added.
.. versionchanged:: 3.0
The result object now has an `exc_info` attribute with the
traceback if available.
2015-07-16 14:26:14 +02:00
.. versionadded:: 4.0
The ``color`` parameter was added.
2014-10-16 20:40:34 +02:00
:param cli: the command to invoke
2018-09-06 20:55:10 +02:00
:param args: the arguments to invoke. It may be given as an iterable
or a string. When given as string it will be interpreted
as a Unix shell command. More details at
:func:`shlex.split`.
2014-10-16 20:40:34 +02:00
:param input: the input data for `sys.stdin`.
:param env: the environment overrides.
:param catch_exceptions: Whether to catch any other exceptions than
``SystemExit``.
:param extra: the keyword arguments to pass to :meth:`main`.
2015-07-16 14:26:14 +02:00
:param color: whether the output should contain color codes. The
application can still override this explicitly.
2014-10-16 20:40:34 +02:00
"""
exc_info = None
2018-09-06 20:55:10 +02:00
with self.isolation(input=input, env=env, color=color) as outstreams:
2014-10-16 20:40:34 +02:00
exception = None
exit_code = 0
2018-09-06 20:55:10 +02:00
if isinstance(args, string_types):
args = shlex.split(args)
2014-10-16 20:40:34 +02:00
try:
2018-09-06 20:55:10 +02:00
prog_name = extra.pop("prog_name")
except KeyError:
prog_name = self.get_default_prog_name(cli)
2015-08-23 03:10:31 +02:00
2018-09-06 20:55:10 +02:00
try:
cli.main(args=args or (), prog_name=prog_name, **extra)
except SystemExit as e:
2014-10-16 20:40:34 +02:00
exc_info = sys.exc_info()
2015-08-23 03:10:31 +02:00
exit_code = e.code
2018-09-06 20:55:10 +02:00
if exit_code is None:
exit_code = 0
if exit_code != 0:
exception = e
2015-08-23 03:10:31 +02:00
if not isinstance(exit_code, int):
sys.stdout.write(str(exit_code))
sys.stdout.write('\n')
exit_code = 1
2018-09-06 20:55:10 +02:00
2014-10-16 20:40:34 +02:00
except Exception as e:
if not catch_exceptions:
raise
exception = e
2018-09-06 20:55:10 +02:00
exit_code = 1
2014-10-16 20:40:34 +02:00
exc_info = sys.exc_info()
finally:
sys.stdout.flush()
2018-09-06 20:55:10 +02:00
stdout = outstreams[0].getvalue()
stderr = outstreams[1] and outstreams[1].getvalue()
2014-10-16 20:40:34 +02:00
return Result(runner=self,
2018-09-06 20:55:10 +02:00
stdout_bytes=stdout,
stderr_bytes=stderr,
2014-10-16 20:40:34 +02:00
exit_code=exit_code,
exception=exception,
exc_info=exc_info)
@contextlib.contextmanager
def isolated_filesystem(self):
"""A context manager that creates a temporary folder and changes
the current working directory to it for isolated filesystem tests.
"""
cwd = os.getcwd()
t = tempfile.mkdtemp()
os.chdir(t)
try:
yield t
finally:
os.chdir(cwd)
try:
shutil.rmtree(t)
except (OSError, IOError):
pass