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 - **JSON formatting** with syntax highlighting
- **Pager integration** with mouse scrolling support - **Pager integration** with mouse scrolling support
- **Stdin support** for piping content - **Stdin support** for piping content
- **Watch mode** for live file redraws
## Installation ## 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. 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 ### Show version
```bash ```bash

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
import pytest
from typer.testing import CliRunner from typer.testing import CliRunner
from rp import __version__ from rp import __version__
@@ -171,6 +172,18 @@ class TestStdinInput:
assert result.exit_code == 0 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: def test_stdin_passes_none_path(self) -> None:
"""When reading from stdin, detect_type should receive path=None.""" """When reading from stdin, detect_type should receive path=None."""
with patch("rp.cli.detect_type") as mock_detect_type: 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"]) result = runner.invoke(app, [str(file), "--no-line-numbers", "--no-pager"])
assert result.exit_code == 0 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", "json",
theme="monokai", theme="monokai",
line_numbers=False, line_numbers=False,
word_wrap=True,
) )
@@ -113,6 +114,7 @@ class TestRenderCode:
"python", "python",
theme="monokai", theme="monokai",
line_numbers=True, line_numbers=True,
word_wrap=True,
) )
def test_renders_unknown_type_as_text_fallback(self, capture_console) -> None: 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 == { assert mock_syntax.call_args_list[0].kwargs == {
"theme": "monokai", "theme": "monokai",
"line_numbers": True, "line_numbers": True,
"word_wrap": True,
} }
assert mock_syntax.call_args_list[1].args == ("some content", "text") assert mock_syntax.call_args_list[1].args == ("some content", "text")
assert mock_syntax.call_args_list[1].kwargs == { assert mock_syntax.call_args_list[1].kwargs == {
"theme": "monokai", "theme": "monokai",
"line_numbers": True, "line_numbers": True,
"word_wrap": True,
} }
def test_renders_rust(self, capture_console) -> None: def test_renders_rust(self, capture_console) -> None:
@@ -156,9 +160,59 @@ class TestRenderCode:
"rust", "rust",
theme="monokai", theme="monokai",
line_numbers=True, 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: class TestLineNumbers:
"""Line number auto-detection logic.""" """Line number auto-detection logic."""

2
uv.lock generated
View File

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