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.
- ruff_explain-1.0.0/PKG-INFO +45 -0
- ruff_explain-1.0.0/README.md +34 -0
- ruff_explain-1.0.0/pyproject.toml +25 -0
- ruff_explain-1.0.0/src/ruff_explain/__init__.py +3 -0
- ruff_explain-1.0.0/src/ruff_explain/cli.py +61 -0
- ruff_explain-1.0.0/src/ruff_explain/render.py +390 -0
- ruff_explain-1.0.0/src/ruff_explain/rules.py +7572 -0
|
@@ -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
|
+

|
|
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
|
+

|
|
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,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()
|