ruff-explain 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,45 @@
1
+ Metadata-Version: 2.3
2
+ Name: ruff-explain
3
+ Version: 1.0.0
4
+ Summary: Render Ruff rule docs in the terminal or open them in a browser
5
+ Author: Jakob Guldberg Aaes
6
+ Author-email: Jakob Guldberg Aaes <jakob1379@gmali.com>
7
+ Requires-Dist: rich>=14.3.3
8
+ Requires-Dist: typer>=0.24.1
9
+ Requires-Python: >=3.14
10
+ Description-Content-Type: text/markdown
11
+
12
+ # ruff-explain
13
+
14
+ `ruff-explain` looks up Ruff rule documentation by rule ID and renders the docs page directly in your terminal with Rich.
15
+
16
+ ![Example render](example.png)
17
+
18
+ ## What it does
19
+
20
+ - Renders Ruff rule docs in the terminal by default.
21
+ - Opens the canonical docs page in your browser with `-o` / `--open`.
22
+ - Resolves rule IDs like `FAST001`, `F401`, and `ARG001` from a bundled rule map.
23
+ - Keeps the output focused on the actual rule content instead of full site chrome.
24
+
25
+ ## Usage
26
+
27
+ ```bash
28
+ uv run ruff-explain FAST001
29
+ uv run ruff-explain F401
30
+ uv run ruff-explain ARG001 -o
31
+ ```
32
+
33
+ ## Run from source
34
+
35
+ Run it from the repo with `uv`:
36
+
37
+ ```bash
38
+ uv run ruff-explain FAST001
39
+ ```
40
+
41
+ ## Notes
42
+
43
+ - Default behavior is terminal rendering.
44
+ - `--open` skips rendering, opens the docs page immediately, and exits.
45
+ - Unknown rule IDs return a non-zero exit code and show close matches when available.
@@ -0,0 +1,34 @@
1
+ # ruff-explain
2
+
3
+ `ruff-explain` looks up Ruff rule documentation by rule ID and renders the docs page directly in your terminal with Rich.
4
+
5
+ ![Example render](example.png)
6
+
7
+ ## What it does
8
+
9
+ - Renders Ruff rule docs in the terminal by default.
10
+ - Opens the canonical docs page in your browser with `-o` / `--open`.
11
+ - Resolves rule IDs like `FAST001`, `F401`, and `ARG001` from a bundled rule map.
12
+ - Keeps the output focused on the actual rule content instead of full site chrome.
13
+
14
+ ## Usage
15
+
16
+ ```bash
17
+ uv run ruff-explain FAST001
18
+ uv run ruff-explain F401
19
+ uv run ruff-explain ARG001 -o
20
+ ```
21
+
22
+ ## Run from source
23
+
24
+ Run it from the repo with `uv`:
25
+
26
+ ```bash
27
+ uv run ruff-explain FAST001
28
+ ```
29
+
30
+ ## Notes
31
+
32
+ - Default behavior is terminal rendering.
33
+ - `--open` skips rendering, opens the docs page immediately, and exits.
34
+ - Unknown rule IDs return a non-zero exit code and show close matches when available.
@@ -0,0 +1,25 @@
1
+ [project]
2
+ name = "ruff-explain"
3
+ version = "1.0.0"
4
+ description = "Render Ruff rule docs in the terminal or open them in a browser"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Jakob Guldberg Aaes", email = "jakob1379@gmali.com" }
8
+ ]
9
+ requires-python = ">=3.14"
10
+ dependencies = [
11
+ "rich>=14.3.3",
12
+ "typer>=0.24.1",
13
+ ]
14
+
15
+ [project.scripts]
16
+ ruff-explain = "ruff_explain:main"
17
+
18
+ [build-system]
19
+ requires = ["uv_build>=0.10.9,<0.11.0"]
20
+ build-backend = "uv_build"
21
+
22
+ [dependency-groups]
23
+ dev = [
24
+ "pytest>=9.0.2",
25
+ ]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ __all__ = ["main"]
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ from difflib import get_close_matches
4
+ import webbrowser
5
+
6
+ import typer
7
+ from rich.console import Console
8
+
9
+ from .render import RulePageError, build_rule_renderable
10
+ from .rules import BASE_URL, RULES
11
+
12
+ app = typer.Typer(
13
+ add_completion=False, help="Look up Ruff rule documentation by rule ID."
14
+ )
15
+ console = Console()
16
+ error_console = Console(stderr=True)
17
+
18
+
19
+ def _rule_url(rule_id: str) -> str | None:
20
+ rule = RULES.get(rule_id.strip().upper())
21
+ if rule is None:
22
+ return None
23
+ return f"{BASE_URL}{rule['slug']}/"
24
+
25
+
26
+ def _unknown_rule(rule_id: str) -> None:
27
+ normalized = rule_id.strip().upper()
28
+ matches = get_close_matches(normalized, RULES, n=3, cutoff=0.6)
29
+ error_console.print(f"[bold red]Unknown Ruff rule:[/bold red] {normalized}")
30
+ if matches:
31
+ error_console.print(f"Did you mean: {', '.join(matches)}")
32
+ raise typer.Exit(code=1)
33
+
34
+
35
+ @app.command()
36
+ def lookup(
37
+ rule_id: str = typer.Argument(..., help="Ruff rule ID, for example FAST001."),
38
+ open_page: bool = typer.Option(
39
+ False, "--open", "-o", help="Open the docs page in your browser."
40
+ ),
41
+ ) -> None:
42
+ url = _rule_url(rule_id)
43
+ if url is None:
44
+ _unknown_rule(rule_id)
45
+
46
+ if open_page:
47
+ webbrowser.open_new_tab(url)
48
+ return
49
+
50
+ normalized = rule_id.strip().upper()
51
+ try:
52
+ console.print(build_rule_renderable(normalized, url, width=console.size.width))
53
+ except RulePageError as exc:
54
+ error_console.print(f"[bold red]Render failed:[/bold red] {exc}")
55
+ console.print(f"[bold]{normalized}[/bold]")
56
+ console.print(f"[link={url}]{url}[/link]")
57
+ raise typer.Exit(code=1) from exc
58
+
59
+
60
+ def main() -> None:
61
+ app()
@@ -0,0 +1,390 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from html.parser import HTMLParser
5
+ import re
6
+ from typing import Callable
7
+ from urllib.error import HTTPError, URLError
8
+ from urllib.request import Request, urlopen
9
+
10
+ from rich.console import Group, RenderableType
11
+ from rich.panel import Panel
12
+ from rich.syntax import Syntax
13
+ from rich.table import Table
14
+ from rich.text import Text
15
+
16
+ from .rules import RULES
17
+
18
+ _WHITESPACE_RE = re.compile(r"\s+")
19
+ _TITLE_RE = re.compile(r"^(?P<name>.+?)\s+\((?P<code>[A-Z]+\d+)\)$")
20
+ _FIX_STYLES = {
21
+ "Always": "bold green",
22
+ "Sometimes": "bold yellow",
23
+ "None": "bold red",
24
+ }
25
+
26
+
27
+ class RulePageError(RuntimeError):
28
+ pass
29
+
30
+
31
+ @dataclass
32
+ class HtmlNode:
33
+ tag: str
34
+ attrs: dict[str, str]
35
+ children: list[HtmlNode | str] = field(default_factory=list)
36
+
37
+
38
+ @dataclass
39
+ class ParagraphBlock:
40
+ text: str
41
+
42
+
43
+ @dataclass
44
+ class ListBlock:
45
+ items: list[str]
46
+
47
+
48
+ @dataclass
49
+ class CodeBlock:
50
+ code: str
51
+
52
+
53
+ @dataclass
54
+ class Section:
55
+ title: str
56
+ blocks: list[ParagraphBlock | ListBlock | CodeBlock] = field(default_factory=list)
57
+
58
+
59
+ @dataclass
60
+ class RulePage:
61
+ name: str
62
+ code: str
63
+ linter: str
64
+ status: str
65
+ since: str
66
+ fix: str
67
+ url: str
68
+ intro_blocks: list[ParagraphBlock | ListBlock | CodeBlock]
69
+ sections: list[Section]
70
+
71
+
72
+ class _DomParser(HTMLParser):
73
+ def __init__(self) -> None:
74
+ super().__init__(convert_charrefs=True)
75
+ self.root = HtmlNode("document", {})
76
+ self._stack = [self.root]
77
+
78
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
79
+ node = HtmlNode(tag, {key: value or "" for key, value in attrs})
80
+ self._stack[-1].children.append(node)
81
+ self._stack.append(node)
82
+
83
+ def handle_endtag(self, tag: str) -> None:
84
+ for index in range(len(self._stack) - 1, 0, -1):
85
+ if self._stack[index].tag == tag:
86
+ del self._stack[index:]
87
+ return
88
+
89
+ def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
90
+ node = HtmlNode(tag, {key: value or "" for key, value in attrs})
91
+ self._stack[-1].children.append(node)
92
+
93
+ def handle_data(self, data: str) -> None:
94
+ if data:
95
+ self._stack[-1].children.append(data)
96
+
97
+
98
+ def fetch_rule_page(url: str) -> str:
99
+ request = Request(url, headers={"User-Agent": "ruff-explain"})
100
+ try:
101
+ with urlopen(request, timeout=15) as response:
102
+ charset = response.headers.get_content_charset() or "utf-8"
103
+ return response.read().decode(charset)
104
+ except (HTTPError, URLError) as exc:
105
+ raise RulePageError(f"Could not fetch {url}: {exc}") from exc
106
+
107
+
108
+ def build_rule_renderable(
109
+ rule_id: str, url: str, html: str | None = None, width: int = 80
110
+ ) -> RenderableType:
111
+ normalized = rule_id.strip().upper()
112
+ document = parse_rule_page(normalized, url, html or fetch_rule_page(url))
113
+
114
+ renderables: list[RenderableType] = [_render_header_panel(document)]
115
+ for section in document.sections:
116
+ renderables.append(_render_section(section, width))
117
+ return Group(*renderables)
118
+
119
+
120
+ def parse_rule_page(rule_id: str, url: str, html: str) -> RulePage:
121
+ parser = _DomParser()
122
+ parser.feed(html)
123
+ article = _find_first(
124
+ parser.root,
125
+ lambda node: node.tag == "article" and _has_class(node, "md-content__inner"),
126
+ )
127
+ if article is None:
128
+ raise RulePageError("Could not find the Ruff article content in the page.")
129
+
130
+ rule = RULES[rule_id]
131
+ title = _extract_title(article) or f"{rule['slug']} ({rule_id})"
132
+ match = _TITLE_RE.match(title)
133
+ name = match.group("name") if match else title
134
+ code = match.group("code") if match else rule_id
135
+
136
+ sections = _extract_sections(article)
137
+ intro_blocks: list[ParagraphBlock | ListBlock | CodeBlock] = []
138
+ if sections and sections[0].title == "Overview":
139
+ intro_blocks = sections.pop(0).blocks
140
+
141
+ return RulePage(
142
+ name=name,
143
+ code=code,
144
+ linter=rule["linter"],
145
+ status=rule["status"],
146
+ since=rule["since"],
147
+ fix=rule["fix"],
148
+ url=url,
149
+ intro_blocks=intro_blocks,
150
+ sections=sections,
151
+ )
152
+
153
+
154
+ def _extract_title(article: HtmlNode) -> str | None:
155
+ heading = _first_child(article, "h1")
156
+ if heading is None:
157
+ return None
158
+ title = _collapse(_inline_text(heading))
159
+ return title or None
160
+
161
+
162
+ def _extract_sections(article: HtmlNode) -> list[Section]:
163
+ sections: list[Section] = []
164
+ current = Section("Overview")
165
+ saw_content = False
166
+
167
+ for child in article.children:
168
+ if isinstance(child, str):
169
+ continue
170
+ if child.tag == "h1":
171
+ continue
172
+ if child.tag == "p" and _contains_tag(child, "small"):
173
+ continue
174
+ if child.tag in {"h2", "h3"}:
175
+ title = _collapse(_inline_text(child))
176
+ if current.blocks:
177
+ sections.append(current)
178
+ current = Section(title)
179
+ saw_content = True
180
+ continue
181
+
182
+ block = _extract_block(child)
183
+ if block is None:
184
+ continue
185
+
186
+ if not saw_content and not current.blocks:
187
+ current = Section("Overview")
188
+ current.blocks.append(block)
189
+
190
+ if current.blocks:
191
+ sections.append(current)
192
+
193
+ return sections
194
+
195
+
196
+ def _extract_block(node: HtmlNode) -> ParagraphBlock | ListBlock | CodeBlock | None:
197
+ if node.tag == "p":
198
+ text = _collapse(_inline_text(node))
199
+ return ParagraphBlock(text) if text else None
200
+ if node.tag in {"ul", "ol"}:
201
+ items = []
202
+ for child in node.children:
203
+ if isinstance(child, HtmlNode) and child.tag == "li":
204
+ text = _collapse(_inline_text(child))
205
+ if text:
206
+ items.append(text)
207
+ return ListBlock(items) if items else None
208
+ if node.tag == "div" and _has_class(node, "highlight"):
209
+ code = _extract_code(node)
210
+ return CodeBlock(code) if code else None
211
+ if node.tag == "pre":
212
+ code = _extract_code(node)
213
+ return CodeBlock(code) if code else None
214
+ return None
215
+
216
+
217
+ def _extract_code(node: HtmlNode) -> str:
218
+ code_node = _find_first(node, lambda child: child.tag == "code")
219
+ source = code_node or node
220
+ code = _node_text(source)
221
+ lines = [line.rstrip() for line in code.splitlines()]
222
+ while lines and not lines[0]:
223
+ lines.pop(0)
224
+ while lines and not lines[-1]:
225
+ lines.pop()
226
+ return "\n".join(lines)
227
+
228
+
229
+ def _render_header_panel(page: RulePage) -> Panel:
230
+ title = Text(page.name)
231
+ title.append(f" ({page.code})")
232
+ title.stylize(f"link {page.url}")
233
+ title.stylize("underline cyan")
234
+
235
+ header = Text()
236
+ header.append("Linter: ", style="bold")
237
+ header.append(page.linter)
238
+ header.append(" - ")
239
+ header.append("Status: ", style="bold")
240
+ header.append(page.status)
241
+ header.append(" - ")
242
+ header.append("Added: ", style="bold")
243
+ header.append(page.since)
244
+ header.append(" - ")
245
+ header.append("Fix: ", style="bold")
246
+ header.append(page.fix, style=_FIX_STYLES.get(page.fix, "bold"))
247
+ header.justify = "center"
248
+
249
+ body_renderables: list[RenderableType] = [header]
250
+ intro = _render_blocks(page.intro_blocks)
251
+ if intro is not None:
252
+ body_renderables.append(Text())
253
+ body_renderables.append(intro)
254
+
255
+ return Panel(
256
+ Group(*body_renderables),
257
+ title=title,
258
+ border_style="cyan",
259
+ padding=(0, 1),
260
+ )
261
+
262
+
263
+ def _render_section(section: Section, width: int) -> RenderableType:
264
+ if section.title.casefold() == "example":
265
+ return _render_example_section(section, width)
266
+
267
+ body = _render_blocks(section.blocks)
268
+ return Panel(body, title=section.title, border_style="blue", padding=(0, 1))
269
+
270
+
271
+ def _render_example_section(section: Section, width: int) -> RenderableType:
272
+ example_blocks: list[ParagraphBlock | ListBlock | CodeBlock] = []
273
+ preferred_blocks: list[ParagraphBlock | ListBlock | CodeBlock] = []
274
+ target = example_blocks
275
+
276
+ for block in section.blocks:
277
+ if isinstance(block, ParagraphBlock) and block.text.casefold().startswith(
278
+ "use instead"
279
+ ):
280
+ target = preferred_blocks
281
+ continue
282
+ target.append(block)
283
+
284
+ example_panel = Panel(
285
+ _render_blocks(example_blocks),
286
+ title="Example: Python",
287
+ border_style="yellow",
288
+ padding=(0, 1),
289
+ )
290
+ if not preferred_blocks:
291
+ return example_panel
292
+
293
+ preferred_panel = Panel(
294
+ _render_blocks(preferred_blocks),
295
+ title="Use instead: Python",
296
+ border_style="green",
297
+ padding=(0, 1),
298
+ )
299
+ if width >= 120:
300
+ table = Table.grid(expand=True, padding=(0, 1))
301
+ table.add_column(ratio=1)
302
+ table.add_column(ratio=1)
303
+ table.add_row(example_panel, preferred_panel)
304
+ return table
305
+ return Group(example_panel, preferred_panel)
306
+
307
+
308
+ def _render_blocks(
309
+ blocks: list[ParagraphBlock | ListBlock | CodeBlock],
310
+ ) -> RenderableType | None:
311
+ if not blocks:
312
+ return None
313
+ renderables: list[RenderableType] = []
314
+ for block in blocks:
315
+ if isinstance(block, ParagraphBlock):
316
+ renderables.append(Text(block.text))
317
+ elif isinstance(block, ListBlock):
318
+ renderables.extend(Text(f"- {item}") for item in block.items)
319
+ else:
320
+ renderables.append(
321
+ Syntax(block.code, "python", line_numbers=False, word_wrap=False)
322
+ )
323
+ spaced: list[RenderableType] = []
324
+ for index, renderable in enumerate(renderables):
325
+ if index:
326
+ spaced.append(Text())
327
+ spaced.append(renderable)
328
+ return Group(*spaced)
329
+
330
+
331
+ def _first_child(node: HtmlNode, tag: str) -> HtmlNode | None:
332
+ for child in node.children:
333
+ if isinstance(child, HtmlNode) and child.tag == tag:
334
+ return child
335
+ return None
336
+
337
+
338
+ def _find_first(
339
+ node: HtmlNode, predicate: Callable[[HtmlNode], bool]
340
+ ) -> HtmlNode | None:
341
+ if predicate(node):
342
+ return node
343
+ for child in node.children:
344
+ if isinstance(child, HtmlNode):
345
+ found = _find_first(child, predicate)
346
+ if found is not None:
347
+ return found
348
+ return None
349
+
350
+
351
+ def _walk(node: HtmlNode) -> list[HtmlNode]:
352
+ nodes = [node]
353
+ for child in node.children:
354
+ if isinstance(child, HtmlNode):
355
+ nodes.extend(_walk(child))
356
+ return nodes
357
+
358
+
359
+ def _has_class(node: HtmlNode, class_name: str) -> bool:
360
+ return class_name in node.attrs.get("class", "").split()
361
+
362
+
363
+ def _contains_tag(node: HtmlNode, tag: str) -> bool:
364
+ return _find_first(node, lambda child: child.tag == tag) is not None
365
+
366
+
367
+ def _node_text(node: HtmlNode | str) -> str:
368
+ if isinstance(node, str):
369
+ return node
370
+ if node.tag == "br":
371
+ return "\n"
372
+ return "".join(_node_text(child) for child in node.children)
373
+
374
+
375
+ def _inline_text(node: HtmlNode | str) -> str:
376
+ if isinstance(node, str):
377
+ return node
378
+ if node.tag == "br":
379
+ return "\n"
380
+ if node.tag == "code":
381
+ return f"`{_collapse(_node_text(node))}`"
382
+ return "".join(_inline_text(child) for child in node.children)
383
+
384
+
385
+ def _collapse(text: str) -> str:
386
+ parts = [
387
+ _WHITESPACE_RE.sub(" ", line).strip()
388
+ for line in text.replace("\xa0", " ").splitlines()
389
+ ]
390
+ return " ".join(part for part in parts if part).strip()