diff --git a/docs/changelog.md b/docs/changelog.md index b32bcb6..697aaac 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `STYLE_STRING_FORMAT_REGEX`, which is no longer used to find `{` style fields. [#75](https://github.com/nhairs/python-json-logger/pull/75) ### Fixed + +- Preserve an exception's own notes when encoding exception instances, including exception group summaries. Related to [#67](https://github.com/nhairs/python-json-logger/issues/67). - `%` style formats no longer treat the escaped literal `%%` as the start of a field, so `"%%(notafield)s"` is correctly read as literal text. [#75](https://github.com/nhairs/python-json-logger/pull/75) - `{` style formats now use `string.Formatter` (as `logging.StrFormatStyle.validate` does) to find fields, so escaped literal braces (`{{`/`}}`) are skipped and a conversion (`{message!r}`) or format spec (`{levelname:>8}`) is no longer treated as part of the field name. [#75](https://github.com/nhairs/python-json-logger/pull/75) diff --git a/pyproject.toml b/pyproject.toml index fe257b4..8c2af40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "python-json-logger" -version = "4.2.1.dev1" +version = "4.2.1.dev2" description = "JSON Log Formatter for the Python Logging Package" authors = [ {name = "Zakaria Zajac", email = "zak@madzak.com"}, diff --git a/src/pythonjsonlogger/defaults.py b/src/pythonjsonlogger/defaults.py index e70abda..0bf398b 100644 --- a/src/pythonjsonlogger/defaults.py +++ b/src/pythonjsonlogger/defaults.py @@ -152,10 +152,19 @@ def use_exception_default(obj: Any) -> TypeGuard[BaseException]: def exception_default(obj: BaseException) -> str: """Default function for exception instances + Includes the exception's own notes, if present. Exception groups retain + their summary without recursively formatting their children. + Args: obj: object to handle + + *Changed in 4.2.1*: Include exception notes. """ - return f"{obj.__class__.__name__}: {obj}" + result = f"{obj.__class__.__name__}: {obj}" + notes = getattr(obj, "__notes__", None) + if isinstance(notes, list): + return "\n".join([result, *(note for note in notes if isinstance(note, str))]) + return result def use_traceback_default(obj: Any) -> TypeGuard[TracebackType]: diff --git a/tests/test_formatters.py b/tests/test_formatters.py index 3f12fcc..44d1748 100644 --- a/tests/test_formatters.py +++ b/tests/test_formatters.py @@ -699,6 +699,100 @@ def test_common_types_encoded( return +@pytest.mark.skipif( + sys.version_info < (3, 11), reason="Exception notes require Python 3.11 or higher" +) +@pytest.mark.parametrize("class_", ALL_FORMATTERS) +@pytest.mark.parametrize( + ["notes", "expected"], + [ + ([], "ValueError: my error"), + (["context"], "ValueError: my error\ncontext"), + ([""], "ValueError: my error\n"), + (["first", "second"], "ValueError: my error\nfirst\nsecond"), + (["line 1\nline 2", "Привет"], "ValueError: my error\nline 1\nline 2\nПривет"), + ], +) +def test_dict_exception_notes( + env: LoggingEnvironment, + class_: type[BaseJsonFormatter], + notes: list[str], + expected: str, +) -> None: + env.set_formatter(class_()) + err = ValueError("my error") + for note in notes: + err.add_note(note) + + env.logger.info({"error": err, "nested": {"error": err}, "errors": [err]}) + log_json = env.load_json() + + assert log_json["error"] == expected + assert log_json["nested"]["error"] == expected + assert log_json["errors"] == [expected] + return + + +@pytest.mark.skipif( + sys.version_info < (3, 11), reason="Exception notes require Python 3.11 or higher" +) +@pytest.mark.parametrize("class_", ALL_FORMATTERS) +def test_dict_exception_note_subclass( + env: LoggingEnvironment, class_: type[BaseJsonFormatter] +) -> None: + class Note(str): + """A string subclass accepted by add_note.""" + + err = ValueError("my error") + err.add_note(Note("context")) + env.set_formatter(class_()) + env.logger.info({"error": err}) + + assert env.load_json()["error"] == "ValueError: my error\ncontext" + return + + +@pytest.mark.parametrize("class_", ALL_FORMATTERS) +def test_dict_exception_message_compatibility( + env: LoggingEnvironment, class_: type[BaseJsonFormatter] +) -> None: + class CustomError(Exception): + """An exception with a custom formatted message.""" + + def __format__(self, format_spec: str) -> str: + return "custom message" + + env.set_formatter(class_()) + env.logger.info({"empty": ValueError(), "custom": CustomError("original message")}) + log_json = env.load_json() + + assert log_json["empty"] == "ValueError: " + assert log_json["custom"] == "CustomError: custom message" + return + + +@pytest.mark.skipif( + sys.version_info < (3, 11), reason="Exception groups and notes require Python 3.11 or higher" +) +@pytest.mark.parametrize("class_", ALL_FORMATTERS) +@pytest.mark.parametrize("with_group_note", [False, True]) +def test_dict_exception_group_notes( + env: LoggingEnvironment, class_: type[BaseJsonFormatter], with_group_note: bool +) -> None: + child = ValueError("child error") + child.add_note("child note") + group = ExceptionGroup("my group", [child]) + expected = "ExceptionGroup: my group (1 sub-exception)" + if with_group_note: + group.add_note("group note") + expected += "\ngroup note" + env.set_formatter(class_()) + env.logger.info({"error": group}) + + assert env.load_json()["error"] == expected + return + + @pytest.mark.parametrize("class_", ALL_FORMATTERS) def test_custom_default(env: LoggingEnvironment, class_: type[BaseJsonFormatter]): def custom_default(obj):