Skip to content

ENH: Added support for multiple functions+description in a See Also block #172

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Apr 10, 2019
135 changes: 84 additions & 51 deletions numpydoc/docscrape.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,9 +236,39 @@ def _parse_param_list(self, content):

return params

_name_rgx = re.compile(r"^\s*(:(?P<role>\w+):"
r"`(?P<name>(?:~\w+\.)?[a-zA-Z0-9_.-]+)`|"
r" (?P<name2>[a-zA-Z0-9_.-]+))\s*", re.X)
# See also supports the following formats.
#
# <FUNCNAME>
# <FUNCNAME> SPACE* COLON SPACE+ <DESC> SPACE*
# <FUNCNAME> ( COMMA SPACE+ <FUNCNAME>)* SPACE*
# <FUNCNAME> ( COMMA SPACE+ <FUNCNAME>)* SPACE* COLON SPACE+ <DESC> SPACE*

# <FUNCNAME> is:
# A legal function name, optionally enclosed in backticks.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

currently a function name enclosed in backticks requires a role.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated doc.

# It may have an optional COLON <ROLE> COLON in front where
# <ROLE> is any nonempty sequence of word characters.
# Examples: func_f1 :meth:`func_h1` :obj:`~baz.obj_r` :class:`class_j`
# <DESC> is a string describing the function.

_role = r":(?P<role>\w+):"
_funcbacktick = r"`(?P<name>(?:~\w+\.)?[a-zA-Z0-9_.-]+)`"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's confusing to mix the use of \w and explicit character classes:

Suggested change
_funcbacktick = r"`(?P<name>(?:~\w+\.)?[a-zA-Z0-9_.-]+)`"
_funcbacktick = r"`(?P<name>(?:~\w+\.)?[\w.-]+)`"

Actually, I don't think the ~ or . should be obligatory.

Suggested change
_funcbacktick = r"`(?P<name>(?:~\w+\.)?[a-zA-Z0-9_.-]+)`"
_funcbacktick = r"`(?P<name>(?:~?\w+\.)?[\w.-]+|\w+)`"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regexes appearing in these patterns were taken from the original regex.

    _name_rgx = re.compile(r"^\s*(:(?P<role>\w+):"
                           r"`(?P<name>(?:~\w+\.)?[a-zA-Z0-9_.-]+)`|"
                           r" (?P<name2>[a-zA-Z0-9_.-]+))\s*", re.X)

The patterns can be changed but I'd prefer that to be in a separate PR, since that will change the meaning.

_funcplain = r"(?P<name2>[a-zA-Z0-9_.-]+)"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
_funcplain = r"(?P<name2>[a-zA-Z0-9_.-]+)"
_funcplain = r"(?P<name2>[a-zA-Z0-9_.-]+)"
Suggested change
_funcplain = r"(?P<name2>[a-zA-Z0-9_.-]+)"
_funcplain = r"(?P<name2>[\w.-]+)"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comment above regarding original _name_rgx.

_funcname = r"(" + _role + _funcbacktick + r"|" + _funcplain + r")"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we intentionally disallowing names in backticks not preceded by roles (i.e. using the default role)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was the effect of the original _name_rgx.

_funcnamenext = _funcname.replace('role', 'rolenext')
_funcnamenext = _funcnamenext.replace('name', 'namenext')
_description = r"(?P<description>\s*:(\s+(?P<desc>\S+.*))?)?\s*$"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(I think we'd like to make the space before the colon obligatory, for consistency with param lists, but I don't think the current code requires it)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe that the original _name_rgx did not require it.

_func_rgx = re.compile(r"^\s*" + _funcname + r"\s*")
_line_rgx = re.compile(
r"^\s*" +
r"(?P<allfuncs>" + # group for all function names
_funcname +
r"(?P<morefuncs>([,]\s+" + _funcnamenext + r")*)" +
r")" + # end of "allfuncs"
r"(?P<trailing>\s*,)?" + # Some function lists have a trailing comma
_description)

# Empty <DESC> elements are replaced with '..'
empty_description = '..'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't get why this is a public attribugte

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is (now) used in test_docscrape.py:line_by_line_compare.


def _parse_see_also(self, content):
"""
Expand All @@ -248,52 +278,49 @@ def _parse_see_also(self, content):
func_name1, func_name2, :meth:`func_name`, func_name3

"""

items = []

def parse_item_name(text):
"""Match ':role:`name`' or 'name'"""
m = self._name_rgx.match(text)
if m:
g = m.groups()
if g[1] is None:
return g[3], None
else:
return g[2], g[1]
raise ParseError("%s is not a item name" % text)

def push_item(name, rest):
if not name:
return
name, role = parse_item_name(name)
items.append((name, list(rest), role))
del rest[:]
"""Match ':role:`name`' or 'name'."""
m = self._func_rgx.match(text)
if not m:
raise ParseError("%s is not a item name" % text)
role = m.group('role')
name = (m.group('name') if role else m.group('name2'))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

redundant parentheses

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed.

return name, role, m.end()

current_func = None
rest = []

for line in content:
if not line.strip():
continue

