6 Commits

Author SHA1 Message Date
868656ea12 Bump version to 0.3.1
All checks were successful
CI / lint (pull_request) Successful in -25s
CI / typecheck (pull_request) Successful in -22s
CI / test (3.12) (pull_request) Successful in -24s
CI / test (3.13) (pull_request) Successful in -23s
CI / test (3.14) (pull_request) Successful in -26s
2026-04-04 14:57:45 -07:00
7df3acde9b fix: enable word wrapping for syntax-highlighted code output
Long code lines were clipped at the terminal width, making them
unreadable. Pass word_wrap=True to all Syntax() calls so lines wrap
instead of being cut off.
2026-04-04 14:47:21 -07:00
579df72869 Merge pull request 'Bump version to 0.3.0' (#2) from dev into main
All checks were successful
CI / lint (push) Successful in -28s
CI / test (3.12) (push) Successful in -24s
CI / test (3.13) (push) Successful in -23s
CI / test (3.14) (push) Successful in -26s
CI / typecheck (push) Successful in -25s
Release / checks (3.12) (push) Successful in -24s
Release / checks (3.13) (push) Successful in -21s
Release / checks (3.14) (push) Successful in -21s
Release / release (push) Successful in -1s
2026-04-04 07:00:54 -07:00
537238f56f Bump version to 0.3.0
All checks were successful
CI / lint (push) Successful in -26s
CI / typecheck (push) Successful in -26s
CI / test (3.14) (push) Successful in -24s
CI / lint (pull_request) Successful in -28s
CI / typecheck (pull_request) Successful in -23s
CI / test (3.12) (pull_request) Successful in -25s
CI / test (3.13) (pull_request) Successful in -23s
CI / test (3.14) (pull_request) Successful in -24s
CI / test (3.12) (push) Successful in -25s
CI / test (3.13) (push) Successful in -25s
2026-04-04 06:58:19 -07:00
598450d9e2 Merge pull request 'Merge dev into main' (#1) from dev into main
All checks were successful
CI / lint (push) Successful in -28s
CI / typecheck (push) Successful in -24s
CI / test (3.12) (push) Successful in -24s
CI / test (3.13) (push) Successful in -23s
CI / test (3.14) (push) Successful in -24s
2026-04-04 06:42:41 -07:00
0f93d3a095 Add watch mode for live file monitoring
All checks were successful
CI / lint (push) Successful in -26s
CI / typecheck (push) Successful in -23s
CI / test (3.12) (push) Successful in -25s
CI / test (3.13) (push) Successful in -24s
CI / test (3.14) (push) Successful in -24s
CI / lint (pull_request) Successful in -28s
CI / test (3.14) (pull_request) Successful in -24s
CI / typecheck (pull_request) Successful in -24s
CI / test (3.12) (pull_request) Successful in -25s
CI / test (3.13) (pull_request) Successful in -24s
Add --watch flag that polls a file and redraws the terminal when its
content changes. Useful for monitoring log files, configuration files,
or any file that changes over time.

Features:
- Polls file every 1 second for changes
- Disables pager (pager would block the watch loop)
- File-only: rejects stdin or - input
- Error recovery: retries when file is missing or temporarily unreadable
- State comparison: only redraws when content actually changes
2026-04-04 05:23:28 -07:00
8 changed files with 329 additions and 28 deletions

View File

@@ -9,6 +9,7 @@ Rich print files — markdown, JSON, code, and more — beautifully in your term
- **JSON formatting** with syntax highlighting
- **Pager integration** with mouse scrolling support
- **Stdin support** for piping content
- **Watch mode** for live file redraws
## Installation
@@ -65,6 +66,15 @@ rp --no-pager file.py # Disable pager
The pager prefers `less -R --mouse` for mouse scrolling when available, otherwise falls back to the system pager.
### Watch a file
```bash
rp --watch README.md
rp --watch --type json data.txt
```
`--watch` is file-only. It does not support stdin or `-`, disables the pager, redraws the screen when the file changes, and keeps retrying if the file becomes missing or temporarily unreadable.
### Show version
```bash

View File

@@ -1,6 +1,6 @@
[project]
name = "rp"
version = "0.2.3"
version = "0.3.1"
description = "Rich print files — markdown, JSON, code, and more — beautifully in your terminal"
readme = "README.md"
license = "MIT"

View File

@@ -2,6 +2,6 @@
from __future__ import annotations
__version__ = "0.2.3"
__version__ = "0.3.1"
__author__ = "Yunxiao Xu"
__license__ = "MIT"

View File

@@ -2,7 +2,9 @@
from __future__ import annotations
from collections.abc import Callable
import sys
import time
from pathlib import Path
from typing import Annotated
@@ -10,10 +12,12 @@ import typer
from rich.console import Console
from rp import __author__, __license__, __version__
from rp.debug import env_debug_enabled, log_debug, make_debug_logger
from rp.debug import DebugLogger, env_debug_enabled, log_debug, make_debug_logger
from rp.detect import detect_type
from rp.render import RenderOptions, render
_WATCH_POLL_INTERVAL = 1.0
app = typer.Typer(
name="rp",
help="Rich print files — markdown, JSON, code, and more — beautifully in your terminal.",
@@ -36,6 +40,103 @@ def version_callback(value: bool) -> None:
raise typer.Exit()
def _read_file_content(file: Path) -> str:
"""Read a UTF-8 text file for rendering."""
if not file.exists():
raise FileNotFoundError(f"File not found: {file}")
if file.is_dir():
raise IsADirectoryError(f"Is a directory: {file}")
try:
return file.read_text(encoding="utf-8")
except UnicodeDecodeError as exc:
raise ValueError(f"Cannot read binary file: {file}") from exc
def _render_content(
*,
console: Console,
debug: DebugLogger | None,
content: str,
file_path: str | None,
file_type: str | None,
guess_content: bool,
theme: str,
line_numbers: bool | None,
pager: bool | None,
) -> None:
"""Detect type and render content with the standard pipeline."""
detected_type = detect_type(
path=file_path,
content=content,
explicit_type=file_type,
guess_content=guess_content,
debug=debug,
)
options = RenderOptions(
theme=theme,
line_numbers=line_numbers,
pager=pager,
debug=debug,
)
render(content, detected_type, console, options)
def _watch_file(
*,
file: Path,
console: Console,
debug: DebugLogger | None,
file_type: str | None,
guess_content: bool,
theme: str,
line_numbers: bool | None,
sleep: Callable[[float], None] | None = None,
) -> None:
"""Poll a file and redraw when its visible state changes."""
if sleep is None:
sleep = time.sleep
previous_state: tuple[str, str] | None = None
while True:
try:
content = _read_file_content(file)
except (
FileNotFoundError,
IsADirectoryError,
PermissionError,
OSError,
ValueError,
) as exc:
state = ("error", str(exc))
if state != previous_state:
log_debug(debug, f"watch: detected error state {state[1]!r}")
console.clear(home=True)
console.print(f"[red]Error:[/red] {state[1]}")
previous_state = state
else:
state = ("content", content)
if state != previous_state:
log_debug(debug, f"watch: rendering update for {str(file)!r}")
console.clear(home=True)
_render_content(
console=console,
debug=debug,
content=content,
file_path=str(file),
file_type=file_type,
guess_content=guess_content,
theme=theme,
line_numbers=line_numbers,
pager=False,
)
previous_state = state
sleep(_WATCH_POLL_INTERVAL)
@app.command()
def main(
file: Annotated[
@@ -66,6 +167,13 @@ def main(
help="Use pager for output. Default: auto-detect.",
),
] = None,
watch: Annotated[
bool,
typer.Option(
"--watch/--no-watch",
help="Watch a file for changes and redraw when it updates.",
),
] = False,
guess_content: Annotated[
bool,
typer.Option(
@@ -92,12 +200,17 @@ def main(
theme: Pygments color theme.
line_numbers: Show line numbers. Default: auto.
pager: Use pager for output. Default: auto-detect.
watch: Watch a file for changes and redraw when it updates.
guess_content: Use heuristic content-based type detection as a fallback.
version: Show version and exit.
"""
console = Console()
debug = make_debug_logger(Console(stderr=True), env_debug_enabled())
if watch and (file is None or str(file) == "-"):
console.print("[red]Error:[/red] --watch only supports file input.")
raise typer.Exit(code=1)
if file is None or str(file) == "-":
log_debug(debug, "input: reading from stdin")
if sys.stdin.isatty():
@@ -106,18 +219,28 @@ def main(
file_path = None
else:
log_debug(debug, f"input: reading file {str(file)!r}")
if not file.exists():
console.print(f"[red]Error:[/red] File not found: {file}")
raise typer.Exit(code=1)
if file.is_dir():
console.print(f"[red]Error:[/red] Is a directory: {file}")
raise typer.Exit(code=1)
if watch:
log_debug(debug, f"watch: polling {str(file)!r}")
_watch_file(
file=file,
console=console,
debug=debug,
file_type=file_type,
guess_content=guess_content,
theme=theme,
line_numbers=line_numbers,
)
raise typer.Exit()
try:
content = file.read_text(encoding="utf-8")
except UnicodeDecodeError:
console.print(f"[red]Error:[/red] Cannot read binary file: {file}")
raise typer.Exit(code=1)
except (PermissionError, OSError) as e:
content = _read_file_content(file)
except (
FileNotFoundError,
IsADirectoryError,
PermissionError,
OSError,
ValueError,
) as e:
console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(code=1)
file_path = str(file)
@@ -126,21 +249,17 @@ def main(
console.print("[dim]Empty input, nothing to display.[/dim]")
raise typer.Exit()
file_type = detect_type(
path=file_path,
content=content,
explicit_type=file_type,
guess_content=guess_content,
_render_content(
console=console,
debug=debug,
)
options = RenderOptions(
content=content,
file_path=file_path,
file_type=file_type,
guess_content=guess_content,
theme=theme,
line_numbers=line_numbers,
pager=pager,
debug=debug,
)
render(content, file_type, console, options)
if __name__ == "__main__":

View File

@@ -83,12 +83,20 @@ def render(
except (SyntaxError, ValueError):
log_debug(options.debug, "render: invalid JSON, falling back to syntax")
renderable = Syntax(
content, "json", theme=options.theme, line_numbers=line_numbers
content,
"json",
theme=options.theme,
line_numbers=line_numbers,
word_wrap=True,
)
else:
try:
renderable = Syntax(
content, file_type, theme=options.theme, line_numbers=line_numbers
content,
file_type,
theme=options.theme,
line_numbers=line_numbers,
word_wrap=True,
)
except Exception:
log_debug(
@@ -96,7 +104,11 @@ def render(
f"render: unknown lexer {file_type!r}, falling back to plain text",
)
renderable = Syntax(
content, "text", theme=options.theme, line_numbers=line_numbers
content,
"text",
theme=options.theme,
line_numbers=line_numbers,
word_wrap=True,
)
padded = Padding(renderable, (1, 2))

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from typer.testing import CliRunner
from rp import __version__
@@ -171,6 +172,18 @@ class TestStdinInput:
assert result.exit_code == 0
def test_watch_rejects_stdin(self) -> None:
result = runner.invoke(app, ["--watch"], input="hello\n")
assert result.exit_code == 1
assert "--watch only supports file input" in result.output
def test_watch_rejects_dash_input(self) -> None:
result = runner.invoke(app, ["-", "--watch"], input="hello\n")
assert result.exit_code == 1
assert "--watch only supports file input" in result.output
def test_stdin_passes_none_path(self) -> None:
"""When reading from stdin, detect_type should receive path=None."""
with patch("rp.cli.detect_type") as mock_detect_type:
@@ -311,3 +324,96 @@ class TestLineNumbersOption:
result = runner.invoke(app, [str(file), "--no-line-numbers", "--no-pager"])
assert result.exit_code == 0
class TestWatchMode:
"""Tests for the watch mode loop."""
def test_watch_disables_pager(self, tmp_file) -> None:
file = tmp_file("x = 1\n", suffix=".py")
with patch("rp.cli.detect_type", return_value="python"):
with patch("rp.cli.time.sleep", side_effect=KeyboardInterrupt):
with patch("rp.cli.render") as mock_render:
with pytest.raises(KeyboardInterrupt):
main(file=file, pager=True, watch=True)
call_args = mock_render.call_args
assert call_args is not None
options = call_args[0][3]
assert options.pager is False
def test_watch_rerenders_on_file_change(self, tmp_file) -> None:
file = tmp_file("x = 1\n", suffix=".py")
sleep_calls = 0
def fake_sleep(_: float) -> None:
nonlocal sleep_calls
sleep_calls += 1
if sleep_calls == 1:
file.write_text("x = 2\n", encoding="utf-8")
return
raise KeyboardInterrupt
with patch("rp.cli.detect_type", return_value="python"):
with patch("rp.cli.time.sleep", side_effect=fake_sleep):
with patch("rp.cli.render") as mock_render:
with pytest.raises(KeyboardInterrupt):
main(file=file, watch=True)
assert [call.args[0] for call in mock_render.call_args_list] == [
"x = 1\n",
"x = 2\n",
]
def test_watch_retries_after_missing_file_and_recovers(self, tmp_file) -> None:
file = tmp_file("x = 1\n", suffix=".py")
sleep_calls = 0
def fake_sleep(_: float) -> None:
nonlocal sleep_calls
sleep_calls += 1
if sleep_calls == 1:
file.unlink()
return
if sleep_calls == 2:
file.write_text("x = 3\n", encoding="utf-8")
return
raise KeyboardInterrupt
with patch("rp.cli.detect_type", return_value="python"):
with patch("rp.cli.time.sleep", side_effect=fake_sleep):
with patch("rp.cli.Console.print") as mock_print:
with patch("rp.cli.render") as mock_render:
with pytest.raises(KeyboardInterrupt):
main(file=file, watch=True)
assert [call.args[0] for call in mock_render.call_args_list] == [
"x = 1\n",
"x = 3\n",
]
mock_print.assert_any_call(f"[red]Error:[/red] File not found: {file}")
def test_watch_recovers_when_file_is_missing_at_startup(
self, tmp_path: Path
) -> None:
file = tmp_path / "startup.py"
sleep_calls = 0
def fake_sleep(_: float) -> None:
nonlocal sleep_calls
sleep_calls += 1
if sleep_calls == 1:
file.write_text("x = 4\n", encoding="utf-8")
return
raise KeyboardInterrupt
with patch("rp.cli.detect_type", return_value="python"):
with patch("rp.cli.time.sleep", side_effect=fake_sleep):
with patch("rp.cli.Console.print") as mock_print:
with patch("rp.cli.render") as mock_render:
with pytest.raises(KeyboardInterrupt):
main(file=file, watch=True)
assert [call.args[0] for call in mock_render.call_args_list] == ["x = 4\n"]
mock_print.assert_any_call(f"[red]Error:[/red] File not found: {file}")

View File

@@ -94,6 +94,7 @@ class TestRenderJson:
"json",
theme="monokai",
line_numbers=False,
word_wrap=True,
)
@@ -113,6 +114,7 @@ class TestRenderCode:
"python",
theme="monokai",
line_numbers=True,
word_wrap=True,
)
def test_renders_unknown_type_as_text_fallback(self, capture_console) -> None:
@@ -136,11 +138,13 @@ class TestRenderCode:
assert mock_syntax.call_args_list[0].kwargs == {
"theme": "monokai",
"line_numbers": True,
"word_wrap": True,
}
assert mock_syntax.call_args_list[1].args == ("some content", "text")
assert mock_syntax.call_args_list[1].kwargs == {
"theme": "monokai",
"line_numbers": True,
"word_wrap": True,
}
def test_renders_rust(self, capture_console) -> None:
@@ -156,9 +160,59 @@ class TestRenderCode:
"rust",
theme="monokai",
line_numbers=True,
word_wrap=True,
)
class TestCodeWrap:
"""Code wrapping (word_wrap) for syntax-highlighted output."""
def test_python_syntax_has_word_wrap(
self, capture_console, sample_python: str
) -> None:
opts = RenderOptions(pager=False)
with patch("rp.render.Syntax", wraps=Syntax) as mock_syntax:
render(sample_python, "python", capture_console, opts)
mock_syntax.assert_called_once()
assert mock_syntax.call_args.kwargs["word_wrap"] is True
def test_long_line_wraps_in_output(self, capture_console) -> None:
long_line = "x = " + "'a' * " * 200 + "1"
opts = RenderOptions(pager=False, line_numbers=False)
render(long_line, "python", capture_console, opts)
output = capture_console.file.getvalue()
assert len(output) > 0
lines = output.split("\n")
non_empty = [line for line in lines if line.strip()]
assert len(non_empty) > 1, "Long line should wrap across multiple visual lines"
def test_text_fallback_has_word_wrap(self, capture_console) -> None:
opts = RenderOptions(pager=False)
def raise_on_bad_lexer(*args, **kwargs):
if args[1] == "unknown_xyz":
raise Exception("bad lexer")
return Syntax(*args, **kwargs)
with patch("rp.render.Syntax", side_effect=raise_on_bad_lexer) as mock_syntax:
render("some text", "unknown_xyz", capture_console, opts)
fallback_call = mock_syntax.call_args_list[-1]
assert fallback_call.args[1] == "text"
assert fallback_call.kwargs["word_wrap"] is True
def test_json_fallback_has_word_wrap(self, capture_console) -> None:
opts = RenderOptions(pager=False)
with patch("rp.render.Syntax", wraps=Syntax) as mock_syntax:
render("{invalid}", "json", capture_console, opts)
mock_syntax.assert_called_once()
assert mock_syntax.call_args.kwargs["word_wrap"] is True
class TestLineNumbers:
"""Line number auto-detection logic."""

2
uv.lock generated
View File

@@ -339,7 +339,7 @@ wheels = [
[[package]]
name = "rp"
version = "0.2.3"
version = "0.3.1"
source = { editable = "." }
dependencies = [
{ name = "pygments" },