-
Notifications
You must be signed in to change notification settings - Fork 69
Expand file tree
/
Copy pathmethod_page_renderer.py
More file actions
382 lines (308 loc) · 13.3 KB
/
Copy pathmethod_page_renderer.py
File metadata and controls
382 lines (308 loc) · 13.3 KB
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
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
# (C) 2026 GoodData Corporation
"""Pre-render method documentation pages from data.json.
For each markdown file with an ``api_ref`` frontmatter key, this script:
1. Resolves the method/function from data.json
2. Generates signature, description, parameters, and returns as HTML
3. Linkifies type names using the same links dictionary as python_ref_builder
4. Replaces everything between the frontmatter and the first ``## `` heading
(typically ``## Example``) with the generated HTML
5. Preserves all hand-written content below that heading
Usage:
python method_page_renderer.py DATA_JSON CONTENT_DIR [--api-ref-url URL_ROOT]
Example:
python method_page_renderer.py data.json versioned_docs/1.3 \
--api-ref-url /1.3/api-reference
"""
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
from typing import Any
# ---------------------------------------------------------------------------
# Link resolution (mirrors python_ref_builder.LinkResolver)
# ---------------------------------------------------------------------------
class LinkResolver:
"""Resolve type names to HTML hyperlinks using a name->path dict."""
def __init__(self, links: dict[str, dict]) -> None:
self.links = links
def type_link(self, name: str) -> str:
if not name:
return name or ""
orig = name
clean = name.replace("]", "").replace("Optional[", "").replace("list[", "").replace("List[", "").strip()
data = self.links.get(clean)
if data and data.get("path"):
return orig.replace(clean, f'<a href="{data["path"]}/">{clean}</a>')
return orig
# ---------------------------------------------------------------------------
# Build links dict from data.json (mirrors python_ref_builder pass 1)
# ---------------------------------------------------------------------------
def build_links(data: dict, url_root: str) -> dict[str, dict]:
"""Walk data.json and build {name: {"path": url, "kind": str}} dict."""
links: dict[str, dict] = {}
def _walk(node: dict, api_ref_root: str) -> None:
for name, obj in node.items():
if not isinstance(obj, dict):
continue
kind = obj.get("kind")
if kind == "module":
if name not in links:
links[name] = {"path": f"{api_ref_root}/{name}".lower(), "kind": "module"}
_walk(obj, f"{api_ref_root}/{name}")
elif kind == "class":
if name not in links:
links[name] = {"path": f"{api_ref_root}/{name}".lower(), "kind": "class"}
_walk(obj, f"{api_ref_root}/{name}")
elif name == "functions":
for fname in obj:
if fname.startswith("_") or fname in links:
continue
links[fname] = {"path": f"{api_ref_root}/{fname}".lower(), "kind": "function"}
_walk(data, url_root)
return links
# ---------------------------------------------------------------------------
# Resolve method data from data.json
# ---------------------------------------------------------------------------
def resolve_method(data: dict, api_ref: str) -> dict[str, Any] | None:
"""Resolve ``ClassName.method_name`` from data.json.
Walks the full tree looking for a class with matching name, then looks up
the method in its ``functions`` dict.
"""
parts = api_ref.split(".")
if len(parts) != 2:
return None
class_name, method_name = parts
def _find_class(node: dict) -> dict | None:
for name, obj in node.items():
if not isinstance(obj, dict):
continue
kind = obj.get("kind")
if kind == "class" and name == class_name:
return obj
if kind == "module":
result = _find_class(obj)
if result is not None:
return result
return None
cls = _find_class(data)
if cls is None:
return None
functions = cls.get("functions", {})
return functions.get(method_name)
# ---------------------------------------------------------------------------
# HTML generation (matches shortcode output)
# ---------------------------------------------------------------------------
def _render_signature(func_data: dict, resolver: LinkResolver) -> str:
"""Build the ``name(params) -> return_type`` line."""
ds = func_data.get("docstring_parsed")
sig = func_data.get("signature", {})
# Build parameter string from docstring params if available, else from signature
if ds and ds.get("params"):
parts: list[str] = []
for p in ds["params"]:
arg = p["arg_name"]
t = p.get("type_name", "")
if t:
arg += f": {t}"
parts.append(arg)
param_str = ", ".join(parts)
elif sig.get("params"):
param_str = ", ".join(p[0] for p in sig["params"])
else:
param_str = ""
ret_ann = sig.get("return_annotation", "")
ret_link = resolver.type_link(ret_ann)
return f"({param_str}) -> {ret_link}" if ret_ann and ret_ann != "None" else f"({param_str})"
def _render_params_table(func_data: dict, resolver: LinkResolver) -> str:
"""Render the Parameters section HTML."""
ds = func_data.get("docstring_parsed")
sig = func_data.get("signature", {})
doc_params = ds.get("params") if ds else None
sig_params = sig.get("params", [])
if doc_params:
rows = ""
for p in doc_params:
name = p["arg_name"]
ptype = resolver.type_link(p.get("type_name", ""))
desc = p.get("description", "") or ""
rows += f'<tr>\n<th padding="0px">{name}\n<th padding="0px">{ptype}\n<th>{desc}\n</tr>\n'
return (
"<h4>Parameters</h4>\n"
'<table class="gd-docs-parameters-block">\n'
"<thead>\n<tr>\n<th>name</th>\n<th>type</th>\n<th>description</th>\n</tr>\n</thead>\n"
f"<tbody>\n{rows}</tbody>\n</table>"
)
elif sig_params:
rows = ""
for p in sig_params:
name = p[0]
ptype = resolver.type_link(p[1]) if len(p) > 1 else ""
rows += f'<tr>\n<th padding="0px">{name}\n<th padding="0px">{ptype}\n<th>\n</tr>\n'
return (
"<h4>Parameters</h4>\n"
'<table class="gd-docs-parameters-block">\n'
"<thead>\n<tr>\n<th>name</th>\n<th>type</th>\n<th>description</th>\n</tr>\n</thead>\n"
f"<tbody>\n{rows}</tbody>\n</table>"
)
else:
return "<h4>Parameters</h4>\n<i>None</i>"
def _render_returns_table(func_data: dict, resolver: LinkResolver) -> str:
"""Render the Returns section HTML."""
ds = func_data.get("docstring_parsed")
sig = func_data.get("signature", {})
ret_ann = sig.get("return_annotation", "")
if not ds:
return "<h4>Returns</h4>\n<i>No docs</i>"
returns = ds.get("returns")
if not returns:
if ret_ann and ret_ann != "None":
# Has return annotation but no docstring returns section
return (
"<h4>Returns</h4>\n"
'<table class="gd-docs-parameters-block">\n'
"<thead>\n<tr>\n<th>type</th>\n<th>description</th>\n</tr>\n</thead>\n"
f'<tbody>\n<tr>\n<th padding="0px">{resolver.type_link(ret_ann)}\n'
"<th>\n</tr>\n</tbody>\n</table>"
)
return "<h4>Returns</h4>\n<i>None</i>"
type_name = returns.get("type_name") or ret_ann
if not type_name or type_name == "None":
return "<h4>Returns</h4>\n<i>None</i>"
desc = returns.get("description", "") or ""
return (
"<h4>Returns</h4>\n"
'<table class="gd-docs-parameters-block">\n'
"<thead>\n<tr>\n<th>type</th>\n<th>description</th>\n</tr>\n</thead>\n"
f'<tbody>\n<tr>\n<th padding="0px">{resolver.type_link(type_name)}\n'
f"<th>{desc}\n</tr>\n</tbody>\n</table>"
)
def render_method_html(method_name: str, func_data: dict, resolver: LinkResolver) -> str:
"""Render full method HTML block matching current shortcode output."""
ds = func_data.get("docstring_parsed")
sig_line = f"<p><code>{method_name}{_render_signature(func_data, resolver)}</code></p>"
desc_html = ""
if ds:
short = ds.get("short_description", "") or ""
long = ds.get("long_description", "") or ""
if short or long:
desc_html = '<div class="python-ref-description">\n'
if short:
desc_html += f"<p>{short}</p>\n"
if long:
desc_html += f"<p>{long}</p>\n"
desc_html += "</div>"
params_html = _render_params_table(func_data, resolver)
returns_html = _render_returns_table(func_data, resolver)
return (
"<!-- AUTO-GENERATED FROM DOCSTRING — do not edit above this line -->\n\n"
'<div class="python-ref">\n'
f"{sig_line}\n"
f"{desc_html}\n"
f"{params_html}\n"
f"{returns_html}\n"
"</div>\n"
)
# ---------------------------------------------------------------------------
# Frontmatter parsing
# ---------------------------------------------------------------------------
_FRONTMATTER_RE = re.compile(r"^---\n(.*?\n)---\n", re.DOTALL)
_API_REF_RE = re.compile(r'^api_ref:\s*["\']?([^"\']+?)["\']?\s*$', re.MULTILINE)
# Match ## or ### headings that are NOT Parameters or Returns (those are auto-generated)
_HEADING_RE = re.compile(r"^#{2,3} (?!Parameters$|Returns$)", re.MULTILINE)
def parse_frontmatter(content: str) -> tuple[str, dict[str, str], str]:
"""Parse frontmatter, returning (frontmatter_block, kv_dict, rest_of_file)."""
m = _FRONTMATTER_RE.match(content)
if not m:
return "", {}, content
fm_block = m.group(0)
fm_text = m.group(1)
rest = content[m.end() :]
kv: dict[str, str] = {}
for line in fm_text.splitlines():
if ":" in line:
key, _, val = line.partition(":")
kv[key.strip()] = val.strip().strip("\"'")
return fm_block, kv, rest
# ---------------------------------------------------------------------------
# Process a single file
# ---------------------------------------------------------------------------
def process_file(
file_path: Path,
data: dict,
resolver: LinkResolver,
*,
dry_run: bool = False,
) -> bool:
"""Process a single markdown file. Returns True if the file was modified."""
content = file_path.read_text(encoding="utf-8")
fm_block, kv, rest = parse_frontmatter(content)
api_ref = kv.get("api_ref")
if not api_ref:
return False
func_data = resolve_method(data, api_ref)
if func_data is None:
print(f"WARN: Could not resolve api_ref={api_ref!r} in {file_path}")
return False
method_name = api_ref.split(".")[-1]
html = render_method_html(method_name, func_data, resolver)
# Find the first ## heading in rest to preserve hand-written content
heading_match = _HEADING_RE.search(rest)
preserved = rest[heading_match.start() :] if heading_match else ""
new_content = fm_block + "\n" + html + "\n" + preserved
if dry_run:
print(f"Would update: {file_path}")
return True
if new_content != content:
file_path.write_text(new_content, encoding="utf-8")
return True
return False
# ---------------------------------------------------------------------------
# Walk content directory
# ---------------------------------------------------------------------------
def process_directory(
content_dir: Path,
data: dict,
resolver: LinkResolver,
*,
dry_run: bool = False,
) -> int:
"""Walk all .md files and process those with api_ref frontmatter."""
count = 0
for md_file in sorted(content_dir.rglob("*.md")):
if md_file.name.startswith("_"):
continue
if process_file(md_file, data, resolver, dry_run=dry_run):
count += 1
return count
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(description="Pre-render method documentation pages from data.json")
parser.add_argument("data_json", help="Path to data.json")
parser.add_argument("content_dir", help="Path to content directory containing .md files")
parser.add_argument(
"--api-ref-url",
default="/latest/api-reference",
help="URL root for API reference links (default: /latest/api-reference)",
)
parser.add_argument(
"--links-json",
help="Path to links.json exported by python_ref_builder (preferred over --api-ref-url)",
)
parser.add_argument("--dry-run", action="store_true", help="Print what would change without writing")
args = parser.parse_args()
with open(args.data_json) as f:
data = json.load(f)
if args.links_json:
with open(args.links_json) as f:
links = json.load(f)
else:
links = build_links(data, args.api_ref_url)
resolver = LinkResolver(links)
content_dir = Path(args.content_dir)
count = process_directory(content_dir, data, resolver, dry_run=args.dry_run)
print(f"{'Would update' if args.dry_run else 'Updated'} {count} file(s) in {content_dir}")
if __name__ == "__main__":
main()