Implement rp CLI: rich-print markdown, JSON, code, and more

- Add typer CLI with auto file-type detection by extension
- Support markdown (rich.Markdown), JSON (rich.JSON), and 30+ code
  languages via Pygments syntax highlighting
- Features: line numbers toggle, pager support, stdin input,
  configurable color themes
- Project metadata: MIT license, author, classifiers, entry point
This commit is contained in:
2026-04-01 15:56:57 -07:00
parent a1b5e72032
commit 1abfcebc24
7 changed files with 404 additions and 4 deletions

View File

@@ -1,2 +1,4 @@
def hello() -> str:
return "Hello from rp!"
"""rp — Rich print files beautifully in your terminal."""
__version__ = "0.1.0"
__author__ = "Yunxiao Xu"

119
src/rp/cli.py Normal file
View File

@@ -0,0 +1,119 @@
"""CLI entry point for rp — rich print files beautifully."""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Annotated, Optional
import typer
from rich.console import Console
from rp import __version__
from rp.detect import detect_type
from rp.render import RenderOptions, render
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",
)
_THEMES = [
"monokai",
"github-dark",
"one-dark",
"vim",
"vs",
"nord",
"dracula",
"solarized-dark",
"solarized-light",
"default",
"friendly",
"native",
"emacs",
]
def version_callback(value: bool) -> None:
if value:
console = Console()
console.print(f"rp {__version__}")
raise typer.Exit()
@app.command()
def main(
file: Annotated[
Optional[Path],
typer.Argument(help="File to rich-print. Use '-' or omit to read from stdin."),
] = None,
type: Annotated[
Optional[str],
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[
Optional[bool],
typer.Option(
"--line-numbers/--no-line-numbers",
help="Show line numbers. Default: auto.",
),
] = None,
pager: Annotated[
Optional[bool],
typer.Option(
"--pager/--no-pager",
help="Use pager for output. Default: auto-detect.",
),
] = None,
version: Annotated[
Optional[bool],
typer.Option(
"--version",
"-v",
help="Show version.",
callback=version_callback,
is_eager=True,
),
] = None,
) -> None:
"""Rich print files beautifully in your terminal."""
console = Console()
if file is None or str(file) == "-":
content = sys.stdin.read()
file_path = None
else:
if not file.exists():
console.print(f"[red]Error:[/red] File not found: {file}")
raise typer.Exit(code=1)
if file.is_dir():
console.print(f"[red]Error:[/red] Is a directory: {file}")
raise typer.Exit(code=1)
content = file.read_text(encoding="utf-8")
file_path = str(file)
if not content:
console.print("[dim]Empty input, nothing to display.[/dim]")
raise typer.Exit()
file_type = detect_type(path=file_path, content=content, explicit_type=type)
options = RenderOptions(
theme=theme,
line_numbers=line_numbers,
pager=pager,
)
render(content, file_type, console, options)
if __name__ == "__main__":
app()

83
src/rp/detect.py Normal file
View File

@@ -0,0 +1,83 @@
"""File type detection for rp."""
from __future__ import annotations
import json
from pathlib import Path
EXTENSION_MAP: dict[str, str] = {
".md": "markdown",
".json": "json",
".py": "python",
".pyw": "python",
".js": "javascript",
".mjs": "javascript",
".ts": "typescript",
".tsx": "typescript",
".jsx": "jsx",
".css": "css",
".scss": "scss",
".html": "html",
".htm": "html",
".xml": "xml",
".yaml": "yaml",
".yml": "yaml",
".toml": "toml",
".sh": "bash",
".bash": "bash",
".zsh": "bash",
".sql": "sql",
".rs": "rust",
".go": "go",
".java": "java",
".c": "c",
".h": "c",
".cpp": "cpp",
".hpp": "cpp",
".cc": "cpp",
".rb": "ruby",
".php": "php",
".swift": "swift",
".kt": "kotlin",
".r": "r",
".R": "r",
".lua": "lua",
".dockerfile": "docker",
".ini": "ini",
".cfg": "ini",
".diff": "diff",
".patch": "diff",
".tex": "latex",
}
_FILENAME_MAP: dict[str, str] = {
"Dockerfile": "docker",
"Makefile": "make",
}
def detect_type(
path: str | None = None,
content: str | None = None,
explicit_type: str | None = None,
) -> str:
if explicit_type is not None:
return explicit_type
if path is not None:
p = Path(path)
name = p.name
if name in _FILENAME_MAP:
return _FILENAME_MAP[name]
suffix = p.suffix.lower()
if suffix in EXTENSION_MAP:
return EXTENSION_MAP[suffix]
if content is not None:
try:
json.loads(content[:1000])
return "json"
except (json.JSONDecodeError, ValueError):
pass
return "text"

55
src/rp/render.py Normal file
View File

@@ -0,0 +1,55 @@
"""Core rendering module for rp."""
from __future__ import annotations
from dataclasses import dataclass
from rich.console import Console
from rich.json import JSON as RichJSON
from rich.markdown import Markdown
from rich.syntax import Syntax
@dataclass
class RenderOptions:
theme: str = "monokai"
line_numbers: bool | None = None
pager: bool | None = None
_MARKDOWN_TYPES = {"markdown", "md"}
_JSON_TYPES = {"json"}
def render(
content: str, file_type: str, console: Console, options: RenderOptions
) -> None:
line_numbers = options.line_numbers
if line_numbers is None:
line_numbers = file_type not in _MARKDOWN_TYPES and file_type not in _JSON_TYPES
use_pager = options.pager
if use_pager is None:
import sys
use_pager = sys.stdout.isatty()
if file_type in _MARKDOWN_TYPES:
renderable = Markdown(content)
elif file_type in _JSON_TYPES:
try:
renderable = RichJSON(content)
except Exception:
renderable = Syntax(
content, "json", theme=options.theme, line_numbers=line_numbers
)
else:
renderable = Syntax(
content, file_type, theme=options.theme, line_numbers=line_numbers
)
if use_pager:
with console.pager(styles=True):
console.print(renderable)
else:
console.print(renderable)