Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,27 @@ and falls back to the configured mode, so a typo in a shared environment cannot
break startup. `LOGURICH_EXTRA_*` values continue to be included in JSON
`record.extra`.

JSON and text-file output render Rich objects without ANSI escape codes. The
JSON schema keeps the public `text` and `record` structure from Logurich 0.9.
JSON output never renders Rich objects as ASCII art. Text-file output renders
them without ANSI escape codes, while JSON serialises them as structured data
under `record.renderables`, leaving `text` and `record.message` free of borders
and box drawing characters. The JSON schema otherwise keeps the public `text`
and `record` structure from Logurich 0.9.

```json
{
"type": "table",
"title": "Metrics",
"columns": ["Name", "Value"],
"rows": [["requests", "42"]]
}
```

`Table`, `Panel`, `Tree`, `Syntax`, `Markdown`, `Rule`, `Group` and `Columns`
have dedicated shapes; `Padding`, `Align` and `Constrain` are unwrapped. Any
other Rich object degrades to `{"type": "text", "text": ...}` and any
non-renderable value to `{"type": "object", "repr": ...}`. Strings passed to
`logger.rich()` are not structured: they stay in `text`. Nesting is capped at
four levels and tables at 100 rows, with a `"truncated": true` marker.

## Rich objects

Expand All @@ -128,7 +147,8 @@ method works after `bind()` and with direct or queued logging.
For multiprocessing, serialisable Rich values reach the listener unchanged. If
a renderable cannot be pickled, Logurich explicitly falls back to a plain,
ANSI-free producer-side rendering; other unpicklable record values produce a
clear logging error.
clear logging error. Such a fallback is a plain string, so in JSON output it
lands in `text` instead of `record.renderables`.

## Multiprocessing

Expand Down
34 changes: 33 additions & 1 deletion docs/migration-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,37 @@ the Rich handler, which stays an explicit `console="rich"` opt-in.
The Click flag changed from `--logger-rich` to the explicit choice
`--logger-console auto|rich|plain|json`. The old flag is an unknown option.

## Rich renderables in JSON output

JSON output no longer embeds the ASCII rendering of Rich objects. Both
`console="json"` and `file="json"` now emit structured data under
`record.renderables`, and `text` and `record.message` keep only the log line:

```diff
{
- "text": "2025-01-01 00:00:00.000 | INFO | report\n# ┏━━━━━━━━━━┳━━━━━━━┓\n# ┃ Name ┃ Value ┃\n# ┡━━━━━━━━━━╇━━━━━━━┩\n# │ requests │ 42 │\n# └──────────┴───────┘",
+ "text": "2025-01-01 00:00:00.000 | INFO | report\n",
"record": {
- "message": "report\n# ┏━━━━━━━━━━┳━━━━━━━┓\n# ┃ Name ┃ Value ┃..."
+ "message": "report",
+ "renderables": [
+ {
+ "type": "table",
+ "title": "Metrics",
+ "columns": ["Name", "Value"],
+ "rows": [["requests", "42"]]
+ }
+ ]
}
}
```

The key is omitted when a record carries no Rich object. Strings passed to
`logger.rich()` are unaffected and stay in `text`. `console="rich"`,
`console="plain"` and `file="text"` are unchanged. Update log pipelines that
grepped rendered tables out of `record.message`; read `record.renderables`
instead. Its shapes are documented in the README.

## Imports to find and replace

Search applications for:
Expand All @@ -109,4 +140,5 @@ Search applications for:
- `ctx(...)` calls that relied on the key being hidden by default;
- identity or `isinstance(..., logging.Logger)` checks on `get_logger()`;
- `bind(...=None)` calls that previously relied on `None` being ignored;
- `global_context_set(...=None)` calls that previously removed ambient values.
- `global_context_set(...=None)` calls that previously removed ambient values;
- JSON consumers parsing rendered tables or panels out of `record.message`.
6 changes: 6 additions & 0 deletions examples/serialize.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from rich.panel import Panel
from rich.syntax import Syntax
from rich.table import Table
from rich.tree import Tree

from logurich import ctx, get_logger, global_context, init_logger

Expand Down Expand Up @@ -44,6 +46,10 @@ def build_table() -> Table:
width=72,
)

tree = Tree("services")
tree.add("api").add("healthy")
logger.rich("INFO", tree, Syntax("print('hello')", "python"))

