diff --git a/numpydoc/docscrape.py b/numpydoc/docscrape.py index f3453c65..fc24a395 100644 --- a/numpydoc/docscrape.py +++ b/numpydoc/docscrape.py @@ -236,9 +236,41 @@ def _parse_param_list(self, content): return params - _name_rgx = re.compile(r"^\s*(:(?P\w+):" - r"`(?P(?:~\w+\.)?[a-zA-Z0-9_.-]+)`|" - r" (?P[a-zA-Z0-9_.-]+))\s*", re.X) + # See also supports the following formats. + # + # + # SPACE* COLON SPACE+ SPACE* + # ( COMMA SPACE+ )* SPACE* + # ( COMMA SPACE+ )* SPACE* COLON SPACE+ SPACE* + + # is one of + # + # COLON COLON BACKTICK BACKTICK + # where + # is a legal function name, and + # is any nonempty sequence of word characters. + # Examples: func_f1 :meth:`func_h1` :obj:`~baz.obj_r` :class:`class_j` + # is a string describing the function. + + _role = r":(?P\w+):" + _funcbacktick = r"`(?P(?:~\w+\.)?[a-zA-Z0-9_.-]+)`" + _funcplain = r"(?P[a-zA-Z0-9_.-]+)" + _funcname = r"(" + _role + _funcbacktick + r"|" + _funcplain + r")" + _funcnamenext = _funcname.replace('role', 'rolenext') + _funcnamenext = _funcnamenext.replace('name', 'namenext') + _description = r"(?P\s*:(\s+(?P\S+.*))?)?\s*$" + _func_rgx = re.compile(r"^\s*" + _funcname + r"\s*") + _line_rgx = re.compile( + r"^\s*" + + r"(?P" + # group for all function names + _funcname + + r"(?P([,]\s+" + _funcnamenext + r")*)" + + r")" + # end of "allfuncs" + r"(?P\s*,)?" + # Some function lists have a trailing comma + _description) + + # Empty elements are replaced with '..' + empty_description = '..' def _parse_see_also(self, content): """ @@ -248,52 +280,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') + 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): @@ -440,24 +469,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) + 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 diff --git a/numpydoc/tests/test_docscrape.py b/numpydoc/tests/test_docscrape.py index 20859488..7dd286c8 100644 --- a/numpydoc/tests/test_docscrape.py +++ b/numpydoc/tests/test_docscrape.py @@ -335,7 +335,7 @@ def line_by_line_compare(a, b): 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')] - assert all(x == y for x, y in zip(a, b)) + assert all(x == y for x, y in zip(a, b)), str([[x, y] for x, y in zip(a, b) if x != y]) def test_str(): @@ -403,7 +403,7 @@ def test_str(): -------- `some`_, `other`_, `funcs`_ - + .. `otherfunc`_ relationship @@ -553,7 +553,7 @@ def test_sphinx_str(): .. seealso:: :obj:`some`, :obj:`other`, :obj:`funcs` - + .. :obj:`otherfunc` relationship @@ -709,36 +709,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():