Skip to content

Commit a254a49

Browse files
authored
Merge pull request #34 from staticf0x/google-style-support
Google style support
2 parents a3abc43 + 33129fc commit a254a49

File tree

3 files changed

+311
-0
lines changed

3 files changed

+311
-0
lines changed

docstring_to_markdown/__init__.py

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

+171
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import re
2+
from textwrap import dedent
3+
from typing import List
4+
5+
# All possible sections in Google style docstrings
6+
SECTION_HEADERS: List[str] = [
7+
"Args",
8+
"Returns",
9+
"Raises",
10+
"Yields",
11+
"Example",
12+
"Examples",
13+
"Attributes",
14+
"Note",
15+
"Todo",
16+
]
17+
18+
# These sections will not be parsed as a list of arguments/return values/etc
19+
PLAIN_TEXT_SECTIONS: List[str] = [
20+
"Examples",
21+
"Example",
22+
"Note",
23+
"Todo",
24+
]
25+
26+
ESCAPE_RULES = {
27+
# Avoid Markdown in magic methods or filenames like __init__.py
28+
r"__(?P<text>\S+)__": r"\_\_\g<text>\_\_",
29+
}
30+
31+
32+
class Section:
33+
def __init__(self, name: str, content: str) -> None:
34+
self.name = name
35+
self.content = ""
36+
37+
self._parse(content)
38+
39+
def _parse(self, content: str) -> None:
40+
content = content.rstrip("\n")
41+
42+
if self.name in PLAIN_TEXT_SECTIONS:
43+
self.content = dedent(content)
44+
return
45+
46+
parts = []
47+
cur_part = []
48+
49+
for line in content.split("\n"):
50+
line = line.replace(" ", "", 1)
51+
52+
if line.startswith(" "):
53+
# Continuation from a multiline description
54+
cur_part.append(line)
55+
continue
56+
57+
if cur_part:
58+
# Leaving multiline description
59+
parts.append(cur_part)
60+
cur_part = [line]
61+
else:
62+
# Entering new description part
63+
cur_part.append(line)
64+
65+
# Last part
66+
parts.append(cur_part)
67+
68+
# Format section
69+
for part in parts:
70+
indentation = ""
71+
skip_first = False
72+
73+
if ":" in part[0]:
74+
spl = part[0].split(":")
75+
76+
arg = spl[0]
77+
description = ":".join(spl[1:]).lstrip()
78+
indentation = (len(arg) + 6) * " "
79+
80+
if description:
81+
self.content += "- `{}`: {}\n".format(arg, description)
82+
else:
83+
skip_first = True
84+
self.content += "- `{}`: ".format(arg)
85+
else:
86+
self.content += "- {}\n".format(part[0])
87+
88+
for n, line in enumerate(part[1:]):
89+
if skip_first and n == 0:
90+
# This ensures that indented args get moved to the
91+
# previous line
92+
self.content += "{}\n".format(line.lstrip())
93+
continue
94+
95+
self.content += "{}{}\n".format(indentation, line.lstrip())
96+
97+
self.content = self.content.rstrip("\n")
98+
99+
def as_markdown(self) -> str:
100+
return "#### {}\n\n{}\n\n".format(self.name, self.content)
101+
102+
103+
class GoogleDocstring:
104+
def __init__(self, docstring: str) -> None:
105+
self.sections: List[Section] = []
106+
self.description: str = ""
107+
108+
self._parse(docstring)
109+
110+
def _parse(self, docstring: str) -> None:
111+
self.sections = []
112+
self.description = ""
113+
114+
buf = ""
115+
cur_section = ""
116+
117+
for line in docstring.split("\n"):
118+
if is_section(line):
119+
# Entering new section
120+
if cur_section:
121+
# Leaving previous section, save it and reset buffer
122+
self.sections.append(Section(cur_section, buf))
123+
buf = ""
124+
125+
# Remember currently parsed section
126+
cur_section = line.rstrip(":")
127+
continue
128+
129+
# Parse section content
130+
if cur_section:
131+
buf += line + "\n"
132+
else:
133+
# Before setting cur_section, we're parsing the function description
134+
self.description += line + "\n"
135+
136+
# Last section
137+
self.sections.append(Section(cur_section, buf))
138+
139+
def as_markdown(self) -> str:
140+
text = self.description
141+
142+
for section in self.sections:
143+
text += section.as_markdown()
144+
145+
return text.rstrip("\n") + "\n" # Only keep one last newline
146+
147+
148+
def is_section(line: str) -> bool:
149+
for section in SECTION_HEADERS:
150+
if re.search(r"{}:".format(section), line):
151+
return True
152+
153+
return False
154+
155+
156+
def looks_like_google(value: str) -> bool:
157+
for section in SECTION_HEADERS:
158+
if re.search(r"{}:\n".format(section), value):
159+
return True
160+
161+
return False
162+
163+
164+
def google_to_markdown(text: str, extract_signature: bool = True) -> str:
165+
# Escape parts we don't want to render
166+
for pattern, replacement in ESCAPE_RULES.items():
167+
text = re.sub(pattern, replacement, text)
168+
169+
docstring = GoogleDocstring(text)
170+
171+
return docstring.as_markdown()

