diff --git a/README.md b/README.md index 15e42db..4ecd489 100644 --- a/README.md +++ b/README.md @@ -130,13 +130,62 @@ and `record` structure from Logurich 0.9. } ``` -`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 +`Table`, `Panel`, `Tree`, `Syntax`, `Markdown`, `Rule`, `Layout`, `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. +The same converter is public, so tools that build reports from Rich objects can +reuse it instead of re-rendering: + +```python +from logurich import SCHEMA_VERSION, serialize_renderables + +payload = serialize_renderables((table,), max_rows=None, styles=True) +``` + +`max_depth` and `max_rows` relax the caps (`max_rows=None` keeps every row), and +`styles=True` switches to fidelity mode: text values become +`{"text": ..., "spans": [...]}` objects instead of plain strings, where each +span carries `start`, `end`, `style` and an optional `link`. Fidelity mode also +adds `justify`, `no_wrap` and per-column `style` to tables, `border_style` and +alignments to panels, and `align`/`style` to rules. Log output always uses the +default mode, so enabling styles never changes what handlers emit. + +`SCHEMA_VERSION` identifies the payload contract. New keys may be added within a +version; existing keys are never renamed or removed. + +## Premarkup + +Premarkup tags transform text *before* Rich parses styling markup. Unknown tags +are left untouched, so Rich still handles them: + +```python +from logurich import process_premarkup_to_text + +process_premarkup_to_text("[defang]http://evil.test/a[/defang]") +# Text: http[:]//evil[.]test/a +``` + +Three actions ship built in: `defang` (neutralise URLs, domains and e-mails), +`color-obs` (highlight observables) and `truncate-url` (shorten long URLs). +Tags may combine actions, which then run in priority order: +`[truncate-url defang]...[/truncate-url defang]`. + +Register your own with `register_premarkup(name, handler, priority=...)`; lower +priorities run first. `unregister_premarkup()` removes one and +`premarkup_actions()` lists them in execution order. + +Premarkup is a standalone utility: it is never applied automatically to log +records, so it costs nothing on the logging path. `process_premarkup()` returns +a markup string that must be handed to `Text.from_markup` for its escapes to +resolve; `process_premarkup_to_text()` does that for you and passes non-string +inputs through unchanged. Inputs longer than `MAX_PREMARKUP_INPUT` are returned +as-is, and untrusted content should go through `rich.markup.escape` first, since +the output is markup. + ## Rich objects `logger.rich(level, *renderables, title="", prefix=True, end="\n", width=None, diff --git a/examples/base.py b/examples/base.py index 1412bac..9b495b0 100644 --- a/examples/base.py +++ b/examples/base.py @@ -24,10 +24,11 @@ def create_rich_table() -> Table: Panel("Rich panel content", border_style="green"), create_rich_table(), title="Structured output", + width=60, ) with logger.contextualize(app=logger.ctx("example", style="yellow")): - logger.info("This log has app context") + logger.info("This [blue]log[/blue] has app context") logger.info( "This log has per-call context", diff --git a/examples/premarkup.py b/examples/premarkup.py new file mode 100644 index 0000000..17ed60a --- /dev/null +++ b/examples/premarkup.py @@ -0,0 +1,38 @@ +from logurich import ( + console, + get_logger, + init_logger, + premarkup_actions, + process_premarkup_to_text, + register_premarkup, + unregister_premarkup, +) + + +def redact(text: str) -> str: + return "".join("*" if char.isdigit() else char for char in text) + + +if __name__ == "__main__": + init_logger("INFO", enqueue=False) + logger = get_logger(__name__) + + console.rule("Built-in premarkup actions") + samples = [ + "[defang]Reach out to http://evil.test/a or mail@evil.test[/defang]", + "[truncate-url]Fetched https://example.test/very/long/path/report?id=7#x[/truncate-url]", + "[color-obs]Observed evil.test during triage[/color-obs]", + "[truncate-url defang]https://evil.test/a/b/c/d/e/f[/truncate-url defang]", + "[bold]Unknown tags such as [nope]this[/nope] reach Rich untouched[/bold]", + ] + for sample in samples: + console.print(process_premarkup_to_text(sample)) + + console.rule("Custom action") + register_premarkup("redact", redact, priority=5) + console.print(process_premarkup_to_text("[redact]Ticket 12345 closed[/redact]")) + names = ", ".join(action.name for action in premarkup_actions()) + console.print(f"Actions in execution order: {names}") + unregister_premarkup("redact") + + logger.info("Premarkup demo complete", samples=len(samples)) diff --git a/examples/serialize.py b/examples/serialize.py index 474989e..5c2099d 100644 --- a/examples/serialize.py +++ b/examples/serialize.py @@ -1,9 +1,11 @@ +import json + 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 +from logurich import ctx, get_logger, global_context, init_logger, serialize_renderables def build_table() -> Table: @@ -54,3 +56,9 @@ def build_table() -> Table: raise RuntimeError("serialize example failure") except RuntimeError: logger.exception("Exception payload") + + # The converter behind record.renderables is public and reusable directly. + linked = Table(title="Fidelity mode") + linked.add_column("Report", justify="center", style="cyan") + linked.add_row("[link=https://example.test/42]open[/link]") + print(json.dumps(serialize_renderables((linked,), styles=True), indent=2)) diff --git a/src/logurich/__init__.py b/src/logurich/__init__.py index c886957..39adf95 100644 --- a/src/logurich/__init__.py +++ b/src/logurich/__init__.py @@ -34,6 +34,16 @@ init_logger, shutdown_logger, ) +from .premarkup import ( + MAX_PREMARKUP_INPUT, + PremarkupAction, + premarkup_actions, + process_premarkup, + process_premarkup_to_text, + register_premarkup, + unregister_premarkup, +) +from .serialize import SCHEMA_VERSION, serialize_renderables from .user_input import timeout, user_input, user_input_with_timeout __all__ = [ @@ -66,4 +76,13 @@ "timeout", "user_input", "user_input_with_timeout", + "SCHEMA_VERSION", + "serialize_renderables", + "MAX_PREMARKUP_INPUT", + "PremarkupAction", + "premarkup_actions", + "process_premarkup", + "process_premarkup_to_text", + "register_premarkup", + "unregister_premarkup", ] diff --git a/src/logurich/premarkup.py b/src/logurich/premarkup.py new file mode 100644 index 0000000..211809d --- /dev/null +++ b/src/logurich/premarkup.py @@ -0,0 +1,380 @@ +"""Pre-processing of custom bracket tags before Rich interprets markup. + +Pre-markup tags such as ``[defang]...[/defang]`` mutate the text they enclose +before Rich parses any styling markup. Unknown tags are left untouched so Rich +still sees them. Actions live in a registry, so applications can add their own +with :func:`register_premarkup`. + +Security notes: + +- The output of :func:`process_premarkup` is meant to be handed to + ``Text.from_markup``. Untrusted content can therefore inject Rich styles; run + it through ``rich.markup.escape`` before processing when that matters. +- Inputs longer than :data:`MAX_PREMARKUP_INPUT` are returned unchanged. The + observable-matching patterns are bounded, but very large inputs still cost + time that a caller may not expect. +""" + +from __future__ import annotations + +import re +import textwrap +import threading +from collections.abc import Callable, Sequence +from dataclasses import dataclass, field +from typing import Any +from urllib.parse import urlsplit + +from rich.text import Text + +MAX_PREMARKUP_INPUT = 10_000 +MAX_URL_DISPLAY_LENGTH = 80 +LAST_PATH_SEGMENT_LIMIT = 24 + +TAG_PATTERN = re.compile(r"\[(/?)([^\]]+)\]") + +URL_PATTERN = re.compile( + r"(?P(?:(?:https?|ftp)://|www\.)[^\s\[\]<>()]+)(?P[.,!?)]*)", + re.IGNORECASE, +) + +# Label counts are bounded so a dotted run cannot drive quadratic backtracking. +EMAIL_PATTERN = re.compile( + r"(?P[A-Za-z0-9._%+-]{1,64}@(?:[A-Za-z0-9-]{1,63}\.){1,10}[A-Za-z]{2,63})" + r"(?P[.,!?)]*)" +) +DOMAIN_PATTERN = re.compile( + r"(?(?:[A-Za-z0-9-]{1,63}\.){1,10}[A-Za-z]{2,63}(?::\d{2,5})?" + r"(?:/[^\s\[\]<>()]*)?)(?P[.,!?)]*)", + re.IGNORECASE, +) + +DEFANG_REPLACEMENTS = { + ".": r"\[.]", + ":": r"\[:]", + "@": r"\[@]", +} + + +@dataclass(frozen=True) +class PremarkupAction: + """A named text transformation applied inside a pre-markup tag.""" + + name: str + handler: Callable[[str], str] + priority: int = 100 + + +_REGISTRY: dict[str, PremarkupAction] = {} +_REGISTRY_LOCK = threading.Lock() + + +@dataclass +class _StackFrame: + tokens: tuple[str, ...] + buffer: list[str] = field(default_factory=list) + + +def register_premarkup( + name: str, + handler: Callable[[str], str], + *, + priority: int = 100, + replace: bool = False, +) -> PremarkupAction: + """Register a pre-markup action under ``name``. + + Args: + name (str): Tag name, without brackets. Must not contain whitespace. + handler (Callable[[str], str]): Transformation applied to the enclosed text. + priority (int): Lower values run earlier when a tag combines actions. + replace (bool): Allow overwriting an already registered name. + + Return: + The registered :class:`PremarkupAction`. + + Raises: + ValueError: If ``name`` is empty, contains whitespace, or is already + registered while ``replace`` is ``False``. + """ + + if not name or name.split() != [name]: + raise ValueError("premarkup action name must be a single non-empty token") + action = PremarkupAction(name=name, handler=handler, priority=priority) + with _REGISTRY_LOCK: + if name in _REGISTRY and not replace: + raise ValueError(f"premarkup action already registered: {name}") + _REGISTRY[name] = action + return action + + +def unregister_premarkup(name: str) -> bool: + """Remove a registered action, returning whether it existed.""" + + with _REGISTRY_LOCK: + return _REGISTRY.pop(name, None) is not None + + +def premarkup_actions() -> tuple[PremarkupAction, ...]: + """Return the registered actions, ordered by priority then name.""" + + with _REGISTRY_LOCK: + actions = list(_REGISTRY.values()) + return tuple(sorted(actions, key=lambda action: (action.priority, action.name))) + + +def _registry_snapshot() -> dict[str, PremarkupAction]: + with _REGISTRY_LOCK: + return dict(_REGISTRY) + + +def process_premarkup(source: str) -> str: + """Evaluate pre-markup tags and return the processed markup string. + + The result still contains Rich markup, including backslash escapes produced + by ``defang``; it is only correct once passed to ``Text.from_markup``. Use + :func:`process_premarkup_to_text` to get a rendered :class:`Text` directly. + """ + + if "[" not in source or len(source) > MAX_PREMARKUP_INPUT: + return source + return _apply_known_actions(source, _registry_snapshot()) + + +def process_premarkup_to_text(source: Any) -> Any: + """Evaluate pre-markup tags and return a :class:`Text`. + + Non-string inputs, such as Rich renderables, are returned unchanged so + callers can pipe mixed content through a single call. + """ + + if not isinstance(source, str): + return source + return Text.from_markup(process_premarkup(source)) + + +def _apply_known_actions(markup: str, actions: dict[str, PremarkupAction]) -> str: + """Evaluate known pre-markup tags and strip them from the output. + + Tags that are not registered are left untouched so Rich can process or + display them later on. + """ + + stack: list[_StackFrame] = [_StackFrame(tokens=())] + pos = 0 + + for match in TAG_PATTERN.finditer(markup): + start, end = match.span() + if start > pos: + stack[-1].buffer.append(markup[pos:start]) + + raw_tokens = match.group(2).strip() + if not raw_tokens: + stack[-1].buffer.append(match.group(0)) + pos = end + continue + + tokens = tuple(raw_tokens.split()) + is_closing = bool(match.group(1)) + + if all(token in actions for token in tokens): + if is_closing: + if len(stack) > 1 and stack[-1].tokens == tokens: + opening = stack.pop() + content = "".join(opening.buffer) + stack[-1].buffer.append(_run_actions(content, tokens, actions)) + else: + # Malformed closing tag; keep it literal. + stack[-1].buffer.append(match.group(0)) + else: + stack.append(_StackFrame(tokens=tokens)) + else: + stack[-1].buffer.append(match.group(0)) + + pos = end + + if pos < len(markup): + stack[-1].buffer.append(markup[pos:]) + + while len(stack) > 1: + opening = stack.pop() + start_tag = "[" + " ".join(opening.tokens) + "]" + stack[-1].buffer.append(start_tag) + stack[-1].buffer.append("".join(opening.buffer)) + + return "".join(stack[0].buffer) + + +def _run_actions( + content: str, tokens: Sequence[str], actions: dict[str, PremarkupAction] +) -> str: + ordered = sorted( + (actions[token] for token in tokens), + key=lambda action: (action.priority, action.name), + ) + result = content + for action in ordered: + result = action.handler(result) + return result + + +def _truncate_urls(text: str) -> str: + return _replace_matches(text, URL_PATTERN, _truncate_match) + + +def _truncate_match(match: re.Match[str]) -> str: + suffix = match.group("suffix") or "" + return _truncate_single_url(match.group("url")) + suffix + + +def _truncate_single_url(url: str) -> str: + original = url + parsed = urlsplit(url) + + # Attempt to recover host information when scheme is absent. + if not parsed.netloc and not parsed.scheme: + fallback_parsed = urlsplit("http://" + url) + if fallback_parsed.netloc: + parsed = parsed._replace( + netloc=fallback_parsed.netloc, path=fallback_parsed.path + ) + + scheme_prefix = f"{parsed.scheme}://" if parsed.scheme else "" + netloc = parsed.netloc + path = parsed.path + + if not netloc: + # Give up on structuring the URL; fall back to a shortened literal. + return textwrap.shorten( + original, width=MAX_URL_DISPLAY_LENGTH, placeholder="..." + ) + + display = scheme_prefix + netloc + + if path and path != "/": + segments = [segment for segment in path.split("/") if segment] + if not segments: + display += "/" + elif len(segments) == 1 and len(segments[0]) <= LAST_PATH_SEGMENT_LIMIT: + display += f"/{segments[0]}" + else: + display += f"/.../{segments[-1][:LAST_PATH_SEGMENT_LIMIT]}" + elif path == "/": + display += "/" + + if parsed.query: + display += "?..." + + if parsed.fragment: + display += "#..." + + if len(display) > MAX_URL_DISPLAY_LENGTH: + display = display[: MAX_URL_DISPLAY_LENGTH - 3] + "..." + + # Avoid returning a "truncated" URL that is no shorter or clearer. + if len(display) >= len(original): + return original + + return display + + +def _defang_content(text: str) -> str: + if "." not in text and "@" not in text: + return text + text = _replace_matches(text, EMAIL_PATTERN, _defang_email_match) + text = _replace_matches(text, URL_PATTERN, _defang_url_match) + return _replace_matches(text, DOMAIN_PATTERN, _defang_domain_match) + + +def _defang_email_match(match: re.Match[str]) -> str: + return _defang_token(match.group("email")) + (match.group("suffix") or "") + + +def _defang_url_match(match: re.Match[str]) -> str: + return _defang_token(match.group("url")) + (match.group("suffix") or "") + + +def _defang_domain_match(match: re.Match[str]) -> str: + return _defang_token(match.group("domain")) + (match.group("suffix") or "") + + +def _defang_token(token: str) -> str: + result: list[str] = [] + length = len(token) + for index, char in enumerate(token): + replacement = DEFANG_REPLACEMENTS.get(char) + if not replacement: + result.append(char) + continue + + if char == ".": + prev_char = token[index - 1] if index > 0 else "" + next_char = token[index + 1] if index + 1 < length else "" + if not (prev_char.isalnum() or next_char.isalnum()): + result.append(char) + continue + + result.append(replacement) + + return "".join(result) + + +def _apply_color_obs(text: str) -> str: + if "." not in text and "@" not in text: + return text + text = _replace_matches(text, EMAIL_PATTERN, _color_email_match) + text = _replace_matches(text, URL_PATTERN, _color_url_match) + return _replace_matches(text, DOMAIN_PATTERN, _color_domain_match) + + +def _color_email_match(match: re.Match[str]) -> str: + return f"[cyan]{match.group('email')}[/cyan]{match.group('suffix') or ''}" + + +def _color_url_match(match: re.Match[str]) -> str: + return f"[cyan]{match.group('url')}[/cyan]{match.group('suffix') or ''}" + + +def _color_domain_match(match: re.Match[str]) -> str: + return f"[cyan]{match.group('domain')}[/cyan]{match.group('suffix') or ''}" + + +def _replace_matches( + text: str, + pattern: re.Pattern[str], + replacer: Callable[[re.Match[str]], str], +) -> str: + if not text: + return text + + result: list[str] = [] + last_end = 0 + for match in pattern.finditer(text): + start, end = match.span() + if start < last_end: + continue + result.append(text[last_end:start]) + result.append(replacer(match)) + last_end = end + result.append(text[last_end:]) + return "".join(result) + + +def _register_builtins() -> None: + register_premarkup("truncate-url", _truncate_urls, priority=0, replace=True) + register_premarkup("color-obs", _apply_color_obs, priority=1, replace=True) + register_premarkup("defang", _defang_content, priority=2, replace=True) + + +_register_builtins() + +__all__ = [ + "MAX_PREMARKUP_INPUT", + "PremarkupAction", + "premarkup_actions", + "process_premarkup", + "process_premarkup_to_text", + "register_premarkup", + "unregister_premarkup", +] diff --git a/src/logurich/serialize.py b/src/logurich/serialize.py index ddefb4c..899c145 100644 --- a/src/logurich/serialize.py +++ b/src/logurich/serialize.py @@ -2,15 +2,18 @@ from __future__ import annotations +from dataclasses import dataclass 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.layout import Layout from rich.padding import Padding from rich.panel import Panel from rich.rule import Rule +from rich.style import Style from rich.syntax import Syntax from rich.table import Table from rich.text import Text @@ -18,11 +21,23 @@ from .console import rich_to_str +SCHEMA_VERSION = 1 + MAX_DEPTH = 4 MAX_TABLE_ROWS = 100 _UNSET = object() _MARKDOWN: Any = _UNSET +_PLACEHOLDER: Any = _UNSET + + +@dataclass(frozen=True) +class _Options: + """Serialisation settings threaded through the recursive walk.""" + + max_depth: int = MAX_DEPTH + max_rows: Optional[int] = MAX_TABLE_ROWS + styles: bool = False def _markdown_type() -> Optional[type]: @@ -39,6 +54,20 @@ def _markdown_type() -> Optional[type]: return _MARKDOWN +def _placeholder_type() -> Optional[type]: + """Return the private filler Rich puts in an empty ``Layout``, if present.""" + + global _PLACEHOLDER + if _PLACEHOLDER is _UNSET: + try: + from rich.layout import _Placeholder + except Exception: + _PLACEHOLDER = None + else: + _PLACEHOLDER = _Placeholder + return _PLACEHOLDER + + def _plain(value: str) -> str: try: return Text.from_markup(value).plain @@ -63,8 +92,82 @@ def _cell_text(value: Any) -> str: return _rendered_text(value) -def _optional_text(value: Any) -> Optional[str]: - return None if value is None else _cell_text(value) +def _optional_text(value: Any, opts: _Options) -> Any: + return None if value is None else _text_value(value, opts) + + +def _style_text(value: Any) -> Optional[str]: + """Normalise a style to its Rich definition string.""" + + if value is None or value == "": + return None + try: + return str(value) + except Exception: + return None + + +def _as_text(value: Any) -> Optional[Text]: + """Return ``value`` as :class:`Text` when it carries recoverable styling.""" + + if isinstance(value, Text): + return value + if isinstance(value, str): + try: + return Text.from_markup(value) + except Exception: + return Text(value) + return None + + +def _spans(text: Text) -> list[dict[str, Any]]: + spans: list[dict[str, Any]] = [] + for span in text.spans: + style = span.style + if not style: + continue + parsed: Optional[Style] + if isinstance(style, Style): + parsed = style + else: + try: + parsed = Style.parse(str(style)) + except Exception: + parsed = None + definition = _style_text(parsed if parsed is not None else style) + if definition is None: + continue + payload: dict[str, Any] = { + "start": span.start, + "end": span.end, + "style": definition, + } + link = getattr(parsed, "link", None) + if link: + payload["link"] = link + spans.append(payload) + return spans + + +def _text_payload(value: Any, opts: _Options) -> dict[str, Any]: + if not opts.styles: + return {"type": "text", "text": _cell_text(value)} + text = _as_text(value) + if text is None: + return {"type": "text", "text": _cell_text(value)} + payload: dict[str, Any] = {"type": "text", "text": text.plain} + spans = _spans(text) + if spans: + payload["spans"] = spans + return payload + + +def _text_value(value: Any, opts: _Options) -> Any: + """Plain string in the default mode, structured payload in fidelity mode.""" + + if not opts.styles: + return _cell_text(value) + return _text_payload(value, opts) def _lexer_name(syntax: Syntax) -> Optional[str]: @@ -74,71 +177,149 @@ def _lexer_name(syntax: Syntax) -> Optional[str]: return getattr(lexer, "name", None) -def _serialize_table(table: Table) -> dict[str, Any]: +def _serialize_columns(table: Table, opts: _Options) -> list[Any]: + if not opts.styles: + return [_cell_text(column.header) for column in table.columns] + return [ + { + "header": _text_value(column.header, opts), + "justify": column.justify, + "no_wrap": column.no_wrap, + "style": _style_text(column.style), + } + for column in table.columns + ] + + +def _serialize_table(table: Table, opts: _Options) -> dict[str, Any]: cells = [list(column.cells) for column in table.columns] - limit = min(table.row_count, MAX_TABLE_ROWS) + limit = ( + table.row_count + if opts.max_rows is None + else min(table.row_count, opts.max_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], + "title": _optional_text(table.title, opts), + "columns": _serialize_columns(table, opts), + "rows": [[_text_value(cell, opts) for cell in row] for row in rows], } + if opts.styles: + data["show_header"] = table.show_header + data["expand"] = table.expand if table.row_count > limit: data["truncated"] = True return data -def _serialize_tree(tree: Tree, depth: int) -> dict[str, Any]: +def _serialize_tree(tree: Tree, depth: int, opts: _Options) -> dict[str, Any]: return { "type": "tree", - "label": _cell_text(tree.label), - "children": [_serialize(child, depth + 1) for child in tree.children], + "label": _text_value(tree.label, opts), + "children": [_serialize(child, depth + 1, opts) for child in tree.children], + } + + +def _serialize_layout(layout: Layout, depth: int, opts: _Options) -> dict[str, Any]: + data: dict[str, Any] = { + "type": "layout", + "name": layout.name, + "direction": getattr(layout.splitter, "name", None), + "visible": layout.visible, + "size": layout.size, + "ratio": layout.ratio, } + children = list(layout.children) + if children: + data["children"] = [_serialize(child, depth + 1, opts) for child in children] + return data + renderable = layout.renderable + placeholder = _placeholder_type() + empty = renderable is layout or ( + placeholder is not None and isinstance(renderable, placeholder) + ) + data["content"] = None if empty else _serialize(renderable, depth + 1, opts) + return data -def _serialize(item: Any, depth: int) -> dict[str, Any]: - if depth > MAX_DEPTH: +def _serialize_panel(item: Panel, depth: int, opts: _Options) -> dict[str, Any]: + data: dict[str, Any] = { + "type": "panel", + "title": _optional_text(item.title, opts), + "subtitle": _optional_text(item.subtitle, opts), + "content": _serialize(item.renderable, depth + 1, opts), + } + if opts.styles: + data["border_style"] = _style_text(item.border_style) + data["title_align"] = item.title_align + data["subtitle_align"] = item.subtitle_align + return data + + +def _serialize_rule(item: Rule, opts: _Options) -> dict[str, Any]: + data: dict[str, Any] = {"type": "rule", "title": _text_value(item.title, opts)} + if opts.styles: + data["align"] = item.align + data["style"] = _style_text(item.style) + return data + + +def _serialize(item: Any, depth: int, opts: _Options) -> dict[str, Any]: + if depth > opts.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, (str, Text)): + return _text_payload(item, opts) if isinstance(item, Table): - return _serialize_table(item) + return _serialize_table(item, opts) if isinstance(item, Panel): - return { - "type": "panel", - "title": _optional_text(item.title), - "subtitle": _optional_text(item.subtitle), - "content": _serialize(item.renderable, depth + 1), - } + return _serialize_panel(item, depth, opts) if isinstance(item, Tree): - return _serialize_tree(item, depth) + return _serialize_tree(item, depth, opts) 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)} + return _serialize_rule(item, opts) + if isinstance(item, Layout): + return _serialize_layout(item, depth, opts) 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], + "items": [_serialize(child, depth + 1, opts) 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) + return _serialize(item.renderable, depth, opts) 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.""" +def serialize_renderables( + renderables: tuple[Any, ...], + *, + max_depth: int = MAX_DEPTH, + max_rows: Optional[int] = MAX_TABLE_ROWS, + styles: bool = False, +) -> list[dict[str, Any]]: + """Convert Rich renderables into JSON-friendly structured payloads. + + Args: + renderables (tuple[Any, ...]): Rich renderables to serialise. + max_depth (int): Recursion limit before falling back to rendered text. + max_rows (Optional[int]): Table row cap, or ``None`` to keep every row. + styles (bool): Emit styles, links and layout metadata. Text values then + become ``{"text": ..., "spans": [...]}`` objects instead of strings. + + Return: + A list of JSON-serialisable payloads, one per renderable. + """ - return [_serialize(item, 0) for item in renderables] + opts = _Options(max_depth=max_depth, max_rows=max_rows, styles=styles) + return [_serialize(item, 0, opts) for item in renderables] diff --git a/tests/test_premarkup.py b/tests/test_premarkup.py new file mode 100644 index 0000000..c45a01d --- /dev/null +++ b/tests/test_premarkup.py @@ -0,0 +1,135 @@ +import time + +import pytest +from rich.text import Text + +from logurich.premarkup import ( + MAX_PREMARKUP_INPUT, + premarkup_actions, + process_premarkup, + process_premarkup_to_text, + register_premarkup, + unregister_premarkup, +) + + +@pytest.fixture +def custom_action(): + register_premarkup("shout", str.upper, priority=50) + yield "shout" + unregister_premarkup("shout") + + +def test_unknown_tags_are_preserved(): + assert process_premarkup("[bold]a[/bold] [nope]b[/nope]") == ( + "[bold]a[/bold] [nope]b[/nope]" + ) + + +def test_text_without_brackets_is_returned_unchanged(): + assert process_premarkup("nothing to do here") == "nothing to do here" + + +def test_unclosed_tag_is_restored_literally(): + assert process_premarkup("[defang]evil.com") == "[defang]evil.com" + + +def test_malformed_closing_tag_stays_literal(): + assert process_premarkup("[/defang]x") == "[/defang]x" + + +def test_empty_tag_stays_literal(): + assert process_premarkup("[]x") == "[]x" + + +def test_defang_handles_url_email_and_domain(): + result = process_premarkup("[defang]http://evil.com/a and bob@evil.com[/defang]") + assert result == r"http\[:]//evil\[.]com/a and bob\[@]evil\[.]com" + + +def test_defang_leaves_isolated_dot_untouched(): + assert process_premarkup("[defang]end . here[/defang]") == "end . here" + + +def test_truncate_url_shortens_long_paths(): + result = process_premarkup( + "[truncate-url]http://x.test/very/long/path/here?q=1#f[/truncate-url]" + ) + assert result == "http://x.test/.../here?...#..." + + +def test_truncate_url_keeps_short_urls(): + assert process_premarkup("[truncate-url]http://x.test[/truncate-url]") == ( + "http://x.test" + ) + + +def test_color_obs_wraps_observables(): + assert process_premarkup("[color-obs]see evil.com now[/color-obs]") == ( + "see [cyan]evil.com[/cyan] now" + ) + + +def test_combined_actions_respect_priority(): + source = "[truncate-url defang]http://x.test/a/b/c/d/e/f/g/h[/truncate-url defang]" + result = process_premarkup(source) + # truncate-url runs first, so the shortened form is what ends up defanged. + assert "..." in result + assert r"\[.]" in result + + +def test_nested_tags_apply_inner_first(): + result = process_premarkup("keep [defang]evil.com[/defang] keep") + assert result == r"keep evil\[.]com keep" + + +def test_to_text_renders_markup(): + assert process_premarkup_to_text("[defang]evil.com[/defang]").plain == "evil[.]com" + + +def test_to_text_passes_non_strings_through(): + marker = Text("already rich") + assert process_premarkup_to_text(marker) is marker + + +def test_oversized_input_is_returned_unchanged(): + source = "[defang]" + ("a.b " * MAX_PREMARKUP_INPUT) + "[/defang]" + assert process_premarkup(source) is source + + +def test_register_and_use_custom_action(custom_action): + assert process_premarkup("[shout]hello[/shout]") == "HELLO" + + +def test_register_rejects_duplicate(custom_action): + with pytest.raises(ValueError): + register_premarkup("shout", str.lower) + + +def test_register_replaces_when_asked(custom_action): + register_premarkup("shout", str.lower, replace=True) + assert process_premarkup("[shout]HELLO[/shout]") == "hello" + + +def test_register_rejects_invalid_name(): + with pytest.raises(ValueError): + register_premarkup("two words", str.upper) + + +def test_unregister_reports_whether_action_existed(custom_action): + assert unregister_premarkup("shout") is True + assert unregister_premarkup("shout") is False + assert process_premarkup("[shout]hello[/shout]") == "[shout]hello[/shout]" + register_premarkup("shout", str.upper) + + +def test_builtin_actions_are_ordered_by_priority(): + names = [action.name for action in premarkup_actions()] + assert names[:3] == ["truncate-url", "color-obs", "defang"] + + +def test_adversarial_dotted_input_stays_fast(): + source = "[defang]" + ("a." * 2000) + "1[/defang]" + start = time.perf_counter() + process_premarkup(source) + assert time.perf_counter() - start < 0.5 diff --git a/tests/test_serialize.py b/tests/test_serialize.py new file mode 100644 index 0000000..7e6b3cd --- /dev/null +++ b/tests/test_serialize.py @@ -0,0 +1,138 @@ +import time + +import pytest +from rich.layout import Layout +from rich.panel import Panel +from rich.rule import Rule +from rich.table import Table +from rich.text import Text + +from logurich.serialize import MAX_DEPTH, serialize_renderables + + +def build_table(rows=1): + table = Table(title="Metrics") + table.add_column("Name", justify="center", style="green") + table.add_column("Value") + for index in range(rows): + table.add_row(f"row-{index}", "42") + return table + + +def test_default_mode_matches_documented_shape(): + payload = serialize_renderables((build_table(),)) + assert payload == [ + { + "type": "table", + "title": "Metrics", + "columns": ["Name", "Value"], + "rows": [["row-0", "42"]], + } + ] + + +def test_default_mode_omits_style_metadata(): + payload = serialize_renderables((Panel(Text("x"), title="T"), Rule("r"))) + assert payload[0] == { + "type": "panel", + "title": "T", + "subtitle": None, + "content": {"type": "text", "text": "x"}, + } + assert payload[1] == {"type": "rule", "title": "r"} + + +def test_max_rows_none_keeps_every_row(): + payload = serialize_renderables((build_table(rows=150),), max_rows=None)[0] + assert len(payload["rows"]) == 150 + assert "truncated" not in payload + + +def test_max_rows_truncates_and_flags(): + payload = serialize_renderables((build_table(rows=5),), max_rows=2)[0] + assert len(payload["rows"]) == 2 + assert payload["truncated"] is True + + +def test_max_depth_falls_back_to_rendered_text(): + nested = Panel(Panel(Panel(Text("deep")))) + payload = serialize_renderables((nested,), max_depth=1) + innermost = payload[0]["content"]["content"] + assert innermost["truncated"] is True + assert innermost["type"] == "text" + + +def test_max_depth_default_is_unchanged(): + assert MAX_DEPTH == 4 + + +def test_styles_emit_spans(): + payload = serialize_renderables( + (Text.from_markup("[bold red]hot[/]"),), styles=True + ) + assert payload[0]["text"] == "hot" + assert payload[0]["spans"] == [{"start": 0, "end": 3, "style": "bold red"}] + + +def test_styles_extract_link(): + text = Text.from_markup("[link=http://example.test/x]doc[/link]") + span = serialize_renderables((text,), styles=True)[0]["spans"][0] + assert span["link"] == "http://example.test/x" + + +def test_styles_omit_spans_when_unstyled(): + payload = serialize_renderables((Text("plain"),), styles=True) + assert payload[0] == {"type": "text", "text": "plain"} + + +def test_styles_expose_table_metadata(): + payload = serialize_renderables((build_table(),), styles=True)[0] + assert payload["columns"][0] == { + "header": {"type": "text", "text": "Name"}, + "justify": "center", + "no_wrap": False, + "style": "green", + } + assert payload["rows"][0][0] == {"type": "text", "text": "row-0"} + assert payload["show_header"] is True + + +def test_styles_expose_panel_and_rule_metadata(): + panel, rule = serialize_renderables( + (Panel(Text("x"), border_style="blue"), Rule("r", style="dim")), styles=True + ) + assert panel["border_style"] == "blue" + assert panel["title_align"] == "center" + assert rule["style"] == "dim" + assert rule["align"] == "center" + + +def test_invalid_style_does_not_raise(): + text = Text("x") + text.stylize("definitely-not-a-style", 0, 1) + payload = serialize_renderables((text,), styles=True) + assert payload[0]["text"] == "x" + + +def test_layout_serialises_tree_and_leaves(): + layout = Layout(name="root") + layout.split_row(Layout(Text("a"), name="left"), Layout(name="right")) + payload = serialize_renderables((layout,), styles=True)[0] + assert payload["type"] == "layout" + assert payload["direction"] == "row" + assert payload["children"][0]["content"] == {"type": "text", "text": "a"} + assert payload["children"][1]["content"] is None + + +def test_layout_reports_visibility(): + layout = Layout(Text("a"), name="hidden") + layout.visible = False + assert serialize_renderables((layout,))[0]["visible"] is False + + +@pytest.mark.parametrize("rows", [500]) +def test_large_table_serialisation_stays_fast(rows): + table = build_table(rows=rows) + start = time.perf_counter() + serialize_renderables((table,), max_rows=None, styles=True) + assert time.perf_counter() - start < 2.0 diff --git a/uv.lock b/uv.lock index d54fd7e..13433a4 100644 --- a/uv.lock +++ b/uv.lock @@ -104,14 +104,14 @@ wheels = [ [[package]] name = "filelock" -version = "3.32.2" +version = "3.32.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.10'", ] -sdist = { url = "https://files.pythonhosted.org/packages/f6/57/3ba6e6cb097f85b855b00163d169f35365f44277df044dcf96d55b8f62a3/filelock-3.32.2.tar.gz", hash = "sha256:c33351e1f49cae33414acbc6d56784e6ecee82514ec90795da1161fc4836b5b8", size = 217172, upload-time = "2026-07-29T22:46:04.895Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/64/a02e6765de08964ed371eca577870593245afc9dfac16d037de7c10d18e6/filelock-3.32.3.tar.gz", hash = "sha256:0ffa185a3540854c95caa7fa76b76cb219d907415e2c5dc9af25fd970563487f", size = 218135, upload-time = "2026-08-13T16:00:05.577Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/e8/72f8cef9fdfeffe06213fe8508039396ee48daa0e3259457ed766173bfd6/filelock-3.32.2-py3-none-any.whl", hash = "sha256:87dd94cf281e586d135fa51132b8e3d9a598b316e90377a288663c9321036c82", size = 98830, upload-time = "2026-07-29T22:46:03.52Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8e/50f46a9c0ce8d2861a394c1347caae037ea0431d2f67d7feb151cbc4649a/filelock-3.32.3-py3-none-any.whl", hash = "sha256:7f0ca4bcc0e181c60dbbd8aa9ab5b120ebb99e4e064e83636340056f833a1f09", size = 98901, upload-time = "2026-08-13T16:00:03.974Z" }, ] [[package]] @@ -179,7 +179,7 @@ click = [ [package.dev-dependencies] dev = [ { name = "pre-commit", version = "4.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pre-commit", version = "4.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pre-commit", version = "4.6.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "ruff" }, @@ -249,11 +249,11 @@ wheels = [ [[package]] name = "packaging" -version = "26.2" +version = "26.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] [[package]] @@ -270,14 +270,14 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.11.0" +version = "4.11.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.10'", ] -sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/d7/e7bfbc86e9f99ff7807e24de7703f032e9c9ba80bb355cf26e0e9bc5a75e/platformdirs-4.11.3.tar.gz", hash = "sha256:66a73d38a849810252df809a3d8bcbda8e26f6c189920e7535ad608a48dbb5ab", size = 33050, upload-time = "2026-08-13T22:43:27.52Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" }, + { url = "https://files.pythonhosted.org/packages/19/a9/c34aebedd3a4c9afe5101b1b8713710b3fec18087c8a36c35d2f909861bd/platformdirs-4.11.3-py3-none-any.whl", hash = "sha256:5ed065d443751de711da036041a7a214122efc4a4de393b3f4137ba5576540e7", size = 23491, upload-time = "2026-08-13T22:43:26.121Z" }, ] [[package]] @@ -310,7 +310,7 @@ wheels = [ [[package]] name = "pre-commit" -version = "4.6.1" +version = "4.6.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.10'", @@ -322,18 +322,18 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/25/3a/ddb78f32a0814e66b18a099377a106a2dcdce92d86a034d69d65df9b256e/pre_commit-4.6.1.tar.gz", hash = "sha256:03e809865c7d178b9979d06c761fcbfe6808fdaded8581a745bb110e52050421", size = 198646, upload-time = "2026-07-21T20:56:58.225Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/89/1f3e8e1fc3e97de0fa963495832f581f025f29471602a309e48808244292/pre_commit-4.6.2.tar.gz", hash = "sha256:8f5d7bfb021ecdbcd9d49d89847082dd24172ccde534390081a679ad046e2441", size = 198670, upload-time = "2026-08-10T22:07:18.421Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/49/bc925106abcdac498074f2cbe6137e94e09f418dd2b7775df5b577dc0313/pre_commit-4.6.1-py2.py3-none-any.whl", hash = "sha256:0e3b2942510d1fb34eec167a3ec57331bf8442122f1153a9fb8b58f5c49b2717", size = 226186, upload-time = "2026-07-21T20:56:57.064Z" }, + { url = "https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl", hash = "sha256:e2dde9a75d3bce11bd3831c26d134df00a2803c1d818be6a0383c3dcda25dc4e", size = 226202, upload-time = "2026-08-10T22:07:16.942Z" }, ] [[package]] name = "pygments" -version = "2.20.0" +version = "2.21.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] [[package]] @@ -380,15 +380,15 @@ wheels = [ [[package]] name = "python-discovery" -version = "1.5.1" +version = "1.5.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock", version = "3.19.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "filelock", version = "3.32.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "filelock", version = "3.32.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/b7/1581a8103855c43567776aa34135e5ec3c597346c23bfd10c7eb5e0b10a4/python_discovery-1.5.1.tar.gz", hash = "sha256:e2ea8b884cd1701f386eda8cf327b87743f1dc21b7f784470799537d95635384", size = 77200, upload-time = "2026-07-31T22:06:02.48Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/b7/ac44da2cf0e53ada0e419033c2d058219c95dc1403126f163304c9e814b1/python_discovery-1.5.2.tar.gz", hash = "sha256:45fd4f20a4e3f9b7bf2e0817870bc8e3b320a19658da177af800768c82dbf354", size = 82350, upload-time = "2026-08-12T14:05:26.419Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/07/a89b539750a159d5101c4eb9fc84e2961f65cefbd5e0b7440b284471c0b0/python_discovery-1.5.1-py3-none-any.whl", hash = "sha256:ac07f44cade589d954e9d6a1e1468539fdddd2cf676beb51da73e0f156b7c932", size = 35752, upload-time = "2026-07-31T22:06:01.116Z" }, + { url = "https://files.pythonhosted.org/packages/71/45/689603d04b3bb8d7faa00f25c24acef993aab7813b3dbbfc472a459ab0b5/python_discovery-1.5.2-py3-none-any.whl", hash = "sha256:3e338c2d0f15dfaeea57493f4c2c6caebe0e998ea815c30ae8bf8ee21f1112d3", size = 38350, upload-time = "2026-08-12T14:05:25.113Z" }, ] [[package]] @@ -466,41 +466,41 @@ wheels = [ [[package]] name = "rich" -version = "14.2.0" +version = "15.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] [[package]] name = "ruff" -version = "0.16.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/70/25/7113f6d5498888c5fb7db34081cba7d5971c4cb1bfb26819966eee68f003/ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7", size = 4877500, upload-time = "2026-07-30T19:37:01.379Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/bd/694da69368e0973de65df2ddc73ab18d43c469d5963d9b150911de6bc513/ruff-0.16.1-py3-none-linux_armv6l.whl", hash = "sha256:58edb313b88f0c5460a26adf5f39a37a3be789494a15e3e411e35fa78b89f9a0", size = 10839126, upload-time = "2026-07-30T19:36:13.697Z" }, - { url = "https://files.pythonhosted.org/packages/3f/f0/b626e5d5bd0dd9576263658ef12885e2288afd1029a48e26ffed65ec1ac1/ruff-0.16.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fde5a99e2f97479af66edd6622c6d5a2a7592c77cf4153d9e4428f5eeb55b60c", size = 11070253, upload-time = "2026-07-30T19:36:17.14Z" }, - { url = "https://files.pythonhosted.org/packages/83/63/f40acfb6b35b88623e71684942b552c3edd96035f5d98f313815f7b277de/ruff-0.16.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d4c20532fca4f7fa609369161d968dd28f65d83dabbd61d8e9c7edbf7001f6", size = 10561425, upload-time = "2026-07-30T19:36:20.04Z" }, - { url = "https://files.pythonhosted.org/packages/aa/dd/14ec0e9c2b4d315547dd38765004b4863e354e1b52cb308272215d9f6f6d/ruff-0.16.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30affbcedf59ad5703d9c91f82266e02b47739f797e1a7b6e158e5526a6dae38", size = 10948879, upload-time = "2026-07-30T19:36:22.476Z" }, - { url = "https://files.pythonhosted.org/packages/33/e9/9d870cbae575030fdef595f04b4b97573c525b5497cce4f4498cf2f85446/ruff-0.16.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24e9c631573cbca9d20f1283f8f479b2afa4a8503504822bd71a293889f16743", size = 10643691, upload-time = "2026-07-30T19:36:24.914Z" }, - { url = "https://files.pythonhosted.org/packages/c4/09/12743d544e2173f53ecd27217c65f90d2bc0f8424a66a60339e56bbc0457/ruff-0.16.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b41bdd48fb420987a9b5212e4957c26ad4abce401fa9ea9d4d85843727945f4f", size = 11435354, upload-time = "2026-07-30T19:36:28.447Z" }, - { url = "https://files.pythonhosted.org/packages/7f/89/a1652b2daee52083c9554a6333b678a8b01d0400f976827bb87857f9449a/ruff-0.16.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0d1e1393b7648079e13669de1c1f4fde06d4583e84d8fd5c1551e0a77a2aa75", size = 12259033, upload-time = "2026-07-30T19:36:31.326Z" }, - { url = "https://files.pythonhosted.org/packages/16/96/ecdcb8c54ee7b123b487f807eb014e6e019155a0b81dfb669acd52f28ce3/ruff-0.16.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07bf434b1c95f4e093be4532068ef4fcf00924eb2ade8796075980902d6fd54a", size = 11667981, upload-time = "2026-07-30T19:36:34.394Z" }, - { url = "https://files.pythonhosted.org/packages/cd/90/c52e12e0d862e9572f2a33aa227409143520abe53111e9a6babbac7b4af8/ruff-0.16.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c", size = 11468183, upload-time = "2026-07-30T19:36:37.339Z" }, - { url = "https://files.pythonhosted.org/packages/2c/6b/4ffb7ad1d83eb16cf8cbb3c8815d3f11c88460fd162d4b372a2059be1c2a/ruff-0.16.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82ae3c0c0d74daf17b968a10b7b3bb3ef297ab7de0c1f749646b25e690ccb150", size = 11470071, upload-time = "2026-07-30T19:36:39.91Z" }, - { url = "https://files.pythonhosted.org/packages/9c/72/32ae7db4c0b5e32ab611787caa19d1546800676d79f7483b7100a3561bf4/ruff-0.16.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4d5f2ed10f8242d83fc08d521301089364e3375375705356f20c0e31606ef3ef", size = 10919503, upload-time = "2026-07-30T19:36:42.65Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ca/3d901ba6ad6fc38da39c3448fc6c59ac945679293a17c3ceb6d6c1cba13e/ruff-0.16.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a4665b309891f83f3e3c25447935f1213e9abbd4b5640af7a1f2def9f8d413c1", size = 10649861, upload-time = "2026-07-30T19:36:45.18Z" }, - { url = "https://files.pythonhosted.org/packages/92/79/894ef1ced26552d5f8c9cf6d85b0687840e1128c55aeab7b9c2d54a0d880/ruff-0.16.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26e9ca5c9bc3971f20d3cf18a957f52ffd6a5f6564ff15c4912a144dcac22494", size = 11148137, upload-time = "2026-07-30T19:36:47.936Z" }, - { url = "https://files.pythonhosted.org/packages/2d/69/3609a09fa1cb46cc28b762363e440a354204e5dff01bd0c8d7437874d6b9/ruff-0.16.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:67e1e1e3fa4f0c82f0e36d4cd61e661f6e7a6196cb1aa92fe0828fa7b8f257cd", size = 11559211, upload-time = "2026-07-30T19:36:50.448Z" }, - { url = "https://files.pythonhosted.org/packages/fc/8a/fb22af2fd78a736e241fabf67e30ce1799a64244026377a49e133af90762/ruff-0.16.1-py3-none-win32.whl", hash = "sha256:d31765e131295b8445caf301e3e8a85b34d1b9b211b4109b7ba457888b051806", size = 10838258, upload-time = "2026-07-30T19:36:53.298Z" }, - { url = "https://files.pythonhosted.org/packages/d4/35/e57fd9fb5d423961df087a00b12d42c0a830288dc2f3b45ecca299158b4f/ruff-0.16.1-py3-none-win_amd64.whl", hash = "sha256:09b05e8b90c2cb06ad63464350e7a45e8e44a2dfe52072ebfba6666ca8d3f596", size = 11961111, upload-time = "2026-07-30T19:36:56.107Z" }, - { url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" }, +version = "0.16.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc", size = 4899731, upload-time = "2026-08-20T17:43:59.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", hash = "sha256:df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7", size = 10006909, upload-time = "2026-08-20T17:43:16.888Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604", size = 10240201, upload-time = "2026-08-20T17:43:19.337Z" }, + { url = "https://files.pythonhosted.org/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255", size = 9835122, upload-time = "2026-08-20T17:43:21.708Z" }, + { url = "https://files.pythonhosted.org/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade", size = 9977162, upload-time = "2026-08-20T17:43:24.236Z" }, + { url = "https://files.pythonhosted.org/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff", size = 9829789, upload-time = "2026-08-20T17:43:26.966Z" }, + { url = "https://files.pythonhosted.org/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80", size = 10527949, upload-time = "2026-08-20T17:43:29.384Z" }, + { url = "https://files.pythonhosted.org/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07", size = 11333695, upload-time = "2026-08-20T17:43:31.872Z" }, + { url = "https://files.pythonhosted.org/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7", size = 10727741, upload-time = "2026-08-20T17:43:34.596Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3", size = 10286522, upload-time = "2026-08-20T17:43:37.288Z" }, + { url = "https://files.pythonhosted.org/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454", size = 10584182, upload-time = "2026-08-20T17:43:39.984Z" }, + { url = "https://files.pythonhosted.org/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b", size = 10134195, upload-time = "2026-08-20T17:43:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d", size = 9825821, upload-time = "2026-08-20T17:43:44.532Z" }, + { url = "https://files.pythonhosted.org/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0", size = 10267658, upload-time = "2026-08-20T17:43:46.989Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d", size = 10697071, upload-time = "2026-08-20T17:43:49.891Z" }, + { url = "https://files.pythonhosted.org/packages/aa/28/0c6dd865859c6d17bc8ccc34cb72b0e02d6c7eb25e8a1e22b5bea681e2c0/ruff-0.16.4-py3-none-win32.whl", hash = "sha256:312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e", size = 10021687, upload-time = "2026-08-20T17:43:52.281Z" }, + { url = "https://files.pythonhosted.org/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", hash = "sha256:05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c", size = 10567657, upload-time = "2026-08-20T17:43:54.78Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579, upload-time = "2026-08-20T17:43:57.135Z" }, ] [[package]] @@ -568,18 +568,18 @@ wheels = [ [[package]] name = "virtualenv" -version = "21.7.1" +version = "21.7.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock", version = "3.19.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "filelock", version = "3.32.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "filelock", version = "3.32.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "platformdirs", version = "4.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "platformdirs", version = "4.11.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "python-discovery" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ea/fa/18004e5cb15541ad2a68ff219c755233b012b12d4ec8663d06a258082bec/virtualenv-21.7.1.tar.gz", hash = "sha256:d0dbfaa5483487baea28d7210ef8d24c9d1bd0f10f449eeb215568825a9b334e", size = 5525237, upload-time = "2026-07-30T15:40:36.36Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/dc/a6eb1ddfa7f1e390fa599b078453c97edb3f6f846b34fb4eac3e8ea16401/virtualenv-21.7.4.tar.gz", hash = "sha256:c9d960c95fa458171e58222a5ccab7465298e4b6559977865e627c4719f1e825", size = 5345511, upload-time = "2026-08-10T22:54:33.316Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/a7/ded126c19495158a05c7202b3389139839d4cf78d622d453867778e0f7a8/virtualenv-21.7.1-py3-none-any.whl", hash = "sha256:6394973f990536e34c05157179146c020284c42fe01da1dfeb0ba16c345280d9", size = 5504576, upload-time = "2026-07-30T15:40:34.512Z" }, + { url = "https://files.pythonhosted.org/packages/40/4c/eb2f52aeeaf30dbd073d315a251a63ae2b8263171ec4428c135140cb0802/virtualenv-21.7.4-py3-none-any.whl", hash = "sha256:376ec93cd6aab3044fa395d7db226db38043b7b5748948044b2a87168525e843", size = 5324444, upload-time = "2026-08-10T22:54:31.515Z" }, ]