"""Run CLI entrypoints in-process so coverage (and test_map) sees the full path.
Subprocess-based CLI tests are invisible to coverage.py here (no
COVERAGE_PROCESS_START/sitecustomize), so they contribute zero coverage and the
incremental gate never maps them to deep core symbols. Calling ``main()``
in-process keeps the same observable result (returncode + captured streams)
while letting coverage measure the real work.
"""
from __future__ import annotations
import contextlib
import importlib
import io
import sys
import traceback
from collections.abc import Callable
from dataclasses import dataclass
@dataclass
class CliResult:
"""Subset of ``subprocess.CompletedProcess`` used by CLI tests."""
returncode: int
stdout: str
stderr: str
def run_cli_main(main_callable: Callable[[], object], argv: list[str], *, prog: str = "cli") -> CliResult:
"""Invoke ``main_callable`` with ``sys.argv`` set to ``[prog, *argv]``.
``SystemExit`` codes and integer return values are mapped to ``returncode``
the same way ``python -m module`` would report them. Other exceptions are
captured as ``returncode=1`` with the traceback on stderr, mirroring a
crashed subprocess instead of propagating into the test.
"""
out = io.StringIO()
err = io.StringIO()
returncode = 0
saved_argv = sys.argv
sys.argv = [prog, *argv]
try:
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
try:
result = main_callable()
except SystemExit as exc:
code = exc.code
if code is None:
returncode = 0
elif isinstance(code, int):
returncode = code
else:
returncode = 1
print(code, file=sys.stderr)
except Exception:
returncode = 1
traceback.print_exc(file=sys.stderr)
else:
returncode = result if isinstance(result, int) else 0
finally:
sys.argv = saved_argv
return CliResult(returncode=returncode, stdout=out.getvalue(), stderr=err.getvalue())
def run_module_main(module_name: str, argv: list[str]) -> CliResult:
"""Import ``module_name`` and run its ``main()`` in-process."""
module = importlib.import_module(module_name)
return run_cli_main(module.main, argv, prog=module_name.rsplit(".", 1)[-1])