diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 94690045..043863fc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,7 +7,7 @@ repos: language_version: python3.8 - repo: https://github.com/PyCQA/isort - rev: 5.9.1 + rev: 5.9.2 hooks: - id: isort additional_dependencies: [toml] diff --git a/CHANGELOG.rst b/CHANGELOG.rst index fa6b7c34..f394f068 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -32,6 +32,8 @@ Changes: - If the `better-exceptions `_ package is present, ``structlog.dev.ConsoleRenderer`` will now pretty-print exceptions using it. Pass ``pretty_exceptions=False`` to disable. This only works if ``format_exc_info`` is **absent** in the processor chain. +- ``KeyValueRenderer`` now accepts an optional ``repr_formatter`` parameter to handle log representation. +- Added logfmt_repr to make KeyValueRenderer output logfmt compatible. ---- diff --git a/docs/api.rst b/docs/api.rst index b1307da5..2feabb41 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -226,6 +226,19 @@ API Reference >>> TimeStamper(fmt="%Y", key="year")(None, None, {}) # doctest: +SKIP {'year': '2013'} +.. autofunction:: logfmt_repr + + .. doctest:: + + >>> from structlog.processors import logfmt_repr + >>> logfmt_repr("string with withespace") + '"string with whitespace"' + >>> logfmt_repr("string") + 'string' + >>> logfmt_repr(42) + '42' + >>> logfmt_repr(None) + 'null' `structlog.stdlib` Module ------------------------- diff --git a/src/structlog/processors.py b/src/structlog/processors.py index 4fcc074d..eb3ffe1c 100644 --- a/src/structlog/processors.py +++ b/src/structlog/processors.py @@ -8,6 +8,7 @@ import datetime import json import operator +import re import sys import time @@ -43,9 +44,25 @@ "format_exc_info", "ExceptionPrettyPrinter", "StackInfoRenderer", + "logfmt_repr", ] +def logfmt_repr(inst: Any) -> str: + if inst is None: + return "null" + if isinstance(inst, str): + return json.dumps(inst) if re.search("\\s", inst) else inst + return repr(inst) + + +def _repr(inst: Any) -> str: + if isinstance(inst, str): + return inst + else: + return repr(inst) + + class KeyValueRenderer: """ Render ``event_dict`` as a list of ``Key=repr(Value)`` pairs. @@ -60,10 +77,12 @@ class KeyValueRenderer: to native strings. Setting this to ``False`` is useful if you want to have human-readable non-ASCII output on Python 2. + :param repr_formatter: A callable to use as a custom :func:`repr()`. .. versionadded:: 0.2.0 *key_order* .. versionadded:: 16.1.0 *drop_missing* .. versionadded:: 17.1.0 *repr_native_str* + .. versionadded:: 21.2.0 *repr_formatter* """ def __init__( @@ -72,6 +91,7 @@ def __init__( key_order: Optional[Sequence[str]] = None, drop_missing: bool = False, repr_native_str: bool = True, + repr_formatter: Callable = _repr, ): # Use an optimized version for each case. if key_order and sort_keys is True: @@ -113,14 +133,7 @@ def ordered_items(event_dict: EventDict) -> List[Tuple[str, Any]]: if repr_native_str is True: self._repr = repr else: - - def _repr(inst: Any) -> str: - if isinstance(inst, str): - return inst - else: - return repr(inst) - - self._repr = _repr + self._repr = repr_formatter def __call__( self, _: WrappedLogger, __: str, event_dict: EventDict diff --git a/tests/test_processors.py b/tests/test_processors.py index 47f2a6cf..2da8496a 100644 --- a/tests/test_processors.py +++ b/tests/test_processors.py @@ -25,6 +25,7 @@ _figure_out_exc_info, _json_fallback_handler, format_exc_info, + logfmt_repr, ) from structlog.threadlocal import wrap_dict @@ -124,6 +125,16 @@ def test_repr_native_str(self, rns): cnt = rv.count("哈") assert 2 == cnt + def test_repr_formatter(self, event_dict): + """ + Injects a custom repr-like formatter when repr_native_str=False + """ + rv = KeyValueRenderer( + repr_formatter=logfmt_repr, repr_native_str=False + )(None, None, event_dict) + + assert r"a= b=[3, 4] x=7 y=test z=(1, 2)" == rv + class TestJSONRenderer: def test_renders_json(self, event_dict): @@ -551,3 +562,27 @@ def test_py3_exception_no_traceback(self): e = ValueError() assert (e.__class__, e, None) == _figure_out_exc_info(e) + + +class TestLogFmtRepr: + @pytest.mark.parametrize( + "obj,expected", + [ + ("word", "word"), # plain word + ("a sentence", '"a sentence"'), # a sentence with spaces + ( + 'a sentence "with quotes"', + '"a sentence \\"with quotes\\""', + ), # a sentence in double quotes + (42, "42"), # int + (3.14, "3.14"), # float + ("127.0.0.1", "127.0.0.1"), # an ip address + ("user@email.test", "user@email.test"), # an email + (None, "null"), # NoneType + ], + ) + def test_logfmt_repr(self, obj, expected): + """ + Ensure logfmt_repr produces logfmt-compatible strings + """ + assert expected == logfmt_repr(obj)