Skip to content

Commit 23dc84b

Browse files
mishig25claude
andauthored
Fix .md export losing method names, signatures and section labels (#810)
`strip_html_from_markdown` still looked for the legacy `<div class="docstring">...<docstring>...</docstring>` markup, which `autodoc` stopped emitting in #797 (it now emits the `<Docstring>` component with metadata as props). Nothing matched, so the whole opening tag was deleted by the generic tag cleanup -- taking the name, anchor and signature with it -- and the `**Parameters:**` / `**Returns:**` labels vanished too, gluing the return type onto the last parameter line. Parse the current form instead: props from the opening tag (whose attribute values are single-line JSON, decoded with `raw_decode` so `}` and `>` inside type annotations can't truncate it), sections from the component body. The signature is rendered from the `parameters` prop, and the parameter groups / yields / raises sections -- previously dropped entirely -- are emitted as well. Types are only wrapped in backticks when they carry no markup, since that would break a resolved `[Name](url)` doc link. Section order (signature, parameters, returns, then the description that follows the component) matches how `Docstring.svelte` renders the page. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6108e85 commit 23dc84b

2 files changed

Lines changed: 215 additions & 65 deletions

File tree

src/doc_builder/utils.py

Lines changed: 136 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import importlib.machinery
1616
import importlib.util
17+
import json
1718
import os
1819
import re
1920
import shutil
@@ -375,61 +376,98 @@ def strip_html_tags(text: str) -> str:
375376
return stripper.get_data()
376377

377378

379+
def _extract_svelte_prop(props: str, prop_name: str):
380+
"""
381+
Read the JSON value of a `name={<json>}` svelte attribute out of an opening-tag
382+
attribute string, or return `None` if the prop is absent or malformed.
383+
384+
The JSON is parsed with `raw_decode` so values containing `}` or `>` (type
385+
annotations, defaults, ...) don't confuse the extraction.
386+
"""
387+
match = re.search(rf"\b{prop_name}=\{{", props)
388+
if match is None:
389+
return None
390+
try:
391+
value, end = json.JSONDecoder().raw_decode(props, match.end())
392+
except ValueError:
393+
return None
394+
if not props[end:].lstrip().startswith("}"):
395+
return None
396+
return value
397+
398+
399+
def _extract_section(block: str, tag: str):
400+
"""Return the (stripped) content of `<tag>...</tag>` in `block`, or `None`."""
401+
match = re.search(rf"<{tag}>(.*?)</{tag}>", block, re.DOTALL)
402+
return match.group(1).strip() if match else None
403+
404+
378405
def extract_docstring_info(docstring_block: str) -> dict:
379-
"""Extract information from a docstring block."""
406+
"""
407+
Extract information from a `<Docstring ...>...</Docstring>` block.
408+
409+
Metadata (name, anchor, source, signature) comes from the component props, the
410+
markdown-bearing sections (parameters, returns, ...) from the component body.
411+
See `doc_builder.autodoc.get_signature_component_svelte` for the emitted shape.
412+
"""
380413
info = {
381414
"name": None,
382415
"anchor": None,
383416
"source": None,
384417
"parameters": None,
385418
"paramsdesc": None,
419+
"paramsgroups": [],
386420
"rettype": None,
387421
"retdesc": None,
388-
"description": None,
422+
"yieldtype": None,
423+
"yielddesc": None,
424+
"raisederrors": None,
425+
"raises": None,
426+
"is_getset_descriptor": False,
389427
}
390428

391-
# Extract name
392-
name_match = re.search(r"<name>(.*?)</name>", docstring_block, re.DOTALL)
393-
if name_match:
394-
raw_name = name_match.group(1).strip()
429+
open_tag = re.match(r"<Docstring\s(?P<props>[^\n]*)>", docstring_block)
430+
props = open_tag.group("props") if open_tag else ""
431+
432+
name = _extract_svelte_prop(props, "name")
433+
if name:
395434
# Remove "class " or "def " prefix if present
396-
cleaned_name = re.sub(r"^(class|def)\s+", "", raw_name)
397-
info["name"] = cleaned_name
398-
399-
# Extract anchor
400-
anchor_match = re.search(r"<anchor>(.*?)</anchor>", docstring_block, re.DOTALL)
401-
if anchor_match:
402-
info["anchor"] = anchor_match.group(1).strip()
403-
404-
# Extract source
405-
source_match = re.search(r"<source>(.*?)</source>", docstring_block, re.DOTALL)
406-
if source_match:
407-
info["source"] = source_match.group(1).strip()
408-
409-
# Extract parameters description
410-
paramsdesc_match = re.search(r"<paramsdesc>(.*?)</paramsdesc>", docstring_block, re.DOTALL)
411-
if paramsdesc_match:
412-
info["paramsdesc"] = paramsdesc_match.group(1).strip()
413-
414-
# Extract return type
415-
rettype_match = re.search(r"<rettype>(.*?)</rettype>", docstring_block, re.DOTALL)
416-
if rettype_match:
417-
info["rettype"] = rettype_match.group(1).strip()
418-
419-
# Extract return description
420-
retdesc_match = re.search(r"<retdesc>(.*?)</retdesc>", docstring_block, re.DOTALL)
421-
if retdesc_match:
422-
info["retdesc"] = retdesc_match.group(1).strip()
423-
424-
# Extract text outside docstring tags but inside the div
425-
# This is the description text
426-
description_match = re.search(r"</docstring>(.*?)(?:</div>|$)", docstring_block, re.DOTALL)
427-
if description_match:
428-
desc_text = description_match.group(1).strip()
429-
# Remove any remaining HTML tags
430-
desc_text = re.sub(r"<[^>]+>", "", desc_text)
431-
if desc_text:
432-
info["description"] = desc_text
435+
info["name"] = re.sub(r"^(class|def)\s+", "", name.strip())
436+
437+
anchor = _extract_svelte_prop(props, "anchor")
438+
# `anchor` is stringified python-side, so a missing anchor arrives as `"None"`.
439+
if anchor and anchor != "None":
440+
info["anchor"] = anchor.strip()
441+
442+
source = _extract_svelte_prop(props, "source")
443+
if source:
444+
info["source"] = source.strip()
445+
446+
parameters = _extract_svelte_prop(props, "parameters")
447+
if isinstance(parameters, list):
448+
info["parameters"] = parameters
449+
450+
info["is_getset_descriptor"] = bool(_extract_svelte_prop(props, "isGetSetDescriptor"))
451+
452+
for key, tag in (
453+
("paramsdesc", "paramsdesc"),
454+
("rettype", "rettype"),
455+
("retdesc", "retdesc"),
456+
("yieldtype", "yieldtype"),
457+
("yielddesc", "yielddesc"),
458+
("raisederrors", "raisederrors"),
459+
("raises", "raises"),
460+
):
461+
info[key] = _extract_section(docstring_block, tag)
462+
463+
# Extra parameter groups, e.g. transformers' "Parameters for sequence generation".
464+
for group in re.findall(r"<paramsgroup>(.*?)</paramsgroup>", docstring_block, re.DOTALL):
465+
info["paramsgroups"].append(
466+
{
467+
"title": _extract_section(group, "paramsgrouptitle"),
468+
"desc": _extract_section(group, "paramsgroupdesc"),
469+
}
470+
)
433471

434472
return info
435473

@@ -476,6 +514,23 @@ def format_parameters(paramsdesc: str) -> str:
476514
return "\n".join(formatted_params)
477515

478516

517+
def format_call_signature(name: str, parameters) -> str:
518+
"""
519+
Render the `parameters` prop (a list of `{"name": ..., "val": ...}`) as a call
520+
signature, e.g. ``HfApi.merge_pull_request(discussion_num: int, token = None)``.
521+
"""
522+
args = ", ".join(f"{param.get('name', '')}{param.get('val', '')}".strip() for param in parameters)
523+
return f"{name}({args})"
524+
525+
526+
def format_type(value: str) -> str:
527+
"""
528+
Wrap a return/yield/raise type in backticks, unless it already carries markup
529+
(a resolved `[Name](url)` doc link, inline code, ...) that backticks would break.
530+
"""
531+
return value if re.search(r"[`\[\]<>]", value) else f"`{value}`"
532+
533+
479534
def process_docstring_block(docstring_block: str) -> str:
480535
"""
481536
Process a docstring block by:
@@ -497,18 +552,20 @@ def process_docstring_block(docstring_block: str) -> str:
497552
parts.append(f"#### {info['name']}")
498553
parts.append("")
499554

555+
# Add the call signature (properties are not callable, so they have none)
556+
if info["parameters"] is not None and not info["is_getset_descriptor"]:
557+
parts.append("```python")
558+
parts.append(format_call_signature(info["name"], info["parameters"]))
559+
parts.append("```")
560+
parts.append("")
561+
500562
# Add source link if available
501563
if info["source"]:
502564
# Strip any HTML from source
503565
source_clean = strip_html_tags(info["source"])
504566
parts.append(f"[Source]({source_clean})")
505567
parts.append("")
506568

507-
# Add description
508-
if info["description"]:
509-
parts.append(info["description"])
510-
parts.append("")
511-
512569
# Add parameters description
513570
if info["paramsdesc"]:
514571
parts.append("**Parameters:**")
@@ -518,22 +575,31 @@ def process_docstring_block(docstring_block: str) -> str:
518575
parts.append(formatted_params)
519576
parts.append("")
520577

521-
# Add return type
522-
if info["rettype"]:
523-
parts.append("**Returns:**")
578+
# Add the extra parameter groups, if any
579+
for group in info["paramsgroups"]:
580+
if not group["desc"]:
581+
continue
582+
parts.append(f"**{group['title'] or 'Parameters'}:**")
524583
parts.append("")
525-
# Strip HTML tags from return type
526-
rettype_clean = strip_html_tags(info["rettype"])
527-
parts.append(f"`{rettype_clean}`")
584+
parts.append(format_parameters(group["desc"]))
528585
parts.append("")
529586

530-
# Add return description
531-
if info["retdesc"]:
532-
if not info["rettype"]:
533-
parts.append("**Returns:**")
534-
parts.append("")
535-
parts.append(info["retdesc"])
587+
# Add the returns / yields / raises sections
588+
for type_key, desc_key, label in (
589+
("rettype", "retdesc", "Returns"),
590+
("yieldtype", "yielddesc", "Yields"),
591+
("raisederrors", "raises", "Raises"),
592+
):
593+
if not info[type_key] and not info[desc_key]:
594+
continue
595+
header = f"**{label}:**"
596+
if info[type_key]:
597+
header += f" {format_type(info[type_key])}"
598+
parts.append(header)
536599
parts.append("")
600+
if info[desc_key]:
601+
parts.append(info[desc_key])
602+
parts.append("")
537603

538604
result = "\n".join(parts)
539605

@@ -611,18 +677,23 @@ def strip_html_from_markdown(content: str) -> str:
611677
Strip HTML from markdown content.
612678
613679
Handles:
614-
- Docstring blocks wrapped in <div class="docstring...">...</div>
680+
- `<Docstring ...>...</Docstring>` components emitted by autodoc, which become a
681+
level-4 heading with the signature, then the `**Parameters:**`/`**Returns:**`
682+
sections. The object description follows the component in the source, so it
683+
keeps its place right after those (same order as the rendered HTML page).
615684
- Other HTML tags throughout the document
616685
"""
617686
result = content
618687

619-
# Process docstring blocks with their wrapping divs
620-
# Pattern to match: <div class="docstring...">...<docstring>...</docstring>...</div>
621-
docstring_pattern = r'<div[^>]*class="docstring[^"]*"[^>]*>.*?<docstring>.*?</docstring>.*?</div>'
688+
# The opening tag's attribute values are single-line JSON (see
689+
# `get_signature_component_svelte`), so it ends at the last `>` on its own line.
690+
docstring_pattern = r"<Docstring\s[^\n]*>\n.*?</Docstring>"
622691

623692
def replace_docstring(match):
624693
block = match.group(0)
625-
return process_docstring_block(block)
694+
# Trailing newline: the object description directly follows the component (with a
695+
# single newline in between), and must not end up glued to the last section.
696+
return process_docstring_block(block) + "\n"
626697

627698
result = re.sub(docstring_pattern, replace_docstring, result, flags=re.DOTALL)
628699

tests/test_utils.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,82 @@ def test_strip_html_still_removes_real_tags(self):
127127
self.assertNotIn("<div", result)
128128
self.assertIn("warning", result)
129129
self.assertIn("inside", result)
130+
131+
def test_strip_html_from_markdown_docstring_component(self):
132+
# Regression: method names, anchors, signatures and the section labels were
133+
# dropped from the `.md` export because the old parser looked for the legacy
134+
# `<docstring>` markup instead of the `<Docstring ...>` props.
135+
content = (
136+
'<div class="docstring border-l-2">\n\n'
137+
'<Docstring name={"class huggingface_hub.HfApi"} anchor={"huggingface_hub.HfApi"} '
138+
'source={"https://github.com/huggingface/huggingface_hub/blob/main/src/huggingface_hub/hf_api.py#L2226"} '
139+
'parameters={[{"name": "endpoint", "val": ": str | None = None"}]}>\n'
140+
"<paramsdesc>- **endpoint** (`str`, *optional*) --\n"
141+
" Endpoint of the Hub.</paramsdesc></Docstring>\n"
142+
"Client to interact with the Hugging Face Hub via HTTP.\n\n"
143+
'<div class="docstring border-l-2">\n\n'
144+
'<Docstring name={"merge_pull_request"} anchor={"huggingface_hub.HfApi.merge_pull_request"} '
145+
'parameters={[{"name": "repo_id", "val": ": str"}, {"name": "discussion_num", "val": ": int"}]}>\n'
146+
"<paramsdesc>- **repo_id** (`str`) --\n"
147+
" A namespace and a repo name separated\n"
148+
" by a `/`.\n"
149+
"- **discussion_num** (`int`) --\n"
150+
" The number of the Pull Request.</paramsdesc>"
151+
"<rettype>[DiscussionStatusChange](https://hf.co/docs#DiscussionStatusChange)</rettype>"
152+
"<retdesc>the status change event</retdesc></Docstring>\n"
153+
"Merges a Pull Request.\n\n"
154+
"</div></div>\n"
155+
)
156+
result = strip_html_from_markdown(content)
157+
158+
# Class: heading with anchor, signature and parameters
159+
self.assertIn("#### huggingface_hub.HfApi[[huggingface_hub.HfApi]]", result)
160+
self.assertIn("huggingface_hub.HfApi(endpoint: str | None = None)", result)
161+
self.assertIn(
162+
"[Source](https://github.com/huggingface/huggingface_hub/blob/main/src/huggingface_hub/hf_api.py#L2226)",
163+
result,
164+
)
165+
# Method: heading with anchor, signature, and both section labels
166+
self.assertIn("#### merge_pull_request[[huggingface_hub.HfApi.merge_pull_request]]", result)
167+
self.assertIn("merge_pull_request(repo_id: str, discussion_num: int)", result)
168+
self.assertEqual(result.count("**Parameters:**"), 2)
169+
# A resolved doc link as return type keeps its markdown (no backtick wrapping)
170+
self.assertIn("**Returns:** [DiscussionStatusChange](https://hf.co/docs#DiscussionStatusChange)", result)
171+
# The description must not be glued to the last section of the docstring block
172+
self.assertIn("the status change event\n\nMerges a Pull Request.", result)
173+
# No leftover component markup
174+
self.assertNotIn("<Docstring", result)
175+
self.assertNotIn("paramsdesc", result)
176+
177+
def test_strip_html_from_markdown_docstring_getset_descriptor(self):
178+
# Properties have no parameters and no anchor: heading only, no empty signature.
179+
content = (
180+
'<div class="docstring border-l-2">\n\n'
181+
'<Docstring name={"content"} anchor={"None"} parameters={[]} isGetSetDescriptor={true}>\n'
182+
"</Docstring>\n"
183+
"Get the content of this `AddedToken`\n\n"
184+
"</div>\n"
185+
)
186+
result = strip_html_from_markdown(content)
187+
self.assertIn("#### content", result)
188+
self.assertNotIn("[[None]]", result)
189+
self.assertNotIn("content()", result)
190+
self.assertIn("Get the content of this `AddedToken`", result)
191+
192+
# ... whereas a parameterless method keeps its (empty) signature
193+
content = '<Docstring name={"dummy.reset"} anchor={"dummy.reset"} parameters={[]}>\n</Docstring>\nResets it.\n'
194+
self.assertIn("dummy.reset()", strip_html_from_markdown(content))
195+
196+
def test_strip_html_from_markdown_docstring_props_with_angle_brackets(self):
197+
# `>` inside the props JSON must not truncate the opening tag.
198+
content = (
199+
'<Docstring name={"dummy.func"} anchor={"dummy.func"} '
200+
'parameters={[{"name": "cb", "val": ": Callable[[int], int] = <factory>"}]}>\n'
201+
"<rettype>`int`</rettype></Docstring>\n"
202+
"Does something.\n"
203+
)
204+
result = strip_html_from_markdown(content)
205+
self.assertIn("#### dummy.func[[dummy.func]]", result)
206+
self.assertIn("dummy.func(cb: Callable[[int], int] = <factory>)", result)
207+
self.assertIn("**Returns:** `int`", result)
208+
self.assertIn("Does something.", result)

0 commit comments

Comments
 (0)