Compare commits
7 Commits
63102473da
...
v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 579df72869 | |||
| 537238f56f | |||
| 598450d9e2 | |||
| 0f93d3a095 | |||
| d94cf2df30 | |||
| 93c777cf6a | |||
| 37502c3083 |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -2,7 +2,7 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main, dev]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
|||||||
10
AGENTS.md
10
AGENTS.md
@@ -24,6 +24,7 @@ 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
|
||||||
@@ -92,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:
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
@@ -152,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`
|
||||||
@@ -190,9 +197,10 @@ rich-print/
|
|||||||
## 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`, `pytest>=8.0`, `pytest-cov>=5.0`.
|
Runtime dependencies. Dev dependencies: `ruff>=0.9`, `mypy>=1.14`, `pytest>=8.0`, `pytest-cov>=5.0`, `types-pygments>=2.19`.
|
||||||
|
|
||||||
## Git Workflow
|
## Git Workflow
|
||||||
|
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "rp"
|
name = "rp"
|
||||||
version = "0.2.3"
|
version = "0.3.0"
|
||||||
description = "Rich print files — markdown, JSON, code, and more — beautifully in your terminal"
|
description = "Rich print files — markdown, JSON, code, and more — beautifully in your terminal"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -2,6 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
__version__ = "0.2.3"
|
__version__ = "0.3.0"
|
||||||
__author__ = "Yunxiao Xu"
|
__author__ = "Yunxiao Xu"
|
||||||
__license__ = "MIT"
|
__license__ = "MIT"
|
||||||
|
|||||||
168
src/rp/cli.py
168
src/rp/cli.py
@@ -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
40
src/rp/debug.py
Normal 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)
|
||||||
206
src/rp/detect.py
206
src/rp/detect.py
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
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"]
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
27
uv.lock
generated
27
uv.lock
generated
@@ -339,9 +339,10 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rp"
|
name = "rp"
|
||||||
version = "0.2.3"
|
version = "0.3.0"
|
||||||
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user