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
420 lines
14 KiB
Python
420 lines
14 KiB
Python
"""Tests for the CLI entry point."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from typer.testing import CliRunner
|
|
|
|
from rp import __version__
|
|
from rp.cli import app, main
|
|
|
|
runner = CliRunner()
|
|
|
|
|
|
class TestVersionFlag:
|
|
"""Tests for the version flags."""
|
|
|
|
def test_version_long_flag(self) -> None:
|
|
result = runner.invoke(app, ["--version"])
|
|
|
|
assert result.exit_code == 0
|
|
assert __version__ in result.output
|
|
|
|
def test_version_short_flag(self) -> None:
|
|
result = runner.invoke(app, ["-v"])
|
|
|
|
assert result.exit_code == 0
|
|
assert __version__ in result.output
|
|
|
|
def test_version_output_format(self) -> None:
|
|
result = runner.invoke(app, ["--version"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "Rich Print (rp)" in result.output
|
|
assert "License: MIT" in result.output
|
|
|
|
|
|
class TestFileInput:
|
|
"""Tests for reading from a file argument."""
|
|
|
|
def test_render_python_file(self, tmp_file) -> None:
|
|
file = tmp_file("def hello(): pass\n", suffix=".py")
|
|
|
|
result = runner.invoke(app, [str(file), "--no-pager"])
|
|
|
|
assert result.exit_code == 0
|
|
assert result.output
|
|
|
|
def test_render_markdown_file(self, tmp_file) -> None:
|
|
file = tmp_file("# Hello\n\nWorld\n", suffix=".md")
|
|
|
|
result = runner.invoke(app, [str(file), "--no-pager"])
|
|
|
|
assert result.exit_code == 0
|
|
|
|
def test_render_json_file(self, tmp_file, sample_json: str) -> None:
|
|
file = tmp_file(sample_json, suffix=".json")
|
|
|
|
result = runner.invoke(app, [str(file), "--no-pager"])
|
|
|
|
assert result.exit_code == 0
|
|
|
|
def test_detect_type_called_with_file_path(self, tmp_file) -> None:
|
|
"""detect_type receives the file path string."""
|
|
file = tmp_file("x = 1\n", suffix=".py")
|
|
|
|
with patch("rp.cli.detect_type") as mock_detect_type:
|
|
mock_detect_type.return_value = "python"
|
|
with patch("rp.cli.render"):
|
|
runner.invoke(app, [str(file), "--no-pager"])
|
|
|
|
mock_detect_type.assert_called_once_with(
|
|
path=str(file),
|
|
content="x = 1\n",
|
|
explicit_type=None,
|
|
guess_content=False,
|
|
debug=None,
|
|
)
|
|
|
|
def test_render_called_with_correct_options(self, tmp_file) -> None:
|
|
"""Render receives the right theme, line_numbers, pager flags."""
|
|
file = tmp_file("x = 1\n", suffix=".py")
|
|
|
|
with patch("rp.cli.detect_type", return_value="python"):
|
|
with patch("rp.cli.render") as mock_render:
|
|
runner.invoke(
|
|
app,
|
|
[
|
|
str(file),
|
|
"--no-pager",
|
|
"--theme",
|
|
"github-dark",
|
|
"--line-numbers",
|
|
],
|
|
)
|
|
|
|
call_args = mock_render.call_args
|
|
assert call_args is not None
|
|
assert call_args[0][0] == "x = 1\n"
|
|
assert call_args[0][1] == "python"
|
|
options = call_args[0][3]
|
|
assert options.theme == "github-dark"
|
|
assert options.line_numbers is True
|
|
assert options.pager is False
|
|
|
|
def test_file_not_found(self) -> None:
|
|
result = runner.invoke(app, ["/nonexistent/file.py"])
|
|
|
|
assert result.exit_code == 1
|
|
assert "Error" in result.output
|
|
|
|
def test_directory_error(self, tmp_path: Path) -> None:
|
|
result = runner.invoke(app, [str(tmp_path)])
|
|
|
|
assert result.exit_code == 1
|
|
assert "directory" in result.output.lower() or "Error" in result.output
|
|
|
|
def test_empty_file(self, tmp_file) -> None:
|
|
file = tmp_file("", suffix=".txt")
|
|
|
|
result = runner.invoke(app, [str(file)])
|
|
|
|
assert result.exit_code == 0
|
|
assert "Empty" in result.output or "empty" in result.output.lower()
|
|
|
|
def test_binary_file_error(self, tmp_file) -> None:
|
|
file = tmp_file("placeholder", suffix=".bin")
|
|
|
|
with patch.object(
|
|
Path,
|
|
"read_text",
|
|
side_effect=UnicodeDecodeError("utf-8", b"x", 0, 1, "boom"),
|
|
):
|
|
result = runner.invoke(app, [str(file)])
|
|
|
|
assert result.exit_code == 1
|
|
assert "Cannot read binary file" in result.output
|
|
|
|
def test_os_error_when_reading_file(self, tmp_file) -> None:
|
|
file = tmp_file("placeholder", suffix=".txt")
|
|
|
|
with patch.object(Path, "read_text", side_effect=OSError("read failed")):
|
|
result = runner.invoke(app, [str(file)])
|
|
|
|
assert result.exit_code == 1
|
|
assert "read failed" in result.output
|
|
|
|
|
|
class TestStdinInput:
|
|
"""Tests for reading from stdin."""
|
|
|
|
def test_stdin_python(self) -> None:
|
|
result = runner.invoke(app, ["--no-pager"], input="x = 1\n")
|
|
|
|
assert result.exit_code == 0
|
|
|
|
def test_stdin_json(self, sample_json: str) -> None:
|
|
result = runner.invoke(app, ["--no-pager"], input=sample_json)
|
|
|
|
assert result.exit_code == 0
|
|
|
|
def test_stdin_empty(self) -> None:
|
|
result = runner.invoke(app, [], input="")
|
|
|
|
assert result.exit_code == 0
|
|
assert "Empty" in result.output or "empty" in result.output.lower()
|
|
|
|
def test_stdin_with_dash(self) -> None:
|
|
result = runner.invoke(app, ["-", "--no-pager"], input="hello\n")
|
|
|
|
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:
|
|
mock_detect_type.return_value = "text"
|
|
with patch("rp.cli.render"):
|
|
runner.invoke(app, ["--no-pager"], input="hello\n")
|
|
|
|
mock_detect_type.assert_called_once_with(
|
|
path=None,
|
|
content="hello\n",
|
|
explicit_type=None,
|
|
guess_content=False,
|
|
debug=None,
|
|
)
|
|
|
|
def test_debug_env_enables_debug_logger(self) -> None:
|
|
with patch("rp.cli.detect_type") as mock_detect_type:
|
|
mock_detect_type.return_value = "text"
|
|
with patch("rp.cli.render"):
|
|
runner.invoke(
|
|
app, ["--no-pager"], input="hello\n", env={"RP_DEBUG": "1"}
|
|
)
|
|
|
|
_, kwargs = mock_detect_type.call_args
|
|
assert kwargs["debug"] is not None
|
|
|
|
def test_debug_output_stays_on_stderr(self, tmp_file) -> None:
|
|
file = tmp_file("# Hello\n", suffix=".md")
|
|
|
|
result = runner.invoke(app, [str(file), "--no-pager"], env={"RP_DEBUG": "1"})
|
|
normalized_stderr = result.stderr.replace("\n", "")
|
|
|
|
assert result.exit_code == 0
|
|
assert "[rp debug]" not in result.stdout
|
|
assert "Hello" in result.stdout
|
|
assert "[rp debug] input: reading file" in result.stderr
|
|
assert repr(str(file)) in normalized_stderr
|
|
assert (
|
|
"[rp debug] type: inferred 'markdown' from extension '.md'" in result.stderr
|
|
)
|
|
assert (
|
|
"[rp debug] render: auto-set line_numbers=False for 'markdown'"
|
|
in result.stderr
|
|
)
|
|
assert "[rp debug] pager: explicitly disabled" in result.stderr
|
|
assert "[rp debug] pager: writing directly to the console" in result.stderr
|
|
|
|
def test_stdin_shows_tty_prompt(self) -> None:
|
|
"""When stdin is a TTY and no input is piped, show the reading hint."""
|
|
with patch("rp.cli.sys.stdin.isatty", return_value=True):
|
|
with patch("rp.cli.sys.stdin.read", return_value="hello\n"):
|
|
with patch("rp.cli.render"):
|
|
with patch("rp.cli.Console.print") as mock_print:
|
|
main(pager=False)
|
|
|
|
mock_print.assert_any_call(
|
|
"[dim]Reading from stdin. Press Ctrl+D to end.[/dim]"
|
|
)
|
|
|
|
|
|
class TestTypeOverride:
|
|
"""Tests for the type override flags."""
|
|
|
|
def test_explicit_type(self, tmp_file) -> None:
|
|
file = tmp_file('{"key": "value"}\n', suffix=".txt")
|
|
|
|
result = runner.invoke(app, [str(file), "--type", "json", "--no-pager"])
|
|
|
|
assert result.exit_code == 0
|
|
|
|
def test_explicit_type_is_forwarded(self, tmp_file) -> None:
|
|
"""The --type flag value is passed as explicit_type to detect_type."""
|
|
file = tmp_file('{"key": "value"}\n', suffix=".txt")
|
|
|
|
with patch("rp.cli.detect_type") as mock_detect_type:
|
|
mock_detect_type.return_value = "json"
|
|
with patch("rp.cli.render"):
|
|
runner.invoke(app, [str(file), "--type", "json", "--no-pager"])
|
|
|
|
_, kwargs = mock_detect_type.call_args
|
|
assert kwargs["explicit_type"] == "json"
|
|
assert kwargs["guess_content"] is False
|
|
|
|
def test_explicit_type_short_flag(self, tmp_file) -> None:
|
|
file = tmp_file("def foo(): pass\n", suffix=".txt")
|
|
|
|
result = runner.invoke(app, [str(file), "-t", "python", "--no-pager"])
|
|
|
|
assert result.exit_code == 0
|
|
|
|
|
|
class TestThemeOption:
|
|
"""Tests for the theme option."""
|
|
|
|
def test_custom_theme(self, tmp_file) -> None:
|
|
file = tmp_file("x = 1\n", suffix=".py")
|
|
|
|
result = runner.invoke(app, [str(file), "--theme", "github-dark", "--no-pager"])
|
|
|
|
assert result.exit_code == 0
|
|
|
|
|
|
class TestGuessContentOption:
|
|
"""Tests for the guess-content flag."""
|
|
|
|
def test_guess_content_is_forwarded(self) -> None:
|
|
with patch("rp.cli.detect_type") as mock_detect_type:
|
|
mock_detect_type.return_value = "diff"
|
|
with patch("rp.cli.render"):
|
|
runner.invoke(app, ["--guess-content", "--no-pager"], input="hello\n")
|
|
|
|
_, kwargs = mock_detect_type.call_args
|
|
assert kwargs["guess_content"] is True
|
|
|
|
def test_guess_content_disabled_by_default(self) -> None:
|
|
with patch("rp.cli.detect_type") as mock_detect_type:
|
|
mock_detect_type.return_value = "text"
|
|
with patch("rp.cli.render"):
|
|
runner.invoke(app, ["--no-pager"], input="hello\n")
|
|
|
|
_, kwargs = mock_detect_type.call_args
|
|
assert kwargs["guess_content"] is False
|
|
|
|
|
|
class TestLineNumbersOption:
|
|
"""Tests for the line number flags."""
|
|
|
|
def test_line_numbers_enabled(self, tmp_file) -> None:
|
|
file = tmp_file("x = 1\n", suffix=".py")
|
|
|
|
result = runner.invoke(app, [str(file), "--line-numbers", "--no-pager"])
|
|
|
|
assert result.exit_code == 0
|
|
|
|
def test_line_numbers_disabled(self, tmp_file) -> None:
|
|
file = tmp_file("x = 1\n", suffix=".py")
|
|
|
|
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}")
|