"""
CLI entry simulation utilities for test environments.
This module provides helpers for simulating the execution of command-line
interface (CLI) entry functions within a controlled environment. It captures
standard output, standard error, exit codes, and uncaught exceptions to support
reproducible and assertion-friendly tests.
The module contains the following main components:
* :class:`EntryRunResult` - Result container for a simulated CLI invocation
* :func:`simulate_entry` - Execute an entry function with mocked arguments and environment
Example::
>>> from hbutils.testing.simulate.entry import simulate_entry
>>> def my_cli():
... print("hello")
...
>>> result = simulate_entry(my_cli, argv=["mycli"])
>>> result.exitcode
0
>>> result.stdout
'hello\\n'
.. note::
Uncaught exceptions raised by the entry function are captured in the
:attr:`EntryRunResult.error` attribute instead of being printed to stderr.
"""
import io
import traceback
from contextlib import contextmanager
from functools import partial
from typing import Optional, List, Callable, Mapping, ContextManager, Any
from unittest.mock import patch
from ..capture import capture_output, capture_exit
__all__ = [
'simulate_entry', 'EntryRunResult',
]
[docs]
class EntryRunResult:
"""
Result container for a simulated CLI entry invocation.
This class encapsulates the execution result of a CLI entry function,
including exit code, captured stdout/stderr content, and any uncaught
exception raised during execution.
:param exitcode: Exit code returned by the entry function.
:type exitcode: int
:param stdout: Output captured from standard output.
:type stdout: Optional[str]
:param stderr: Output captured from standard error.
:type stderr: Optional[str]
:param error: Uncaught exception raised by the entry function.
:type error: Optional[BaseException]
"""
[docs]
def __init__(
self,
exitcode: int,
stdout: Optional[str],
stderr: Optional[str],
error: Optional[BaseException],
) -> None:
"""
Initialize the entry run result.
:param exitcode: Exit code returned by the entry function.
:type exitcode: int
:param stdout: Output captured from standard output.
:type stdout: Optional[str]
:param stderr: Output captured from standard error.
:type stderr: Optional[str]
:param error: Uncaught exception raised by the entry function.
:type error: Optional[BaseException]
"""
self.__exitcode = exitcode
self.__stdout = stdout
self.__stderr = stderr
self.__error = error
@property
def exitcode(self) -> int:
"""
Exit code returned by the entry function.
:return: The exit code value.
:rtype: int
"""
return self.__exitcode
@property
def stdout(self) -> Optional[str]:
"""
Output captured from standard output stream.
:return: The stdout content, or ``None`` if no output was captured.
:rtype: Optional[str]
"""
return self.__stdout
@property
def stderr(self) -> Optional[str]:
"""
Output captured from standard error stream.
:return: The stderr content, or ``None`` if no output was captured.
:rtype: Optional[str]
"""
return self.__stderr
@property
def error(self) -> Optional[BaseException]:
"""
Uncaught exception raised inside the entry function.
:return: The exception object, or ``None`` if no exception was raised.
:rtype: Optional[BaseException]
"""
return self.__error
def _assert_okay_message(self) -> str:
"""
Generate a detailed error message for assertion failures.
:return: Formatted error message containing exit code, exception details,
and output streams.
:rtype: 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('---------------------------------')
pp('[Stdout]')
pp(self.stdout)
pp()
if self.stderr:
pp('---------------------------------')
pp('[Stderr]')
pp(self.stderr)
pp()
return sf.getvalue()
[docs]
def assert_okay(self) -> None:
"""
Assert that the entry execution was successful.
This checks that the exit code is ``0`` and no uncaught exception was
raised. If the assertion fails, a detailed error message is provided.
:raises AssertionError: If the exit code is not ``0`` or an exception
was raised.
Example::
>>> def my_cli():
... print("hello")
...
>>> result = simulate_entry(my_cli, ['mycli'])
>>> result.assert_okay()
"""
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[None]:
"""
Context manager to mock ``sys.argv``.
:param argv: Command line arguments to mock. If ``None``, ``sys.argv`` is
not mocked.
:type argv: Optional[List[str]]
:return: Context manager for mocking ``sys.argv``.
:rtype: ContextManager[None]
Example::
>>> import sys
>>> with _mock_argv(['script.py', 'arg1', 'arg2']):
... print(sys.argv)
['script.py', 'arg1', 'arg2']
"""
if argv is not None:
with patch('sys.argv', argv):
yield
else:
yield
@contextmanager
def _mock_environ(envs: Optional[Mapping[str, str]] = None) -> ContextManager[None]:
"""
Context manager to mock ``os.environ``.
:param envs: Environment variables to mock. If ``None``, ``os.environ`` is
not mocked.
:type envs: Optional[Mapping[str, str]]
:return: Context manager for mocking ``os.environ``.
:rtype: ContextManager[None]
Example::
>>> import os
>>> with _mock_environ({'MY_VAR': 'value'}):
... print(os.environ['MY_VAR'])
value
"""
if envs is not None:
with patch.dict('os.environ', envs, clear=False):
yield
else:
yield
[docs]
def simulate_entry(
entry: Callable[[], Any],
argv: Optional[List[str]] = None,
envs: Optional[Mapping[str, str]] = None,
) -> EntryRunResult:
"""
Simulate execution of a CLI entry function.
This function executes a CLI entry function in a controlled environment,
capturing its output, exit code, and any uncaught exceptions for testing
purposes.
:param entry: Entry function; should be callable without arguments.
:type entry: Callable[[], Any]
:param argv: Command line arguments. If ``None``, ``sys.argv`` is not mocked.
:type argv: Optional[List[str]]
:param envs: Environment variables. If ``None``, ``os.environ`` is not mocked.
:type envs: Optional[Mapping[str, str]]
:return: A result object containing exit code, stdout, stderr, and error.
:rtype: 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::
If an uncaught exception is raised inside the entry function, it
will be captured in :attr:`EntryRunResult.error` instead of being
printed to ``stderr``.
>>> 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)