diff --git a/docs/index.md b/docs/index.md index 4fbcd6f6..f648b636 100644 --- a/docs/index.md +++ b/docs/index.md @@ -19,10 +19,10 @@ import pathlib, tomllib for sponsor in tomllib.loads(pathlib.Path("pyproject.toml").read_text())["tool"]["sponcon"]["sponsors"]: print(f'') ]]] --> - - - - + + + + ```{include} ../README.md diff --git a/src/structlog/_frames.py b/src/structlog/_frames.py index 35c06eaa..6acd7549 100644 --- a/src/structlog/_frames.py +++ b/src/structlog/_frames.py @@ -22,9 +22,6 @@ def _format_exception(exc_info: ExcInfo) -> str: Shamelessly stolen from stdlib's logging module. """ - if exc_info == (None, None, None): # type: ignore[comparison-overlap] - return "MISSING" - sio = StringIO() traceback.print_exception(exc_info[0], exc_info[1], exc_info[2], None, sio) diff --git a/src/structlog/processors.py b/src/structlog/processors.py index 65e6e00e..6bd460c7 100644 --- a/src/structlog/processors.py +++ b/src/structlog/processors.py @@ -19,7 +19,7 @@ import threading import time -from types import FrameType +from types import FrameType, TracebackType from typing import ( Any, Callable, @@ -28,6 +28,7 @@ NamedTuple, Sequence, TextIO, + cast, ) from ._frames import ( @@ -38,7 +39,12 @@ from ._log_levels import NAME_TO_LEVEL, add_log_level from ._utils import get_processname from .tracebacks import ExceptionDictTransformer -from .typing import EventDict, ExceptionTransformer, ExcInfo, WrappedLogger +from .typing import ( + EventDict, + ExceptionTransformer, + ExcInfo, + WrappedLogger, +) __all__ = [ @@ -407,11 +413,9 @@ def __init__( def __call__( self, logger: WrappedLogger, name: str, event_dict: EventDict ) -> EventDict: - exc_info = event_dict.pop("exc_info", None) + exc_info = _figure_out_exc_info(event_dict.pop("exc_info", None)) if exc_info: - event_dict["exception"] = self.format_exception( - _figure_out_exc_info(exc_info) - ) + event_dict["exception"] = self.format_exception(exc_info) return event_dict @@ -586,21 +590,30 @@ def __call__( return event_dict -def _figure_out_exc_info(v: Any) -> ExcInfo: +def _figure_out_exc_info(v: Any) -> ExcInfo | None: """ - Depending on the Python version will try to do the smartest thing possible - to transform *v* into an ``exc_info`` tuple. + Try to convert *v* into an ``exc_info`` tuple. + + Return ``None`` if *v* does not represent an exception or if there is no + current exception. """ if isinstance(v, BaseException): return (v.__class__, v, v.__traceback__) - if isinstance(v, tuple): - return v + if isinstance(v, tuple) and len(v) == 3: + has_type = isinstance(v[0], type) and issubclass(v[0], BaseException) + has_exc = isinstance(v[1], BaseException) + has_tb = v[2] is None or isinstance(v[2], TracebackType) + if has_type and has_exc and has_tb: + return v if v: - return sys.exc_info() # type: ignore[return-value] + result = sys.exc_info() + if result == (None, None, None): + return None + return cast(ExcInfo, result) - return v + return None class ExceptionPrettyPrinter: diff --git a/tests/processors/test_renderers.py b/tests/processors/test_renderers.py index 8f225c80..9fa24e5c 100644 --- a/tests/processors/test_renderers.py +++ b/tests/processors/test_renderers.py @@ -514,12 +514,12 @@ def test_nop_missing(self): def test_formats_tuple(self): """ - If exc_info is a tuple, it is used. + If exc_info is an arbitrary 3-tuple, it is not used. """ formatter = ExceptionRenderer(lambda exc_info: exc_info) d = formatter(None, None, {"exc_info": (None, None, 42)}) - assert {"exception": (None, None, 42)} == d + assert {} == d def test_gets_exc_info_on_bool(self): """ @@ -580,6 +580,4 @@ def test_no_exception(self, ei): """ A missing exception does not blow up. """ - assert {"exception": "MISSING"} == format_exc_info( - None, None, {"exc_info": ei} - ) + assert {} == format_exc_info(None, None, {"exc_info": ei}) diff --git a/tests/test_dev.py b/tests/test_dev.py index 00d40cd4..6e4d5cb2 100644 --- a/tests/test_dev.py +++ b/tests/test_dev.py @@ -545,10 +545,7 @@ def test_no_exception(self): r = dev.ConsoleRenderer(colors=False) assert ( - "hi" - == r( - None, None, {"event": "hi", "exc_info": (None, None, None)} - ).rstrip() + "hi" == r(None, None, {"event": "hi", "exc_info": None}).rstrip() ) def test_columns_warns_about_meaningless_arguments(self, recwarn): diff --git a/tests/test_tracebacks.py b/tests/test_tracebacks.py index 2de04776..a67f7086 100644 --- a/tests/test_tracebacks.py +++ b/tests/test_tracebacks.py @@ -15,6 +15,8 @@ import pytest +import structlog + from structlog import tracebacks @@ -730,3 +732,74 @@ def test_json_traceback_value_error( monkeypatch.setattr(kwargs["suppress"][0], "__file__", None) with pytest.raises(ValueError, match=next(iter(kwargs.keys()))): tracebacks.ExceptionDictTransformer(**kwargs) + + +class TestLogException: + """ + Higher level "integration tests" for "Logger.exception()". + """ + + @pytest.fixture() + def cap_logs(self) -> structlog.testing.LogCapture: + """ + Create a LogCapture to be used as processor and fixture for retrieving + logs in tests. + """ + return structlog.testing.LogCapture() + + @pytest.fixture() + def logger( + self, cap_logs: structlog.testing.LogCapture + ) -> structlog.Logger: + """ + Create a logger with the dict_tracebacks and a LogCapture processor. + """ + old_processors = structlog.get_config()["processors"] + structlog.configure([structlog.processors.dict_tracebacks, cap_logs]) + logger = structlog.get_logger("dict_tracebacks") + try: + yield logger + finally: + structlog.configure(processors=old_processors) + + def test_log_explicit_exception( + self, logger: structlog.Logger, cap_logs: structlog.testing.LogCapture + ) -> None: + """ + The log row contains a traceback when "Logger.exception()" is + explicitly called with an exception instance. + """ + try: + 1 / 0 + except ZeroDivisionError as e: + logger.exception("onoes", exception=e) + + log_row = cap_logs.entries[0] + assert log_row["exception"][0]["exc_type"] == "ZeroDivisionError" + + def test_log_implicit_exception( + self, logger: structlog.Logger, cap_logs: structlog.testing.LogCapture + ) -> None: + """ + The log row contains a traceback when "Logger.exception()" is called + in an "except" block but without explicitly passing the exception. + """ + try: + 1 / 0 + except ZeroDivisionError: + logger.exception("onoes") + + log_row = cap_logs.entries[0] + assert log_row["exception"][0]["exc_type"] == "ZeroDivisionError" + + def test_no_exception( + self, logger: structlog.Logger, cap_logs: structlog.testing.LogCapture + ) -> None: + """ + "Logger.exception()" should not be called outside an "except" block + but this cases is gracefully handled and does not lead to an exception. + + See: https://github.com/hynek/structlog/issues/634 + """ + logger.exception("onoes") + assert [{"event": "onoes", "log_level": "error"}] == cap_logs.entries