Skip to content

Commit 9475e52

Browse files
committed
Basic support for Google style docstrings
1 parent a3abc43 commit 9475e52

File tree

3 files changed

+178
-0
lines changed

3 files changed

+178
-0
lines changed

docstring_to_markdown/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from .google import google_to_markdown, looks_like_google
12
from .rst import looks_like_rst, rst_to_markdown
23

34
__version__ = "0.12"
@@ -10,4 +11,8 @@ class UnknownFormatError(Exception):
1011
def convert(docstring: str) -> str:
1112
if looks_like_rst(docstring):
1213
return rst_to_markdown(docstring)
14+
15+
if looks_like_google(docstring):
16+
return google_to_markdown(docstring)
17+
1318
raise UnknownFormatError()

docstring_to_markdown/google.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import re
2+
from typing import Dict, List, Union
3+
4+
_GOOGLE_SECTIONS: List[str] = [
5+
"Args",
6+
"Returns",
7+
"Raises",
8+
"Yields",
9+
"Example",
10+
"Examples",
11+
"Attributes",
12+
"Note",
13+
]
14+
15+
ESCAPE_RULES = {
16+
# Avoid Markdown in magic methods or filenames like __init__.py
17+
r"__(?P<text>\S+)__": r"\_\_\g<text>\_\_",
18+
}
19+
20+
21+
class Section:
22+
def __init__(self, name: str, content: str) -> None:
23+
self.name = name
24+
self.content = ""
25+
26+
self._parse(content)
27+
28+
def _parse(self, content: str) -> None:
29+
content = content.rstrip("\n")
30+
31+
parts = []
32+
cur_part = []
33+
34+
for line in content.split("\n"):
35+
line = line.replace(" ", "", 1)
36+
37+
if line.startswith(" "):
38+
# Continuation from a multiline description
39+
cur_part.append(line)
40+
continue
41+
42+
if cur_part:
43+
# Leaving multiline description
44+
parts.append(cur_part)
45+
cur_part = [line]
46+
else:
47+
# Entering new description part
48+
cur_part.append(line)
49+
50+
# Last part
51+
parts.append(cur_part)
52+
53+
# Format section
54+
for part in parts:
55+
self.content += "- {}\n".format(part[0])
56+
57+
for line in part[1:]:
58+
self.content += " {}\n".format(line)
59+
60+
self.content = self.content.rstrip("\n")
61+
62+
def as_markdown(self) -> str:
63+
return "# {}\n\n{}\n\n".format(self.name, self.content)
64+
65+
def __repr__(self) -> str:
66+
return "Section(name={}, content={})".format(self.name, self.content)
67+
68+
69+
class GoogleDocstring:
70+
def __init__(self, docstring: str) -> None:
71+
self.sections: list[Section] = []
72+
self.description: str = ""
73+
74+
self._parse(docstring)
75+
76+
def _parse(self, docstring: str) -> None:
77+
self.sections = []
78+
self.description = ""
79+
80+
buf = ""
81+
cur_section = ""
82+
83+
for line in docstring.split("\n"):
84+
if is_section(line):
85+
# Entering new section
86+
if cur_section:
87+
# Leaving previous section, save it and reset buffer
88+
self.sections.append(Section(cur_section, buf))
89+
buf = ""
90+
91+
# Remember currently parsed section
92+
cur_section = line.rstrip(":")
93+
continue
94+
95+
# Parse section content
96+
if cur_section:
97+
buf += line + "\n"
98+
else:
99+
# Before setting cur_section, we're parsing the function description
100+
self.description += line + "\n"
101+
102+
# Last section
103+
self.sections.append(Section(cur_section, buf))
104+
105+
def as_markdown(self) -> str:
106+
text = self.description
107+
108+
for section in self.sections:
109+
text += section.as_markdown()
110+
111+
return text.rstrip("\n") + "\n" # Only keep one last newline
112+
113+
114+
def is_section(line: str) -> bool:
115+
for section in _GOOGLE_SECTIONS:
116+
if re.search(r"{}:".format(section), line):
117+
return True
118+
119+
return False
120+
121+
122+
def looks_like_google(value: str) -> bool:
123+
for section in _GOOGLE_SECTIONS:
124+
if re.search(r"{}:\n".format(section), value):
125+
return True
126+
127+
return False
128+
129+
130+
def google_to_markdown(text: str, extract_signature: bool = True) -> str:
131+
# Escape parts we don't want to render
132+
for pattern, replacement in ESCAPE_RULES.items():
133+
text = re.sub(pattern, replacement, text)
134+
135+
docstring = GoogleDocstring(text)
136+
137+
return docstring.as_markdown()

tests/test_google.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from docstring_to_markdown.google import google_to_markdown, looks_like_google
2+
3+
BASIC_EXAMPLE = """Do **something**.
4+
5+
Args:
6+
a: some arg
7+
b: some arg
8+
9+
Returns:
10+
Same *stuff*
11+
"""
12+
13+
BASIC_EXAMPLE_MD = """Do **something**.
14+
15+
# Args
16+
17+
- a: some arg
18+
- b: some arg
19+
20+
# Returns
21+
22+
- Same *stuff*
23+
"""
24+
25+
26+
def test_looks_like_google_recognises_google():
27+
assert looks_like_google(BASIC_EXAMPLE)
28+
29+
30+
def test_looks_like_google_ignores_plain_text():
31+
assert not looks_like_google("This is plain text")
32+
assert not looks_like_google("See Also\n--------\n")
33+
34+
35+
def test_google_to_markdown():
36+
assert google_to_markdown(BASIC_EXAMPLE) == BASIC_EXAMPLE_MD

0 commit comments

Comments
 (0)