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