-
Notifications
You must be signed in to change notification settings - Fork 17
/
_renderer.py
254 lines (198 loc) · 8.37 KB
/
_renderer.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
from __future__ import annotations
import base64
import html
import re
from importlib.resources import files
from pathlib import Path
from typing import Literal, Optional, TypedDict, Union
import quartodoc.ast as qast
from griffe import dataclasses as dc
from griffe import expressions as exp
from griffe.docstrings import dataclasses as ds
from plum import dispatch
from quartodoc import MdRenderer
from quartodoc.renderers.base import convert_rst_link_to_md, sanitize
SHINY_PATH = Path(files("shiny").joinpath())
SHINYLIVE_CODE_TEMPLATE = """
```{{shinylive-python}}
#| standalone: true
#| components: [editor, viewer]
#| layout: vertical
#| viewerHeight: 400{0}
```
"""
DOCSTRING_TEMPLATE = """\
{rendered}
{header} Examples
{examples}
"""
# This is the same as the FileContentJson type in TypeScript.
class FileContentJson(TypedDict):
name: str
content: str
type: Literal["text", "binary"]
class Renderer(MdRenderer):
style = "shiny"
@dispatch
def render(self, el: qast.DocstringSectionSeeAlso):
# The See Also section in the Shiny docs has bare function references, ones that
# lack a leading :func: and backticks. This function fixes them. In the future,
# we can fix the docstrings in Shiny, once we decide on a standard. Then we can
# remove this function.
return prefix_bare_functions_with_func(el.value)
@dispatch
def render(self, el: Union[dc.Object, dc.Alias]):
# If `el` is a protocol class that only has a `__call__` method,
# then we want to display information about the method, not the class.
if len(el.members) == 1 and "__call__" in el.members.keys():
return self.render(el.members["__call__"])
# Not a __call__ Alias, so render as normal.
rendered = super().render(el)
converted = convert_rst_link_to_md(rendered)
if isinstance(el, dc.Alias) and "experimental" in el.target_path:
p_example_dir = SHINY_PATH / "experimental" / "examples" / el.name
else:
p_example_dir = SHINY_PATH / "examples" / el.name
if (p_example_dir / "app.py").exists():
example = ""
files = list(p_example_dir.glob("**/*"))
# Sort, and then move app.py to first position.
files.sort()
app_py_idx = files.index(p_example_dir / "app.py")
files = [files[app_py_idx]] + files[:app_py_idx] + files[app_py_idx + 1 :]
for f in files:
if f.is_dir():
continue
file_info = read_file(f, p_example_dir)
if file_info["type"] == "text":
example += f"\n## file: {file_info['name']}\n{file_info['content']}"
else:
example += f"\n## file: {file_info['name']}\n## type: binary\n{file_info['content']}"
example = SHINYLIVE_CODE_TEMPLATE.format(example)
return DOCSTRING_TEMPLATE.format(
rendered=converted,
examples=example,
header="#" * (self.crnt_header_level + 1),
)
return converted
@dispatch
def render(self, el: ds.DocstringSectionText):
# functions like shiny.ui.tags.b have html in their docstrings, so
# we escape them. Note that we are only escaping text sections, but
# since these cover the top text of the docstring, it should solve
# the immediate problem.
rendered = super().render(el)
return html_escape_except_backticks(rendered)
@dispatch
def render_annotation(self, el: str):
return sanitize(el)
# TODO-future; Can be removed once we use quartodoc 0.3.5
# Related: https://github.com/machow/quartodoc/pull/205
@dispatch
def render(self, el: ds.DocstringAttribute):
row = [
sanitize(el.name),
self.render_annotation(el.annotation),
sanitize(el.description or "", allow_markdown=True),
]
return row
@dispatch
def render_annotation(self, el: None):
return ""
@dispatch
def render_annotation(self, el: exp.Expression):
# an expression is essentially a list[exp.Name | str]
# e.g. Optional[TagList]
# -> [Name(source="Optional", ...), "[", Name(...), "]"]
return "".join(map(self.render_annotation, el))
@dispatch
def render_annotation(self, el: exp.Name):
# e.g. Name(source="Optional", full="typing.Optional")
return f"[{el.source}](`{el.full}`)"
@dispatch
def summarize(self, el: dc.Object | dc.Alias):
result = super().summarize(el)
return html.escape(result)
# Consolidate the parameter type info into a single column
@dispatch
def render(self, el: ds.DocstringParameter):
param = f'<span class="parameter-name">{el.name}</span>'
annotation = self.render_annotation(el.annotation)
if annotation:
param = f'{param}<span class="parameter-annotation-sep">:</span> <span class="parameter-annotation">{annotation}</span>'
if el.default:
param = f'{param} <span class="parameter-default-sep">=</span> <span class="parameter-default">{el.default}</span>'
# Wrap everything in a code block to allow for links
param = "<code>" + param + "</code>"
clean_desc = sanitize(el.description, allow_markdown=True)
return (param, clean_desc)
@dispatch
def render(self, el: ds.DocstringSectionParameters):
rows = list(map(self.render, el.value))
header = ["Parameter", "Description"]
return self._render_table(rows, header)
@dispatch
def signature(self, el: dc.Function, source: Optional[dc.Alias] = None):
if el.name == "__call__":
# Ex: experimental.ui._card.ImgContainer.__call__(self, *args: Tag) -> Tagifiable
sig = super().signature(el, source)
# Remove leading function name (before `__call__`) and `self` parameter
# Ex: __call__(*args: Tag) -> Tagifiable
sig = re.sub(r"[^`\s]*__call__\(self, ", "__call__(", sig, count=1)
return sig
# Not a __call__ Function, so render as normal.
return super().signature(el, source)
def html_escape_except_backticks(s: str) -> str:
"""
HTML-escape a string, except for content inside of backticks.
Examples
--------
s = "This is a <b>test</b> string with `backticks <i>unescaped</i>`."
print(html_escape_except_backticks(s))
#> This is a <b>test</b> string with `backticks <i>unescaped</i>`.
"""
# Split the string using backticks as delimiters
parts = re.split(r"(`[^`]*`)", s)
# Iterate over the parts, escaping the non-backtick parts, and preserving backticks in the backtick parts
escaped_parts = [
html.escape(part) if i % 2 == 0 else part for i, part in enumerate(parts)
]
# Join the escaped parts back together
escaped_string = "".join(escaped_parts)
return escaped_string
def prefix_bare_functions_with_func(s: str) -> str:
"""
The See Also section in the Shiny docs has bare function references, ones that lack
a leading :func: and backticks. This function fixes them.
If there are bare function references, like "~shiny.ui.panel_sidebar", this will
prepend with :func: and wrap in backticks.
For example, if the input is this:
"~shiny.ui.panel_sidebar :func:`~shiny.ui.panel_sidebar`"
This function will return:
":func:`~shiny.ui.panel_sidebar` :func:`~shiny.ui.panel_sidebar`"
"""
def replacement(match: re.Match[str]) -> str:
return f":func:`{match.group(0)}`"
pattern = r"(?<!:`)~\w+(\.\w+)*"
return re.sub(pattern, replacement, s)
def read_file(file: str | Path, root_dir: str | Path | None = None) -> FileContentJson:
file = Path(file)
if root_dir is None:
root_dir = Path("/")
root_dir = Path(root_dir)
type: Literal["text", "binary"] = "text"
try:
with open(file, "r") as f:
file_content = f.read()
type = "text"
except UnicodeDecodeError:
# If text failed, try binary.
with open(file, "rb") as f:
file_content_bin = f.read()
file_content = base64.b64encode(file_content_bin).decode("utf-8")
type = "binary"
return {
"name": str(file.relative_to(root_dir)),
"content": file_content,
"type": type,
}