Improvements to pydocstyle Python parsing

* Moves relative path parsing away from the base mapper implementation
* Change argument parsing from splitting first line of source with ',' to use
  AST traversal instead. This is not complete, but mostly PoC for now. Full
  traversal into argument type nodes will allow us to get nested dict() etc.
  We should open a ticket to track this work
* Cleans up some of the templates to reduce duplicate titles
* Adds a directive for nesting rST from constructs that might have headings.
  Remove the first heading in this case to address the case where a module has a
  docstring with a heading up front
* Adds tests
* Replaces example module with module that has more failing cases of parsing

Closes #78
Fixes #80
Fixes #81
Fixes #82
Fixes #83
Fixes #84
Fixes #85
This commit is contained in:
Anthony Johnson 2016-11-02 16:29:28 -07:00
parent 04805b5044
commit f607d5e1db
13 changed files with 384 additions and 1833 deletions

35
autoapi/directives.py Normal file
View File

@ -0,0 +1,35 @@
"""AutoAPI directives"""
from docutils.parsers.rst import Directive
from docutils import nodes
from sphinx.util.nodes import nested_parse_with_titles
class NestedParse(Directive):
"""Nested parsing to remove the first heading of included rST
This is used to handle the case where we like to remove user supplied
headings from module docstrings. This is required to reduce the number of
duplicate headings on sections.
"""
has_content = 1
required_arguments = 0
optional_arguments = 0
final_argument_whitespace = False
option_spec = {}
def run(self):
node = nodes.paragraph()
node.document = self.state.document
nested_parse_with_titles(self.state, self.content, node)
try:
title_node = node[0][0]
if isinstance(title_node, nodes.title):
if isinstance(title_node[0], nodes.Text):
del node[0][0][0]
except IndexError:
pass
return [node]

View File

