diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a0e35cf..36360ed1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,10 +17,23 @@ You can find our backwards-compatibility policy [here](https://github.com/hynek/ ### Added +- The colorful development logger is now even more configurable! + Choose freely your colors and the order of the key-value pairs! + Implement your own formatters for certain keys! + + Implementing the output on top of the new columns API has changed the default very slightly, but shouldn't be noticeable. + [#577](https://github.com/hynek/structlog/issues/577) + - Async log methods (those starting with an `a`) now also support the collection of callsite information using `structlog.processors.CallsiteParameterAdder`. [#565](https://github.com/hynek/structlog/issues/565) +### Changed + +- `structlog.stdlib.recreate_defaults()` now also adds `structlog.stdlib.add_logger_name` to the processors. + Check out the updated screenshot in the README! + + ### Fixed - The return value from `get_logger()` (a `BoundLoggerLazyProxy`) now passes `isinstance`-checks against `structlog.typing.BindableLogger` on Python 3.12. diff --git a/docs/_static/console_renderer.png b/docs/_static/console_renderer.png index 20a123e2..df8e503d 100644 Binary files a/docs/_static/console_renderer.png and b/docs/_static/console_renderer.png differ diff --git a/docs/api.rst b/docs/api.rst index 5a911533..e027738d 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -78,6 +78,12 @@ API Reference .. autoclass:: ConsoleRenderer :members: get_default_level_styles +.. autoclass:: Column +.. autoclass:: ColumnFormatter(typing.Protocol) + :members: __call__ +.. autoclass:: KeyValueColumnFormatter +.. autoclass:: LogLevelColumnFormatter + .. autofunction:: plain_traceback .. autoclass:: RichTracebackFormatter .. autofunction:: rich_traceback diff --git a/docs/console-output.md b/docs/console-output.md index aa5fbb60..302342a2 100644 --- a/docs/console-output.md +++ b/docs/console-output.md @@ -25,36 +25,90 @@ For pretty exceptions to work, {func}`~structlog.processors.format_exc_info` mus ::: *structlog*'s default configuration already uses {class}`~structlog.dev.ConsoleRenderer`, therefore if you want nice colorful output on the console, you don't have to do anything except installing Rich or *better-exceptions* (and Colorama on Windows). -If you want to use it along with standard library logging, we suggest the following configuration: +If you want to use it along with standard library logging, there's the {func}`structlog.stdlib.recreate_defaults` helper. + +:::{seealso} +{doc}`exceptions` for more information on how to configure exception rendering. +For the console and beyond. +::: + +(columns-config)= + +## Console Output Configuration + +:::{versionadded} 23.3.0 +::: + +You can freely configure how the key-value pairs are formatted: colors, order, and how values are stringified. + +For that {class}`~structlog.dev.ConsoleRenderer` accepts the *columns* parameter that takes a list of {class}`~structlog.dev.Column`s. +It allows you to assign a formatter to each key and a default formatter for the rest (by passing an empty key name). +The order of the column definitions is the order in which the columns are rendered; +the rest is -- depending on the *sort_keys* argument to {class}`~structlog.dev.ConsoleRenderer` -- either sorted alphabetically or in the order of the keys in the event dictionary. + +You can use a column definition to drop a key-value pair from the output by returning an empty string from the formatter. + +When the API talks about "styles", it means ANSI control strings. +You can find them, for example, in [Colorama](https://github.com/tartley/colorama). + + +It's best demonstrated by an example: ```python import structlog - -structlog.configure( - processors=[ - structlog.stdlib.add_logger_name, - structlog.stdlib.add_log_level, - structlog.stdlib.PositionalArgumentsFormatter(), - structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M.%S"), - structlog.processors.StackInfoRenderer(), - structlog.dev.ConsoleRenderer() # <=== - ], - context_class=dict, - logger_factory=structlog.stdlib.LoggerFactory(), - wrapper_class=structlog.stdlib.BoundLogger, - cache_logger_on_first_use=True, +import colorama + +cr = structlog.dev.ConsoleRenderer( + columns=[ + # Render the timestamp without the key name in yellow. + structlog.dev.Column( + "timestamp", + structlog.dev.KeyValueColumnFormatter( + key_style=None, + value_style=colorama.Fore.YELLOW, + reset_style=colorama.Style.RESET_ALL, + value_repr=str, + ), + ), + # Render the event without the key name in bright magenta. + structlog.dev.Column( + "event", + structlog.dev.KeyValueColumnFormatter( + key_style=None, + value_style=colorama.Style.BRIGHT + colorama.Fore.MAGENTA, + reset_style=colorama.Style.RESET_ALL, + value_repr=str, + ), + ), + # Default formatter for all keys not explicitly mentioned. The key is + # cyan, the value is green. + structlog.dev.Column( + "", + structlog.dev.KeyValueColumnFormatter( + key_style=colorama.Fore.CYAN, + value_style=colorama.Fore.GREEN, + reset_style=colorama.Style.RESET_ALL, + value_repr=str, + ), + ), + ] ) + +structlog.configure(processors=structlog.get_config()["processors"][:-1]+[cr]) ``` -:::{seealso} -{doc}`exceptions` for more information on how to configure exception rendering. -For the console and beyond. +:::{hint} +You can replace only the last processor using: + +```python +structlog.configure(processors=structlog.get_config()["processors"][:-1]+[cr]) +``` ::: ## Standard Environment Variables -*structlog*'s default configuration uses colors if standard out is a TTY (i.e. an interactive session). +*structlog*'s default configuration uses colors if standard out is a TTY (that is, an interactive session). It's possible to override this behavior by setting two standard environment variables to any value except an empty string: diff --git a/show_off.py b/show_off.py index 45242ddb..8fad80ab 100644 --- a/show_off.py +++ b/show_off.py @@ -16,6 +16,8 @@ class SomeClass: y: str +structlog.stdlib.recreate_defaults() # so we have logger names + log = structlog.get_logger("some_logger") log.debug("debugging is hard", a_list=[1, 2, 3]) diff --git a/src/structlog/dev.py b/src/structlog/dev.py index 808da12d..e0125516 100644 --- a/src/structlog/dev.py +++ b/src/structlog/dev.py @@ -20,13 +20,14 @@ from types import ModuleType from typing import ( Any, - Iterable, + Callable, Literal, Protocol, Sequence, TextIO, Type, Union, + cast, ) from ._frames import _format_exception @@ -164,6 +165,156 @@ class _PlainStyles: kv_value = "" +class ColumnFormatter(Protocol): + """ + :class:`~typing.Protocol` for column formatters. + + See `KeyValueColumnFormatter` and `LogLevelColumnFormatter` for examples. + + .. versionadded:: 23.3.0 + """ + + def __call__(self, key: str, value: object) -> str: + """ + Format *value* for *key*. + + This method is responsible for formatting, *key*, the ``=``, and the + *value*. That means that it can use any string instead of the ``=`` and + it can leave out both the *key* or the *value*. + + If it returns an empty string, the column is omitted completely. + """ + + +@dataclass +class Column: + """ + A column defines the way a key-value pair is formatted, and, by it's + position to the *columns* argument of `ConsoleRenderer`, the order in which + it is rendered. + + Args: + key: + The key for which this column is responsible. Leave empty to define + it as the default formatter. + + formatter: The formatter for columns with *key*. + + .. versionadded:: 23.3.0 + """ + + key: str + formatter: ColumnFormatter + + +@dataclass +class KeyValueColumnFormatter: + """ + Format a key-value pair. + + Args: + key_style: The style to apply to the key. If None, the key is omitted. + + value_style: The style to apply to the value. + + reset_style: The style to apply whenever a style is no longer needed. + + value_repr: + A callable that returns the string representation of the value. + + width: The width to pad the value to. If 0, no padding is done. + + prefix: + A string to prepend to the formatted key-value pair. May contain + styles. + + postfix: + A string to append to the formatted key-value pair. May contain + styles. + + .. versionadded:: 23.3.0 + """ + + key_style: str | None + value_style: str + reset_style: str + value_repr: Callable[[object], str] + width: int = 0 + prefix: str = "" + postfix: str = "" + + def __call__(self, key: str, value: object) -> str: + sio = StringIO() + + if self.prefix: + sio.write(self.prefix) + sio.write(self.reset_style) + + if self.key_style is not None: + sio.write(self.key_style) + sio.write(key) + sio.write(self.reset_style) + sio.write("=") + + sio.write(self.value_style) + sio.write(_pad(self.value_repr(value), self.width)) + sio.write(self.reset_style) + + if self.postfix: + sio.write(self.postfix) + sio.write(self.reset_style) + + return sio.getvalue() + + +class LogLevelColumnFormatter: + """ + Format a log level according to *level_styles*. + + The width is padded to the longest level name (if *level_styles* is passed + -- otherwise there's no way to know the lengths of all levels). + + Args: + level_styles: + A dictionary of level names to styles that are applied to it. If + None, the level is formatted as a plain ``[level]``. + + reset_style: + What to use to reset the style after the level name. Ignored if + if *level_styles* is None. + + .. versionadded:: 23.3.0 + """ + + level_styles: dict[str, str] | None + reset_style: str + width: int + + def __init__(self, level_styles: dict[str, str], reset_style: str) -> None: + self.level_styles = level_styles + if level_styles: + self.width = len( + max(self.level_styles.keys(), key=lambda e: len(e)) + ) + self.reset_style = reset_style + else: + self.width = 0 + self.reset_style = "" + + def __call__(self, key: str, value: object) -> str: + level = cast(str, value) + style = ( + "" + if self.level_styles is None + else self.level_styles.get(level, "") + ) + + return f"[{style}{_pad(level, self.width)}{self.reset_style}]" + + +_NOTHING = object() + + def plain_traceback(sio: TextIO, exc_info: ExcInfo) -> None: """ "Pretty"-print *exc_info* to *sio* using our own plain formatter. @@ -275,29 +426,40 @@ class ConsoleRenderer: *after* the log line. If Rich_ or better-exceptions_ are present, in colors and with extra context. - Parameters: + Args: + columns: + A list of `Column` objects defining both the order and format of + the key-value pairs in the output. If passed, most other arguments + become meaningless. - pad_event: Pad the event to this many characters. + **Must** contain a column with ``key=''`` that defines the default + formatter. + + .. seealso:: `columns-config` + + pad_event: + Pad the event to this many characters. Ignored if *columns* are + passed. colors: Use colors for a nicer output. `True` by default. On Windows only - if Colorama_ is installed. + if Colorama_ is installed. Ignored if *columns* are passed. force_colors: Force colors even for non-tty destinations. Use this option if your logs are stored in a file that is meant to be streamed to the - console. Only meaningful on Windows. + console. Only meaningful on Windows. Ignored if *columns* are + passed. repr_native_str: - When `True`, `repr` is also applied to native strings (i.e. unicode - on Python 3 and bytes on Python 2). Setting this to `False` is - useful if you want to have human-readable non-ASCII output on - Python 2. The ``event`` key is *never* `repr` -ed. + When `True`, `repr` is also applied to ``str``s. The ``event`` key + is *never* `repr` -ed. Ignored if *columns* are passed. level_styles: When present, use these styles for colors. This must be a dict from level names (strings) to Colorama styles. The default can be - obtained by calling `ConsoleRenderer.get_default_level_styles` + obtained by calling `ConsoleRenderer.get_default_level_styles`. + Ignored when *columns* are passed. exception_formatter: A callable to render ``exc_infos``. If Rich_ or better-exceptions_ @@ -307,18 +469,25 @@ class ConsoleRenderer: `RichTracebackFormatter` like `rich_traceback`, or implement your own. - sort_keys: Whether to sort keys when formatting. `True` by default. + sort_keys: + Whether to sort keys when formatting. `True` by default. Ignored if + *columns* are passed. event_key: The key to look for the main log message. Needed when you rename it - e.g. using `structlog.processors.EventRenamer`. + e.g. using `structlog.processors.EventRenamer`. Ignored if + *columns* are passed. timestamp_key: The key to look for timestamp of the log message. Needed when you - rename it e.g. using `structlog.processors.EventRenamer`. + rename it e.g. using `structlog.processors.EventRenamer`. Ignored + if *columns* are passed. Requires the Colorama_ package if *colors* is `True` **on Windows**. + Raises: + ValueError: If there's not exactly one default column formatter. + .. _Colorama: https://pypi.org/project/colorama/ .. _better-exceptions: https://pypi.org/project/better-exceptions/ .. _Rich: https://pypi.org/project/rich/ @@ -352,9 +521,10 @@ class ConsoleRenderer: .. versionadded:: 21.3.0 *sort_keys* .. versionadded:: 22.1.0 *event_key* .. versionadded:: 23.2.0 *timestamp_key* + .. versionadded:: 23.3.0 *columns* """ - def __init__( + def __init__( # noqa: PLR0912 self, pad_event: int = _EVENT_WIDTH, colors: bool = _has_colors, @@ -365,7 +535,57 @@ def __init__( sort_keys: bool = True, event_key: str = "event", timestamp_key: str = "timestamp", + columns: list[Column] | None = None, ): + self._exception_formatter = exception_formatter + self._sort_keys = sort_keys + + if columns is not None: + to_warn = [] + + def add_meaningless_arg(arg: str) -> None: + to_warn.append( + f"The `{arg}` argument is ignored when passing `columns`.", + ) + + if pad_event != _EVENT_WIDTH: + add_meaningless_arg("pad_event") + + if colors != _has_colors: + add_meaningless_arg("colors") + + if force_colors is not False: + add_meaningless_arg("force_colors") + + if repr_native_str is not False: + add_meaningless_arg("repr_native_str") + + if level_styles is not None: + add_meaningless_arg("level_styles") + + if event_key != "event": + add_meaningless_arg("event_key") + + if timestamp_key != "timestamp": + add_meaningless_arg("timestamp_key") + + for w in to_warn: + warnings.warn(w, stacklevel=2) + + defaults = [col for col in columns if col.key == ""] + if not defaults: + raise ValueError( + "Must pass a default column formatter (a column with `key=''`)." + ) + if len(defaults) > 1: + raise ValueError("Only one default column formatter allowed.") + + self._default_column_formatter = defaults[0].formatter + self._columns = [col for col in columns if col.key] + + return + + # Create default columns configuration. styles: Styles if colors: if _IS_WINDOWS: # pragma: no cover @@ -391,24 +611,67 @@ def __init__( styles = _PlainStyles self._styles = styles - self._pad_event = pad_event - if level_styles is None: - self._level_to_color = self.get_default_level_styles(colors) - else: - self._level_to_color = level_styles + level_to_color = ( + self.get_default_level_styles(colors) + if level_styles is None + else level_styles + ) - for key in self._level_to_color: - self._level_to_color[key] += styles.bright + for key in level_to_color: + level_to_color[key] += styles.bright self._longest_level = len( - max(self._level_to_color.keys(), key=lambda e: len(e)) + max(level_to_color.keys(), key=lambda e: len(e)) ) self._repr_native_str = repr_native_str - self._exception_formatter = exception_formatter - self._sort_keys = sort_keys - self._event_key = event_key - self._timestamp_key = timestamp_key + + self._default_column_formatter = KeyValueColumnFormatter( + styles.kv_key, + styles.kv_value, + styles.reset, + value_repr=self._repr, + width=0, + ) + + logger_name_formatter = KeyValueColumnFormatter( + key_style=None, + value_style=styles.bright + styles.logger_name, + reset_style=styles.reset, + value_repr=str, + prefix="[", + postfix="]", + ) + + self._columns = [ + Column( + timestamp_key, + KeyValueColumnFormatter( + key_style=None, + value_style=styles.timestamp, + reset_style=styles.reset, + value_repr=str, + ), + ), + Column( + "level", + LogLevelColumnFormatter( + level_to_color, reset_style=styles.reset + ), + ), + Column( + event_key, + KeyValueColumnFormatter( + key_style=None, + value_style=styles.bright, + reset_style=styles.reset, + value_repr=str, + width=pad_event, + ), + ), + Column("logger", logger_name_formatter), + Column("logger_name", logger_name_formatter), + ] def _repr(self, val: Any) -> str: """ @@ -423,75 +686,24 @@ def _repr(self, val: Any) -> str: return repr(val) - def __call__( # noqa: PLR0912 + def __call__( self, logger: WrappedLogger, name: str, event_dict: EventDict ) -> str: - sio = StringIO() - - ts = event_dict.pop(self._timestamp_key, None) - if ts is not None: - sio.write( - # can be a number if timestamp is UNIXy - self._styles.timestamp - + str(ts) - + self._styles.reset - + " " - ) - level = event_dict.pop("level", None) - if level is not None: - sio.write( - "[" - + self._level_to_color.get(level, "") - + _pad(level, self._longest_level) - + self._styles.reset - + "] " - ) - - # force event to str for compatibility with standard library - event = event_dict.pop(self._event_key, None) - if not isinstance(event, str): - event = str(event) - - if event_dict: - event = _pad(event, self._pad_event) + self._styles.reset + " " - else: - event += self._styles.reset - sio.write(self._styles.bright + event) - - logger_name = event_dict.pop("logger", None) - if logger_name is None: - logger_name = event_dict.pop("logger_name", None) - - if logger_name is not None: - sio.write( - "[" - + self._styles.logger_name - + self._styles.bright - + logger_name - + self._styles.reset - + "] " - ) - stack = event_dict.pop("stack", None) exc = event_dict.pop("exception", None) exc_info = event_dict.pop("exc_info", None) - event_dict_keys: Iterable[str] = event_dict.keys() - if self._sort_keys: - event_dict_keys = sorted(event_dict_keys) - - sio.write( - " ".join( - self._styles.kv_key - + key - + self._styles.reset - + "=" - + self._styles.kv_value - + self._repr(event_dict[key]) - + self._styles.reset - for key in event_dict_keys - ) - ) + kvs = [ + col.formatter(col.key, val) + for col in self._columns + if (val := event_dict.pop(col.key, _NOTHING)) is not _NOTHING + ] + [ + self._default_column_formatter(key, event_dict[key]) + for key in (sorted(event_dict) if self._sort_keys else event_dict) + ] + + sio = StringIO() + sio.write((" ".join(kv for kv in kvs if kv)).rstrip(" ")) if stack is not None: sio.write("\n" + stack) @@ -510,6 +722,7 @@ def __call__( # noqa: PLR0912 "if you want pretty exceptions.", stacklevel=2, ) + sio.write("\n" + exc) return sio.getvalue() @@ -527,8 +740,7 @@ def get_default_level_styles(colors: bool = True) -> Any: my_styles["EVERYTHING_IS_ON_FIRE"] = my_styles["critical"] renderer = ConsoleRenderer(level_styles=my_styles) - Parameters: - + Args: colors: Whether to use colorful styles. This must match the *colors* parameter to `ConsoleRenderer`. Default: `True`. diff --git a/src/structlog/stdlib.py b/src/structlog/stdlib.py index 9fbe5c7e..f5fbd0d3 100644 --- a/src/structlog/stdlib.py +++ b/src/structlog/stdlib.py @@ -63,8 +63,7 @@ def recreate_defaults(*, log_level: int | None = logging.NOTSET) -> None: As with vanilla defaults, the backwards-compatibility guarantees don't apply to the settings applied here. - Parameters: - + Args: log_level: If `None`, don't configure standard library logging **at all**. @@ -75,6 +74,8 @@ def recreate_defaults(*, log_level: int | None = logging.NOTSET) -> None: configure it yourself. .. versionadded:: 22.1.0 + .. versionchanged:: 23.3.0 + Added `add_logger_name`. """ if log_level is not None: kw = {"force": True} @@ -91,6 +92,7 @@ def recreate_defaults(*, log_level: int | None = logging.NOTSET) -> None: processors=[ merge_contextvars, add_log_level, + add_logger_name, StackInfoRenderer(), _config._BUILTIN_DEFAULT_PROCESSORS[-2], # TimeStamper _config._BUILTIN_DEFAULT_PROCESSORS[-1], # ConsoleRenderer @@ -1000,7 +1002,10 @@ def __init__( super().__init__(*args, fmt=fmt, **kwargs) # type: ignore[misc] if processor and processors: - msg = "The `processor` and `processors` arguments are mutually exclusive." + msg = ( + "The `processor` and `processors` arguments are mutually" + " exclusive." + ) raise TypeError(msg) self.processors: Sequence[Processor] @@ -1050,9 +1055,11 @@ def format(self, record: logging.LogRecord) -> str: logger = self.logger meth_name = record.levelname.lower() ed = { - "event": record.getMessage() - if self.use_get_message - else str(record.msg), + "event": ( + record.getMessage() + if self.use_get_message + else str(record.msg) + ), "_record": record, "_from_structlog": False, } diff --git a/tests/test_dev.py b/tests/test_dev.py index 531e76df..00d40cd4 100644 --- a/tests/test_dev.py +++ b/tests/test_dev.py @@ -28,28 +28,21 @@ def test_negative(self): assert len("test") == len(dev._pad("test", 2)) -@pytest.fixture(name="cr") +@pytest.fixture(name="cr", scope="session") def _cr(): return dev.ConsoleRenderer( colors=dev._has_colors, exception_formatter=dev.plain_traceback ) -@pytest.fixture(name="styles") +@pytest.fixture(name="styles", scope="session") def _styles(cr): return cr._styles -@pytest.fixture(name="padded") +@pytest.fixture(name="padded", scope="session") def _padded(styles): - return ( - styles.bright + dev._pad("test", dev._EVENT_WIDTH) + styles.reset + " " - ) - - -@pytest.fixture(name="unpadded") -def _unpadded(styles): - return styles.bright + "test" + styles.reset + return styles.bright + dev._pad("test", dev._EVENT_WIDTH) + styles.reset class TestConsoleRenderer: @@ -70,23 +63,23 @@ def test_missing_colorama(self): "installed." ) in e.value.args[0] - def test_plain(self, cr, unpadded): + def test_plain(self, cr, padded): """ Works with a plain event_dict with only the event. """ rv = cr(None, None, {"event": "test"}) - assert unpadded == rv + assert padded == rv - def test_timestamp(self, cr, styles, unpadded): + def test_timestamp(self, cr, styles, padded): """ Timestamps get prepended. """ rv = cr(None, None, {"event": "test", "timestamp": 42}) - assert (styles.timestamp + "42" + styles.reset + " " + unpadded) == rv + assert (styles.timestamp + "42" + styles.reset + " " + padded) == rv - def test_event_stringified(self, cr, unpadded): + def test_event_stringified(self, cr, padded): """ Event is cast to string. """ @@ -94,7 +87,7 @@ def test_event_stringified(self, cr, unpadded): rv = cr(None, None, {"event": not_a_string}) - assert unpadded == rv + assert padded == rv def test_event_renamed(self): """ @@ -112,15 +105,18 @@ def test_timestamp_renamed(self): """ cr = dev.ConsoleRenderer(colors=False, timestamp_key="ts") - assert "2023-09-07 le event" == cr( - None, - None, - {"ts": "2023-09-07", "event": "le event"}, + assert ( + "2023-09-07 le event" + == cr( + None, + None, + {"ts": "2023-09-07", "event": "le event"}, + ).rstrip() ) def test_level(self, cr, styles, padded): """ - Levels are rendered aligned, in square brackets, and color coded. + Levels are rendered aligned, in square brackets, and color-coded. """ rv = cr( None, None, {"event": "test", "level": "critical", "foo": "bar"} @@ -134,6 +130,7 @@ def test_level(self, cr, styles, padded): + styles.reset + "] " + padded + + " " + styles.kv_key + "foo" + styles.reset @@ -168,6 +165,7 @@ def test_init_accepts_overriding_levels(self, styles, padded): + styles.reset + "] " + padded + + " " + styles.kv_key + "foo" + styles.reset @@ -185,12 +183,14 @@ def test_logger_name(self, cr, styles, padded): assert ( padded - + "[" - + dev.BLUE + + " [" + + styles.reset + styles.bright + + dev.BLUE + "some_module" + styles.reset - + "] " + + "]" + + styles.reset ) == rv def test_logger_name_name(self, cr, padded, styles): @@ -199,12 +199,14 @@ def test_logger_name_name(self, cr, padded, styles): """ assert ( padded - + "[" - + dev.BLUE + + " [" + + styles.reset + styles.bright + + dev.BLUE + "yolo" + styles.reset - + "] " + + "]" + + styles.reset ) == cr(None, None, {"event": "test", "logger_name": "yolo"}) def test_key_values(self, cr, styles, padded): @@ -215,6 +217,7 @@ def test_key_values(self, cr, styles, padded): assert ( padded + + " " + styles.kv_key + "foo" + styles.reset @@ -246,6 +249,7 @@ def test_key_values_unsorted(self, styles, padded): assert ( padded + + " " + styles.kv_key + "key" + styles.reset @@ -264,7 +268,9 @@ def test_key_values_unsorted(self, styles, padded): ) == rv @pytest.mark.parametrize("wrap", [True, False]) - def test_exception_rendered(self, cr, padded, recwarn, wrap): + def test_exception_rendered( + self, cr, recwarn, wrap, styles, padded, monkeypatch + ): """ Exceptions are rendered after a new line if they are already rendered in the event dict. @@ -275,10 +281,15 @@ def test_exception_rendered(self, cr, padded, recwarn, wrap): # Wrap the formatter to provoke the warning. if wrap: - cr._exception_formatter = lambda s, ei: dev.plain_tracebacks(s, ei) + monkeypatch.setattr( + cr, + "_exception_formatter", + lambda s, ei: dev.plain_traceback(s, ei), + ) + rv = cr(None, None, {"event": "test", "exception": exc}) - assert (padded + "\n" + exc) == rv + assert (f"{padded}\n" + exc) == rv if wrap: (w,) = recwarn.list @@ -287,16 +298,16 @@ def test_exception_rendered(self, cr, padded, recwarn, wrap): "if you want pretty exceptions.", ) == w.message.args - def test_stack_info(self, cr, padded): + def test_stack_info(self, cr, styles, padded): """ Stack traces are rendered after a new line. """ stack = "fake stack" rv = cr(None, None, {"event": "test", "stack": stack}) - assert (padded + "\n" + stack) == rv + assert (f"{padded}\n" + stack) == rv - def test_exc_info_tuple(self, cr, padded): + def test_exc_info_tuple(self, cr, styles, padded): """ If exc_info is a tuple, it is used. """ @@ -310,9 +321,9 @@ def test_exc_info_tuple(self, cr, padded): exc = dev._format_exception(ei) - assert (padded + "\n" + exc) == rv + assert (f"{padded}\n" + exc) == rv - def test_exc_info_bool(self, cr, padded): + def test_exc_info_bool(self, cr, styles, padded): """ If exc_info is True, it is obtained using sys.exc_info(). """ @@ -325,9 +336,9 @@ def test_exc_info_bool(self, cr, padded): exc = dev._format_exception(ei) - assert (padded + "\n" + exc) == rv + assert (f"{padded}\n" + exc) == rv - def test_exc_info_exception(self, cr, padded): + def test_exc_info_exception(self, cr, styles, padded): """ If exc_info is an exception, it is used by converting to a tuple. """ @@ -341,7 +352,7 @@ def test_exc_info_exception(self, cr, padded): exc = dev._format_exception((ei.__class__, ei, ei.__traceback__)) - assert (padded + "\n" + exc) == rv + assert (f"{padded}\n" + exc) == rv def test_pad_event_param(self, styles): """ @@ -420,12 +431,15 @@ def test_everything(self, cr, styles, padded, explicit_ei): + styles.reset + "] " + padded - + "[" - + dev.BLUE + + " [" + + styles.reset + styles.bright + + dev.BLUE + "some_module" + styles.reset - + "] " + + "]" + + styles.reset + + " " + styles.kv_key + "foo" + styles.reset @@ -483,6 +497,7 @@ def test_colorama_force_colors(self, styles, padded): + styles.reset + "] " + padded + + " " + styles.kv_key + "foo" + styles.reset @@ -536,6 +551,69 @@ def test_no_exception(self): ).rstrip() ) + def test_columns_warns_about_meaningless_arguments(self, recwarn): + """ + If columns is set, a warning is emitted for all ignored arguments. + """ + dev.ConsoleRenderer( + columns=[dev.Column("", lambda k, v: "")], + pad_event=42, + colors=not dev._has_colors, + force_colors=True, + repr_native_str=True, + level_styles=dev._PlainStyles, + event_key="not event", + timestamp_key="not timestamp", + ) + + assert { + f"The `{arg}` argument is ignored when passing `columns`." + for arg in ( + "pad_event", + "colors", + "force_colors", + "repr_native_str", + "level_styles", + "event_key", + "timestamp_key", + ) + } == {str(w.message) for w in recwarn.list} + + def test_detects_default_column(self): + """ + The default renderer is detected and removed from the columns list. + """ + fake_formatter = object() + llcf = dev.Column("log_level", dev.LogLevelColumnFormatter(None, "")) + + cr = dev.ConsoleRenderer( + columns=[dev.Column("", fake_formatter), llcf] + ) + + assert fake_formatter is cr._default_column_formatter + assert [llcf] == cr._columns + + def test_enforces_presence_of_exactly_one_default_formatter(self): + """ + If there is no, or more than one, default formatter, raise ValueError. + """ + with pytest.raises( + ValueError, + match="Must pass a default column formatter", + ): + dev.ConsoleRenderer(columns=[]) + + with pytest.raises( + ValueError, + match="Only one default column formatter allowed.", + ): + dev.ConsoleRenderer( + columns=[ + dev.Column("", lambda k, v: ""), + dev.Column("", lambda k, v: ""), + ] + ) + class TestSetExcInfo: def test_wrong_name(self): @@ -622,3 +700,13 @@ def test_does_not_blow_up(self): dev.better_traceback(sio, sys.exc_info()) assert sio.getvalue().startswith("\n") + + +class TestLogLevelColumnFormatter: + def test_no_style(self): + """ + No level_styles means no control characters and no padding. + """ + assert "[critical]" == dev.LogLevelColumnFormatter(None, "foo")( + "", "critical" + ) diff --git a/tests/test_stdlib.py b/tests/test_stdlib.py index 569bbbd7..48c9f36f 100644 --- a/tests/test_stdlib.py +++ b/tests/test_stdlib.py @@ -879,7 +879,7 @@ def test_foreign_pre_chain_add_logger_name(self, capsys): assert ( "", - "foo [sample-name] [in test_foreign_pr" + "foo [sample-name] [in test_foreign_pr" "e_chain_add_logger_name]\n", ) == capsys.readouterr()