try:
raise RuntimeError("serialize example failure")
except RuntimeError:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "logurich"
version = "1.0.0b2"
version = "1.0.0"
description = "A Python library combining standard logging and Rich for beautiful logging."
authors = [
{ name = "PakitoSec", email = "jeromep83@gmail.com" }
Expand Down
2 changes: 1 addition & 1 deletion src/logurich/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Public package exports for logurich."""

__version__ = "1.0.0b2"
__version__ = "1.0.0"

from .console import (
console,
Expand Down
28 changes: 18 additions & 10 deletions src/logurich/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from logging import Formatter, Handler, LogRecord
from pathlib import Path
from time import perf_counter
from typing import TYPE_CHECKING, Any, Union
from typing import TYPE_CHECKING, Any, Optional, Union

from rich.console import ConsoleRenderable, Group
from rich.constrain import Constrain
Expand All @@ -20,6 +20,7 @@
from rich.text import Text

from .console import rich_console_renderer, rich_get_console, rich_to_str
from .serialize import serialize_renderables
from .struct import logger_state

if TYPE_CHECKING:
Expand Down Expand Up @@ -101,7 +102,9 @@ def build_prefix(self, record: LogRecord) -> str:
f"{source}{padding} | "
)

def format_file(self, record: LogRecord) -> str:
def format_file(
self, record: LogRecord, *, renderables: Optional[tuple[Any, ...]] = None
) -> str:
prefix_markup = self.build_prefix(record)
prefix_plain = _safe_text_from_markup(prefix_markup).plain
context_markup = "".join(self.build_context(record, is_rich_handler=False))
Expand All @@ -112,21 +115,22 @@ def format_file(self, record: LogRecord) -> str:
exception_text = getattr(record, "formatted_exception", "").rstrip("\n")
stack_text = getattr(record, "formatted_stack", "").rstrip("\n")

items = self._renderables(record) if renderables is None else renderables

parts: list[str] = []
if message_plain or not self._renderables(record):
if message_plain or not items:
line = f"{prefix_plain}{context_plain}{message_plain}"
if stack_text:
line = f"{line}\n{stack_text}" if line else stack_text
if exception_text:
line = f"{line}\n{exception_text}" if line else exception_text
parts.append(line)

renderables = self._renderables(record)
if renderables:
if items:
rendered = rich_console_renderer(
prefix_markup,
getattr(record, "render_prefix", True),
renderables,
items,
getattr(record, "render_width", None),
)
parts.append(
Expand All @@ -142,22 +146,24 @@ def format_file(self, record: LogRecord) -> str:
return "\n".join(part for part in parts if part)

def format_json(self, record: LogRecord) -> str:
text = self.format_file(record)
renderables = self._renderables(record)
text_items = tuple(item for item in renderables if isinstance(item, str))
rich_items = tuple(item for item in renderables if not isinstance(item, str))
text = self.format_file(record, renderables=text_items)
end = getattr(record, "end", "\n")
rendered_text = f"{text}{end}" if text else ""
extra = self._serialize_extra(record)
created_at = datetime.fromtimestamp(record.created).astimezone()
exception_data = getattr(record, "exception_data", None)
file_path = str(Path(record.pathname))
elapsed_seconds = perf_counter() - SERIALIZATION_START
renderables = self._renderables(record)
message_value = record.getMessage()
if renderables and text:
if text_items and text:
lines = text.splitlines()
continuation = "\n".join(lines[1:])
if continuation:
message_value = f"{message_value}\n{continuation}"
payload = {
payload: dict[str, Any] = {
"text": rendered_text,
"record": {
"elapsed": {
Expand Down Expand Up @@ -193,6 +199,8 @@ def format_json(self, record: LogRecord) -> str:
},
},
}
if rich_items:
payload["record"]["renderables"] = serialize_renderables(rich_items)
return json.dumps(payload, default=str, ensure_ascii=False)

def _serialize_extra(self, record: LogRecord) -> dict[str, Any]:
Expand Down
144 changes: 144 additions & 0 deletions src/logurich/serialize.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""Structured JSON serialisation of Rich renderables."""

from __future__ import annotations

from typing import Any, Optional

from rich.align import Align
from rich.columns import Columns
from rich.console import ConsoleRenderable, Group
from rich.constrain import Constrain
from rich.padding import Padding
from rich.panel import Panel
from rich.rule import Rule
from rich.syntax import Syntax
from rich.table import Table
from rich.text import Text
from rich.tree import Tree

from .console import rich_to_str

MAX_DEPTH = 4
MAX_TABLE_ROWS = 100

_UNSET = object()
_MARKDOWN: Any = _UNSET


def _markdown_type() -> Optional[type]:
"""Return ``rich.markdown.Markdown`` lazily, or ``None`` when unavailable."""

global _MARKDOWN
if _MARKDOWN is _UNSET:
try:
from rich.markdown import Markdown
except Exception:
_MARKDOWN = None
else:
_MARKDOWN = Markdown
return _MARKDOWN


def _plain(value: str) -> str:
try:
return Text.from_markup(value).plain
except Exception:
return value


def _rendered_text(item: Any) -> str:
try:
return rich_to_str(item, ansi=False, end="").rstrip("\n")
except Exception:
return str(item)


def _cell_text(value: Any) -> str:
if value is None:
return ""
if isinstance(value, Text):
return value.plain
if isinstance(value, str):
return _plain(value)
return _rendered_text(value)


def _optional_text(value: Any) -> Optional[str]:
return None if value is None else _cell_text(value)


def _lexer_name(syntax: Syntax) -> Optional[str]:
lexer = getattr(syntax, "_lexer", None)
if isinstance(lexer, str):
return lexer
return getattr(lexer, "name", None)


def _serialize_table(table: Table) -> dict[str, Any]:
cells = [list(column.cells) for column in table.columns]
limit = min(table.row_count, MAX_TABLE_ROWS)
rows = [
[column[index] if index < len(column) else "" for column in cells]
for index in range(limit)
]
data: dict[str, Any] = {
"type": "table",
"title": _optional_text(table.title),
"columns": [_cell_text(column.header) for column in table.columns],
"rows": [[_cell_text(cell) for cell in row] for row in rows],
}
if table.row_count > limit:
data["truncated"] = True
return data


def _serialize_tree(tree: Tree, depth: int) -> dict[str, Any]:
return {
"type": "tree",
"label": _cell_text(tree.label),
"children": [_serialize(child, depth + 1) for child in tree.children],
}


def _serialize(item: Any, depth: int) -> dict[str, Any]:
if depth > MAX_DEPTH:
return {"type": "text", "text": _rendered_text(item), "truncated": True}
if isinstance(item, str):
return {"type": "text", "text": _plain(item)}
if isinstance(item, Text):
return {"type": "text", "text": item.plain}
if isinstance(item, Table):
return _serialize_table(item)
if isinstance(item, Panel):
return {
"type": "panel",
"title": _optional_text(item.title),
"subtitle": _optional_text(item.subtitle),
"content": _serialize(item.renderable, depth + 1),
}
if isinstance(item, Tree):
return _serialize_tree(item, depth)
if isinstance(item, Syntax):
return {"type": "syntax", "lexer": _lexer_name(item), "code": item.code}
if isinstance(item, Rule):
return {"type": "rule", "title": _cell_text(item.title)}
markdown = _markdown_type()
if markdown is not None and isinstance(item, markdown):
return {"type": "markdown", "markup": item.markup}
if isinstance(item, (Group, Columns)):
return {
"type": "group",
"items": [_serialize(child, depth + 1) for child in item.renderables],
}
# Transparent wrappers only carry layout, so they keep the child's depth.
if isinstance(item, (Padding, Align, Constrain)):
return _serialize(item.renderable, depth)
if isinstance(item, ConsoleRenderable):
return {"type": "text", "text": _rendered_text(item)}
return {"type": "object", "repr": repr(item)}


def serialize_renderables(renderables: tuple[Any, ...]) -> list[dict[str, Any]]:
"""Convert Rich renderables into JSON-friendly structured payloads."""

return [_serialize(item, 0) for item in renderables]
1 change: 1 addition & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,7 @@ def test_json_console_contract(buffer):
assert payload["record"]["message"] == "Login ok"
assert payload["record"]["extra"]["user"] == "alice"
assert payload["record"]["level"] == {"name": "INFO", "no": logging.INFO}
assert "renderables" not in payload["record"]
assert "\x1b[" not in json.dumps(payload)


Expand Down
20 changes: 20 additions & 0 deletions tests/test_mp.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import logging
import multiprocessing as mp
import os
Expand Down Expand Up @@ -211,6 +212,25 @@ def test_unpickleable_rich_value_falls_back_to_text(buffer):
assert "lambda" in output


def test_queued_json_keeps_structured_renderables(buffer):
table = Table(title="Metrics")
table.add_column("Name")
table.add_row("requests")
init_logger("INFO", console="json", enqueue=True)
get_logger("tests.queue-json").rich("INFO", table, title="queued")
shutdown_logger()

payload = json.loads(buffer.getvalue().splitlines()[0])
assert payload["record"]["renderables"] == [
{
"type": "table",
"title": "Metrics",
"columns": ["Name"],
"rows": [["requests"]],
}
]


def test_plain_record_skips_pickle_validation(monkeypatch):
record = logging.LogRecord(
"tests.queue-plain-fast-path",
Expand Down
Loading