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

This commit was merged in pull request #1.
This commit is contained in:
2026-04-04 06:42:41 -07:00
15 changed files with 962 additions and 48 deletions

View File

@@ -2,7 +2,7 @@ name: CI
on: on:
push: push:
branches: [main] branches: [main, dev]
pull_request: pull_request:
branches: [main] branches: [main]

View File

@@ -24,10 +24,13 @@ uv run rp <file> # Rich-print a file
uv run rp --help # Show help uv run rp --help # Show help
uv run rp --version # Show version uv run rp --version # Show version
echo '{"a":1}' | uv run rp # Read from stdin echo '{"a":1}' | uv run rp # Read from stdin
cat patch.txt | uv run rp --guess-content # Enable heuristic content guessing
``` ```
### Testing ### Testing
This project does not yet have automated tests. When adding tests:
Run tests with pytest:
```bash ```bash
# Run all tests # Run all tests
uv run pytest uv run pytest
@@ -35,8 +38,11 @@ uv run pytest
# Run a single test file # Run a single test file
uv run pytest tests/test_detect.py uv run pytest tests/test_detect.py
# Run a single test class
uv run pytest tests/test_detect.py::TestExplicitType
# Run a single test function # Run a single test function
uv run pytest tests/test_detect.py::test_detect_json_from_content uv run pytest tests/test_detect.py::TestExplicitType::test_explicit_overrides_everything
# Run with verbose output # Run with verbose output
uv run pytest -v uv run pytest -v
@@ -87,6 +93,7 @@ def detect_type(
path: str | None = None, path: str | None = None,
content: str | None = None, content: str | None = None,
explicit_type: str | None = None, explicit_type: str | None = None,
guess_content: bool = False,
) -> str: ) -> str:
... ...
``` ```
@@ -147,6 +154,11 @@ When adding new file types:
2. Use Pygments lexer name as the value 2. Use Pygments lexer name as the value
3. For special filenames (e.g., `Dockerfile`), add to `_FILENAME_MAP` 3. For special filenames (e.g., `Dockerfile`), add to `_FILENAME_MAP`
Detection stays conservative by default: explicit type, filename map, extension map,
shebang, and JSON detection all run before falling back to `text`. Heuristic
Pygments content guessing is opt-in via `--guess-content` and should remain a
late fallback.
### Rendering ### Rendering
- `markdown` and `md` → use `rich.markdown.Markdown` - `markdown` and `md` → use `rich.markdown.Markdown`
- `json` → use `rich.json.JSON` with fallback to `Syntax` - `json` → use `rich.json.JSON` with fallback to `Syntax`
@@ -160,32 +172,77 @@ Version metadata must stay in sync between `pyproject.toml` and `src/rp/__init__
## Project Structure ## Project Structure
``` ```
rich-viewer/ rich-print/
├── pyproject.toml # Project config, dependencies, entry point ├── pyproject.toml # Project config, dependencies, entry point
├── uv.lock # Lockfile (commit this) ├── uv.lock # Lockfile (commit this)
├── .gitignore ├── .gitignore
├── README.md ├── README.md
├── AGENTS.md # This file ├── AGENTS.md # This file
── src/ ── src/
└── rp/ └── rp/
├── __init__.py # Version metadata ├── __init__.py # Version metadata
├── cli.py # Typer CLI entry point ├── cli.py # Typer CLI entry point
├── detect.py # File type detection ├── detect.py # File type detection
├── render.py # Rendering logic ├── render.py # Rendering logic
├── pager.py # Pager integration ├── pager.py # Pager integration
└── py.typed # PEP 561 marker └── py.typed # PEP 561 marker
└── tests/
├── conftest.py
├── test_cli.py
├── test_detect.py
├── test_render.py
└── test_pager.py
``` ```
## Dependencies ## Dependencies
- `rich>=13.0` — Terminal rendering (Markdown, JSON, Syntax) - `rich>=13.0` — Terminal rendering (Markdown, JSON, Syntax)
- `pygments>=2.13.0` — Lexer detection and syntax lexers
- `typer>=0.12` — CLI framework - `typer>=0.12` — CLI framework
Runtime dependencies. Dev dependencies: `ruff>=0.9`, `mypy>=1.14`. Runtime dependencies. Dev dependencies: `ruff>=0.9`, `mypy>=1.14`, `pytest>=8.0`, `pytest-cov>=5.0`, `types-pygments>=2.19`.
## Git Conventions ## Git Workflow
- Write clear, imperative commit messages
- Reference issues/PRs when applicable ### Branches
- Keep commits focused (one logical change per commit)
- **Long-lived branches**
- `main` — stable release branch; only updated via merges from `dev`
- `dev` — integration branch for day-to-day development
- **Short-lived branches**
- `feature/<topic>` — new features
- `fix/<topic>` — bug fixes
- `docs/<topic>` — documentation changes
- `chore/<topic>` — maintenance, tooling, CI
- `refactor/<topic>` — refactoring without behavior change
- `test/<topic>` — adding or improving tests
### Branching Rules
- Start normal work from the latest `dev`
- Keep branches short-lived and focused on one logical change
- Do not commit directly to `main` or `dev` unless explicitly requested
- Delete the short-lived branch after it is merged
### Pull Requests
- Open normal PRs into `dev`, not `main`
- Use **squash merge** for PRs into `dev`
- Ensure CI passes before merging
- Keep PRs focused; split unrelated changes into separate PRs
### Release Flow
- Merge `dev` into `main` when changes are ready to release
- Tag releases from `main` with version numbers (e.g., `v1.2.3`)
- If a hotfix is made directly against `main`, merge it back to `dev`
### Agent Expectations
- Unless the user says otherwise, assume new work should target `dev`
- If a branch must be created, follow the naming rules above
- Do not create or push branches automatically unless the user asks
- Do not commit changes unless the user explicitly asks
## Notes for Agents ## Notes for Agents
- This is a small, focused CLI tool — prefer simplicity over abstraction - This is a small, focused CLI tool — prefer simplicity over abstraction

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
@@ -49,6 +50,13 @@ rp --type python script.txt
rp -t json data.txt rp -t json data.txt
``` ```
### Guess from content
```bash
cat patch.txt | rp --guess-content
rp --guess-content snippet.txt
```
### Pager control ### Pager control
```bash ```bash
@@ -58,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
@@ -72,6 +89,14 @@ rp --version
uv sync uv sync
``` ```
### Testing
```bash
uv run pytest # Run all tests
uv run pytest -v # Verbose output
uv run pytest tests/test_detect.py # Single file
```
### Linting & Type Checking ### Linting & Type Checking
```bash ```bash
@@ -86,6 +111,13 @@ uv run mypy src/
uv build uv build
``` ```
## Workflow
- `main` — stable release branch
- `dev` — integration branch for day-to-day work
- Short-lived branches: `feature/<topic>`, `fix/<topic>`, `docs/<topic>`
- Open PRs into `dev` (squash merge); merge `dev` to `main` for releases
## License ## License
MIT MIT

View File

@@ -22,6 +22,7 @@ classifiers = [
] ]
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"pygments>=2.13.0",
"rich>=13.0", "rich>=13.0",
"typer>=0.12", "typer>=0.12",
] ]
@@ -39,6 +40,7 @@ dev = [
"mypy>=1.14", "mypy>=1.14",
"pytest>=8.0", "pytest>=8.0",
"pytest-cov>=5.0", "pytest-cov>=5.0",
"types-pygments>=2.19.0.20260402",
] ]
[tool.pytest.ini_options] [tool.pytest.ini_options]

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,9 +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 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.",
@@ -35,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[
@@ -65,6 +167,20 @@ 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[
bool,
typer.Option(
"--guess-content/--no-guess-content",
help="Use heuristic content-based type detection as a fallback.",
),
] = False,
version: Annotated[ version: Annotated[
bool | None, bool | None,
typer.Option( typer.Option(
@@ -84,28 +200,47 @@ 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.
version: Show version and exit. version: Show version and exit.
""" """
console = Console() 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) == "-": if file is None or str(file) == "-":
log_debug(debug, "input: reading from stdin")
if sys.stdin.isatty(): if sys.stdin.isatty():
console.print("[dim]Reading from stdin. Press Ctrl+D to end.[/dim]") console.print("[dim]Reading from stdin. Press Ctrl+D to end.[/dim]")
content = sys.stdin.read() content = sys.stdin.read()
file_path = None file_path = None
else: else:
if not file.exists(): log_debug(debug, f"input: reading file {str(file)!r}")
console.print(f"[red]Error:[/red] File not found: {file}") if watch:
raise typer.Exit(code=1) log_debug(debug, f"watch: polling {str(file)!r}")
if file.is_dir(): _watch_file(
console.print(f"[red]Error:[/red] Is a directory: {file}") file=file,
raise typer.Exit(code=1) console=console,
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)
@@ -114,14 +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(path=file_path, content=content, explicit_type=file_type) _render_content(
console=console,
options = RenderOptions( debug=debug,
content=content,
file_path=file_path,
file_type=file_type,
guess_content=guess_content,
theme=theme, theme=theme,
line_numbers=line_numbers, line_numbers=line_numbers,
pager=pager, pager=pager,
) )
render(content, file_type, console, options)
if __name__ == "__main__": if __name__ == "__main__":

40
src/rp/debug.py Normal file
View File

@@ -0,0 +1,40 @@
"""Debug logging helpers for rp."""
from __future__ import annotations
import os
from collections.abc import Callable, Mapping
from rich.console import Console
from rich.text import Text
DEBUG_ENV_VAR = "RP_DEBUG"
_FALSE_DEBUG_VALUES = {"", "0", "false", "no", "off"}
type DebugLogger = Callable[[str], None]
def env_debug_enabled(env: Mapping[str, str] | None = None) -> bool:
"""Return whether debug logging is enabled via ``RP_DEBUG``."""
source = os.environ if env is None else env
value = source.get(DEBUG_ENV_VAR)
if value is None:
return False
return value.strip().lower() not in _FALSE_DEBUG_VALUES
def make_debug_logger(console: Console, enabled: bool) -> DebugLogger | None:
"""Return a console-backed debug logger when debug mode is enabled."""
if not enabled:
return None
def _log(message: str) -> None:
console.print(Text.assemble(("[rp debug] ", "dim"), message))
return _log
def log_debug(debug: DebugLogger | None, message: str) -> None:
"""Emit a debug line when a debug logger is configured."""
if debug is not None:
debug(message)

View File

@@ -3,8 +3,15 @@
from __future__ import annotations from __future__ import annotations
import json import json
import shlex
from numbers import Real
from pathlib import Path from pathlib import Path
from pygments.lexers import guess_lexer
from pygments.util import ClassNotFound
from rp.debug import DebugLogger, log_debug
EXTENSION_MAP: dict[str, str] = { EXTENSION_MAP: dict[str, str] = {
".md": "markdown", ".md": "markdown",
".json": "json", ".json": "json",
@@ -52,13 +59,179 @@ EXTENSION_MAP: dict[str, str] = {
_FILENAME_MAP: dict[str, str] = { _FILENAME_MAP: dict[str, str] = {
"Dockerfile": "docker", "Dockerfile": "docker",
"Makefile": "make", "Makefile": "make",
".bashrc": "bash",
".zshrc": "bash",
".profile": "bash",
} }
_SHEBANG_MAP: dict[str, str] = {
"python": "python",
"python3": "python",
"python2": "python",
"sh": "bash",
"bash": "bash",
"zsh": "bash",
}
_ENV_OPTIONS_WITH_ARG = {
"-C",
"-u",
"--argv0",
"--block-signal",
"--chdir",
"--default-signal",
"--ignore-signal",
"--unset",
}
_ENV_LONG_OPTIONS_WITH_ARG = {
opt.lstrip("-") for opt in _ENV_OPTIONS_WITH_ARG if opt.startswith("--")
}
def _is_env_assignment(part: str) -> bool:
"""Return whether a token looks like an env-style variable assignment."""
name, separator, _ = part.partition("=")
return (
bool(separator)
and bool(name)
and (name[0].isalpha() or name[0] == "_")
and all(char.isalnum() or char == "_" for char in name[1:])
)
def _detect_env_program(parts: list[str]) -> str | None:
"""Return the actual program name from an env-based shebang."""
remaining = parts[1:]
while remaining:
part = remaining.pop(0)
if part.startswith("--split-string="):
try:
remaining = shlex.split(part.partition("=")[2]) + remaining
except ValueError:
return None
continue
if part in {"-S", "--split-string"}:
if not remaining:
return None
try:
remaining = shlex.split(remaining.pop(0)) + remaining
except ValueError:
return None
continue
if part.startswith("-S"):
try:
remaining = shlex.split(part[2:]) + remaining
except ValueError:
return None
continue
if part == "--":
if not remaining:
return None
return Path(remaining[0]).name
if part in _ENV_OPTIONS_WITH_ARG:
if not remaining:
return None
remaining.pop(0)
continue
if any(part.startswith(f"{option}=") for option in _ENV_LONG_OPTIONS_WITH_ARG):
continue
if part.startswith("-"):
continue
if _is_env_assignment(part):
continue
return Path(part).name
return None
def _detect_shebang(content: str) -> str | None:
"""Return a lexer from the first shebang line when recognized."""
lines = content.splitlines()
first_line = lines[0] if lines else content
if not first_line.startswith("#!"):
return None
command = first_line[2:].strip()
if not command:
return None
try:
parts = shlex.split(command)
except ValueError:
return None
if not parts:
return None
program = Path(parts[0]).name
if program == "env":
env_program = _detect_env_program(parts)
if env_program is None:
return None
program = env_program
return _SHEBANG_MAP.get(program)
def _detect_pygments_content(
content: str,
debug: DebugLogger | None = None,
) -> str | None:
"""Return a lexer guessed from content when Pygments is confident."""
try:
lexer = guess_lexer(content)
except ClassNotFound:
log_debug(debug, "type: Pygments could not guess a lexer from content")
return None
aliases: list[str] = getattr(lexer, "aliases", [])
alias = aliases[0] if aliases else None
analyse_text = getattr(lexer, "analyse_text", None)
score_value = analyse_text(content) if callable(analyse_text) else 0.0
score = float(score_value) if isinstance(score_value, Real) else 0.0
guessed = alias if alias is not None else lexer.__class__.__name__
log_debug(debug, f"type: content guess -> lexer={guessed!r}, score={score:.3f}")
if alias is None:
log_debug(debug, "type: rejected content guess because the lexer has no alias")
return None
if alias == "text":
log_debug(
debug,
"type: rejected content guess because the lexer resolved to plain text",
)
return None
if score < 0.5:
log_debug(
debug,
"type: rejected content guess because the score is below 0.500",
)
return None
log_debug(debug, f"type: inferred {alias!r} from heuristic content guessing")
return alias
def detect_type( def detect_type(
path: str | None = None, path: str | None = None,
content: str | None = None, content: str | None = None,
explicit_type: str | None = None, explicit_type: str | None = None,
guess_content: bool = False,
debug: DebugLogger | None = None,
) -> str: ) -> str:
"""Detect the file type for syntax highlighting. """Detect the file type for syntax highlighting.
@@ -66,30 +239,44 @@ def detect_type(
1. Explicit type if provided 1. Explicit type if provided
2. Filename-based mapping (for example, Dockerfile) 2. Filename-based mapping (for example, Dockerfile)
3. Extension-based mapping 3. Extension-based mapping
4. JSON if content starts with '{' or '[' and parses successfully 4. Shebang-based mapping for script content
5. Fallback to 'text' 5. JSON if content starts with '{' or '[' and parses successfully
6. Heuristic content guessing when enabled
7. Fallback to 'text'
Args: Args:
path: File path for filename and extension detection. path: File path for filename and extension detection.
content: File content for JSON detection. content: File content for JSON detection.
explicit_type: Override auto-detection with explicit type. explicit_type: Override auto-detection with explicit type.
guess_content: Enable heuristic content-based lexer guessing.
debug: Optional debug logger for inference details.
Returns: Returns:
Pygments lexer name for the detected file type. Pygments lexer name for the detected file type.
""" """
if explicit_type is not None: if explicit_type is not None:
log_debug(debug, f"type: using explicit override {explicit_type!r}")
return explicit_type return explicit_type
if path is not None: if path is not None:
p = Path(path) p = Path(path)
name = p.name name = p.name
if name in _FILENAME_MAP: if name in _FILENAME_MAP:
return _FILENAME_MAP[name] detected = _FILENAME_MAP[name]
log_debug(debug, f"type: inferred {detected!r} from filename {name!r}")
return detected
suffix = p.suffix.lower() suffix = p.suffix.lower()
if suffix in EXTENSION_MAP: if suffix in EXTENSION_MAP:
return EXTENSION_MAP[suffix] detected = EXTENSION_MAP[suffix]
log_debug(debug, f"type: inferred {detected!r} from extension {suffix!r}")
return detected
if content is not None: if content is not None:
shebang_type = _detect_shebang(content)
if shebang_type is not None:
log_debug(debug, f"type: inferred {shebang_type!r} from shebang")
return shebang_type
stripped = content.strip() stripped = content.strip()
if stripped and ( if stripped and (
(stripped[0] == "{" and stripped[-1] == "}") (stripped[0] == "{" and stripped[-1] == "}")
@@ -97,8 +284,17 @@ def detect_type(
): ):
try: try:
json.loads(stripped) json.loads(stripped)
log_debug(debug, "type: inferred 'json' from JSON content")
return "json" return "json"
except (json.JSONDecodeError, ValueError): except (json.JSONDecodeError, ValueError):
pass log_debug(debug, "type: JSON-like content failed to parse")
if guess_content:
pygments_type = _detect_pygments_content(content, debug=debug)
if pygments_type is not None:
return pygments_type
else:
log_debug(debug, "type: skipped heuristic content guessing")
log_debug(debug, "type: fell back to 'text'")
return "text" return "text"

View File

@@ -9,8 +9,10 @@ import subprocess
from rich.pager import Pager, SystemPager from rich.pager import Pager, SystemPager
from rp.debug import DebugLogger, log_debug
def _mouse_capable_less() -> str | None:
def _mouse_capable_less(debug: DebugLogger | None = None) -> str | None:
"""Return a ``less`` path that likely supports ``--mouse``. """Return a ``less`` path that likely supports ``--mouse``.
Mouse support was added in ``less`` 543. This probe is best-effort: it Mouse support was added in ``less`` 543. This probe is best-effort: it
@@ -24,6 +26,7 @@ def _mouse_capable_less() -> str | None:
""" """
less = shutil.which("less") less = shutil.which("less")
if less is None: if less is None:
log_debug(debug, "pager: 'less' was not found")
return None return None
try: try:
@@ -31,16 +34,21 @@ def _mouse_capable_less() -> str | None:
[less, "--version"], capture_output=True, text=True, timeout=5 [less, "--version"], capture_output=True, text=True, timeout=5
) )
except (subprocess.TimeoutExpired, OSError): except (subprocess.TimeoutExpired, OSError):
log_debug(debug, "pager: failed to probe the installed 'less' binary")
return None return None
version_str = result.stdout or result.stderr or "" version_str = result.stdout or result.stderr or ""
match = re.search(r"\bless\s+(\d+)\b", version_str, re.IGNORECASE) match = re.search(r"\bless\s+(\d+)\b", version_str, re.IGNORECASE)
if match is None: if match is None:
log_debug(debug, "pager: could not parse the installed 'less' version")
return None return None
if int(match.group(1)) < 543: version = int(match.group(1))
if version < 543:
log_debug(debug, f"pager: installed 'less' {version} is too old for --mouse")
return None return None
log_debug(debug, f"pager: using 'less' {version} from {less!r}")
return less return less
@@ -53,29 +61,41 @@ class MousePager(Pager):
trigger a second pager session. trigger a second pager session.
""" """
def __init__(self, debug: DebugLogger | None = None) -> None:
"""Initialize the pager with an optional debug logger."""
self._debug = debug
def show(self, content: str) -> None: def show(self, content: str) -> None:
"""Display rendered content in ``less -R --mouse`` when possible. """Display rendered content in ``less -R --mouse`` when possible.
Args: Args:
content: Fully rendered text from Rich. content: Fully rendered text from Rich.
""" """
less = _mouse_capable_less() less = _mouse_capable_less(self._debug)
if less is None: if less is None:
log_debug(self._debug, "pager: falling back to Rich's SystemPager")
SystemPager().show(content) SystemPager().show(content)
return return
try: try:
log_debug(self._debug, "pager: launching 'less -R --mouse'")
result = subprocess.run( result = subprocess.run(
[less, "-R", "--mouse"], [less, "-R", "--mouse"],
input=content, input=content,
text=True, text=True,
) )
except OSError: except OSError:
log_debug(self._debug, "pager: failed to launch 'less', using SystemPager")
SystemPager().show(content) SystemPager().show(content)
return return
if result.returncode in (-signal.SIGINT, 128 + signal.SIGINT): if result.returncode in (-signal.SIGINT, 128 + signal.SIGINT):
log_debug(self._debug, "pager: interrupted by user")
return return
if result.returncode != 0: if result.returncode != 0:
log_debug(
self._debug,
f"pager: 'less' exited with status {result.returncode}, using SystemPager",
)
SystemPager().show(content) SystemPager().show(content)

View File

@@ -10,6 +10,7 @@ from rich.markdown import Markdown
from rich.padding import Padding from rich.padding import Padding
from rich.syntax import Syntax from rich.syntax import Syntax
from rp.debug import DebugLogger, log_debug
from rp.pager import MousePager from rp.pager import MousePager
@@ -21,11 +22,13 @@ class RenderOptions:
theme: Pygments color theme name. theme: Pygments color theme name.
line_numbers: Show line numbers. ``None`` enables them for code only. line_numbers: Show line numbers. ``None`` enables them for code only.
pager: Use pager for output. ``None`` auto-detects terminal output. pager: Use pager for output. ``None`` auto-detects terminal output.
debug: Optional debug logger for render and pager decisions.
""" """
theme: str = "monokai" theme: str = "monokai"
line_numbers: bool | None = None line_numbers: bool | None = None
pager: bool | None = None pager: bool | None = None
debug: DebugLogger | None = None
_MARKDOWN_TYPES: set[str] = {"markdown", "md"} _MARKDOWN_TYPES: set[str] = {"markdown", "md"}
@@ -49,12 +52,27 @@ def render(
line_numbers = options.line_numbers line_numbers = options.line_numbers
if line_numbers is None: if line_numbers is None:
line_numbers = file_type not in _MARKDOWN_TYPES and file_type not in _JSON_TYPES line_numbers = file_type not in _MARKDOWN_TYPES and file_type not in _JSON_TYPES
log_debug(
options.debug,
f"render: auto-set line_numbers={line_numbers} for {file_type!r}",
)
use_pager = options.pager use_pager = options.pager
if use_pager is None: if use_pager is None:
import sys import sys
use_pager = sys.stdout.isatty() stdout_is_tty = sys.stdout.isatty()
use_pager = stdout_is_tty
log_debug(
options.debug,
f"pager: auto-detected {'enabled' if use_pager else 'disabled'} "
f"because stdout is {'a' if stdout_is_tty else 'not a'} TTY",
)
else:
log_debug(
options.debug,
f"pager: explicitly {'enabled' if use_pager else 'disabled'}",
)
renderable: RenderableType renderable: RenderableType
if file_type in _MARKDOWN_TYPES: if file_type in _MARKDOWN_TYPES:
@@ -63,6 +81,7 @@ def render(
try: try:
renderable = RichJSON(content) renderable = RichJSON(content)
except (SyntaxError, ValueError): except (SyntaxError, ValueError):
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
) )
@@ -72,6 +91,10 @@ def render(
content, file_type, theme=options.theme, line_numbers=line_numbers content, file_type, theme=options.theme, line_numbers=line_numbers
) )
except Exception: except Exception:
log_debug(
options.debug,
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
) )
@@ -79,7 +102,9 @@ def render(
padded = Padding(renderable, (1, 2)) padded = Padding(renderable, (1, 2))
if use_pager: if use_pager:
with console.pager(pager=MousePager(), styles=True): log_debug(options.debug, "pager: opening pager")
with console.pager(pager=MousePager(debug=options.debug), styles=True):
console.print(padded) console.print(padded)
else: else:
log_debug(options.debug, "pager: writing directly to the console")
console.print(padded) console.print(padded)

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__
@@ -74,6 +75,8 @@ class TestFileInput:
path=str(file), path=str(file),
content="x = 1\n", content="x = 1\n",
explicit_type=None, explicit_type=None,
guess_content=False,
debug=None,
) )
def test_render_called_with_correct_options(self, tmp_file) -> None: def test_render_called_with_correct_options(self, tmp_file) -> None:
@@ -169,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:
@@ -180,8 +195,42 @@ class TestStdinInput:
path=None, path=None,
content="hello\n", content="hello\n",
explicit_type=None, 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: def test_stdin_shows_tty_prompt(self) -> None:
"""When stdin is a TTY and no input is piped, show the reading hint.""" """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.isatty", return_value=True):
@@ -216,6 +265,7 @@ class TestTypeOverride:
_, kwargs = mock_detect_type.call_args _, kwargs = mock_detect_type.call_args
assert kwargs["explicit_type"] == "json" assert kwargs["explicit_type"] == "json"
assert kwargs["guess_content"] is False
def test_explicit_type_short_flag(self, tmp_file) -> None: def test_explicit_type_short_flag(self, tmp_file) -> None:
file = tmp_file("def foo(): pass\n", suffix=".txt") file = tmp_file("def foo(): pass\n", suffix=".txt")
@@ -236,6 +286,28 @@ class TestThemeOption:
assert result.exit_code == 0 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: class TestLineNumbersOption:
"""Tests for the line number flags.""" """Tests for the line number flags."""
@@ -252,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}")

60
tests/test_debug.py Normal file
View 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"]

View File

@@ -70,7 +70,13 @@ class TestExtensionMap:
assert detect_type(path="archive.tar.gz") == "text" assert detect_type(path="archive.tar.gz") == "text"
def test_hidden_file(self) -> None: def test_hidden_file(self) -> None:
assert detect_type(path=".bashrc") == "text" assert detect_type(path=".bashrc") == "bash"
def test_zshrc(self) -> None:
assert detect_type(path=".zshrc") == "bash"
def test_profile(self) -> None:
assert detect_type(path=".profile") == "bash"
@pytest.mark.parametrize( @pytest.mark.parametrize(
("suffix", "expected"), ("suffix", "expected"),
@@ -80,8 +86,73 @@ class TestExtensionMap:
assert detect_type(path=f"example{suffix}") == expected assert detect_type(path=f"example{suffix}") == expected
class TestShebangDetection:
"""Priority 4: detect common scripts from shebangs."""
def test_env_python3_shebang(self) -> None:
assert detect_type(content="#!/usr/bin/env python3\nprint('hi')\n") == "python"
def test_env_python3_with_assignment(self) -> None:
assert (
detect_type(content="#!/usr/bin/env FOO=1 python3\nprint('hi')\n")
== "python"
)
def test_env_ignore_environment_before_program(self) -> None:
assert detect_type(content="#!/usr/bin/env -i bash\necho hi\n") == "bash"
def test_env_split_string_shebang(self) -> None:
assert (
detect_type(content="#!/usr/bin/env -S python3 -u\nprint('hi')\n")
== "python"
)
def test_env_compact_split_string_shebang(self) -> None:
assert (
detect_type(content="#!/usr/bin/env -Spython3 -u\nprint('hi')\n")
== "python"
)
def test_env_quoted_split_string_shebang(self) -> None:
assert (
detect_type(
content='#!/usr/bin/env --split-string="python3 -u"\nprint("hi")\n'
)
== "python"
)
def test_env_long_option_with_equals(self) -> None:
assert (
detect_type(content="#!/usr/bin/env --ignore-signal=TERM bash\necho hi\n")
== "bash"
)
def test_direct_python_shebang(self) -> None:
assert detect_type(content="#!/usr/bin/python3\nprint('hi')\n") == "python"
def test_bash_shebang(self) -> None:
assert detect_type(content="#!/bin/bash\necho hi\n") == "bash"
def test_sh_shebang(self) -> None:
assert detect_type(content="#!/bin/sh\necho hi\n") == "bash"
def test_zsh_shebang(self) -> None:
assert detect_type(content="#!/bin/zsh\necho hi\n") == "bash"
def test_extension_wins_over_shebang(self) -> None:
assert (
detect_type(path="script.py", content="#!/bin/bash\necho hi\n") == "python"
)
def test_filename_wins_over_shebang(self) -> None:
assert detect_type(path=".bashrc", content="#!/usr/bin/env python3\n") == "bash"
def test_unrecognized_shebang_falls_through(self) -> None:
assert detect_type(content="#!/usr/bin/env ruby\nputs 'hi'\n") == "text"
class TestContentJsonDetection: class TestContentJsonDetection:
"""Priority 4: detect JSON from content when path gives no clue.""" """Priority 5: detect JSON from content when path gives no clue."""
def test_json_object(self, sample_json: str) -> None: def test_json_object(self, sample_json: str) -> None:
assert detect_type(content=sample_json) == "json" assert detect_type(content=sample_json) == "json"
@@ -104,9 +175,55 @@ class TestContentJsonDetection:
def test_content_with_valid_path_takes_priority(self) -> None: def test_content_with_valid_path_takes_priority(self) -> None:
assert detect_type(path="script.py", content='{"a": 1}') == "python" assert detect_type(path="script.py", content='{"a": 1}') == "python"
def test_shebang_takes_priority_over_json(self) -> None:
assert detect_type(content='#!/usr/bin/env python3\n{"a": 1}\n') == "python"
class TestGuessContent:
"""Priority 6: optional heuristic content guessing."""
def test_guess_content_disabled_by_default(self) -> None:
diff = "--- a/file\n+++ b/file\n@@ -1 +1 @@\n-old\n+new\n"
assert detect_type(content=diff) == "text"
def test_guess_content_detects_diff(self) -> None:
diff = "--- a/file\n+++ b/file\n@@ -1 +1 @@\n-old\n+new\n"
assert detect_type(content=diff, guess_content=True) == "diff"
def test_guess_content_rejects_low_confidence_false_positive(self) -> None:
python_snippet = "def f():\n return 1\n"
assert detect_type(content=python_snippet, guess_content=True) == "text"
def test_json_still_wins_over_guess_content(self, sample_json: str) -> None:
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: class TestFallback:
"""Priority 5: fallback to 'text'.""" """Priority 7: fallback to 'text'."""
def test_no_args(self) -> None: def test_no_args(self) -> None:
assert detect_type() == "text" assert detect_type() == "text"

View File

@@ -25,6 +25,14 @@ class TestMouseCapableLess:
with patch("rp.pager.shutil.which", return_value=None): with patch("rp.pager.shutil.which", return_value=None):
assert _mouse_capable_less() is 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: def test_returns_none_when_less_too_old(self) -> None:
with patch("rp.pager.shutil.which", return_value="/usr/bin/less"): with patch("rp.pager.shutil.which", return_value="/usr/bin/less"):
mock_result = MagicMock() mock_result = MagicMock()
@@ -99,6 +107,19 @@ class TestMousePager:
mock_instance.show.assert_called_once_with("content") 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: def test_uses_less_when_available(self) -> None:
pager = MousePager() pager = MousePager()
mock_result = MagicMock() mock_result = MagicMock()

View File

@@ -35,6 +35,10 @@ class TestRenderOptions:
assert opts.line_numbers is True assert opts.line_numbers is True
assert opts.pager is False assert opts.pager is False
def test_default_debug_is_none(self) -> None:
opts = RenderOptions()
assert opts.debug is None
class TestRenderMarkdown: class TestRenderMarkdown:
"""Markdown rendering.""" """Markdown rendering."""
@@ -238,6 +242,18 @@ class TestPagerBehavior:
assert len(capture_console.file.getvalue()) > 0 assert len(capture_console.file.getvalue()) > 0
mock_pager.assert_not_called() 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: class TestPadding:
"""Rendered output is wrapped in padding.""" """Rendered output is wrapped in padding."""

25
uv.lock generated
View File

@@ -342,6 +342,7 @@ name = "rp"
version = "0.2.3" version = "0.2.3"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "pygments" },
{ name = "rich" }, { name = "rich" },
{ name = "typer" }, { name = "typer" },
] ]
@@ -352,10 +353,12 @@ dev = [
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-cov" }, { name = "pytest-cov" },
{ name = "ruff" }, { name = "ruff" },
{ name = "types-pygments" },
] ]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "pygments", specifier = ">=2.13.0" },
{ name = "rich", specifier = ">=13.0" }, { name = "rich", specifier = ">=13.0" },
{ name = "typer", specifier = ">=0.12" }, { name = "typer", specifier = ">=0.12" },
] ]
@@ -366,6 +369,7 @@ dev = [
{ name = "pytest", specifier = ">=8.0" }, { name = "pytest", specifier = ">=8.0" },
{ name = "pytest-cov", specifier = ">=5.0" }, { name = "pytest-cov", specifier = ">=5.0" },
{ name = "ruff", specifier = ">=0.9" }, { name = "ruff", specifier = ">=0.9" },
{ name = "types-pygments", specifier = ">=2.19.0.20260402" },
] ]
[[package]] [[package]]
@@ -417,6 +421,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" },
] ]
[[package]]
name = "types-docutils"
version = "0.22.3.20260322"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/44/bb/243a87fc1605a4a94c2c343d6dbddbf0d7ef7c0b9550f360b8cda8e82c39/types_docutils-0.22.3.20260322.tar.gz", hash = "sha256:e2450bb997283c3141ec5db3e436b91f0aa26efe35eb9165178ca976ccb4930b", size = 57311, upload-time = "2026-03-22T04:08:44.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/4a/22c090cd4615a16917dff817cbe7c5956da376c961e024c241cd962d2c3d/types_docutils-0.22.3.20260322-py3-none-any.whl", hash = "sha256:681d4510ce9b80a0c6a593f0f9843d81f8caa786db7b39ba04d9fd5480ac4442", size = 91978, upload-time = "2026-03-22T04:08:43.117Z" },
]
[[package]]
name = "types-pygments"
version = "2.19.0.20260402"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "types-docutils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a6/a8/5834c55d900ce31b31367eedb82e664347dfa551a957b74a0ce0cd9f4f9a/types_pygments-2.19.0.20260402.tar.gz", hash = "sha256:bd26e1f662c9a3b8ea56668ddc099b809ffd54931bee97b15853bc4a8e5d4250", size = 18808, upload-time = "2026-04-02T04:21:13.058Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/f2/b23659df4219fc4f39a45ede527cc209d961c7ff957678536a7a9d9ac9c5/types_pygments-2.19.0.20260402-py3-none-any.whl", hash = "sha256:5b0d863cec1c43ba38c946fb6e89d389c1cf287806f72360336fba4482f8daeb", size = 25672, upload-time = "2026-04-02T04:21:11.916Z" },
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.15.0" version = "4.15.0"