Skip to content

Commit 2f7298a

Browse files
sscherfkehynek
andauthored
Handle Logger.exception() outside "except" block (hynek#680)
* Handle Logger.exception() outside "except" block Fixes: hynek#634 * Continue fixing * Try a more radical approach to fix _figure_out_exc_info() The main problem of the function is that *v* can really be anything (depending on what the users) do and the annotated return type did not properly match it (e.g., if *v* was "0", the return value would be "0"). The function now rigorously checks the user input and either returns the desired result (an exc info tuple) or None. This makes it easier and safer to use this function. * Revert hynek#657 The changes introduced in hynek#657 are no longer needed since _format_exception() is now only called if _figure_out_exc_info() detects an actual exception * Simplify branch condition * Apply suggestions from code review Co-authored-by: Hynek Schlawack <hs@ox.cx> * Add changelog entry Also remove the entry for the now obsolete hynek#657 --------- Co-authored-by: Hynek Schlawack <hs@ox.cx>
1 parent 234b6fe commit 2f7298a

File tree

8 files changed

+110
-48
lines changed

8 files changed

+110
-48
lines changed

CHANGELOG.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,14 @@ You can find our backwards-compatibility policy [here](https://github.com/hynek/
3030

3131
## Fixed
3232

33-
- `structlog.traceback.ExceptionDictTransformer` now correctly handles missing exceptions.
34-
35-
[#657](https://github.com/hynek/structlog/pull/657)
33+
- Fix handling calls to `{logger}.exception()` outside of exception blocks.
34+
Depending on the structlog configuration,
35+
this either resulted in an event dict key `exception: "MISSING"` or lead to an error.
36+
Now, an invalid or missing `exc_info` will just be ignored.
37+
This means, that calling `{logger}.exception()` outside of an exception block is basically the same as calling `{logger}.error()`.
38+
39+
[#634](https://github.com/hynek/structlog/issues/634)
40+
[#680](https://github.com/hynek/structlog/pull/680)
3641

3742
- Instantiating `structlog.dev.ConsoleRenderer` does not mutate the passed *styles* dict anymore.
3843

src/structlog/_frames.py

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,22 +16,12 @@
1616
from .typing import ExcInfo
1717

1818

19-
def is_missing_exc_info(exc_info: ExcInfo) -> bool:
20-
"""
21-
Return True if exc_info is the missing value.
22-
"""
23-
return exc_info == (None, None, None) # type: ignore[comparison-overlap]
24-
25-
2619
def _format_exception(exc_info: ExcInfo) -> str:
2720
"""
2821
Prettyprint an `exc_info` tuple.
2922
3023
Shamelessly stolen from stdlib's logging module.
3124
"""
32-
if is_missing_exc_info(exc_info):
33-
return "MISSING"
34-
3525
sio = StringIO()
3626

3727
traceback.print_exception(exc_info[0], exc_info[1], exc_info[2], None, sio)

src/structlog/dev.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -733,11 +733,9 @@ def __call__(
733733
if exc_info or exc is not None:
734734
sio.write("\n\n" + "=" * 79 + "\n")
735735

736+
exc_info = _figure_out_exc_info(exc_info)
736737
if exc_info:
737-
exc_info = _figure_out_exc_info(exc_info)
738-
739-
if exc_info != (None, None, None):
740-
self._exception_formatter(sio, exc_info)
738+
self._exception_formatter(sio, exc_info)
741739
elif exc is not None:
742740
if self._exception_formatter is not plain_traceback:
743741
warnings.warn(

src/structlog/processors.py

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import threading
2020
import time
2121

22-
from types import FrameType
22+
from types import FrameType, TracebackType
2323
from typing import (
2424
Any,
2525
Callable,
@@ -28,6 +28,7 @@
2828
NamedTuple,
2929
Sequence,
3030
TextIO,
31+
cast,
3132
)
3233

3334
from ._frames import (
@@ -38,7 +39,12 @@
3839
from ._log_levels import NAME_TO_LEVEL, add_log_level
3940
from ._utils import get_processname
4041
from .tracebacks import ExceptionDictTransformer
41-
from .typing import EventDict, ExceptionTransformer, ExcInfo, WrappedLogger
42+
from .typing import (
43+
EventDict,
44+
ExceptionTransformer,
45+
ExcInfo,
46+
WrappedLogger,
47+
)
4248

4349

4450
__all__ = [
@@ -407,11 +413,9 @@ def __init__(
407413
def __call__(
408414
self, logger: WrappedLogger, name: str, event_dict: EventDict
409415
) -> EventDict:
410-
exc_info = event_dict.pop("exc_info", None)
416+
exc_info = _figure_out_exc_info(event_dict.pop("exc_info", None))
411417
if exc_info:
412-
event_dict["exception"] = self.format_exception(
413-
_figure_out_exc_info(exc_info)
414-
)
418+
event_dict["exception"] = self.format_exception(exc_info)
415419

416420
return event_dict
417421

@@ -586,21 +590,30 @@ def __call__(
586590
return event_dict
587591

588592

589-
def _figure_out_exc_info(v: Any) -> ExcInfo:
593+
def _figure_out_exc_info(v: Any) -> ExcInfo | None:
590594
"""
591-
Depending on the Python version will try to do the smartest thing possible
592-
to transform *v* into an ``exc_info`` tuple.
595+
Try to convert *v* into an ``exc_info`` tuple.
596+
597+
Return ``None`` if *v* does not represent an exception or if there is no
598+
current exception.
593599
"""
594600
if isinstance(v, BaseException):
595601
return (v.__class__, v, v.__traceback__)
596602

597-
if isinstance(v, tuple):
598-
return v
603+
if isinstance(v, tuple) and len(v) == 3:
604+
has_type = isinstance(v[0], type) and issubclass(v[0], BaseException)
605+
has_exc = isinstance(v[1], BaseException)
606+
has_tb = v[2] is None or isinstance(v[2], TracebackType)
607+
if has_type and has_exc and has_tb:
608+
return v
599609

600610
if v:
601-
return sys.exc_info() # type: ignore[return-value]
611+
result = sys.exc_info()
612+
if result == (None, None, None):
613+
return None
614+
return cast(ExcInfo, result)
602615

603-
return v
616+
return None
604617

605618

606619
class ExceptionPrettyPrinter:

src/structlog/tracebacks.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,6 @@
2222
from types import ModuleType, TracebackType
2323
from typing import Any, Iterable, Sequence, Tuple, Union
2424

25-
from ._frames import is_missing_exc_info
26-
2725

2826
try:
2927
import rich
@@ -418,8 +416,6 @@ def __init__(
418416
self.use_rich = use_rich
419417

420418
def __call__(self, exc_info: ExcInfo) -> list[dict[str, Any]]:
421-
if is_missing_exc_info(exc_info):
422-
return []
423419
trace = extract(
424420
*exc_info,
425421
show_locals=self.show_locals,

tests/processors/test_renderers.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -514,12 +514,12 @@ def test_nop_missing(self):
514514

515515
def test_formats_tuple(self):
516516
"""
517-
If exc_info is a tuple, it is used.
517+
If exc_info is an arbitrary 3-tuple, it is not used.
518518
"""
519519
formatter = ExceptionRenderer(lambda exc_info: exc_info)
520520
d = formatter(None, None, {"exc_info": (None, None, 42)})
521521

522-
assert {"exception": (None, None, 42)} == d
522+
assert {} == d
523523

524524
def test_gets_exc_info_on_bool(self):
525525
"""
@@ -580,6 +580,4 @@ def test_no_exception(self, ei):
580580
"""
581581
A missing exception does not blow up.
582582
"""
583-
assert {"exception": "MISSING"} == format_exc_info(
584-
None, None, {"exc_info": ei}
585-
)
583+
assert {} == format_exc_info(None, None, {"exc_info": ei})

tests/test_dev.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -545,10 +545,7 @@ def test_no_exception(self):
545545
r = dev.ConsoleRenderer(colors=False)
546546

547547
assert (
548-
"hi"
549-
== r(
550-
None, None, {"event": "hi", "exc_info": (None, None, None)}
551-
).rstrip()
548+
"hi" == r(None, None, {"event": "hi", "exc_info": None}).rstrip()
552549
)
553550

554551
def test_columns_warns_about_meaningless_arguments(self, recwarn):

tests/test_tracebacks.py

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515

1616
import pytest
1717

18+
import structlog
19+
1820
from structlog import tracebacks
1921

2022

@@ -772,12 +774,75 @@ def test_json_traceback_value_error(
772774
tracebacks.ExceptionDictTransformer(**kwargs)
773775

774776

775-
def test_exception_dict_transformer_missing_exc_info():
777+
class TestLogException:
776778
"""
777-
ExceptionDictTransformer returns an empty list if exc_info is missing.
779+
Higher level integration tests for `Logger.exception()`.
778780
"""
779-
transformer = tracebacks.ExceptionDictTransformer()
780781

781-
result = transformer(exc_info=(None, None, None))
782+
@pytest.fixture
783+
def cap_logs(self) -> structlog.testing.LogCapture:
784+
"""
785+
Create a LogCapture to be used as processor and fixture for retrieving
786+
logs in tests.
787+
"""
788+
return structlog.testing.LogCapture()
789+
790+
@pytest.fixture
791+
def logger(
792+
self, cap_logs: structlog.testing.LogCapture
793+
) -> structlog.Logger:
794+
"""
795+
Create a logger with the dict_tracebacks and a LogCapture processor.
796+
"""
797+
old_processors = structlog.get_config()["processors"]
798+
structlog.configure([structlog.processors.dict_tracebacks, cap_logs])
799+
logger = structlog.get_logger("dict_tracebacks")
800+
try:
801+
yield logger
802+
finally:
803+
structlog.configure(processors=old_processors)
804+
805+
def test_log_explicit_exception(
806+
self, logger: structlog.Logger, cap_logs: structlog.testing.LogCapture
807+
) -> None:
808+
"""
809+
The log row contains a traceback when `Logger.exception()` is
810+
explicitly called with an exception instance.
811+
"""
812+
try:
813+
1 / 0
814+
except ZeroDivisionError as e:
815+
logger.exception("onoes", exception=e)
816+
817+
log_row = cap_logs.entries[0]
818+
819+
assert log_row["exception"][0]["exc_type"] == "ZeroDivisionError"
820+
821+
def test_log_implicit_exception(
822+
self, logger: structlog.Logger, cap_logs: structlog.testing.LogCapture
823+
) -> None:
824+
"""
825+
The log row contains a traceback when `Logger.exception()` is called
826+
in an `except` block but without explicitly passing the exception.
827+
"""
828+
try:
829+
1 / 0
830+
except ZeroDivisionError:
831+
logger.exception("onoes")
832+
833+
log_row = cap_logs.entries[0]
834+
835+
assert log_row["exception"][0]["exc_type"] == "ZeroDivisionError"
836+
837+
def test_no_exception(
838+
self, logger: structlog.Logger, cap_logs: structlog.testing.LogCapture
839+
) -> None:
840+
"""
841+
`Logger.exception()` should not be called outside an `except` block
842+
but this cases is gracefully handled and does not lead to an exception.
843+
844+
See: https://github.com/hynek/structlog/issues/634
845+
"""
846+
logger.exception("onoes")
782847

783-
assert [] == result
848+
assert [{"event": "onoes", "log_level": "error"}] == cap_logs.entries

0 commit comments

Comments
 (0)