Input & UX

Rendering Markdown and Syntax Highlighting with Rich

Show release notes, long help and source files nicely in a Python CLI: Rich Markdown and Syntax, a pager for long output, plain text in pipes, and tests.

Updated

Some CLI output is really a document: release notes after an upgrade, a long explanation behind mytool explain E104, a generated report, a snippet of the configuration file that failed to parse. Printed raw, Markdown is readable but noisy — hashes, asterisks and backticks everywhere — and source code is a grey wall. Rich renders both properly in the terminal: Markdown turns headings, lists, emphasis, links and fenced code into styled text, and Syntax highlights source code in hundreds of languages with optional line numbers and highlighted lines. The craft is in the edges: falling back to the plain source when output is piped, paging documents longer than the screen, and keeping colours legible on any terminal theme. This guide builds a changelog command that renders Markdown and a show command that highlights a file around a given line, and tests both. It belongs to the interactive terminal UI with Rich topic.

Prerequisites

Rendered for people, verbatim for programs

Render or pass through? How a command line tool decides whether to render Markdown and highlighted code with Rich or write the original text unchanged. Render or pass through? Is the console a terminal? Yes, short document Render Markdown or Syntax Yes, long document Pager console.pager(styles=True) No (pipe or file) Verbatim source bytes, no reflow Rich strips colour in pipes, but only an early return stops the reformatting.

The same command serves two audiences. On a terminal, a person wants headings, colour and wrapping. In a pipe — mytool changelog > NOTES.md, mytool show app.py | grep TODO — a program wants the original text, byte for byte, with no escape codes and no reflowed lines. Rich already strips colour when the console is not a terminal, but it would still reformat Markdown: headings centred and underlined, lists re-indented, long lines wrapped at the detected width. For document output that is wrong, so the recipe checks console.is_terminal and writes the source unchanged when it is false.

The recipe

# src/mytool/render.py
from __future__ import annotations

import sys
from pathlib import Path

from rich.console import Console
from rich.markdown import Markdown
from rich.syntax import Syntax


def make_console() -> Console:
    return Console(soft_wrap=False)


def show_markdown(text: str, console: Console, *, pager: bool = False) -> None:
    """Render Markdown on a terminal; pass the source through unchanged otherwise."""
    if not console.is_terminal:
        console.file.write(text if text.endswith("\n") else text + "\n")
        return
    md = Markdown(text, code_theme="ansi_dark", hyperlinks=True)
    if pager:
        with console.pager(styles=True):
            console.print(md)
    else:
        console.print(md)


def show_source(path: Path, console: Console, *, line_numbers: bool = True,
                highlight: tuple[int, int] | None = None) -> None:
    """Print a file with syntax highlighting, or verbatim when piped."""
    code = path.read_text(encoding="utf-8", errors="replace")
    if not console.is_terminal:
        console.file.write(code)
        return
    lexer = Syntax.guess_lexer(str(path), code=code)
    syntax = Syntax(
        code, lexer, theme="ansi_dark", line_numbers=line_numbers, word_wrap=False,
        line_range=highlight and (max(1, highlight[0] - 3), highlight[1] + 3),
        highlight_lines=set(range(highlight[0], highlight[1] + 1)) if highlight else None,
    )
    console.print(syntax)
# src/mytool/cli.py
from pathlib import Path
from typing import Annotated, Optional

import typer

from mytool.render import make_console, show_markdown, show_source

app = typer.Typer()
NOTES = """\
# mytool 2.0

## Breaking changes

- `--output` is now `--format`.
- Python 3.10 is required.

```toml
[output]
format = "json"
```
"""


@app.command()
def changelog(pager: bool = typer.Option(True, help="Page long output.")) -> None:
    """Show what changed in this release."""
    show_markdown(NOTES, make_console(), pager=pager)


@app.command()
def show(
    path: Annotated[Path, typer.Argument(exists=True, dir_okay=False)],
    line: Annotated[Optional[int], typer.Option(help="Highlight this line with context.")] = None,
) -> None:
    """Print a source file with syntax highlighting."""
    show_source(path, make_console(), highlight=(line, line) if line else None)

Markdown

