Home | History | Annotate | Download | only in tools
      1 #!/usr/bin/env python
      2 # -*- coding: utf-8 -*-
      3 
      4 # Check for stylistic and formal issues in .rst and .py
      5 # files included in the documentation.
      6 #
      7 # 01/2009, Georg Brandl
      8 
      9 # TODO: - wrong versions in versionadded/changed
     10 #       - wrong markup after versionchanged directive
     11 
     12 from __future__ import with_statement
     13 
     14 import os
     15 import re
     16 import sys
     17 import getopt
     18 from os.path import join, splitext, abspath, exists
     19 from collections import defaultdict
     20 
     21 directives = [
     22     # standard docutils ones
     23     'admonition', 'attention', 'caution', 'class', 'compound', 'container',
     24     'contents', 'csv-table', 'danger', 'date', 'default-role', 'epigraph',
     25     'error', 'figure', 'footer', 'header', 'highlights', 'hint', 'image',
     26     'important', 'include', 'line-block', 'list-table', 'meta', 'note',
     27     'parsed-literal', 'pull-quote', 'raw', 'replace',
     28     'restructuredtext-test-directive', 'role', 'rubric', 'sectnum', 'sidebar',
     29     'table', 'target-notes', 'tip', 'title', 'topic', 'unicode', 'warning',
     30     # Sphinx and Python docs custom ones
     31     'acks', 'attribute', 'autoattribute', 'autoclass', 'autodata',
     32     'autoexception', 'autofunction', 'automethod', 'automodule', 'centered',
     33     'cfunction', 'class', 'classmethod', 'cmacro', 'cmdoption', 'cmember',
     34     'code-block', 'confval', 'cssclass', 'ctype', 'currentmodule', 'cvar',
     35     'data', 'decorator', 'decoratormethod', 'deprecated-removed',
     36     'deprecated(?!-removed)', 'describe', 'directive', 'doctest', 'envvar',
     37     'event', 'exception', 'function', 'glossary', 'highlight', 'highlightlang',
     38     'impl-detail', 'index', 'literalinclude', 'method', 'miscnews', 'module',
     39     'moduleauthor', 'opcode', 'pdbcommand', 'productionlist',
     40     'tabularcolumns', 'testcode', 'testoutput', 'testsetup', 'toctree', 'todo',
     41     'todolist', 'versionadded', 'versionchanged'
     42 ]
     43 
     44 all_directives = '(' + '|'.join(directives) + ')'
     45 seems_directive_re = re.compile(r'(?<!\.)\.\. %s([^a-z:]|:(?!:))' % all_directives)
     46 default_role_re = re.compile(r'(^| )`\w([^`]*?\w)?`($| )')
     47 leaked_markup_re = re.compile(r'[a-z]::\s|`|\.\.\s*\w+:')
     48 
     49 
     50 checkers = {}
     51 
     52 checker_props = {'severity': 1, 'falsepositives': False}
     53 
     54 def checker(*suffixes, **kwds):
     55     """Decorator to register a function as a checker."""
     56     def deco(func):
     57         for suffix in suffixes:
     58             checkers.setdefault(suffix, []).append(func)
     59         for prop in checker_props:
     60             setattr(func, prop, kwds.get(prop, checker_props[prop]))
     61         return func
     62     return deco
     63 
     64 
     65 @checker('.py', severity=4)
     66 def check_syntax(fn, lines):
     67     """Check Python examples for valid syntax."""
     68     code = ''.join(lines)
     69     if '\r' in code:
     70         if os.name != 'nt':
     71             yield 0, '\\r in code file'
     72         code = code.replace('\r', '')
     73     try:
     74         compile(code, fn, 'exec')
     75     except SyntaxError, err:
     76         yield err.lineno, 'not compilable: %s' % err
     77 
     78 
     79 @checker('.rst', severity=2)
     80 def check_suspicious_constructs(fn, lines):
     81     """Check for suspicious reST constructs."""
     82     inprod = False
     83     for lno, line in enumerate(lines):
     84         if seems_directive_re.search(line):
     85             yield lno+1, 'comment seems to be intended as a directive'
     86         if '.. productionlist::' in line:
     87             inprod = True
     88         elif not inprod and default_role_re.search(line):
     89             yield lno+1, 'default role used'
     90         elif inprod and not line.strip():
     91             inprod = False
     92 
     93 
     94 @checker('.py', '.rst')
     95 def check_whitespace(fn, lines):
     96     """Check for whitespace and line length issues."""
     97     for lno, line in enumerate(lines):
     98         if '\r' in line:
     99             yield lno+1, '\\r in line'
    100         if '\t' in line:
    101             yield lno+1, 'OMG TABS!!!1'
    102         if line[:-1].rstrip(' \t') != line[:-1]:
    103             yield lno+1, 'trailing whitespace'
    104 
    105 
    106 @checker('.rst', severity=0)
    107 def check_line_length(fn, lines):
    108     """Check for line length; this checker is not run by default."""
    109     for lno, line in enumerate(lines):
    110         if len(line) > 81:
    111             # don't complain about tables, links and function signatures
    112             if line.lstrip()[0] not in '+|' and \
    113                'http://' not in line and \
    114                not line.lstrip().startswith(('.. function',
    115                                              '.. method',
    116                                              '.. cfunction')):
    117                 yield lno+1, "line too long"
    118 
    119 
    120 @checker('.html', severity=2, falsepositives=True)
    121 def check_leaked_markup(fn, lines):
    122     """Check HTML files for leaked reST markup; this only works if
    123     the HTML files have been built.
    124     """
    125     for lno, line in enumerate(lines):
    126         if leaked_markup_re.search(line):
    127             yield lno+1, 'possibly leaked markup: %r' % line
    128 
    129 
    130 def main(argv):
    131     usage = '''\
    132 Usage: %s [-v] [-f] [-s sev] [-i path]* [path]
    133 
    134 Options:  -v       verbose (print all checked file names)
    135           -f       enable checkers that yield many false positives
    136           -s sev   only show problems with severity >= sev
    137           -i path  ignore subdir or file path
    138 ''' % argv[0]
    139     try:
    140         gopts, args = getopt.getopt(argv[1:], 'vfs:i:')
    141     except getopt.GetoptError:
    142         print usage
    143         return 2
    144 
    145     verbose = False
    146     severity = 1
    147     ignore = []
    148     falsepos = False
    149     for opt, val in gopts:
    150         if opt == '-v':
    151             verbose = True
    152         elif opt == '-f':
    153             falsepos = True
    154         elif opt == '-s':
    155             severity = int(val)
    156         elif opt == '-i':
    157             ignore.append(abspath(val))
    158 
    159     if len(args) == 0:
    160         path = '.'
    161     elif len(args) == 1:
    162         path = args[0]
    163     else:
    164         print usage
    165         return 2
    166 
    167     if not exists(path):
    168         print 'Error: path %s does not exist' % path
    169         return 2
    170 
    171     count = defaultdict(int)
    172     out = sys.stdout
    173 
    174     for root, dirs, files in os.walk(path):
    175         # ignore subdirs controlled by svn
    176         if '.svn' in dirs:
    177             dirs.remove('.svn')
    178 
    179         # ignore subdirs in ignore list
    180         if abspath(root) in ignore:
    181             del dirs[:]
    182             continue
    183 
    184         for fn in files:
    185             fn = join(root, fn)
    186             if fn[:2] == './':
    187                 fn = fn[2:]
    188 
    189             # ignore files in ignore list
    190             if abspath(fn) in ignore:
    191                 continue
    192 
    193             ext = splitext(fn)[1]
    194             checkerlist = checkers.get(ext, None)
    195             if not checkerlist:
    196                 continue
    197 
    198             if verbose:
    199                 print 'Checking %s...' % fn
    200 
    201             try:
    202                 with open(fn, 'r') as f:
    203                     lines = list(f)
    204             except (IOError, OSError), err:
    205                 print '%s: cannot open: %s' % (fn, err)
    206                 count[4] += 1
    207                 continue
    208 
    209             for checker in checkerlist:
    210                 if checker.falsepositives and not falsepos:
    211                     continue
    212                 csev = checker.severity
    213                 if csev >= severity:
    214                     for lno, msg in checker(fn, lines):
    215                         print >>out, '[%d] %s:%d: %s' % (csev, fn, lno, msg)
    216                         count[csev] += 1
    217     if verbose:
    218         print
    219     if not count:
    220         if severity > 1:
    221             print 'No problems with severity >= %d found.' % severity
    222         else:
    223             print 'No problems found.'
    224     else:
    225         for severity in sorted(count):
    226             number = count[severity]
    227             print '%d problem%s with severity %d found.' % \
    228                   (number, number > 1 and 's' or '', severity)
    229     return int(bool(count))
    230 
    231 
    232 if __name__ == '__main__':
    233     sys.exit(main(sys.argv))
    234