diff --git a/CHANGELOG.md b/CHANGELOG.md index c8f7721..0c33e8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). version, and creates a GitHub release. The process is documented in `docs/releasing.md`. Publishing to PyPI is deliberately left out until the distribution name is settled (#20). +- `setup_logging(use_emoji=...)` turns the level emoji in the verbose console + layout off (#25). ### Changed @@ -119,3 +121,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). fields. Before, they had only the message and the context. The internal `emoji` and `context` fields no longer leak into JSON, and the output is identical across python-json-logger 2.x and later releases (#42). +- The emoji map listed levels that are never registered (`SYSTEM`, `SECURITY`, + `NETWORK`, `DATABASE`, `STARTUP`, `SHUTDOWN`) and could never match, so they + were removed. The level name is no longer followed by a stray space when a + level has no emoji (#25). diff --git a/README.md b/README.md index 317943c..ddd42ef 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ log.info("this one is carol's", extra={"user_id": "carol"}) | `to_console` | `True` | Log to the console. | | `console_level` | `None` | Console level, defaulting to `level`. | | `console_stream` | `None` | Console stream, for example `sys.stderr`. Defaults to `sys.stdout`. | +| `use_emoji` | `True` | Show a level emoji in the verbose console layout. Set `False` for terminals that render emoji badly. | | `mode` | `"verbose"` | Console layout: `"verbose"` (coloured, with source location and context) or `"compact"` (`[LEVEL] message`). | | `use_json` | `False` | Write JSON lines to the console instead. Needs the `json` extra. | | `to_file` | `False` | Log to `file_path`. | diff --git a/pylogkit/log_setup.py b/pylogkit/log_setup.py index 4978ca6..5c9834e 100644 --- a/pylogkit/log_setup.py +++ b/pylogkit/log_setup.py @@ -75,6 +75,14 @@ SYSLOG_FORMAT = "%(name)s[%(process)d]: %(levelname)s %(context)s - %(message)s" # One colour per level, used for the level name and for the message text. +_LEVEL_EMOJI = { + "DEBUG": "🐛", + "INFO": "â„šī¸", + "WARNING": "âš ī¸", + "ERROR": "❌", + "CRITICAL": "đŸ’Ĩ", +} + _LEVEL_COLOURS = { "DEBUG": "cyan", "INFO": "blue", @@ -191,7 +199,7 @@ def _build_console_formatter(stream: IO[str] | None = None) -> SmartFieldFormatt """ return SmartFieldFormatter( fmt=f"%({COLOUR_TIMESTAMP})s%(asctime)s%(reset)s " - "[%(log_color)s%(levelname)s %(emoji)s%(reset)s] " + "[%(log_color)s%(levelname)s%(emoji)s%(reset)s] " f"[%({COLOUR_MODULE_NAME})s%(name)s%(reset)s] " f"%({COLOUR_FILENAME})s%(filename)s%(reset)s::" f"%({COLOUR_FUNCTION})s%(funcName)s%(reset)s():" @@ -214,28 +222,29 @@ class ContextFilter(logging.Filter): Values already present on the record (for example those supplied through ``extra=`` or by :class:`ContextualLoggerAdapter`) take precedence over the global context. + + Args: + use_emoji: Set ``record.emoji`` to the level's emoji (preceded by a + space, so that it lines up after the level name). When false the + field is empty. """ + def __init__(self, use_emoji: bool = True) -> None: + """Create the filter, optionally without level emoji.""" + super().__init__() + self._use_emoji = use_emoji + def filter(self, record: logging.LogRecord) -> bool: """Add the context fields and emoji to ``record`` and accept it.""" context = get_log_context() for k, v in context.items(): if not hasattr(record, k): setattr(record, k, v) - emoji_map = { - "DEBUG": "🐛", - "INFO": "â„šī¸", - "WARNING": "âš ī¸", - "ERROR": "❌", - "CRITICAL": "đŸ’Ĩ", - "SYSTEM": "đŸ–Ĩī¸", - "SECURITY": "🔐", - "NETWORK": "🌐", - "DATABASE": "đŸ—„ī¸", - "STARTUP": "🚀", - "SHUTDOWN": "🛑", - } - record.emoji = emoji_map.get(record.levelname, "") + record.emoji = ( + f" {_LEVEL_EMOJI[record.levelname]}" + if self._use_emoji and record.levelname in _LEVEL_EMOJI + else "" + ) return True @@ -365,6 +374,7 @@ def setup_logging( syslog_address: tuple[str, int] | str = ("localhost", 514), syslog_facility: int = SysLogHandler.LOG_USER, console_stream: IO[str] | None = None, + use_emoji: bool = True, ) -> logging.Logger: """Configure and return a logger with console, file, JSON and syslog output. @@ -413,6 +423,9 @@ def setup_logging( Console colour is disabled when the stream is not a terminal, when ``NO_COLOR`` is set or ``TERM`` is ``dumb``, unless ``FORCE_COLOR`` is set. + use_emoji: Show a level emoji after the level name in the verbose + console layout. Set it to false for terminals and log viewers + that render emoji badly. Returns: The configured :class:`logging.Logger`. @@ -476,7 +489,7 @@ def setup_logging( syslog_handler.setFormatter(syslog_formatter) logger.addHandler(syslog_handler) - context_filter = ContextFilter() + context_filter = ContextFilter(use_emoji=use_emoji) for handler in logger.handlers: handler.addFilter(context_filter) diff --git a/tests/test_emoji.py b/tests/test_emoji.py new file mode 100644 index 0000000..c7f63d3 --- /dev/null +++ b/tests/test_emoji.py @@ -0,0 +1,44 @@ +"""Tests for the level emoji in the verbose console layout.""" + +import logging + +from pylogkit import log_setup, setup_logging + +STANDARD_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"} + + +def test_emoji_map_only_names_registered_levels(): + """Every emoji entry belongs to a level that exists, so none is dead code.""" + registered = {logging.getLevelName(n) for n in (10, 20, 30, 40, 50)} + + assert set(log_setup._LEVEL_EMOJI) == STANDARD_LEVELS == registered + + +def test_emoji_is_shown_by_default(capsys): + """The verbose layout puts the emoji right after the level name.""" + setup_logging("emoji-on", level="DEBUG") + + logging.getLogger("emoji-on").error("boom") + + assert "[ERROR ❌]" in capsys.readouterr().out + + +def test_emoji_can_be_disabled(capsys): + """Without emoji the level name is not followed by stray spacing.""" + setup_logging("emoji-off", use_emoji=False) + + logging.getLogger("emoji-off").error("boom") + + out = capsys.readouterr().out + assert "[ERROR]" in out + assert "❌" not in out + + +def test_unknown_level_has_no_emoji_and_no_spacing(capsys): + """A custom level prints cleanly.""" + logging.addLevelName(25, "NOTICE") + setup_logging("emoji-custom", level="DEBUG") + + logging.getLogger("emoji-custom").log(25, "custom") + + assert "[NOTICE]" in capsys.readouterr().out