Markdown(text) parses CommonMark with the markdown-it library that Rich depends on, so tables, fenced code blocks, block quotes, numbered and nested lists all render. Fenced code blocks are highlighted with Syntax internally; code_theme picks the colours. hyperlinks=True emits terminal hyperlinks, so [the docs](https://…) becomes clickable text in terminals that support OSC 8 links and plain text elsewhere.

Rendered Markdown is centred on headings and wraps paragraphs to the console width, which is what you want for prose. It is not what you want for help text that users compare with documentation line by line; keep those as plain text.

Syntax highlighting

Syntax.guess_lexer(path, code=...) chooses a lexer from the file name and falls back to content-based guessing, so .py, .toml, Dockerfile and .env all highlight correctly. The show command adds two options that make highlighting useful for error reporting rather than decoration: line_range limits output to a window around the interesting line, and highlight_lines marks it. That is the shape of a good “error in your config file at line 12” message — three lines of context either side, the offending line highlighted, line numbers on.

Highlighting around a line Terminal session showing a source file with line numbers and a highlighted line, then the same command piped to grep producing plain text. Highlighting around a line bash $ mytool show src/app.py --line 10 7 x7 = 7 8 x8 = 8 ❱ 10 x10 = 10 13 x13 = 13 $ mytool show src/app.py | grep x10 x10 = 10 Three lines of context either side of the line that matters.

Themes that work on any background

Rich’s Pygments-based themes, such as monokai, set an explicit background colour. They look great on a dark terminal and like a black box pasted onto a light one. The ansi_dark and ansi_light themes use the terminal’s own ANSI palette instead, so colours follow the user’s chosen scheme. ansi_dark is the safer default for most users; offer a --theme option or a config setting for the rest, as in theming Rich output consistently.

Paging long documents

A changelog longer than the screen scrolls past before anyone reads the top. console.pager() captures everything printed inside the with block and sends it to the system pager when the block ends. Without a PAGER environment variable, Python uses less and sets LESS=-R… so colours come through; users who set PAGER=less themselves need -R in it, or styles appear as raw escape codes — styles=True is what tells Rich to keep colours in paged output at all. Page only on a terminal (the recipe’s early return handles that), make paging switchable with --no-pager as git does, and skip it when the output fits on one screen if you can measure it first.

UX considerations

  • Never page or style in pipes. Check console.is_terminal before anything else; scripts and redirects get the source unchanged.
  • Respect NO_COLOR. Rich honours it automatically, as described in respecting NO_COLOR and FORCE_COLOR; rendering still improves structure without colour.
  • Keep output width sensible. Very wide terminals make prose hard to read; Console(width=min(100, console.width)) or a padded Panel keeps line length comfortable.
  • Line numbers help errors, hurt copying. Turn them on for error context and off when showing a snippet the user might copy.
  • Fall back gracefully on unknown languages. An unrecognised file type renders as plain text with line numbers; never fail the command because of highlighting.
Choosing a code theme Comparison of Rich syntax themes that use the terminal palette with themes that paint their own background. Choosing a code theme Theme Background Light terminal Dark terminal ansi_dark terminal’s own readable good ansi_light terminal’s own good readable monokai fixed dark black box good ANSI themes follow the colour scheme the user already chose.

Testing the behaviour

Tests use two kinds of console: a Rich Console writing to a StringIO with force_terminal=True to exercise the rendered path, and the CliRunner, which captures output through a non-terminal stream and therefore exercises the pipe path:

# tests/test_render.py
import io

from rich.console import Console
from typer.testing import CliRunner

from mytool.cli import NOTES, app
from mytool.render import show_markdown, show_source


def terminal_console() -> Console:
    return Console(file=io.StringIO(), force_terminal=True, width=60, color_system="standard")


def test_markdown_is_rendered_on_a_terminal():
    console = terminal_console()
    show_markdown(NOTES, console)
    out = console.file.getvalue()
    assert "\x1b[" in out                          # styled
    assert "## Breaking" not in out                 # headings rendered, not raw
    assert "Python 3.10 is required" in out


def test_markdown_passes_through_when_piped():
    result = CliRunner().invoke(app, ["changelog"])
    assert result.exit_code == 0
    assert result.stdout == NOTES                   # unchanged source, no escape codes


def test_source_is_highlighted_with_context(tmp_path):
    path = tmp_path / "app.py"
    path.write_text("".join(f"x{n} = {n}\n" for n in range(1, 21)))
    console = terminal_console()
    show_source(path, console, highlight=(10, 10))
    lines = console.file.getvalue().splitlines()
    assert len(lines) == 7                          # line 10 with three lines either side
    assert "x7" in lines[0] and "x13" in lines[-1]


def test_source_is_verbatim_when_piped(tmp_path):
    path = tmp_path / "app.py"
    path.write_text("print('hi')\n")
    result = CliRunner().invoke(app, ["show", str(path)])
    assert result.stdout == "print('hi')\n"

Fixing the width (width=60) and colour system makes the rendered output deterministic across machines. Assert on structure — escape codes present, raw heading markers absent, the right window of lines — rather than exact bytes, which change with Rich releases. The pager path is the one thing worth leaving to a manual check: run mytool changelog once in a real terminal after changing it.

Conclusion

Rich’s Markdown and Syntax turn document-shaped output into something pleasant to read. Render only when the console is a terminal and pass the source through untouched otherwise; use ansi_dark or ansi_light themes so colours follow the user’s terminal; show source with line_range and highlight_lines for error context; page long documents with console.pager(styles=True) behind a --no-pager switch; and test the rendered and piped paths separately with a forced-terminal console and the CLI runner.

Frequently asked questions

Can I render the README or help text from my package as Markdown?

Yes. Read it with importlib.resources from package data and pass it to Markdown. Keep the file in the package (not only at the repository root) so it is present after installation.

Does Rich render Markdown tables?

Yes, recent Rich versions render GitHub-style pipe tables as Rich tables. Very wide tables wrap or truncate at the console width, so keep tables in terminal-facing documents narrow.

How do I show a diff with highlighting?

Syntax(diff_text, "diff") colours added and removed lines. For side-by-side or word-level diffs, generate the diff with difflib and style it yourself, or call git diff --color when the files are in a repository.

Why is highlighting slow on a huge file?

Pygments tokenises the whole input. line_range limits what is printed, but the highlighting work can still cover far more than the window. For very large files, slice the lines you need yourself and pass start_line so the numbers stay correct.

Should rendered Markdown include images?

Terminals cannot show images reliably, and Rich renders an image as an icon followed by its alt text. Keep images out of terminal-facing documents, or link to the web version instead.