m = self._name_rgx.match(line)
if m and line[m.end():].strip().startswith(':'):
push_item(current_func, rest)
current_func, line = line[:m.end()], line[m.end():]
rest = [line.split(':', 1)[1].strip()]
if not rest[0]:
rest = []
elif not line.startswith(' '):
push_item(current_func, rest)
current_func = None
if ',' in line:
for func in line.split(','):
if func.strip():
push_item(func, [])
elif line.strip():
current_func = line
elif current_func is not None:
line_match = self._line_rgx.match(line)
description = None
if line_match:
description = line_match.group('desc')
if line_match.group('trailing'):
self._error_location(
'Unexpected comma after function list at index %d of '
'line "%s"' % (line_match.end('trailing'), line),
error=False)
if not description and line.startswith(' '):
rest.append(line.strip())
push_item(current_func, rest)
elif line_match:
funcs = []
text = line_match.group('allfuncs')
while True:
if not text.strip():
break
name, role, match_end = parse_item_name(text)
funcs.append((name, role))
text = text[match_end:].strip()
if text and text[0] == ',':
text = text[1:].strip()
rest = list(filter(None, [description]))
items.append((funcs, rest))
else:
raise ParseError("%s is not a item name" % line)
return items

def _parse_index(self, section, content):
Expand Down Expand Up @@ -440,24 +467,30 @@ def _str_see_also(self, func_role):
return []
out = []
out += self._str_header("See Also")
out += ['']
last_had_desc = True
for func, desc, role in self['See Also']:
if role:
link = ':%s:`%s`' % (role, func)
elif func_role:
link = ':%s:`%s`' % (func_role, func)
else:
link = "`%s`_" % func
if desc or last_had_desc:
out += ['']
out += [link]
else:
out[-1] += ", %s" % link
for funcs, desc in self['See Also']:
assert isinstance(funcs, (list, tuple))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we allow a tuple?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only allow lists.

links = []
for func, role in funcs:
if role:
link = ':%s:`%s`' % (role, func)
elif func_role:
link = ':%s:`%s`' % (func_role, func)
else:
link = "`%s`_" % func
links.append(link)
link = ', '.join(links)
out += [link]
if desc:
out += self._str_indent([' '.join(desc)])
last_had_desc = True
else:
last_had_desc = False
out += self._str_indent([self.empty_description])

if last_had_desc:
out += ['']
out += ['']
return out

Expand Down
64 changes: 40 additions & 24 deletions numpydoc/tests/test_docscrape.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,10 +331,16 @@ def _strip_blank_lines(s):


def line_by_line_compare(a, b):
empty_description = NumpyDocString.empty_description # '..'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if it's important that these are inserted, why do we want to disregard them in testing?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the disregarding of these lines and updated the expected output to account for that.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you forgotten to commit this?

empty_description_rgx = re.compile(
r"^\s+" + re.escape(empty_description) + "$")

a = textwrap.dedent(a)
b = textwrap.dedent(b)
a = [l.rstrip() for l in _strip_blank_lines(a).split('\n')]
b = [l.rstrip() for l in _strip_blank_lines(b).split('\n')]
a = [empty_description_rgx.sub('', l) for l in a]
b = [empty_description_rgx.sub('', l) for l in b]
assert all(x == y for x, y in zip(a, b))


Expand Down Expand Up @@ -709,36 +715,46 @@ def test_see_also():
multiple lines
func_f, func_g, :meth:`func_h`, func_j,
func_k
func_f1, func_g1, :meth:`func_h1`, func_j1
func_f2, func_g2, :meth:`func_h2`, func_j2 : description of multiple
:obj:`baz.obj_q`
:obj:`~baz.obj_r`
:class:`class_j`: fubar
foobar
""")

assert len(doc6['See Also']) == 13
for func, desc, role in doc6['See Also']:
if func in ('func_a', 'func_b', 'func_c', 'func_f',
'func_g', 'func_h', 'func_j', 'func_k', 'baz.obj_q',
'~baz.obj_r'):
assert(not desc)
else:
assert(desc)

if func == 'func_h':
assert role == 'meth'
elif func == 'baz.obj_q' or func == '~baz.obj_r':
assert role == 'obj'
elif func == 'class_j':
assert role == 'class'
else:
assert role is None

if func == 'func_d':
assert desc == ['some equivalent func']
elif func == 'foo.func_e':
assert desc == ['some other func over', 'multiple lines']
elif func == 'class_j':
assert desc == ['fubar', 'foobar']
assert len(doc6['See Also']) == 10
for funcs, desc in doc6['See Also']:
for func, role in funcs:
if func in ('func_a', 'func_b', 'func_c', 'func_f',
'func_g', 'func_h', 'func_j', 'func_k', 'baz.obj_q',
'func_f1', 'func_g1', 'func_h1', 'func_j1',
'~baz.obj_r'):
assert not desc, str([func, desc])
elif func in ('func_f2', 'func_g2', 'func_h2', 'func_j2'):
assert desc, str([func, desc])
else:
assert desc, str([func, desc])

if func == 'func_h':
assert role == 'meth'
elif func == 'baz.obj_q' or func == '~baz.obj_r':
assert role == 'obj'
elif func == 'class_j':
assert role == 'class'
elif func in ['func_h1', 'func_h2']:
assert role == 'meth'
else:
assert role is None, str([func, role])

if func == 'func_d':
assert desc == ['some equivalent func']
elif func == 'foo.func_e':
assert desc == ['some other func over', 'multiple lines']
elif func == 'class_j':
assert desc == ['fubar', 'foobar']
elif func in ['func_f2', 'func_g2', 'func_h2', 'func_j2']:
assert desc == ['description of multiple'], str([desc, ['description of multiple']])


def test_see_also_parse_error():
Expand Down