Skip to content

Commit 49b7773

Browse files
authored
ENH: Add support for variable doc-comments (starting with '#:') (#292)
* Add support for variable docstrings starting with '#:' * Get tests passing on Windows * Updates from the PRD * Add some unit tests, lint * Update pdoc/__init__.py * PR fixes * PR updates, add documentation * Revert unrelated change * Touch ups * Reword docs
1 parent 1ec0446 commit 49b7773

File tree

4 files changed

+125
-41
lines changed

4 files changed

+125
-41
lines changed

pdoc/__init__.py

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,7 @@ def _pep224_docstrings(doc_obj: Union['Module', 'Class'], *,
244244
_init_tree=None) -> Tuple[Dict[str, str],
245245
Dict[str, str]]:
246246
"""
247-
Extracts PEP-224 docstrings for variables of `doc_obj`
247+
Extracts PEP-224 docstrings and doc-comments (`#: ...`) for variables of `doc_obj`
248248
(either a `pdoc.Module` or `pdoc.Class`).
249249
250250
Returns a tuple of two dicts mapping variable names to their docstrings.
@@ -284,12 +284,7 @@ def _pep224_docstrings(doc_obj: Union['Module', 'Class'], *,
284284
instance_vars, _ = _pep224_docstrings(doc_obj, _init_tree=node)
285285
break
286286

287-
for assign_node, str_node in _pairwise(ast.iter_child_nodes(tree)):
288-
if not (isinstance(assign_node, (ast.Assign, ast.AnnAssign)) and
289-
isinstance(str_node, ast.Expr) and
290-
isinstance(str_node.value, ast.Str)):
291-
continue
292-
287+
def get_name(assign_node):
293288
if isinstance(assign_node, ast.Assign) and len(assign_node.targets) == 1:
294289
target = assign_node.targets[0]
295290
elif isinstance(assign_node, ast.AnnAssign):
@@ -298,7 +293,7 @@ def _pep224_docstrings(doc_obj: Union['Module', 'Class'], *,
298293
# > Putting the instance variable annotations together in the class
299294
# > makes it easier to find them, and helps a first-time reader of the code.
300295
else:
301-
continue
296+
return None
302297

303298
if not _init_tree and isinstance(target, ast.Name):
304299
name = target.id
@@ -308,9 +303,22 @@ def _pep224_docstrings(doc_obj: Union['Module', 'Class'], *,
308303
target.value.id == 'self'):
309304
name = target.attr
310305
else:
311-
continue
306+
return None
312307

313308
if not _is_public(name) and not _is_whitelisted(name, doc_obj):
309+
return None
310+
311+
return name
312+
313+
# For handling PEP-224 docstrings for variables
314+
for assign_node, str_node in _pairwise(ast.iter_child_nodes(tree)):
315+
if not (isinstance(assign_node, (ast.Assign, ast.AnnAssign)) and
316+
isinstance(str_node, ast.Expr) and
317+
isinstance(str_node.value, ast.Str)):
318+
continue
319+
320+
name = get_name(assign_node)
321+
if not name:
314322
continue
315323

316324
docstring = inspect.cleandoc(str_node.value.s).strip()
@@ -319,6 +327,43 @@ def _pep224_docstrings(doc_obj: Union['Module', 'Class'], *,
319327

320328
vars[name] = docstring
321329

330+
# For handling '#:' docstrings for variables
331+
for assign_node in ast.iter_child_nodes(tree):
332+
if not isinstance(assign_node, (ast.Assign, ast.AnnAssign)):
333+
continue
334+
335+
name = get_name(assign_node)
336+
if not name:
337+
continue
338+
339+
# Already documented. PEP-224 method above takes precedence.
340+
if name in vars:
341+
continue
342+
343+
def get_indent(line):
344+
return len(line) - len(line.lstrip())
345+
346+
source_lines = doc_obj.source.splitlines() # type: ignore
347+
assign_line = source_lines[assign_node.lineno - 1]
348+
assign_indent = get_indent(assign_line)
349+
comment_lines = []
350+
MARKER = '#: '
351+
for line in reversed(source_lines[:assign_node.lineno - 1]):
352+
if get_indent(line) == assign_indent and line.lstrip().startswith(MARKER):
353+
comment_lines.append(line.split(MARKER, maxsplit=1)[1])
354+
else:
355+
break
356+
357+
# Since we went 'up' need to reverse lines to be in correct order
358+
comment_lines = comment_lines[::-1]
359+
360+
# Finally: check for a '#: ' comment at the end of the assignment line itself.
361+
if MARKER in assign_line:
362+
comment_lines.append(assign_line.rsplit(MARKER, maxsplit=1)[1])
363+
364+
if comment_lines:
365+
vars[name] = '\n'.join(comment_lines)
366+
322367
return vars, instance_vars
323368

324369

pdoc/documentation.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -107,24 +107,26 @@ In the default HTML template, such inherited docstrings are greyed out.
107107
[variable docstrings]: #docstrings-for-variables
108108

109109
Python by itself [doesn't allow docstrings attached to variables][PEP-224].
110-
However, `pdoc` supports docstrings attached to module (or global)
111-
variables, class variables, and object instance variables; all in
112-
the same way as proposed in [PEP-224], with a docstring following the
113-
variable assignment.
110+
However, `pdoc` supports documenting module (or global)
111+
variables, class variables, and object instance variables via
112+
two different mechanisms: [PEP-224] and `#:` doc-comments.
113+
114114
For example:
115115

116116
[PEP-224]: http://www.python.org/dev/peps/pep-0224
117117

118118
module_variable = 1
119-
"""Docstring for module_variable."""
119+
"""PEP 224 docstring for module_variable."""
120120

121121
class C:
122-
class_variable = 2
123-
"""Docstring for class_variable."""
122+
#: Documentation comment for class_variable
123+
#: spanning over three lines.
124+
class_variable = 2 #: Assignment line is included.
124125

125126
def __init__(self):
127+
#: Instance variable's doc-comment
126128
self.variable = 3
127-
"""Docstring for instance variable."""
129+
"""But note, PEP 224 docstrings take precedence."""
128130

129131
While the resulting variables have no `__doc__` attribute,
130132
`pdoc` compensates by reading the source code (when available)

pdoc/html_helpers.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,11 @@ def format_git_link(template: str, dobj: pdoc.Doc):
560560
commit = _git_head_commit()
561561
abs_path = inspect.getfile(inspect.unwrap(dobj.obj))
562562
path = _project_relative_path(abs_path)
563+
564+
# Urls should always use / instead of \\
565+
if os.name == 'nt':
566+
path = path.replace('\\', '/')
567+
563568
lines, start_line = inspect.getsourcelines(dobj.obj)
564569
end_line = start_line + len(lines) - 1
565570
url = template.format(**locals())

pdoc/test/__init__.py

Lines changed: 56 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -106,25 +106,21 @@ class CliTest(unittest.TestCase):
106106
"""
107107
ALL_FILES = [
108108
'example_pkg',
109-
'example_pkg/index.html',
110-
'example_pkg/index.m.html',
111-
'example_pkg/module.html',
112-
'example_pkg/_private',
113-
'example_pkg/_private/index.html',
114-
'example_pkg/_private/module.html',
115-
'example_pkg/subpkg',
116-
'example_pkg/subpkg/_private.html',
117-
'example_pkg/subpkg/index.html',
118-
'example_pkg/subpkg2',
119-
'example_pkg/subpkg2/_private.html',
120-
'example_pkg/subpkg2/module.html',
121-
'example_pkg/subpkg2/index.html',
109+
os.path.join('example_pkg', 'index.html'),
110+
os.path.join('example_pkg', 'index.m.html'),
111+
os.path.join('example_pkg', 'module.html'),
112+
os.path.join('example_pkg', '_private'),
113+
os.path.join('example_pkg', '_private', 'index.html'),
114+
os.path.join('example_pkg', '_private', 'module.html'),
115+
os.path.join('example_pkg', 'subpkg'),
116+
os.path.join('example_pkg', 'subpkg', '_private.html'),
117+
os.path.join('example_pkg', 'subpkg', 'index.html'),
118+
os.path.join('example_pkg', 'subpkg2'),
119+
os.path.join('example_pkg', 'subpkg2', '_private.html'),
120+
os.path.join('example_pkg', 'subpkg2', 'module.html'),
121+
os.path.join('example_pkg', 'subpkg2', 'index.html'),
122122
]
123-
PUBLIC_FILES = [f for f in ALL_FILES if '/_' not in f]
124-
125-
if os.name == 'nt':
126-
ALL_FILES = [i.replace('/', '\\') for i in ALL_FILES]
127-
PUBLIC_FILES = [i.replace('/', '\\') for i in PUBLIC_FILES]
123+
PUBLIC_FILES = [f for f in ALL_FILES if (os.path.sep + '_') not in f]
128124

129125
def setUp(self):
130126
pdoc.reset()
@@ -190,7 +186,7 @@ def test_html(self):
190186
'.subpkg2': [f for f in self.PUBLIC_FILES
191187
if 'subpkg2' in f or f == EXAMPLE_MODULE],
192188
'._private': [f for f in self.ALL_FILES
193-
if EXAMPLE_MODULE + '/_private' in f or f == EXAMPLE_MODULE],
189+
if EXAMPLE_MODULE + os.path.sep + '_private' in f or f == EXAMPLE_MODULE],
194190
}
195191
for package, expected_files in package_files.items():
196192
with self.subTest(package=package):
@@ -203,7 +199,8 @@ def test_html(self):
203199
filenames_files = {
204200
('module.py',): ['module.html'],
205201
('module.py', 'subpkg2'): ['module.html', 'subpkg2',
206-
'subpkg2/index.html', 'subpkg2/module.html'],
202+
os.path.join('subpkg2', 'index.html'),
203+
os.path.join('subpkg2', 'module.html')],
207204
}
208205
with chdir(TESTS_BASEDIR):
209206
for filenames, expected_files in filenames_files.items():
@@ -216,9 +213,11 @@ def test_html(self):
216213

