diff --git a/tmt/__main__.py b/tmt/__main__.py index ea9fb49c5b..4a9e391d02 100644 --- a/tmt/__main__.py +++ b/tmt/__main__.py @@ -37,7 +37,11 @@ def run_cli() -> None: raise SystemExit(2) from error except Exception as nested_error: - print(f"Error: failed while importing tmt package: {nested_error}", file=sys.stderr) + import traceback + + print(f"Error: failed while reporting exception: {nested_error}", file=sys.stderr) + traceback.print_exc() + raise SystemExit(2) from nested_error diff --git a/tmt/log.py b/tmt/log.py index 753e8e3902..af03615fc6 100644 --- a/tmt/log.py +++ b/tmt/log.py @@ -44,6 +44,7 @@ import click +from tmt._compat.pathlib import Path from tmt._compat.warnings import deprecated if TYPE_CHECKING: @@ -280,9 +281,13 @@ class LogRecordDetails: class LogfileHandler(logging.FileHandler): + emitting_to: list[Path] = [] + def __init__(self, filepath: 'tmt.utils.Path') -> None: super().__init__(filepath, mode='a') + LogfileHandler.emitting_to.append(filepath) + # ignore[type-arg]: StreamHandler is a generic type, but such expression would be incompatible # with older Python versions. Since it's not critical to mark the handler as "str only", we can @@ -482,7 +487,7 @@ def __init__( self.quiet = quiet self.topics = topics or DEFAULT_TOPICS - self.apply_colors_output = apply_colors_output + self.apply_colors_output = self._apply_colors_output = apply_colors_output self.apply_colors_logging = apply_colors_logging self._decolorize_output = create_decolorizer(apply_colors_output) @@ -498,6 +503,16 @@ def __repr__(self) -> str: f' apply_colors_logging={self.apply_colors_logging}' f'>') + @property + def apply_colors_output(self) -> bool: + return self._apply_colors_output + + @apply_colors_output.setter + def apply_colors_output(self, value: bool) -> None: + self._apply_colors_output = value + + self._decolorize_output = create_decolorizer(self._apply_colors_output) + @property def labels_span(self) -> int: """ Length of rendered labels """ diff --git a/tmt/utils/__init__.py b/tmt/utils/__init__.py index 5c69ec3d6d..7465c14ad4 100644 --- a/tmt/utils/__init__.py +++ b/tmt/utils/__init__.py @@ -39,6 +39,7 @@ Generic, Literal, Optional, + TextIO, TypeVar, Union, cast, @@ -2399,6 +2400,29 @@ class FinishError(GeneralError): """ Finish step error """ +class TracebackVerbosity(enum.Enum): + """ Levels of logged traveback verbosity """ + + #: Render only exception and its causes. + DEFAULT = '0' + #: Render also call stack for exception and each of its causes. + VERBOSE = '1' + #: Render also call stack and local variables for exception and each of its causes. + FULL = 'full' + + @classmethod + def from_spec(cls, spec: str) -> 'TracebackVerbosity': + try: + return TracebackVerbosity(spec) + + except ValueError: + raise SpecificationError(f"Invalid traceback verbosity '{spec}'.") + + @classmethod + def from_env(cls) -> 'TracebackVerbosity': + return TracebackVerbosity.from_spec(os.getenv('TMT_SHOW_TRACEBACK', '0').lower()) + + def render_run_exception_streams( stdout: Optional[str], stderr: Optional[str], @@ -2437,7 +2461,9 @@ def render_run_exception(exception: RunError) -> Iterator[str]: yield from render_run_exception_streams(exception.stdout, exception.stderr, verbose=verbose) -def render_exception_stack(exception: BaseException) -> Iterator[str]: +def render_exception_stack( + exception: BaseException, + traceback_verbosity: TracebackVerbosity = TracebackVerbosity.DEFAULT) -> Iterator[str]: """ Render traceback of the given exception """ exception_traceback = traceback.TracebackException( @@ -2459,7 +2485,7 @@ def render_exception_stack(exception: BaseException) -> Iterator[str]: yield f'File {Y(frame.filename)}, line {Y(str(frame.lineno))}, in {Y(frame.name)}' yield f' {B(frame.line)}' - if os.getenv('TMT_SHOW_TRACEBACK', '0').lower() == 'full' and frame.locals: + if traceback_verbosity is TracebackVerbosity.FULL and frame.locals: yield '' for k, v in frame.locals.items(): @@ -2468,7 +2494,9 @@ def render_exception_stack(exception: BaseException) -> Iterator[str]: yield '' -def render_exception(exception: BaseException) -> Iterator[str]: +def render_exception( + exception: BaseException, + traceback_verbosity: TracebackVerbosity = TracebackVerbosity.DEFAULT) -> Iterator[str]: """ Render the exception and its causes for printing """ def _indent(iterable: Iterable[str]) -> Iterator[str]: @@ -2486,16 +2514,17 @@ def _indent(iterable: Iterable[str]) -> Iterator[str]: yield '' yield from render_run_exception(exception) - if os.getenv('TMT_SHOW_TRACEBACK', '0') != '0': + if traceback_verbosity is not TracebackVerbosity.DEFAULT: yield '' - yield from _indent(render_exception_stack(exception)) + yield from _indent(render_exception_stack( + exception, traceback_verbosity=traceback_verbosity)) # Follow the chain and render all causes def _render_cause(number: int, cause: BaseException) -> Iterator[str]: yield '' yield f'Cause number {number}:' yield '' - yield from _indent(render_exception(cause)) + yield from _indent(render_exception(cause, traceback_verbosity=traceback_verbosity)) def _render_causes(causes: list[BaseException]) -> Iterator[str]: yield '' @@ -2516,13 +2545,52 @@ def _render_causes(causes: list[BaseException]) -> Iterator[str]: yield from _render_causes(causes) -def show_exception(exception: BaseException) -> None: - """ Display the exception and its causes """ +def show_exception( + exception: BaseException, + include_logfiles: bool = True) -> None: + """ + Display the exception and its causes. + + :param exception: exception to log. + :param include_logfiles: if set, exception will be logged into known + logfiles as well as to standard error output. + """ from tmt.cli import EXCEPTION_LOGGER - EXCEPTION_LOGGER.print('', file=sys.stderr) - EXCEPTION_LOGGER.print('\n'.join(render_exception(exception)), file=sys.stderr) + traceback_verbosity = TracebackVerbosity.from_env() + + def _render_exception(traceback_verbosity: TracebackVerbosity) -> Iterator[str]: + yield '' + yield from render_exception(exception, traceback_verbosity=traceback_verbosity) + + for line in _render_exception(traceback_verbosity): + EXCEPTION_LOGGER.print(line, file=sys.stderr) + + if include_logfiles: + logger = EXCEPTION_LOGGER.clone() + logger.apply_colors_output = False + + logfile_streams: list[TextIO] = [] + + with contextlib.ExitStack() as stack: + for path in tmt.log.LogfileHandler.emitting_to: + try: + # SIM115: all opened files are added on exit stack, and they + # will get collected and closed properly. + stream: TextIO = open(path, 'a') # noqa: SIM115 + + logfile_streams.append(stream) + stack.enter_context(stream) + + except Exception as exc: + show_exception( + GeneralError(f"Cannot log error into logfile '{path}'.", causes=[exc]), + include_logfiles=False) + + for line in _render_exception(traceback_verbosity=TracebackVerbosity.FULL): + for stream in logfile_streams: + logger.print(line, file=stream) # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~