tests/test_google.py

+135
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import pytest
2+
3+
from docstring_to_markdown.google import google_to_markdown, looks_like_google
4+
5+
BASIC_EXAMPLE = """Do **something**.
6+
7+
Some more detailed description.
8+
9+
Args:
10+
a: some arg
11+
b: some arg
12+
13+
Returns:
14+
Same *stuff*
15+
"""
16+
17+
BASIC_EXAMPLE_MD = """Do **something**.
18+
19+
Some more detailed description.
20+
21+
#### Args
22+
23+
- `a`: some arg
24+
- `b`: some arg
25+
26+
#### Returns
27+
28+
- Same *stuff*
29+
"""
30+
31+
ESCAPE_MAGIC_METHOD = """Example.
32+
33+
Args:
34+
a: see __init__.py
35+
"""
36+
37+
ESCAPE_MAGIC_METHOD_MD = """Example.
38+
39+
#### Args
40+
41+
- `a`: see \\_\\_init\\_\\_.py
42+
"""
43+
44+
PLAIN_SECTION = """Example.
45+
46+
Args:
47+
a: some arg
48+
49+
Note:
50+
Do not use this.
51+
52+
Example:
53+
Do it like this.
54+
"""
55+
56+
PLAIN_SECTION_MD = """Example.
57+
58+
#### Args
59+
60+
- `a`: some arg
61+
62+
#### Note
63+
64+
Do not use this.
65+
66+
#### Example
67+
68+
Do it like this.
69+
"""
70+
71+
MULTILINE_ARG_DESCRIPTION = """Example.
72+
73+
Args:
74+
a (str): This is a long description
75+
spanning over several lines
76+
also with broken indentation
77+
b (str): Second arg
78+
c (str):
79+
On the next line
80+
And also multiple lines
81+
"""
82+
83+
MULTILINE_ARG_DESCRIPTION_MD = """Example.
84+
85+
#### Args
86+
87+
- `a (str)`: This is a long description
88+
spanning over several lines
89+
also with broken indentation
90+
- `b (str)`: Second arg
91+
- `c (str)`: On the next line
92+
And also multiple lines
93+
"""
94+
95+
GOOGLE_CASES = {
96+
"basic example": {
97+
"google": BASIC_EXAMPLE,
98+
"md": BASIC_EXAMPLE_MD,
99+
},
100+
"escape magic method": {
101+
"google": ESCAPE_MAGIC_METHOD,
102+
"md": ESCAPE_MAGIC_METHOD_MD,
103+
},
104+
"plain section": {
105+
"google": PLAIN_SECTION,
106+
"md": PLAIN_SECTION_MD,
107+
},
108+
"multiline arg description": {
109+
"google": MULTILINE_ARG_DESCRIPTION,
110+
"md": MULTILINE_ARG_DESCRIPTION_MD,
111+
},
112+
}
113+
114+
115+
@pytest.mark.parametrize(
116+
"google",
117+
[case["google"] for case in GOOGLE_CASES.values()],
118+
ids=GOOGLE_CASES.keys(),
119+
)
120+
def test_looks_like_google_recognises_google(google):
121+
assert looks_like_google(google)
122+
123+
124+
def test_looks_like_google_ignores_plain_text():
125+
assert not looks_like_google("This is plain text")
126+
assert not looks_like_google("See Also\n--------\n")
127+
128+
129+
@pytest.mark.parametrize(
130+
"google,markdown",
131+
[[case["google"], case["md"]] for case in GOOGLE_CASES.values()],
132+
ids=GOOGLE_CASES.keys(),
133+
)
134+
def test_google_to_markdown(google, markdown):
135+
assert google_to_markdown(google) == markdown

0 commit comments

Comments
 (0)