Skip to content

Commit 8f1ac50

Browse files
pvanmulbregtjnothman
authored andcommitted
ENH: Added support for multiple functions+description in a See Also block (#172)
1 parent a482f66 commit 8f1ac50

File tree

2 files changed

+123
-78
lines changed

2 files changed

+123
-78
lines changed

numpydoc/docscrape.py

+86-51
Original file line numberDiff line numberDiff line change
@@ -238,9 +238,41 @@ def _parse_param_list(self, content):
238238

239239
return params
240240

241-
_name_rgx = re.compile(r"^\s*(:(?P<role>\w+):"
242-
r"`(?P<name>(?:~\w+\.)?[a-zA-Z0-9_.-]+)`|"
243-
r" (?P<name2>[a-zA-Z0-9_.-]+))\s*", re.X)
241+
# See also supports the following formats.
242+
#
243+
# <FUNCNAME>
244+
# <FUNCNAME> SPACE* COLON SPACE+ <DESC> SPACE*
245+
# <FUNCNAME> ( COMMA SPACE+ <FUNCNAME>)* SPACE*
246+
# <FUNCNAME> ( COMMA SPACE+ <FUNCNAME>)* SPACE* COLON SPACE+ <DESC> SPACE*
247+
248+
# <FUNCNAME> is one of
249+
# <PLAIN_FUNCNAME>
250+
# COLON <ROLE> COLON BACKTICK <PLAIN_FUNCNAME> BACKTICK
251+
# where
252+
# <PLAIN_FUNCNAME> is a legal function name, and
253+
# <ROLE> is any nonempty sequence of word characters.
254+
# Examples: func_f1 :meth:`func_h1` :obj:`~baz.obj_r` :class:`class_j`
255+
# <DESC> is a string describing the function.
256+
257+
_role = r":(?P<role>\w+):"
258+
_funcbacktick = r"`(?P<name>(?:~\w+\.)?[a-zA-Z0-9_.-]+)`"
259+
_funcplain = r"(?P<name2>[a-zA-Z0-9_.-]+)"
260+
_funcname = r"(" + _role + _funcbacktick + r"|" + _funcplain + r")"
261+
_funcnamenext = _funcname.replace('role', 'rolenext')
262+
_funcnamenext = _funcnamenext.replace('name', 'namenext')
263+
_description = r"(?P<description>\s*:(\s+(?P<desc>\S+.*))?)?\s*$"
264+
_func_rgx = re.compile(r"^\s*" + _funcname + r"\s*")
265+
_line_rgx = re.compile(
266+
r"^\s*" +
267+
r"(?P<allfuncs>" + # group for all function names
268+
_funcname +
269+
r"(?P<morefuncs>([,]\s+" + _funcnamenext + r")*)" +
270+
r")" + # end of "allfuncs"
271+
r"(?P<trailing>\s*,)?" + # Some function lists have a trailing comma
272+
_description)
273+
274+
# Empty <DESC> elements are replaced with '..'
275+
empty_description = '..'
244276

245277
def _parse_see_also(self, content):
246278
"""
@@ -250,52 +282,49 @@ def _parse_see_also(self, content):
250282
func_name1, func_name2, :meth:`func_name`, func_name3
251283
252284
"""
285+
253286
items = []
254287

255288
def parse_item_name(text):
256-
"""Match ':role:`name`' or 'name'"""
257-
m = self._name_rgx.match(text)
258-
if m:
259-
g = m.groups()
260-
if g[1] is None:
261-
return g[3], None
262-
else:
263-
return g[2], g[1]
264-
raise ParseError("%s is not a item name" % text)
265-
266-
def push_item(name, rest):
267-
if not name:
268-
return
269-
name, role = parse_item_name(name)
270-
items.append((name, list(rest), role))
271-
del rest[:]
289+
"""Match ':role:`name`' or 'name'."""
290+
m = self._func_rgx.match(text)
291+
if not m:
292+
raise ParseError("%s is not a item name" % text)
293+
role = m.group('role')
294+
name = m.group('name') if role else m.group('name2')
295+
return name, role, m.end()
272296

273-
current_func = None
274297
rest = []
275-
276298
for line in content:
277299
if not line.strip():
278300
continue
279301

280-
m = self._name_rgx.match(line)
281-
if m and line[m.end():].strip().startswith(':'):
282-
push_item(current_func, rest)
283-
current_func, line = line[:m.end()], line[m.end():]
284-
rest = [line.split(':', 1)[1].strip()]
285-
if not rest[0]:
286-
rest = []
287-
elif not line.startswith(' '):
288-
push_item(current_func, rest)
289-
current_func = None
290-
if ',' in line:
291-
for func in line.split(','):
292-
if func.strip():
293-
push_item(func, [])
294-
elif line.strip():
295-
current_func = line
296-
elif current_func is not None:
302+
line_match = self._line_rgx.match(line)
303+
description = None
304+
if line_match:
305+
description = line_match.group('desc')
306+
if line_match.group('trailing'):
307+
self._error_location(
308+
'Unexpected comma after function list at index %d of '
309+
'line "%s"' % (line_match.end('trailing'), line),
310+
error=False)
311+
if not description and line.startswith(' '):
297312
rest.append(line.strip())
298-
push_item(current_func, rest)
313+
elif line_match:
314+
funcs = []
315+
text = line_match.group('allfuncs')
316+
while True:
317+
if not text.strip():
318+
break
319+
name, role, match_end = parse_item_name(text)
320+
funcs.append((name, role))
321+
text = text[match_end:].strip()
322+
if text and text[0] == ',':
323+
text = text[1:].strip()
324+
rest = list(filter(None, [description]))
325+
items.append((funcs, rest))
326+
else:
327+
raise ParseError("%s is not a item name" % line)
299328
return items
300329

301330
def _parse_index(self, section, content):
@@ -445,24 +474,30 @@ def _str_see_also(self, func_role):
445474
return []
446475
out = []
447476
out += self._str_header("See Also")
477+
out += ['']
448478
last_had_desc = True
449-
for func, desc, role in self['See Also']:
450-
if role:
451-
link = ':%s:`%s`' % (role, func)
452-
elif func_role:
453-
link = ':%s:`%s`' % (func_role, func)
454-
else:
455-
link = "`%s`_" % func
456-
if desc or last_had_desc:
457-
out += ['']
458-
out += [link]
459-
else:
460-
out[-1] += ", %s" % link
479+
for funcs, desc in self['See Also']:
480+
assert isinstance(funcs, list)
481+
links = []
482+
for func, role in funcs:
483+
if role:
484+
link = ':%s:`%s`' % (role, func)
485+
elif func_role:
486+
link = ':%s:`%s`' % (func_role, func)
487+
else:
488+
link = "`%s`_" % func
489+
links.append(link)
490+
link = ', '.join(links)
491+
out += [link]
461492
if desc:
462493
out += self._str_indent([' '.join(desc)])
463494
last_had_desc = True
464495
else:
465496
last_had_desc = False
497+
out += self._str_indent([self.empty_description])
498+
499+
if last_had_desc:
500+
out += ['']
466501
out += ['']
467502
return out
468503

numpydoc/tests/test_docscrape.py

+37-27
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,7 @@ def line_by_line_compare(a, b):
386386
b = textwrap.dedent(b)
387387
a = [l.rstrip() for l in _strip_blank_lines(a).split('\n')]
388388
b = [l.rstrip() for l in _strip_blank_lines(b).split('\n')]
389-
assert all(x == y for x, y in zip(a, b))
389+
assert all(x == y for x, y in zip(a, b)), str([[x, y] for x, y in zip(a, b) if x != y])
390390

391391

392392
def test_str():
@@ -454,7 +454,7 @@ def test_str():
454454
--------
455455
456456
`some`_, `other`_, `funcs`_
457-
457+
..
458458
`otherfunc`_
459459
relationship
460460
@@ -623,7 +623,7 @@ def test_sphinx_str():
623623
.. seealso::
624624
625625
:obj:`some`, :obj:`other`, :obj:`funcs`
626-
626+
..
627627
:obj:`otherfunc`
628628
relationship
629629
@@ -779,36 +779,46 @@ def test_see_also():
779779
multiple lines
780780
func_f, func_g, :meth:`func_h`, func_j,
781781
func_k
782+
func_f1, func_g1, :meth:`func_h1`, func_j1
783+
func_f2, func_g2, :meth:`func_h2`, func_j2 : description of multiple
782784
:obj:`baz.obj_q`
783785
:obj:`~baz.obj_r`
784786
:class:`class_j`: fubar
785787
foobar
786788
""")
787789

788-
assert len(doc6['See Also']) == 13
789-
for func, desc, role in doc6['See Also']:
790-
if func in ('func_a', 'func_b', 'func_c', 'func_f',
791-
'func_g', 'func_h', 'func_j', 'func_k', 'baz.obj_q',
792-
'~baz.obj_r'):
793-
assert(not desc)
794-
else:
795-
assert(desc)
796-
797-
if func == 'func_h':
798-
assert role == 'meth'
799-
elif func == 'baz.obj_q' or func == '~baz.obj_r':
800-
assert role == 'obj'
801-
elif func == 'class_j':
802-
assert role == 'class'
803-
else:
804-
assert role is None
805-
806-
if func == 'func_d':
807-
assert desc == ['some equivalent func']
808-
elif func == 'foo.func_e':
809-
assert desc == ['some other func over', 'multiple lines']
810-
elif func == 'class_j':
811-
assert desc == ['fubar', 'foobar']
790+
assert len(doc6['See Also']) == 10
791+
for funcs, desc in doc6['See Also']:
792+
for func, role in funcs:
793+
if func in ('func_a', 'func_b', 'func_c', 'func_f',
794+
'func_g', 'func_h', 'func_j', 'func_k', 'baz.obj_q',
795+
'func_f1', 'func_g1', 'func_h1', 'func_j1',
796+
'~baz.obj_r'):
797+
assert not desc, str([func, desc])
798+
elif func in ('func_f2', 'func_g2', 'func_h2', 'func_j2'):
799+
assert desc, str([func, desc])
800+
else:
801+
assert desc, str([func, desc])
802+
803+
if func == 'func_h':
804+
assert role == 'meth'
805+
elif func == 'baz.obj_q' or func == '~baz.obj_r':
806+
assert role == 'obj'
807+
elif func == 'class_j':
808+
assert role == 'class'
809+
elif func in ['func_h1', 'func_h2']:
810+
assert role == 'meth'
811+
else:
812+
assert role is None, str([func, role])
813+
814+
if func == 'func_d':
815+
assert desc == ['some equivalent func']
816+
elif func == 'foo.func_e':
817+
assert desc == ['some other func over', 'multiple lines']
818+
elif func == 'class_j':
819+
assert desc == ['fubar', 'foobar']
820+
elif func in ['func_f2', 'func_g2', 'func_h2', 'func_j2']:
821+
assert desc == ['description of multiple'], str([desc, ['description of multiple']])
812822

813823

814824
def test_see_also_parse_error():

0 commit comments

Comments
 (0)