polyllmkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,231 @@
1
+ """mdrender — render markdown from a file or stdin through rich to stdout.
2
+
3
+ A standalone CLI over the same rich pipeline as the modal viewer
4
+ (:mod:`llmkit.md.view`). Two roles:
5
+
6
+ 1. Validation tool: see exactly how the rich pipeline renders a
7
+ markdown stream, headlessly, without any modal UI.
8
+ 2. Drop-in formatter for a streaming producer — a single-process
9
+ replacement for mdansi / glow / mdcat with live re-render.
10
+
11
+ Two modes:
12
+
13
+ --no-stream (default): consume all stdin, render once at EOF, emit
14
+ to stdout. Best for piped one-shot use.
15
+ --stream : re-render incrementally as stdin arrives
16
+ (rich.live.Live + Markdown). Best for live
17
+ use with a streaming producer such as the
18
+ bridge. Re-render is debounced so frequent
19
+ small chunks don't thrash the terminal.
20
+ Note: Live repaints in place, so very long
21
+ documents may overflow visible area — use
22
+ bin/mdview (alt-screen + scroll) for
23
+ documents larger than one screenful.
24
+
25
+ Theming: ``--theme-file`` points at an INI theme (see themes/*.ini).
26
+ Rich has no global theme config, so the file is loaded explicitly here
27
+ and applied to this Console only. Without it, Rich's stock markdown
28
+ styling is used.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import argparse
34
+ import configparser
35
+ import os
36
+ import select
37
+ import sys
38
+ from typing import List, Optional, Tuple, cast
39
+
40
+ from rich.console import Console
41
+ from rich.markdown import Markdown
42
+ from rich.style import Style
43
+ from rich.text import Text
44
+ from rich.theme import Theme
45
+
46
+ from .stream import LiveMarkdownStream, Mode
47
+
48
+ # Rich's default Pygments style for fenced code blocks.
49
+ _DEFAULT_CODE_THEME = "monokai"
50
+
51
+
52
+ def _load_theme(path: Optional[str]) -> Tuple[Optional[Theme], str]:
53
+ """Load a markdown Theme + Pygments code-theme name from an INI file.
54
+
55
+ The INI has a ``[styles]`` section (Rich style names → style specs,
56
+ layered onto Rich's defaults via ``inherit=True``) and an optional
57
+ ``[code]`` section with ``theme = <pygments style>`` for fenced code
58
+ blocks. Returns ``(None, "monokai")`` — Rich's stock look — when
59
+ *path* is falsy or the file can't be read.
60
+ """
61
+ if not path:
62
+ return None, _DEFAULT_CODE_THEME
63
+ cfg = configparser.ConfigParser()
64
+ if not cfg.read(path):
65
+ return None, _DEFAULT_CODE_THEME
66
+ theme: Optional[Theme] = None
67
+ if cfg.has_section("styles"):
68
+ styles = {name: Style.parse(value) for name, value in cfg.items("styles")}
69
+ if styles:
70
+ theme = Theme(styles, inherit=True)
71
+ code_theme = cfg.get("code", "theme", fallback=_DEFAULT_CODE_THEME)
72
+ return theme, code_theme
73
+
74
+
75
+ def main(argv: Optional[List[str]] = None) -> int:
76
+ p = argparse.ArgumentParser(
77
+ prog="mdrender",
78
+ description="Render markdown from a file or stdin through rich to stdout.",
79
+ )
80
+ p.add_argument(
81
+ "--render",
82
+ choices=["markdown", "raw"],
83
+ default="markdown",
84
+ help="Render mode (default: markdown). raw = ANSI passthrough.",
85
+ )
86
+ p.add_argument(
87
+ "--stream",
88
+ action="store_true",
89
+ help="Re-render incrementally as stdin arrives (uses rich.live). "
90
+ "Default: consume all stdin, render once at EOF.",
91
+ )
92
+ p.add_argument(
93
+ "--debounce",
94
+ type=float,
95
+ default=0.1,
96
+ help="Min seconds between re-renders in --stream mode (default 0.1).",
97
+ )
98
+ p.add_argument(
99
+ "--color",
100
+ choices=["always", "auto", "never"],
101
+ default="auto",
102
+ help="When to emit ANSI color (default: auto — colorized only "
103
+ "if stdout is a tty). Use 'always' when piping to a pager "
104
+ "or capturing to a file you'll view later.",
105
+ )
106
+ p.add_argument(
107
+ "--theme-file",
108
+ default=None,
109
+ help="Path to an INI theme file (see themes/*.ini). Without it, "
110
+ "Rich's default markdown styling is used.",
111
+ )
112
+ p.add_argument(
113
+ "--pager",
114
+ action="store_true",
115
+ help="Pipe output through $PAGER (or `less -R` if unset). "
116
+ "Implies --color always. Incompatible with --stream.",
117
+ )
118
+ p.add_argument(
119
+ "file",
120
+ nargs="?",
121
+ default=None,
122
+ help="Markdown source file. If omitted (or '-'), read from stdin.",
123
+ )
124
+ args = p.parse_args(argv)
125
+
126
+ if args.pager and args.stream:
127
+ print("mdrender: --pager and --stream are incompatible", file=sys.stderr)
128
+ return 2
129
+
130
+ theme, code_theme = _load_theme(args.theme_file)
131
+
132
+ # Color resolution. rich's Console respects:
133
+ # force_terminal=True — emit colors regardless of stdout tty
134
+ # no_color=True — strip colors regardless of tty
135
+ # neither set — auto-detect via isatty()
136
+ force_terminal: Optional[bool] = None
137
+ no_color: Optional[bool] = None
138
+ if args.color == "always" or args.pager:
139
+ force_terminal = True
140
+ elif args.color == "never":
141
+ no_color = True
142
+ console = Console(force_terminal=force_terminal, no_color=no_color, theme=theme)
143
+
144
+ # "-" is the conventional stdin sentinel; treat it like no file.
145
+ source: Optional[str] = args.file if args.file not in (None, "-") else None
146
+
147
+ try:
148
+ mode = cast(Mode, args.render)
149
+ if args.pager:
150
+ # console.pager(styles=True) spawns $PAGER (or `less -RFX`)
151
+ # and pipes our captured output through it on context exit.
152
+ # styles=True ensures ANSI is preserved through the pipe.
153
+ with console.pager(styles=True):
154
+ return _run_oneshot(console, mode, code_theme, source)
155
+ if args.stream:
156
+ return _run_stream(console, mode, args.debounce, theme, code_theme, source)
157
+ return _run_oneshot(console, mode, code_theme, source)
158
+ except KeyboardInterrupt:
159
+ return 130
160
+ except BrokenPipeError:
161
+ return 0
162
+ except OSError as e:
163
+ print(f"mdrender: {e}", file=sys.stderr)
164
+ return 1
165
+
166
+
167
+ def _run_oneshot(
168
+ console: Console, mode: str, code_theme: str, source: Optional[str]
169
+ ) -> int:
170
+ if source is None:
171
+ text = sys.stdin.read()
172
+ else:
173
+ with open(source, encoding="utf-8", errors="replace") as f:
174
+ text = f.read()
175
+ if mode == "markdown":
176
+ console.print(Markdown(text, code_theme=code_theme))
177
+ else:
178
+ console.print(Text.from_ansi(text), end="")
179
+ return 0
180
+
181
+
182
+ def _run_stream(
183
+ console: Console,
184
+ mode: Mode,
185
+ debounce: float,
186
+ theme: Optional[Theme],
187
+ code_theme: str,
188
+ source: Optional[str],
189
+ ) -> int:
190
+ """Read the source (stdin or a file) as a byte stream, feeding chunks
191
+ to LiveMarkdownStream.
192
+
193
+ Interface parallels textual's:
194
+
195
+ stream = Markdown.get_stream(widget)
196
+ for chunk in source: await stream.write(chunk)
197
+ await stream.stop()
198
+
199
+ We poll the raw fd with select() + non-blocking os.read() — using
200
+ ``for line in sys.stdin`` would buffer ~4 KB at a pipe before
201
+ yielding, killing the live-update feel through slow producers. A
202
+ file (or fifo) path is opened directly; the same loop drains it.
203
+ """
204
+ if source is None:
205
+ fd = sys.stdin.fileno()
206
+ close_fd = False
207
+ else:
208
+ fd = os.open(source, os.O_RDONLY)
209
+ close_fd = True
210
+ stream = LiveMarkdownStream.get_stream(
211
+ console, mode=mode, debounce=debounce, theme=theme, code_theme=code_theme
212
+ )
213
+ try:
214
+ while True:
215
+ select.select([fd], [], [], debounce)
216
+ try:
217
+ chunk = os.read(fd, 4096)
218
+ except OSError:
219
+ break
220
+ if not chunk:
221
+ break
222
+ stream.write(chunk.decode("utf-8", errors="replace"))
223
+ finally:
224
+ stream.stop()
225
+ if close_fd:
226
+ os.close(fd)
227
+ return 0
228
+
229
+
230
+ if __name__ == "__main__":
231
+ sys.exit(main())
@@ -0,0 +1,244 @@
1
+ """Reusable streaming-Markdown renderer that mirrors textual's API.
2
+
3
+ Interface parallels textual's ``Markdown.get_stream()`` so callers can
4
+ swap the target (rich Console vs textual Markdown widget) without
5
+ restructuring their code:
6
+
7
+ # textual side
8
+ stream = Markdown.get_stream(markdown_widget)
9
+ try:
10
+ async for chunk in source: await stream.write(chunk)
11
+ finally:
12
+ await stream.stop()
13
+
14
+ # rich side (this module)
15
+ stream = LiveMarkdownStream.get_stream(Console())
16
+ try:
17
+ for chunk in source: stream.write(chunk)
18
+ finally:
19
+ stream.stop()
20
+
21
+ The append mechanism mirrors textual too. From reading
22
+ ``textual.widgets._markdown.Markdown.append``:
23
+
24
+ 1. Track ``_last_parsed_line`` — the source line up to which we've
25
+ committed rendered output that won't change.
26
+ 2. On write: parse ONLY the new source (from _last_parsed_line on)
27
+ with markdown-it-py, not the whole document.
28
+ 3. Advance _last_parsed_line to the START of the LAST top-level
29
+ token. Everything before that is "complete" and gets frozen.
30
+ The trailing block is potentially incomplete (more chunks may
31
+ extend it) so it stays mutable.
32
+ 4. Render the newly-frozen prefix once, append to a cached ANSI
33
+ string. The mutable trailing block is re-rendered each tick
34
+ into ``Text.from_ansi(frozen + fresh_trailing)``.
35
+
36
+ Cost per write: parse only new source (typically a few lines).
37
+ Cost per render: re-render only the trailing block (small).
38
+ Cost per stop: one final commit + render.
39
+
40
+ This is the same shape as textual's mount-new-blocks pattern adapted
41
+ for rich's flat ANSI-string output instead of a widget tree.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import io
47
+ import time
48
+ from types import TracebackType
49
+ from typing import Literal, Optional, Type
50
+
51
+ from markdown_it import MarkdownIt
52
+ from rich.console import Console, RenderableType
53
+ from rich.live import Live
54
+ from rich.markdown import Markdown
55
+ from rich.text import Text
56
+ from rich.theme import Theme
57
+
58
+ Mode = Literal["markdown", "raw"]
59
+
60
+
61
+ class LiveMarkdownStream:
62
+ """Streaming Markdown writer backed by ``rich.live.Live``.
63
+
64
+ Don't instantiate directly — use :meth:`get_stream`, which mirrors
65
+ ``textual.widgets.Markdown.get_stream(widget)``.
66
+
67
+ Caveats:
68
+ Live repaints in place. Documents that grow past the visible
69
+ terminal area will overflow / scroll oddly — for long content
70
+ use a scrollable widget (textual MarkdownViewer) instead.
71
+ """
72
+
73
+ # ── factory (mirrors textual's Markdown.get_stream) ─────────────────
74
+ @classmethod
75
+ def get_stream(
76
+ cls,
77
+ console: Console,
78
+ *,
79
+ mode: Mode = "markdown",
80
+ debounce: float = 0.1,
81
+ theme: Optional[Theme] = None,
82
+ code_theme: str = "monokai",
83
+ ) -> "LiveMarkdownStream":
84
+ """Create a started stream against ``console``. Caller is
85
+ responsible for ``stop()`` (or use the returned object as a
86
+ context manager)."""
87
+ stream = cls(
88
+ console=console,
89
+ mode=mode,
90
+ debounce=debounce,
91
+ theme=theme,
92
+ code_theme=code_theme,
93
+ )
94
+ stream.start()
95
+ return stream
96
+
97
+ def __init__(
98
+ self,
99
+ console: Console,
100
+ mode: Mode = "markdown",
101
+ debounce: float = 0.1,
102
+ theme: Optional[Theme] = None,
103
+ code_theme: str = "monokai",
104
+ ) -> None:
105
+ self.console = console
106
+ self.mode: Mode = mode
107
+ self.debounce = debounce
108
+ self._theme = theme
109
+ self._code_theme = code_theme
110
+ self._buf = ""
111
+ self._last_render = 0.0
112
+ self._live: Optional[Live] = None
113
+ self._stopped = False
114
+ # ── incremental-parse state (markdown mode only) ────────────────
115
+ # _last_parsed_line: line index in _buf up to which we've
116
+ # committed rendered output (won't change on further appends).
117
+ # _committed_ansi: rendered ANSI for lines [0 : _last_parsed_line].
118
+ # _parser: shared MarkdownIt instance — re-using across writes
119
+ # is fine since parse() is stateless.
120
+ self._last_parsed_line = 0
121
+ self._committed_ansi = ""
122
+ self._parser = MarkdownIt("gfm-like") if mode == "markdown" else None
123
+
124
+ # ── public API (mirrors textual's MarkdownStream) ───────────────────
125
+ def start(self) -> None:
126
+ """Open the underlying Live context."""
127
+ if self._live is not None:
128
+ return
129
+ self._live = Live(
130
+ self._build_renderable(),
131
+ console=self.console,
132
+ auto_refresh=False,
133
+ transient=False,
134
+ )
135
+ self._live.__enter__()
136
+
137
+ def write(self, chunk: str) -> None:
138
+ """Append a markdown fragment. Commits any newly-complete
139
+ blocks to the cached prefix; re-renders the live view if
140
+ the debounce window has elapsed. Empty chunks are no-ops."""
141
+ if self._stopped:
142
+ raise RuntimeError("Can't write to the stream after it has stopped.")
143
+ if not chunk:
144
+ return
145
+ self._buf += chunk
146
+ self._commit_complete_blocks()
147
+ now = time.monotonic()
148
+ if now - self._last_render >= self.debounce:
149
+ self._refresh()
150
+ self._last_render = now
151
+
152
+ def stop(self) -> None:
153
+ """Final commit + render + close the Live context."""
154
+ if self._stopped:
155
+ return
156
+ self._stopped = True
157
+ if self._live is not None:
158
+ try:
159
+ self._commit_complete_blocks()
160
+ self._refresh()
161
+ finally:
162
+ self._live.__exit__(None, None, None)
163
+ self._live = None
164
+
165
+ @property
166
+ def buffer(self) -> str:
167
+ """The accumulated text seen so far."""
168
+ return self._buf
169
+
170
+ # ── context-manager sugar ───────────────────────────────────────────
171
+ def __enter__(self) -> "LiveMarkdownStream":
172
+ self.start()
173
+ return self
174
+
175
+ def __exit__(
176
+ self,
177
+ exc_type: Optional[Type[BaseException]],
178
+ exc_val: Optional[BaseException],
179
+ exc_tb: Optional[TracebackType],
180
+ ) -> None:
181
+ self.stop()
182
+
183
+ # ── incremental-parse internals ─────────────────────────────────────
184
+ def _commit_complete_blocks(self) -> None:
185
+ """Mirrors textual's Markdown.append's commit logic. Parses only
186
+ the source from ``_last_parsed_line`` onward, finds the start
187
+ of the LAST top-level token, and freezes everything before it
188
+ into ``_committed_ansi``. The trailing block remains mutable —
189
+ future writes may extend it."""
190
+ if self._parser is None: # raw mode: nothing to commit
191
+ return
192
+ lines = self._buf.splitlines(keepends=True)
193
+ if self._last_parsed_line >= len(lines):
194
+ return
195
+ new_source = "".join(lines[self._last_parsed_line :])
196
+ if not new_source:
197
+ return
198
+ tokens = self._parser.parse(new_source)
199
+ # Find the start line of the last top-level token. That's the
200
+ # boundary: everything before is "complete enough" to commit.
201
+ last_block_start: Optional[int] = None
202
+ for token in reversed(tokens):
203
+ if token.map is not None and token.level == 0:
204
+ last_block_start = token.map[0]
205
+ break
206
+ if last_block_start is None or last_block_start == 0:
207
+ # No fully-completed blocks since the last commit.
208
+ return
209
+ committed_source = "".join(
210
+ lines[self._last_parsed_line : self._last_parsed_line + last_block_start]
211
+ )
212
+ if committed_source:
213
+ self._committed_ansi += self._render_to_ansi(committed_source)
214
+ self._last_parsed_line += last_block_start
215
+
216
+ def _refresh(self) -> None:
217
+ if self._live is not None:
218
+ self._live.update(self._build_renderable(), refresh=True)
219
+
220
+ def _build_renderable(self) -> RenderableType:
221
+ """Compose the current frame: cached committed-ANSI prefix +
222
+ freshly-rendered trailing (incomplete) block."""
223
+ if self.mode == "raw":
224
+ return Text.from_ansi(self._buf)
225
+ # markdown mode: prefix + trailing render
226
+ lines = self._buf.splitlines(keepends=True)
227
+ trailing_source = "".join(lines[self._last_parsed_line :])
228
+ trailing_ansi = self._render_to_ansi(trailing_source) if trailing_source else ""
229
+ return Text.from_ansi(self._committed_ansi + trailing_ansi)
230
+
231
+ def _render_to_ansi(self, markdown_source: str) -> str:
232
+ """Render a markdown fragment to a captured ANSI string at the
233
+ Console's current width. Used for both the frozen-prefix cache
234
+ and the trailing-block per-tick render."""
235
+ sink = io.StringIO()
236
+ Console(
237
+ file=sink,
238
+ force_terminal=True,
239
+ color_system="truecolor",
240
+ width=self.console.size.width,
241
+ legacy_windows=False,
242
+ theme=self._theme,
243
+ ).print(Markdown(markdown_source, code_theme=self._code_theme), end="")
244
+ return sink.getvalue()
@@ -0,0 +1 @@
1
+ """Scrollable modal markdown viewer (textual)."""
@@ -0,0 +1,8 @@
1
+ """``python -m llmkit.md.view`` → :func:`.cli.main`."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())