Add opt-in debug logging across the CLI pipeline
Thread RP_DEBUG-driven logging through input, detection, rendering, and pager decisions so CLI behavior is inspectable without polluting stdout. Add focused coverage for debug wiring, env parsing, and stderr-only output.
This commit is contained in:
@@ -75,6 +75,7 @@ class TestFileInput:
|
||||
content="x = 1\n",
|
||||
explicit_type=None,
|
||||
guess_content=False,
|
||||
debug=None,
|
||||
)
|
||||
|
||||
def test_render_called_with_correct_options(self, tmp_file) -> None:
|
||||
@@ -182,8 +183,41 @@ class TestStdinInput:
|
||||
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):
|
||||
|
||||
60
tests/test_debug.py
Normal file
60
tests/test_debug.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Tests for debug helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from rp.debug import DEBUG_ENV_VAR, env_debug_enabled, log_debug, make_debug_logger
|
||||
|
||||
|
||||
class TestEnvDebugEnabled:
|
||||
"""Environment-based debug mode toggles."""
|
||||
|
||||
def test_disabled_by_default(self) -> None:
|
||||
assert env_debug_enabled({}) is False
|
||||
|
||||
def test_false_like_values_disable_debug(self) -> None:
|
||||
for value in ["", "0", "false", "no", "off"]:
|
||||
assert env_debug_enabled({DEBUG_ENV_VAR: value}) is False
|
||||
|
||||
def test_false_like_values_are_trimmed_and_case_insensitive(self) -> None:
|
||||
for value in [" False ", "\tNO\n", " Off "]:
|
||||
assert env_debug_enabled({DEBUG_ENV_VAR: value}) is False
|
||||
|
||||
def test_truthy_values_enable_debug(self) -> None:
|
||||
assert env_debug_enabled({DEBUG_ENV_VAR: "1"}) is True
|
||||
assert env_debug_enabled({DEBUG_ENV_VAR: "true"}) is True
|
||||
|
||||
def test_truthy_values_are_trimmed_and_case_insensitive(self) -> None:
|
||||
for value in [" TRUE ", " Yes ", "\ton\n"]:
|
||||
assert env_debug_enabled({DEBUG_ENV_VAR: value}) is True
|
||||
|
||||
|
||||
class TestMakeDebugLogger:
|
||||
"""Console-backed debug logger output."""
|
||||
|
||||
def test_logs_visible_prefix(self) -> None:
|
||||
stream = io.StringIO()
|
||||
console = Console(file=stream, force_terminal=False)
|
||||
logger = make_debug_logger(console, enabled=True)
|
||||
|
||||
assert logger is not None
|
||||
logger("hello")
|
||||
|
||||
assert stream.getvalue() == "[rp debug] hello\n"
|
||||
|
||||
|
||||
class TestLogDebug:
|
||||
"""Nil-safe debug logger forwarding."""
|
||||
|
||||
def test_noop_when_logger_is_none(self) -> None:
|
||||
log_debug(None, "hello")
|
||||
|
||||
def test_forwards_to_logger(self) -> None:
|
||||
messages: list[str] = []
|
||||
|
||||
log_debug(messages.append, "hello")
|
||||
|
||||
assert messages == ["hello"]
|
||||
@@ -198,6 +198,30 @@ class TestGuessContent:
|
||||
assert detect_type(content=sample_json, guess_content=True) == "json"
|
||||
|
||||
|
||||
class TestDebugLogging:
|
||||
"""Debug logging describes inference decisions."""
|
||||
|
||||
def test_logs_extension_source(self) -> None:
|
||||
messages: list[str] = []
|
||||
|
||||
assert detect_type(path="script.py", debug=messages.append) == "python"
|
||||
assert messages == ["type: inferred 'python' from extension '.py'"]
|
||||
|
||||
def test_logs_content_guess_and_score(self) -> None:
|
||||
messages: list[str] = []
|
||||
diff = "--- a/file\n+++ b/file\n@@ -1 +1 @@\n-old\n+new\n"
|
||||
|
||||
assert (
|
||||
detect_type(content=diff, guess_content=True, debug=messages.append)
|
||||
== "diff"
|
||||
)
|
||||
assert any(
|
||||
"type: content guess -> lexer='diff', score=" in message
|
||||
for message in messages
|
||||
)
|
||||
assert "type: inferred 'diff' from heuristic content guessing" in messages
|
||||
|
||||
|
||||
class TestFallback:
|
||||
"""Priority 7: fallback to 'text'."""
|
||||
|
||||
|
||||
@@ -25,6 +25,14 @@ class TestMouseCapableLess:
|
||||
with patch("rp.pager.shutil.which", return_value=None):
|
||||
assert _mouse_capable_less() is None
|
||||
|
||||
def test_logs_less_not_found(self) -> None:
|
||||
messages: list[str] = []
|
||||
|
||||
with patch("rp.pager.shutil.which", return_value=None):
|
||||
assert _mouse_capable_less(messages.append) is None
|
||||
|
||||
assert messages == ["pager: 'less' was not found"]
|
||||
|
||||
def test_returns_none_when_less_too_old(self) -> None:
|
||||
with patch("rp.pager.shutil.which", return_value="/usr/bin/less"):
|
||||
mock_result = MagicMock()
|
||||
@@ -99,6 +107,19 @@ class TestMousePager:
|
||||
|
||||
mock_instance.show.assert_called_once_with("content")
|
||||
|
||||
def test_logs_system_pager_fallback(self) -> None:
|
||||
messages: list[str] = []
|
||||
pager = MousePager(debug=messages.append)
|
||||
|
||||
with patch("rp.pager._mouse_capable_less", return_value=None):
|
||||
with patch("rp.pager.SystemPager") as mock_system_pager:
|
||||
mock_instance = MagicMock()
|
||||
mock_system_pager.return_value = mock_instance
|
||||
|
||||
pager.show("content")
|
||||
|
||||
assert messages == ["pager: falling back to Rich's SystemPager"]
|
||||
|
||||
def test_uses_less_when_available(self) -> None:
|
||||
pager = MousePager()
|
||||
mock_result = MagicMock()
|
||||
|
||||
@@ -35,6 +35,10 @@ class TestRenderOptions:
|
||||
assert opts.line_numbers is True
|
||||
assert opts.pager is False
|
||||
|
||||
def test_default_debug_is_none(self) -> None:
|
||||
opts = RenderOptions()
|
||||
assert opts.debug is None
|
||||
|
||||
|
||||
class TestRenderMarkdown:
|
||||
"""Markdown rendering."""
|
||||
@@ -238,6 +242,18 @@ class TestPagerBehavior:
|
||||
assert len(capture_console.file.getvalue()) > 0
|
||||
mock_pager.assert_not_called()
|
||||
|
||||
def test_logs_auto_pager_decision(
|
||||
self, capture_console, sample_python: str
|
||||
) -> None:
|
||||
messages: list[str] = []
|
||||
opts = RenderOptions(pager=None, debug=messages.append)
|
||||
|
||||
with patch("sys.stdout.isatty", return_value=False):
|
||||
render(sample_python, "python", capture_console, opts)
|
||||
|
||||
assert "pager: auto-detected disabled because stdout is not a TTY" in messages
|
||||
assert "pager: writing directly to the console" in messages
|
||||
|
||||
|
||||
class TestPadding:
|
||||
"""Rendered output is wrapped in padding."""
|
||||
|
||||
Reference in New Issue
Block a user