From 38caac687502bb0833444afb1392666030a67c5d Mon Sep 17 00:00:00 2001 From: PakitoSec Date: Fri, 7 Aug 2026 10:33:57 +0200 Subject: [PATCH] feat(json-rendering): serialize Rich renderables structurally --- README.md | 26 ++++++- docs/migration-v1.md | 34 ++++++++- examples/serialize.py | 6 ++ pyproject.toml | 2 +- src/logurich/__init__.py | 2 +- src/logurich/handler.py | 28 +++++--- src/logurich/serialize.py | 144 ++++++++++++++++++++++++++++++++++++++ tests/test_core.py | 1 + tests/test_mp.py | 20 ++++++ tests/test_rich.py | 126 +++++++++++++++++++++++++++++++++ uv.lock | 2 +- 11 files changed, 374 insertions(+), 17 deletions(-) create mode 100644 src/logurich/serialize.py diff --git a/README.md b/README.md index 67c187d..15e42db 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/docs/migration-v1.md b/docs/migration-v1.md index 6fae5b7..ea6884e 100644 --- a/docs/migration-v1.md +++ b/docs/migration-v1.md @@ -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: @@ -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`. diff --git a/examples/serialize.py b/examples/serialize.py index 1effa8e..474989e 100644 --- a/examples/serialize.py +++ b/examples/serialize.py @@ -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 @@ -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: diff --git a/pyproject.toml b/pyproject.toml index 27f825a..127993b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/src/logurich/__init__.py b/src/logurich/__init__.py index 60b870b..c886957 100644 --- a/src/logurich/__init__.py +++ b/src/logurich/__init__.py @@ -1,6 +1,6 @@ """Public package exports for logurich.""" -__version__ = "1.0.0b2" +__version__ = "1.0.0" from .console import ( console, diff --git a/src/logurich/handler.py b/src/logurich/handler.py index 1d3235a..0aa56f5 100644 --- a/src/logurich/handler.py +++ b/src/logurich/handler.py @@ -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 @@ -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: @@ -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)) @@ -112,8 +115,10 @@ 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 @@ -121,12 +126,11 @@ def format_file(self, record: LogRecord) -> str: 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( @@ -142,7 +146,10 @@ 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) @@ -150,14 +157,13 @@ def format_json(self, record: LogRecord) -> str: 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": { @@ -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]: diff --git a/src/logurich/serialize.py b/src/logurich/serialize.py new file mode 100644 index 0000000..ddefb4c --- /dev/null +++ b/src/logurich/serialize.py @@ -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] diff --git a/tests/test_core.py b/tests/test_core.py index ed51fa4..15576b6 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -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) diff --git a/tests/test_mp.py b/tests/test_mp.py index ca25837..e329451 100644 --- a/tests/test_mp.py +++ b/tests/test_mp.py @@ -1,3 +1,4 @@ +import json import logging import multiprocessing as mp import os @@ -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", diff --git a/tests/test_rich.py b/tests/test_rich.py index 077529c..c8953ae 100644 --- a/tests/test_rich.py +++ b/tests/test_rich.py @@ -4,9 +4,14 @@ import re import pytest +from rich.console import Group from rich.logging import RichHandler 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 logurich import LogurichLogger, get_logger, init_logger, shutdown_logger @@ -162,6 +167,127 @@ def test_json_file_uses_newline_framing_when_end_is_empty(tmp_path, buffer): assert not payloads[1]["text"].endswith("\n") +def test_json_serializes_table_structurally(buffer): + init_logger("INFO", console="json", enqueue=False) + get_logger("tests.json-table").rich("INFO", build_table(), title="report") + shutdown_logger() + + payload = json.loads(buffer.getvalue().splitlines()[0]) + assert payload["record"]["renderables"] == [ + { + "type": "table", + "title": "Metrics", + "columns": ["Name", "Value"], + "rows": [["requests", "42"]], + } + ] + assert payload["record"]["message"] == "report" + assert "┏" not in payload["text"] + assert "┏" not in json.dumps(payload) + + +def test_json_serializes_nested_panel(buffer): + init_logger("INFO", console="json", enqueue=False) + get_logger("tests.json-panel").rich( + "INFO", Panel(build_table(), title="wrap", subtitle="sub") + ) + shutdown_logger() + + payload = json.loads(buffer.getvalue().splitlines()[0]) + assert payload["record"]["renderables"] == [ + { + "type": "panel", + "title": "wrap", + "subtitle": "sub", + "content": { + "type": "table", + "title": "Metrics", + "columns": ["Name", "Value"], + "rows": [["requests", "42"]], + }, + } + ] + + +def test_json_serializes_tree_syntax_and_group(buffer): + tree = Tree("root") + tree.add("branch").add("leaf") + init_logger("INFO", console="json", enqueue=False) + get_logger("tests.json-misc").rich( + "INFO", + tree, + Syntax("print(1)", "python"), + Group(Text("a"), Rule("done")), + ) + shutdown_logger() + + renderables = json.loads(buffer.getvalue().splitlines()[0])["record"]["renderables"] + assert renderables == [ + { + "type": "tree", + "label": "root", + "children": [ + { + "type": "tree", + "label": "branch", + "children": [{"type": "tree", "label": "leaf", "children": []}], + } + ], + }, + {"type": "syntax", "lexer": "python", "code": "print(1)"}, + { + "type": "group", + "items": [ + {"type": "text", "text": "a"}, + {"type": "rule", "title": "done"}, + ], + }, + ] + + +def test_json_falls_back_to_text_and_repr(buffer): + class Custom: + def __rich_console__(self, console, options): + yield Text("custom body") + + marker = object() + init_logger("INFO", console="json", enqueue=False) + get_logger("tests.json-fallback").rich("INFO", Custom(), marker) + shutdown_logger() + + renderables = json.loads(buffer.getvalue().splitlines()[0])["record"]["renderables"] + assert renderables[0] == {"type": "text", "text": "custom body"} + assert renderables[1] == {"type": "object", "repr": repr(marker)} + + +def test_json_omits_renderables_when_only_text(buffer): + init_logger("INFO", console="json", enqueue=False) + get_logger("tests.json-text-only").rich("INFO", "just text", prefix=False) + shutdown_logger() + + payload = json.loads(buffer.getvalue().splitlines()[0]) + assert "renderables" not in payload["record"] + assert payload["text"] == "just text\n" + + +def test_json_file_serializes_renderables_structurally(tmp_path, buffer): + init_logger( + "INFO", + log_filename="structured.jsonl", + log_folder=str(tmp_path), + console="plain", + file="json", + rotation=None, + enqueue=False, + ) + get_logger("tests.json-file-table").rich("INFO", build_table()) + shutdown_logger() + + payload = json.loads((tmp_path / "structured.jsonl").read_text().splitlines()[0]) + assert payload["record"]["renderables"][0]["type"] == "table" + assert "┏" not in json.dumps(payload) + + def test_rich_stacklevel_points_to_caller(buffer): init_logger("INFO", console="json", enqueue=False) logger = get_logger("tests.stacklevel") diff --git a/uv.lock b/uv.lock index a12e1be..d54fd7e 100644 --- a/uv.lock +++ b/uv.lock @@ -164,7 +164,7 @@ wheels = [ [[package]] name = "logurich" -version = "1.0.0b2" +version = "1.0.0" source = { editable = "." } dependencies = [ { name = "rich" },