"""CLI entry point for rp.""" from __future__ import annotations from collections.abc import Callable import sys import time from pathlib import Path from typing import Annotated import typer from rich.console import Console 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.render import RenderOptions, render _WATCH_POLL_INTERVAL = 1.0 app = typer.Typer( name="rp", help="Rich print files — markdown, JSON, code, and more — beautifully in your terminal.", no_args_is_help=False, rich_markup_mode="rich", ) def version_callback(value: bool) -> None: """Print version information and exit. Args: value: Flag value from typer callback. """ if value: console = Console() console.print(f"Rich Print (rp) {__version__}") console.print(f"Copyright (c) 2026 {__author__}") console.print(f"License: {__license__}") raise typer.Exit() def _read_file_content(file: Path) -> str: """Read a UTF-8 text file for rendering.""" if not file.exists(): raise FileNotFoundError(f"File not found: {file}") if file.is_dir(): raise IsADirectoryError(f"Is a directory: {file}") try: return file.read_text(encoding="utf-8") except UnicodeDecodeError as exc: raise ValueError(f"Cannot read binary file: {file}") from exc def _render_content( *, console: Console, debug: DebugLogger | None, content: str, file_path: str | None, file_type: str | None, guess_content: bool, theme: str, line_numbers: bool | None, pager: bool | None, ) -> None: """Detect type and render content with the standard pipeline.""" detected_type = detect_type( path=file_path, content=content, explicit_type=file_type, guess_content=guess_content, debug=debug, ) options = RenderOptions( theme=theme, line_numbers=line_numbers, pager=pager, debug=debug, ) render(content, detected_type, console, options) def _watch_file( *, file: Path, console: Console, debug: DebugLogger | None, file_type: str | None, guess_content: bool, theme: str, line_numbers: bool | None, sleep: Callable[[float], None] | None = None, ) -> None: """Poll a file and redraw when its visible state changes.""" if sleep is None: sleep = time.sleep previous_state: tuple[str, str] | None = None while True: try: content = _read_file_content(file) except ( FileNotFoundError, IsADirectoryError, PermissionError, OSError, ValueError, ) as exc: state = ("error", str(exc)) if state != previous_state: log_debug(debug, f"watch: detected error state {state[1]!r}") console.clear(home=True) console.print(f"[red]Error:[/red] {state[1]}") previous_state = state else: state = ("content", content) if state != previous_state: log_debug(debug, f"watch: rendering update for {str(file)!r}") console.clear(home=True) _render_content( console=console, debug=debug, content=content, file_path=str(file), file_type=file_type, guess_content=guess_content, theme=theme, line_numbers=line_numbers, pager=False, ) previous_state = state sleep(_WATCH_POLL_INTERVAL) @app.command() def main( file: Annotated[ Path | None, typer.Argument(help="File to rich-print. Use '-' or omit to read from stdin."), ] = None, file_type: Annotated[ str | None, typer.Option( "--type", "-t", help="Force file type (e.g. json, python, markdown)." ), ] = None, theme: Annotated[ str, typer.Option("--theme", help="Pygments color theme."), ] = "monokai", line_numbers: Annotated[ bool | None, typer.Option( "--line-numbers/--no-line-numbers", help="Show line numbers. Default: auto.", ), ] = None, pager: Annotated[ bool | None, typer.Option( "--pager/--no-pager", help="Use pager for output. Default: auto-detect.", ), ] = None, watch: Annotated[ bool, typer.Option( "--watch/--no-watch", help="Watch a file for changes and redraw when it updates.", ), ] = False, guess_content: Annotated[ bool, typer.Option( "--guess-content/--no-guess-content", help="Use heuristic content-based type detection as a fallback.", ), ] = False, version: Annotated[ bool | None, typer.Option( "--version", "-v", help="Show version.", callback=version_callback, is_eager=True, ), ] = None, ) -> None: """Render a file or stdin input in the terminal. Args: file: File to rich-print. Use '-' or omit to read from stdin. file_type: Force file type (e.g. json, python, markdown). theme: Pygments color theme. line_numbers: Show line numbers. Default: auto. pager: Use pager for output. Default: auto-detect. watch: Watch a file for changes and redraw when it updates. guess_content: Use heuristic content-based type detection as a fallback. version: Show version and exit. """ console = Console() debug = make_debug_logger(Console(stderr=True), env_debug_enabled()) if watch and (file is None or str(file) == "-"): console.print("[red]Error:[/red] --watch only supports file input.") raise typer.Exit(code=1) if file is None or str(file) == "-": log_debug(debug, "input: reading from stdin") if sys.stdin.isatty(): console.print("[dim]Reading from stdin. Press Ctrl+D to end.[/dim]") content = sys.stdin.read() file_path = None else: log_debug(debug, f"input: reading file {str(file)!r}") if watch: log_debug(debug, f"watch: polling {str(file)!r}") _watch_file( file=file, console=console, debug=debug, file_type=file_type, guess_content=guess_content, theme=theme, line_numbers=line_numbers, ) raise typer.Exit() try: content = _read_file_content(file) except ( FileNotFoundError, IsADirectoryError, PermissionError, OSError, ValueError, ) as e: console.print(f"[red]Error:[/red] {e}") raise typer.Exit(code=1) file_path = str(file) if not content: console.print("[dim]Empty input, nothing to display.[/dim]") raise typer.Exit() _render_content( console=console, debug=debug, content=content, file_path=file_path, file_type=file_type, guess_content=guess_content, theme=theme, line_numbers=line_numbers, pager=pager, ) if __name__ == "__main__": app()