Skip to content

Commit

Permalink
Add custom logger that does soft-wrapping (#10)
Browse files Browse the repository at this point in the history
Fixes: #6
Related: Textualize/rich#438
Related: Textualize/rich#344
  • Loading branch information
ssbarnea authored Nov 16, 2020
1 parent 7dcd1f1 commit 6003ec9
Show file tree
Hide file tree
Showing 4 changed files with 196 additions and 1 deletion.
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,26 @@ import sys
console = Console(soft_wrap=True)
console.print(...) # no longer need to pass soft_wrap to each print
```

## Soft-wrapping logger

Rich logger assumes that you always have a fixed width console and it does
wrap logged output according to it. Our alternative logger does exactly the
opposite: it ignores the columns of the current console and prints output
using a Console with soft wrapping enabled.

The result are logged lines that can be displayed on any terminal or web
page as they will allow the client to decide when to perform the wrapping.

```python
import logging
from enrich.logging import RichHandler

FORMAT = "%(message)s"
logging.basicConfig(
level="NOTSET", format=FORMAT, datefmt="[%X]", handlers=[RichHandler()]
)

log = logging.getLogger("rich")
log.info("Text that we do not want pre-wrapped by logger: %s", 100 * "x")
```
84 changes: 84 additions & 0 deletions src/enrich/logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""Implements enriched RichHandler"""
from datetime import datetime
from typing import TYPE_CHECKING, Iterable, Optional

from rich.logging import RichHandler as OriginalRichHandler
from rich.text import Text, TextType

if TYPE_CHECKING:
from rich.console import Console, ConsoleRenderable


# Based on https://github.com/willmcgugan/rich/blob/master/rich/_log_render.py
class FluidLogRender: # pylint: disable=too-few-public-methods
"""Renders log by not using columns and avoiding any wrapping."""

def __init__(
self,
show_time: bool = False,
show_level: bool = False,
show_path: bool = True,
time_format: str = "[%x %X]",
) -> None:
self.show_time = show_time
self.show_level = show_level
self.show_path = show_path
self.time_format = time_format
self._last_time: Optional[str] = None

def __call__( # pylint: disable=too-many-arguments
self,
console: "Console",
renderables: Iterable["ConsoleRenderable"],
log_time: datetime = None,
time_format: str = None,
level: TextType = "",
path: str = None,
line_no: int = None,
link_path: str = None,
) -> Text:

result = Text()
if self.show_time:
if log_time is None:
log_time = datetime.now()
log_time_display = log_time.strftime(time_format or self.time_format) + " "
if log_time_display == self._last_time:
result += Text(" " * len(log_time_display))
else:
result += Text(log_time_display)
self._last_time = log_time_display
if self.show_level:
if not isinstance(level, Text):
level = Text(level)
if len(level) < 8:
level += " " * (8 - len(level))
result += level

for elem in renderables:
result += elem

if self.show_path and path:
path_text = Text(" ", style="repr.filename")
path_text.append(
path, style=f"link file://{link_path}" if link_path else ""
)
if line_no:
path_text.append(f":{line_no}")
result += path_text

return result


class RichHandler(OriginalRichHandler):
"""Enriched handler that does not wrap."""

def __init__(self, *args, **kwargs): # type: ignore
super().__init__(*args, **kwargs)
# RichHandler constructor does not allow custom renderer
# https://github.com/willmcgugan/rich/issues/438
self._log_render = FluidLogRender(
show_time=kwargs.get("show_time", False),
show_level=kwargs.get("show_level", True),
show_path=kwargs.get("show_path", False),
)
88 changes: 88 additions & 0 deletions src/enrich/test/test_logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Tests related to enriched RichHandler"""
import io
import logging
import re
from typing import Tuple, Union

import pytest

from enrich.console import Console
from enrich.logging import RichHandler


def strip_ansi_escape(text: Union[str, bytes]) -> str:
"""Remove all ANSI escapes from string or bytes.
If bytes is passed instead of string, it will be converted to string
using UTF-8.
"""
if isinstance(text, bytes):
text = text.decode("utf-8")

return re.sub(r"\x1b[^m]*m", "", text)


@pytest.fixture(name="rich_logger")
def rich_logger_fixture() -> Tuple[logging.Logger, logging.Handler]:
"""Returns tuple with logger and handler to be tested."""
rich_handler = RichHandler(
console=Console(
file=io.StringIO(),
force_terminal=True,
width=80,
color_system="truecolor",
soft_wrap=True,
),
enable_link_path=False,
)

logging.basicConfig(
level="NOTSET", format="%(message)s", datefmt="[DATE]", handlers=[rich_handler]
)
rich_log = logging.getLogger("rich")
rich_log.addHandler(rich_handler)
return (rich_log, rich_handler)


def test_logging(rich_logger) -> None:
"""Test that logger does not wrap."""

(logger, rich_handler) = rich_logger

text = 10 * "x" # a long text that would likely wrap on a normal console
logger.error("%s %s", text, 123)

# verify that the long text was not wrapped
output = strip_ansi_escape(rich_handler.console.file.getvalue())
assert text in output
assert "ERROR" in output
assert "\n" not in output[:-1]


if __name__ == "__main__":
handler = RichHandler(
console=Console(
force_terminal=True,
width=55510, # this is expected to have no effect
color_system="truecolor",
soft_wrap=True,
),
enable_link_path=False,
show_time=True,
show_level=True,
show_path=True,
)
logging.basicConfig(
level="NOTSET",
format="%(message)s",
# datefmt="[DATE]",
handlers=[handler],
)
log = logging.getLogger("rich")
# log.addHandler(handler)
data = {"foo": "text", "bar": None, "number": 123}
log.error("This was a long error")
log.warning("This was warning %s apparently", 123)
log.info("Having info is good")
log.debug("Some kind of debug message %s", None)
log.info("Does this dictionary %s render ok?", data)
2 changes: 1 addition & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ passenv =
setenv =
PIP_DISABLE_VERSION_CHECK=1
PIP_USE_FEATURE={env:PIP_USE_FEATURE:2020-resolver}
PYTEST_REQPASS=3
PYTEST_REQPASS=4
PYTHONDONTWRITEBYTECODE=1
PYTHONUNBUFFERED=1
commands =
Expand Down

0 comments on commit 6003ec9

Please sign in to comment.