Source code for hbutils.testing.simulate.entry

"""
Overview:
    Simulation for cli entry.
"""
import io
import traceback
from contextlib import contextmanager
from functools import partial
from typing import Optional, List, Callable, Mapping, ContextManager
from unittest.mock import patch

from ..capture import capture_output, capture_exit

__all__ = [
    'simulate_entry', 'EntryRunResult',
]


[docs]class EntryRunResult: """ Overview: Run result of one entry. """
[docs] def __init__(self, exitcode: int, stdout: Optional[str], stderr: Optional[str], error: Optional[BaseException]): """ Constructor of :class:`EntryRunResult`. :param exitcode: Exit code. :param stdout: Output in standard output stream. :param stderr: Output in standard error stream. :param error: Uncaught exception raised inside. """ self.__exitcode = exitcode self.__stdout = stdout self.__stderr = stderr self.__error = error
@property def exitcode(self) -> int: """ Exit code. """ return self.__exitcode @property def stdout(self) -> Optional[str]: """ Output in standard output stream. """ return self.__stdout @property def stderr(self) -> Optional[str]: """ Output in standard error stream. """ return self.__stderr @property def error(self) -> Optional[BaseException]: """ Uncaught exception raised inside. """ return self.__error def _assert_okay_message(self) -> str: with io.StringIO() as sf: pp = partial(print, file=sf) if self.error is not None: pp(f'Exitcode - {self.exitcode!r}, with uncaught exception:') traceback.print_tb(self.error.__traceback__, file=sf) pp(f'{type(self.error)}: {self.error.args!r}') else: pp(f'Exitcode - {self.exitcode!r}.') if self.stdout: pp(f'---------------------------------') pp(f'[Stdout]') pp(self.stdout) pp() if self.stderr: pp(f'---------------------------------') pp(f'[Stderr]') pp(self.stderr) pp() return sf.getvalue() def assert_okay(self): assert self.exitcode == 0 and self.error is None, self._assert_okay_message()
# See: https://stackoverflow.com/questions/36136480/what-is-pythons-default-exit-code _OKAY_EXITCODE = 0x0 _ERROR_EXITCODE = 0x1 _USAGE_EXITCODE = 0x2 @contextmanager def _mock_argv(argv: Optional[List[str]] = None) -> ContextManager: if argv is not None: with patch('sys.argv', argv): yield else: yield @contextmanager def _mock_environ(envs: Optional[Mapping[str, str]] = None) -> ContextManager: if envs is not None: with patch.dict('os.environ', envs, clear=False): yield else: yield
[docs]def simulate_entry(entry: Callable, argv: Optional[List[str]] = None, envs: Optional[Mapping[str, str]] = None) -> EntryRunResult: """ Overview: CLI entry's simulation. :param entry: Entry function, should be a simple function without any arguments. :param argv: Command line arguments. Default is ``None``, which means do not mock ``sys.argv``. :param envs: Environment arguments. Default is ``None``, which means do not mock ``os.environ``. :returns: A result object, in form of :class:`EntryRunResult`. Examples:: We create a simple CLI code with `click package <https://click.palletsprojects.com/>`_, \ named ``test_cli1.py`` .. code-block:: python :linenos: import sys import click @click.command('cli1', help='CLI-1 example') @click.option('-c', type=int, help='optional C value', default=None) @click.argument('a', type=int) @click.argument('b', type=int) def cli1(a, b, c): if c is None: print(f'{a} + {b} = {a + b}') else: print(f'{a} + {b} + {c} = {a + b + c}', file=sys.stderr) if __name__ == '__main__': cli1() When we can try to simulate it. >>> from hbutils.testing import simulate_entry >>> from test_cli1 import cli1 >>> r1 = simulate_entry(cli1, ['cli1', '2', '3']) >>> print(r1.exitcode) 0 >>> print(r1.stdout) 2 + 3 = 5 >>> r2 = simulate_entry(cli1, ['cli1', '2', '3', '-c', '24']) # option >>> print(r2.exitcode) 0 >>> print(r2.stderr) 2 + 3 + 24 = 29 >>> r3 = simulate_entry(cli1, ['cli', '--help']) # help >>> print(r3.stdout) Usage: cli [OPTIONS] A B CLI-1 example Options: -c INTEGER optional C value --help Show this message and exit. >>> r4 = simulate_entry(cli1, ['cli', 'dklsfj']) # misusage >>> print(r4.exitcode) 2 >>> print(r4.stderr) Usage: cli [OPTIONS] A B Try 'cli --help' for help. Error: Invalid value for 'A': 'dklsfj' is not a valid integer. .. note:: Please note that if there is uncaught exception raised inside the entry function, \ it will be caught and put into ``error`` property instead of be being printed \ to ``stderr``. For example >>> from hbutils.testing import simulate_entry >>> def my_cli(): ... raise ValueError(233) >>> >>> r = simulate_entry(my_cli) >>> print(r.exitcode) # will be 0x1 1 >>> print(r.stdout) # nothing >>> print(r.stderr) # nothing as well >>> print(repr(r.error)) # HERE!!! ValueError(233) """ try: with capture_output() as _out, capture_exit(_OKAY_EXITCODE) as _exit, \ _mock_argv(argv), _mock_environ(envs): entry() except BaseException as err: return EntryRunResult(_ERROR_EXITCODE, _out.stdout, _out.stderr, err) else: return EntryRunResult(_exit.exitcode, _out.stdout, _out.stderr, None)