217214
def test_html_multiple_files(self):
218215
with chdir(TESTS_BASEDIR):
219-
with run_html(EXAMPLE_MODULE + '/module.py', EXAMPLE_MODULE + '/subpkg2'):
220-
self._basic_html_assertions(
221-
['module.html', 'subpkg2', 'subpkg2/index.html', 'subpkg2/module.html'])
216+
with run_html(os.path.join(EXAMPLE_MODULE, 'module.py'),
217+
os.path.join(EXAMPLE_MODULE, 'subpkg2')):
218+
self._basic_html_assertions(['module.html', 'subpkg2',
219+
os.path.join('subpkg2', 'index.html'),
220+
os.path.join('subpkg2', 'module.html')])
222221

223222
def test_html_identifier(self):
224223
for package in ('', '._private'):
@@ -232,7 +231,7 @@ def test_html_identifier(self):
232231
def test_html_ref_links(self):
233232
with run_html(EXAMPLE_MODULE, config='show_source_code=False'):
234233
self._check_files(
235-
file_pattern=EXAMPLE_MODULE + '/index.html',
234+
file_pattern=os.path.join(EXAMPLE_MODULE, 'index.html'),
236235
include_patterns=[
237236
'href="#example_pkg.B">',
238237
'href="#example_pkg.A">',
@@ -1099,6 +1098,39 @@ def func(self):
10991098
self.assertIsInstance(cls.doc['name'], pdoc.Variable)
11001099
self.assertEqual(cls.doc['name'].type_annotation(), 'str')
11011100

1101+
def test_doc_comment_docstrings(self):
1102+
with temp_dir() as path:
1103+
filename = os.path.join(path, 'doc_comment_docstrs.py')
1104+
with open(filename, 'w') as f:
1105+
f.write('''#: Not included
1106+
1107+
#: Line 1
1108+
#: Line 2
1109+
var1 = 1 #: Line 3
1110+
1111+
#: Not included
1112+
var2 = 1
1113+
"""PEP-224 takes precedence"""
1114+
1115+
#: This should not appear
1116+
class C:
1117+
#: class var
1118+
class_var = 1
1119+
1120+
#: This also should not show
1121+
def __init__(self):
1122+
#: instance var
1123+
self.instance_var = 1
1124+
''')
1125+
1126+
mod = pdoc.Module(pdoc.import_module(filename))
1127+
1128+
self.assertEqual(mod.doc['var1'].docstring, 'Line 1\nLine 2\nLine 3')
1129+
self.assertEqual(mod.doc['var2'].docstring, 'PEP-224 takes precedence')
1130+
self.assertEqual(mod.doc['C'].docstring, '')
1131+
self.assertEqual(mod.doc['C'].doc['class_var'].docstring, 'class var')
1132+
self.assertEqual(mod.doc['C'].doc['instance_var'].docstring, 'instance var')
1133+
11021134

11031135
class HtmlHelpersTest(unittest.TestCase):
11041136
"""

0 commit comments

Comments
 (0)