@ -10,8 +10,10 @@ import shutil
from sphinx.util.console import darkgreen, bold from sphinx.util.console import darkgreen, bold
from sphinx.addnodes import toctree from sphinx.addnodes import toctree
from sphinx.errors import ExtensionError from sphinx.errors import ExtensionError
from docutils.parsers.rst import directives
from .backends import default_file_mapping, default_ignore_patterns, default_backend_mapping from .backends import default_file_mapping, default_ignore_patterns, default_backend_mapping
from .directives import NestedParse
from .settings import API_ROOT from .settings import API_ROOT
default_options = ['members', 'undoc-members', 'private-members', 'special-members'] default_options = ['members', 'undoc-members', 'private-members', 'special-members']
@ -75,7 +77,6 @@ def run_autoapi(app):
out_suffix = app.config.source_suffix[0] out_suffix = app.config.source_suffix[0]
# Actual meat of the run. # Actual meat of the run.
app.info(bold('[AutoAPI] ') + darkgreen('Loading Data')) app.info(bold('[AutoAPI] ') + darkgreen('Loading Data'))
domain_obj.load( domain_obj.load(
patterns=file_patterns, patterns=file_patterns,
@ -140,3 +141,4 @@ def setup(app):
app.add_config_value('autoapi_add_toctree_entry', True, 'html') app.add_config_value('autoapi_add_toctree_entry', True, 'html')
app.add_config_value('autoapi_template_dir', [], 'html') app.add_config_value('autoapi_template_dir', [], 'html')
app.add_stylesheet('autoapi.css') app.add_stylesheet('autoapi.css')
directives.register_directive('autoapi-nested-parse', NestedParse)

View File

@ -41,7 +41,6 @@ class PythonMapperBase(object):
:var list children: Children of this object :var list children: Children of this object
:var list parameters: Parameters to this object :var list parameters: Parameters to this object
:var list methods: Methods on this object :var list methods: Methods on this object
''' '''
language = 'base' language = 'base'
@ -49,9 +48,8 @@ class PythonMapperBase(object):
# Create a page in the output for this object. # Create a page in the output for this object.
top_level_object = False top_level_object = False
def __init__(self, obj, path, options=None, jinja_env=None, url_root=None): def __init__(self, obj, options=None, jinja_env=None, url_root=None):
self.obj = obj self.obj = obj
self.path = path
self.options = options self.options = options
if jinja_env: if jinja_env:
self.jinja_env = jinja_env self.jinja_env = jinja_env
@ -125,11 +123,9 @@ class PythonMapperBase(object):
def include_dir(self, root): def include_dir(self, root):
"""Return directory of file""" """Return directory of file"""
return os.path.join( parts = [root]
root, parts.extend(self.pathname.split(os.path.sep))
os.path.dirname(self.path.relative), return '/'.join(parts)
self.pathname,
)
@property @property
def include_path(self): def include_path(self):
@ -199,7 +195,7 @@ class SphinxMapperBase(object):
''' '''
for path in self.find_files(patterns=patterns, dirs=dirs, ignore=ignore): for path in self.find_files(patterns=patterns, dirs=dirs, ignore=ignore):
data = self.read_file(path=path.absolute) data = self.read_file(path=path)
if data: if data:
self.paths[path] = data self.paths[path] = data
@ -227,14 +223,10 @@ class SphinxMapperBase(object):
continue continue
# Make sure the path is full # Make sure the path is full
if os.path.isabs(filename): if not os.path.isabs(filename):
ret_path = filename filename = os.path.join(root, filename)
else:
ret_path = os.path.join(root, filename)
rel_path = ret_path.replace(_dir, '') files_to_read.append(filename)
path_obj = Path(ret_path, rel_path[1:])
files_to_read.append(path_obj)
for _path in self.app.status_iterator( for _path in self.app.status_iterator(
files_to_read, files_to_read,
@ -266,7 +258,7 @@ class SphinxMapperBase(object):
for obj in self.create_class(data, options=options, path=path): for obj in self.create_class(data, options=options, path=path):
self.add_object(obj) self.add_object(obj)
def create_class(self, obj, options=None, **kwargs): def create_class(self, obj, options=None, path=None, **kwargs):
''' '''
Create class object. Create class object.

View File

@ -1,88 +1,92 @@
import sys
import os import os
import re
import textwrap import textwrap
import ast
from collections import defaultdict from collections import defaultdict
from pydocstyle.parser import Parser from pydocstyle.parser import Parser
if sys.version_info < (3,):
from itertools import izip_longest as zip_longest
else:
from itertools import zip_longest
from .base import PythonMapperBase, SphinxMapperBase from .base import PythonMapperBase, SphinxMapperBase
from ..utils import slugify from ..utils import slugify
class PythonSphinxMapper(SphinxMapperBase): class PythonSphinxMapper(SphinxMapperBase):
'''Auto API domain handler for Python """Auto API domain handler for Python
Parses directly from Python files. Parses directly from Python files.
:param app: Sphinx application passed in as part of the extension :param app: Sphinx application passed in as part of the extension
''' """
def load(self, patterns, dirs, **kwargs):
"""Load objects from the filesystem into the ``paths`` dictionary
Also include an attribute on the object, ``relative_path`` which is the
shortened, relative path the package/module
"""
for dir_ in dirs:
for path in self.find_files(patterns=patterns, dirs=[dir_], **kwargs):
data = self.read_file(path=path)
data.relative_path = os.path.relpath(path, dir_)
if data:
self.paths[path] = data
def read_file(self, path, **kwargs): def read_file(self, path, **kwargs):
'''Read file input into memory, returning deserialized objects """Read file input into memory, returning deserialized objects
:param path: Path of file to read :param path: Path of file to read
''' """
try: try:
parsed_data = Parser()(open(path), path) parsed_data = Parser()(open(path), path)
return parsed_data return parsed_data
except IOError: except (IOError, TypeError, ImportError):
self.app.warn('Error reading file: {0}'.format(path))
except TypeError:
self.app.warn('Error reading file: {0}'.format(path))
except ImportError:
self.app.warn('Error reading file: {0}'.format(path)) self.app.warn('Error reading file: {0}'.format(path))
return None return None
def create_class(self, data, options=None, **kwargs): def create_class(self, data, options=None, path=None, **kwargs):
"""Create a class from the passed in data """Create a class from the passed in data
:param data: dictionary data of pydocstyle output :param data: dictionary data of pydocstyle output
""" """
obj_map = dict((cls.type, cls) for cls obj_map = dict((cls.type, cls) for cls
in [PythonClass, PythonFunction, PythonModule, PythonMethod, PythonPackage]) in [PythonClass, PythonFunction, PythonModule,
PythonMethod, PythonPackage])
try: try:
cls = obj_map[data.kind] cls = obj_map[data.kind]
except KeyError: except KeyError:
self.app.warn("Unknown Type: %s" % data.kind) self.app.warn("Unknown type: %s" % data.kind)
else: else:
path = kwargs.get('path')
obj = cls(data, jinja_env=self.jinja_env, obj = cls(data, jinja_env=self.jinja_env,
options=self.app.config.autoapi_options, path=path options=self.app.config.autoapi_options, **kwargs)
)
for child_data in data.children: for child_data in data.children:
for child_obj in self.create_class(child_data, options=options, path=path): for child_obj in self.create_class(child_data, options=options,
**kwargs):
obj.children.append(child_obj) obj.children.append(child_obj)
self.add_object(child_obj)
yield obj yield obj
class PythonPythonMapper(PythonMapperBase): class PythonPythonMapper(PythonMapperBase):
language = 'python' language = 'python'
is_callable = False
def __init__(self, obj, **kwargs): def __init__(self, obj, **kwargs):
super(PythonPythonMapper, self).__init__(obj, **kwargs) super(PythonPythonMapper, self).__init__(obj, **kwargs)
# Properly name the object with dot notation self.name = self._get_full_name(obj)
if self.top_level_object: self.id = slugify(self.name)
name = self.path.relative.split('.')[0].replace('/', '.')
else:
name = '.'.join([
os.path.dirname(self.path.relative).replace('/', '.'),
obj.name
])
self.id = slugify(name)
self.name = name
# Optional # Optional
self.children = [] self.children = []
try: self.args = []
args = obj.source.split('\n')[0] if self.is_callable:
args = args.split('(')[1] self.args = self._get_arguments(obj)
args = args.split(')')[0]
self.args = args.split(',')
except:
args = ''
self.docstring = obj.docstring or '' self.docstring = obj.docstring or ''
self.docstring = textwrap.dedent(self.docstring) self.docstring = textwrap.dedent(self.docstring)
self.docstring = self.docstring.replace("'''", '').replace('"""', '') self.docstring = self.docstring.replace("'''", '').replace('"""', '')
@ -95,34 +99,151 @@ class PythonPythonMapper(PythonMapperBase):
self.item_map = defaultdict(list) self.item_map = defaultdict(list)
@property @property
def undoc_member(self): def is_undoc_member(self):
return self.docstring == '' return self.docstring == ''
@property @property
def private_member(self): def is_private_member(self):
return self.short_name[0] == '_' return self.short_name[0] == '_'
@property @property
def special_member(self): def is_special_member(self):
return self.short_name[0:2] == '__' return self.short_name[0:2] == '__'
@property @property
def display(self): def display(self):
if self.undoc_member and 'undoc-members' not in self.options: if self.is_undoc_member and 'undoc-members' not in self.options:
return False return False
if self.private_member and 'private-members' not in self.options: if self.is_private_member and 'private-members' not in self.options:
return False return False
if self.special_member and 'special-members' not in self.options: if self.is_special_member and 'special-members' not in self.options:
return False return False
return True return True
@staticmethod
def _get_full_name(obj):
"""Recursively build the full name of the object from pydocstyle
Uses an additional attribute added to the object, ``relative_path``.
This is the shortened path of the object name, if the object is a
package or module.
:param obj: pydocstyle object, as returned from Parser()
:returns: Dotted name of object
:rtype: str
"""
def _inner(obj, parts=[]):
obj_kind = obj.kind
obj_name = obj.name
if obj_kind == 'module':
obj_name = getattr(obj, 'relative_path', None) or obj.name
obj_name = obj_name.replace('/', '.')
ext = '.py'
if obj_name.endswith(ext):
obj_name = obj_name[:-len(ext)]
elif obj_kind == 'package':
obj_name = getattr(obj, 'relative_path', None) or obj.name
exts = ['/__init__.py', '.py']
for ext in exts:
if obj_name.endswith(ext):
obj_name = obj_name[:-len(ext)]
obj_name = obj_name.split('/').pop()
parts.insert(0, obj_name)
try:
return _inner(obj.parent, parts)
except AttributeError:
pass
return parts
return '.'.join(_inner(obj))
@staticmethod
def _get_arguments(obj):
"""Get arguments from a pydocstyle object
:param obj: pydocstyle object, as returned from Parser()
:returns: list of argument or argument and value pairs
:rtype: list
"""
arguments = []
source = textwrap.dedent(obj.source)
# Bare except here because AST parsing can throw any number of
# exceptions, including SyntaxError
try:
parsed = ast.parse(source)
except: # noqa
return
parsed_args = parsed.body[0].args
arg_names = [arg.id if sys.version_info < (3,) else arg.arg
for arg in parsed_args.args]
# Get defaults for display based on AST node type
arg_defaults = []
pydocstyle_map = {
ast.Name: 'id',
ast.Num: 'n',
ast.Str: lambda obj: '"{0}"'.format(obj.s),
ast.Call: lambda obj: obj.func.id,
# TODO these require traversal into the AST nodes. Add this for more
# complete argument parsing, or handle with a custom AST traversal.
ast.List: lambda _: 'list',
ast.Tuple: lambda _: 'tuple',
ast.Set: lambda _: 'set',
ast.Dict: lambda _: 'dict',
}
if sys.version_info >= (3,):
pydocstyle_map.update({
ast.NameConstant: 'value',
})
for value in parsed_args.defaults:
default = None
try:
default = pydocstyle_map[type(value)](value)
except TypeError:
default = getattr(value, pydocstyle_map[type(value)])
except KeyError:
pass
if default is None:
default = 'None'
arg_defaults.append(default)
# Apply defaults padded to the end of the longest list. AST returns
# argument defaults as a short array that applies to the end of the list
# of arguments
for (name, default) in zip_longest(reversed(arg_names),
reversed(arg_defaults)):
arg = name
if default is not None:
arg = '{0}={1}'.format(name, default)
arguments.insert(0, arg)
# Add *args and **kwargs
if parsed_args.vararg:
arguments.append('*{0}'.format(
parsed_args.vararg
if sys.version_info < (3,3)
else parsed_args.vararg.arg
))
if parsed_args.kwarg:
arguments.append('**{0}'.format(
parsed_args.kwarg
if sys.version_info < (3,3)
else parsed_args.kwarg.arg
))
return arguments
class PythonFunction(PythonPythonMapper): class PythonFunction(PythonPythonMapper):
type = 'function' type = 'function'
is_callable = True
class PythonMethod(PythonPythonMapper): class PythonMethod(PythonPythonMapper):
type = 'method' type = 'method'
is_callable = True
class PythonModule(PythonPythonMapper): class PythonModule(PythonPythonMapper):

View File

@ -1,13 +1,13 @@
{% if not 'nested' in obj._human %} .. autoapi-hidden::
Class {{ obj.short_name }} {{ obj.short_name }}
~~~~~~{{ "~" * obj.short_name|length }} {{ "=" * obj.short_name|length }}
{% endif %}
.. py:class:: {{ obj.short_name }}{% if obj.args %}({{ obj.args|join(',') }}){% endif %} .. py:class:: {{ obj.short_name }}{% if obj.args %}({{ obj.args|join(',') }}){% endif %}
{%- if obj.docstring %} {%- if obj.docstring %}
{{ obj.docstring|indent(3) }} .. autoapi-nested-parse::
{{ obj.docstring|indent(6) }}
{% endif %} {% endif %}

View File

@ -2,4 +2,3 @@
{{ obj.docstring|indent(3) }} {{ obj.docstring|indent(3) }}

View File

@ -3,7 +3,7 @@
.. method:: {{ obj.name }}({{ obj.args[1:]|join(',') }}) .. method:: {{ obj.name }}({{ obj.args[1:]|join(',') }})
{% if obj.docstring %} {% if obj.docstring %}
{{ obj.docstring }} {{ obj.docstring|indent(3) }}
{% endif %} {% endif %}
{% endif %} {% endif %}

View File

@ -1,14 +1,15 @@
Module {{ obj.name }} {{ obj.name }}
-------{{ "-" * obj.name|length }} {{ "=" * obj.name|length }}
.. py:module:: {{ obj.name }}
{%- if obj.docstring %} {%- if obj.docstring %}
{{ obj.docstring }} .. autoapi-nested-parse::
{{ obj.docstring|indent(3) }}
{% endif %} {% endif %}
.. py:module:: {{ obj.name }}
{% block content %} {% block content %}
{%- for obj_item in obj.children %} {%- for obj_item in obj.children %}
@ -16,4 +17,3 @@ Module {{ obj.name }}
{%- endfor %} {%- endfor %}
{% endblock %} {% endblock %}

View File

@ -1,19 +1 @@
Package {{ obj.name }} {% extends "python/module.rst" %}
========{{ "=" * obj.name|length }}
{%- if obj.docstring %}
{{ obj.docstring }}
{% endif %}
.. py:module:: {{ obj.name }}
{% block content %}
{%- for obj_item in obj.children %}
{{ obj_item.rendered|indent(0) }}
{%- endfor %}
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@ -3,15 +3,15 @@ import os
import sys import sys
import shutil import shutil
import unittest import unittest
from contextlib import contextmanager
from mock import patch from mock import patch
from sphinx.application import Sphinx from sphinx.application import Sphinx
class LanguageIntegrationTests(unittest.TestCase): @contextmanager
def sphinx_build(test_dir):
def _run_test(self, test_dir, test_file, test_string):
os.chdir('tests/{0}'.format(test_dir)) os.chdir('tests/{0}'.format(test_dir))
try: try:
app = Sphinx( app = Sphinx(
@ -22,14 +22,21 @@ class LanguageIntegrationTests(unittest.TestCase):
buildername='text', buildername='text',
) )
app.build(force_all=True) app.build(force_all=True)
with open(test_file) as fin: yield
text = fin.read().strip()
self.assertIn(test_string, text)
finally: finally:
shutil.rmtree('_build') shutil.rmtree('_build')
os.chdir('../..') os.chdir('../..')
class LanguageIntegrationTests(unittest.TestCase):
def _run_test(self, test_dir, test_file, test_string):
with sphinx_build(test_dir):
with open(test_file) as fin:
text = fin.read().strip()
self.assertIn(test_string, text)
class JavaScriptTests(LanguageIntegrationTests): class JavaScriptTests(LanguageIntegrationTests):
def _js_read(self, path): def _js_read(self, path):
@ -60,12 +67,27 @@ class GoTests(LanguageIntegrationTests):
class PythonTests(LanguageIntegrationTests): class PythonTests(LanguageIntegrationTests):
@unittest.skipIf(sys.version_info > (3, 0), 'Epydoc does not support Python 3')
def test_integration(self): def test_integration(self):
self._run_test( with sphinx_build('pyexample'):
'pyexample', example_file = open('_build/text/autoapi/example/index.txt').read()
'_build/text/autoapi/example/index.txt', self.assertIn(
'Compute the square root of x and return it' 'class example.Foo',
example_file
)
self.assertIn(
'example.Foo.method_okay(foo=None, bar=None)',
example_file
)
self.assertIn(
'example.Foo.method_multiline(foo=None, bar=None, baz=None)',
example_file
)
self.assertIn(
'example.Foo.method_tricky(foo=None, bar=dict)',
example_file
)
self.assertFalse(
os.path.exists('_build/text/autoapi/method_multiline')
) )
@ -96,7 +118,6 @@ class DotNetTests(LanguageIntegrationTests):
class IntegrationTests(LanguageIntegrationTests): class IntegrationTests(LanguageIntegrationTests):
@unittest.skipIf(sys.version_info > (3, 0), 'Epydoc does not support Python 3')
def test_template_overrides(self): def test_template_overrides(self):
self._run_test( self._run_test(
'templateexample', 'templateexample',
@ -107,7 +128,6 @@ class IntegrationTests(LanguageIntegrationTests):
class TOCTreeTests(LanguageIntegrationTests): class TOCTreeTests(LanguageIntegrationTests):
@unittest.skipIf(sys.version_info > (3, 0), 'Epydoc does not support Python 3')
def test_toctree_overrides(self): def test_toctree_overrides(self):
self._run_test( self._run_test(
'toctreeexample', 'toctreeexample',

View File

@ -4,10 +4,12 @@
import os import os
import unittest import unittest
from collections import namedtuple
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from autoapi.mappers import dotnet from autoapi.mappers import dotnet
from autoapi.mappers import python
from autoapi.settings import TEMPLATE_DIR from autoapi.settings import TEMPLATE_DIR
@ -135,3 +137,93 @@ class DotNetObjectTests(unittest.TestCase):
self.assertEqual(cls.include_path, '/autoapi/Foo/Bar/Widget/index') self.assertEqual(cls.include_path, '/autoapi/Foo/Bar/Widget/index')
cls = dotnet.DotNetClass({'id': 'Foo.Bar.Widget'}, url_root='/autofoo') cls = dotnet.DotNetClass({'id': 'Foo.Bar.Widget'}, url_root='/autofoo')
self.assertEqual(cls.include_path, '/autofoo/Foo/Bar/Widget/index') self.assertEqual(cls.include_path, '/autofoo/Foo/Bar/Widget/index')
class PythonObjectTests(unittest.TestCase):
def test_full_name(self):
"""Full name resolution on nested objects"""
Source = namedtuple('Source', ['kind', 'name', 'parent'])
obj_module = Source(kind='module', name='example/example.py', parent=None)
obj_class = Source(kind='class', name='Foo', parent=obj_module)
obj_method = Source(kind='method', name='bar', parent=obj_class)
self.assertEqual(
python.PythonPythonMapper._get_full_name(obj_module),
'example.example'
)
self.assertEqual(
python.PythonPythonMapper._get_full_name(obj_class),
'example.example.Foo'
)
self.assertEqual(
python.PythonPythonMapper._get_full_name(obj_method),
'example.example.Foo.bar'
)
def test_arguments(self):
"""Argument parsing of source"""
Source = namedtuple('Source', ['source', 'docstring'])
obj = Source(
source=('def foobar(self, bar, baz=42, foo=True,\n'
' *args, **kwargs):\n'
' "This is a docstring"\n'
' return True\n'),
docstring='"This is a docstring"',
)
self.assertEqual(
python.PythonPythonMapper._get_arguments(obj),
['self', 'bar', 'baz=42', 'foo=True', '*args', '**kwargs']
)
def test_advanced_arguments(self):
"""Advanced argument parsing"""
Source = namedtuple('Source', ['source', 'docstring'])
obj = Source(
source=('def foobar(self, a, b, c=42, d="string", e=(1,2),\n'
' f={"a": True}, g=None, h=[1,2,3,4],\n'
' i=dict(a=True), j=False, *args, **kwargs):\n'
' "This is a docstring"\n'
' return True\n'),
docstring='"This is a docstring"',
)
self.assertEqual(
python.PythonPythonMapper._get_arguments(obj),
[
'self',
'a',
'b',
'c=42',
'd="string"',
'e=tuple',
'f=dict',
'g=None',
'h=list',
'i=dict',
'j=False',
'*args',
'**kwargs',
]
)
def test_bunk_whitespace(self):
"""Whitespace in definition throws off argument parsing"""
Source = namedtuple('Source', ['source', 'docstring'])
obj = Source(
source=(' def method_foo(self, a, b,\n'
' c):\n'
' call_something()\n'
' "This is a docstring"\n'
' return True\n'),
docstring='"This is a docstring"',
)
self.assertEqual(
python.PythonPythonMapper._get_arguments(obj),
['self', 'a', 'b', 'c']
)

View File

@ -5,6 +5,8 @@ envlist = py27,py35,lint,docs
setenv = setenv =
LANG=C LANG=C
deps = -r{toxinidir}/requirements.txt deps = -r{toxinidir}/requirements.txt
pytest
mock
commands = commands =
py.test {posargs} py.test {posargs}