source: specdomain/trunk/src/specdomain/sphinxcontrib/specmacrofileparser.py @ 1007

Last change on this file since 1007 was 1007, checked in by jemian, 10 years ago

refs #8, much, MUCH better reporting, almost ready for release, need to improve the "howto" documentation in the docs/ subdir

  • Property svn:eol-style set to native
  • Property svn:executable set to *
  • Property svn:keywords set to Author Date Id Revision URL Header
File size: 25.9 KB
Line 
1#!/usr/bin/env python
2
3########### SVN repository information ###################
4# $Date: 2012-07-12 22:56:32 +0000 (Thu, 12 Jul 2012) $
5# $Author: jemian $
6# $Revision: 1007 $
7# $HeadURL: specdomain/trunk/src/specdomain/sphinxcontrib/specmacrofileparser.py $
8# $Id: specmacrofileparser.py 1007 2012-07-12 22:56:32Z jemian $
9########### SVN repository information ###################
10
11
12"""
13Construct a SPEC macro source code file parser for
14use by the specdomain for Sphinx.  This parser locates
15code blocks in the SPEC macro source code file across multiple lines.
16
17:copyright: Copyright 2012 by BCDA, Advanced Photon Source, Argonne National Laboratory
18:license: ANL Open Source License, see LICENSE for details.
19"""
20
21import os
22import re
23from pprint import pprint        #@UnusedImport
24
25#   http://www.txt2re.com/index-python.php3
26#  http://regexpal.com/
27
28string_start                = r'^'
29string_end                  = r'$'
30match_all                   = r'.*'
31non_greedy_filler           = match_all + r'?'
32non_greedy_whitespace       = r'\s*?'
33#double_quote_string_match   = r'("' + non_greedy_filler + r'")'
34#prog_name_match             = r'([a-z_]\w*)'
35#word_match                  = r'((?:[a-z_]\w*))'
36#cdef_match                  = r'(cdef)'
37extended_comment_marker     = r'\"{3}'
38extended_comment_match      = r'(' + extended_comment_marker + r')'
39macro_name                  = r'[a-zA-Z_][\w_]*'
40macro_name_match            = r'(' + macro_name + r')'
41arglist_match               = r'(' + match_all + r')'
42non_greedy_filler_match     = r'(' + non_greedy_filler + r')'
43variable_name_match         = r'(@?' + macro_name + r'\[?\]?)'
44
45
46       
47extended_comment_block_sig_re = re.compile(
48                        string_start
49                        + non_greedy_whitespace
50                        + extended_comment_marker
51                        + r'(' + non_greedy_filler + r')'
52                        + extended_comment_marker
53                        + non_greedy_filler
54                        + string_end, 
55                        re.IGNORECASE|re.DOTALL|re.MULTILINE)
56
57variable_description_re = re.compile(
58                        string_start
59                        + non_greedy_filler
60                        + r'#:'
61                        + non_greedy_whitespace
62                        + r'(' + non_greedy_filler + r')'
63                        + non_greedy_whitespace
64                        + string_end, 
65                        re.IGNORECASE|re.DOTALL|re.MULTILINE)
66
67   
68lgc_variable_sig_re = re.compile(
69                        r''
70                        + string_start
71                        + non_greedy_whitespace
72                        + r'(local|global|constant)'        # 1: object type
73                        + non_greedy_whitespace
74                        + r'(' + non_greedy_filler + r')'   # 2: too complicated to parse all at once
75                        + string_end
76                        , 
77                        re.DOTALL
78                        |re.MULTILINE
79                    )
80
81variable_name_re = re.compile(
82                        variable_name_match, 
83                        re.IGNORECASE|re.DOTALL|re.MULTILINE
84                        )
85
86spec_macro_declaration_match_re = re.compile(
87                        string_start
88                        + r'\s*?'                           # optional blank space
89                        + r'(r?def)'                        # 1: def_type (rdef | def)
90                        + non_greedy_whitespace
91                        + macro_name_match                  # 2: macro_name
92                        + non_greedy_filler_match           # 3: optional arguments
93                        + r'\'\{?'                          # start body section
94                        + non_greedy_filler_match           # 4: body
95                        + r'\}?\''                          # end body section
96                        + r'(#.*?)?'                        # 5: optional comment
97                        + string_end, 
98                        re.IGNORECASE|re.DOTALL|re.MULTILINE)
99   
100args_match = re.compile(
101                          r'\('
102                        + arglist_match                     # 1:  argument list
103                        + r'\)', 
104                        re.DOTALL)
105
106class SpecMacrofileParser:
107    '''
108    Parse a SPEC macro file for macro definitions,
109    variable declarations, and extended comments.
110
111        Since 2002, SPEC has allowed for triple-quoted
112        strings as extended comments.  Few, if any, have used them.
113        Assume all extended comments contain ReST formatted comments,
114        *including initial section titles or transitions*.
115   
116    Assume macro definitions are not nested (but test for this).
117   
118    Assume macro files are small enough to load completely in memory.
119       
120    An additional step would be to parse for:
121    * def
122    * cdef
123    * rdef
124    * global
125    * local
126    * constant
127    * array
128    * ...
129    '''
130   
131    def __init__(self, macrofile):
132        self.buf = None
133        self.findings = []
134        self.filename = None
135        self.read(macrofile)
136        self.parse_macro_file()
137   
138    def read(self, macrofile):
139        """
140        load the SPEC macro source code file into an internal buffer (self.buf).
141        Also remember the start and end position of each line (self.line_positions).
142       
143        :param str filename: name (with optional path) of SPEC macro file
144            (The path is relative to the ``.rst`` document.)
145        """
146        if not os.path.exists(macrofile):
147            raise RuntimeError, "file not found: " + macrofile
148        self.filename = macrofile
149        buf = open(macrofile, 'r').readlines()
150        offset = 0
151        lines = []
152        for linenumber, line in enumerate(buf):
153            end = offset+len(line)
154            lines.append([linenumber+1, offset, end])
155            offset = end
156        self.buf = ''.join(buf)
157        self.line_positions = lines
158   
159    def std_read(self, macrofile):
160        """
161        load the SPEC macro source code file into an internal buffer
162       
163        :param str filename: name (with optional path) of SPEC macro file
164            (The path is relative to the ``.rst`` document.)
165        """
166        if not os.path.exists(macrofile):
167            raise RuntimeError, "file not found: " + macrofile
168        self.filename = macrofile
169        self.buf = open(macrofile, 'r').read()
170
171    def parse_macro_file(self):
172        """
173        Figure out what can be documented in the file's contents (in self.buf)
174       
175            each of the list_something() methods returns a
176            list of dictionaries where each dictionary
177            has the keys: objtype, start_line, end_line, and others
178        """
179        db = {}
180        # first, the file parsing
181        for func in (self.list_def_macros, 
182                     self.list_cdef_macros,
183                     self.list_variables,
184                     self.list_extended_comments,
185                     self.list_descriptive_comments,
186                     ):
187            for item in func():
188                s = item['start_line']
189                if s not in db.keys():
190                    db[s] = []
191                db[s].append(item)
192       
193        # then, the analysis of what was found
194        # proceed line-by-line in order
195        # TODO: could override this rule with an option
196        self.findings = []
197        description = ''
198        clear_description = False
199        found_first_global_extended_comment = False
200        for linenumber in sorted(db.keys()):
201            #print linenumber, ':', ', '.join([d['objtype'] for d in db[linenumber]])
202           
203            line = db[linenumber]
204            item = line[-1]
205            if item['objtype'] in ('def', 'function def'):
206                # identify all the children of this item
207                parent = item['name']
208                found_first_local_extended_comment = False
209                for row in xrange(item['start_line']+1, item['end_line']-1):
210                    if row in db.keys():
211                        for thing in db[row]:
212                            thing['parent'] = parent
213                            if thing['objtype'] == 'extended comment':
214                                if not found_first_local_extended_comment:
215                                    # TODO: could override this rule with an option
216                                    item['description'] = thing['text']
217                                    found_first_local_extended_comment = False
218                if not item['name'].startswith('_'):
219                    # TODO: could override this rule with an option
220                    self.findings.append(item)
221                item['summary'] = self._extract_summary(item.get('description', ''))
222           
223            if item['objtype'] == 'extended comment':
224                start = item['start_line']
225                if item['parent'] == None:
226                    if not found_first_global_extended_comment:
227                        # TODO: could override this rule with an option
228                        self.findings.append(item)
229                        found_first_global_extended_comment = True
230
231            if item['objtype'] == 'descriptive comment':
232                description = item['text']
233
234            for item in line:
235                if item['objtype'] in ('local', 'global', 'constant', 'rdef', 'cdef'):
236                    if len(description)>0:
237                        item['description'] = description
238                        item['summary'] = self._extract_summary(description)
239                        clear_description = True
240                    if not item['name'].startswith('_'):
241                        # TODO: could override this rule with an option
242                        self.findings.append(item)
243           
244            if clear_description:
245                description, clear_description = '', False
246   
247    def _extract_summary(self, description):
248        """
249        return the short summary line from the item description text
250       
251        The summary line is the first line in the docstring,
252        such as the line above.
253       
254        For our purposes now, we return the first paragraph,
255        if it is not a parameter block such as ``:param var: ...``.
256        """
257        if len(description) == 0:
258            return ''
259        text = []
260        for line in description.strip().splitlines():
261            if len(line.strip()) == 0:
262                break
263            if not line.strip().startswith(':'):
264                text.append(line)
265        return ' '.join(text)
266
267    def list_extended_comments(self):
268        """
269        parse the internal buffer for triple-quoted strings, possibly multiline
270       
271        Usually, an extended comment is used at the top of a macro file
272        to describe the file's contents.  It is also used at the top
273        of a macro definition to describe the macro.  The first line
274        of an extended comment for a macro should be a short summary,
275        followed by a blank line, then either a parameter list or
276        more extensive documentation, as needed.
277        """
278        items = []
279        for mo in extended_comment_block_sig_re.finditer(self.buf):
280            start = self.find_pos_in_line_number(mo.start(1))
281            end = self.find_pos_in_line_number(mo.end(1))
282            text = mo.group(1)
283            items.append({
284                            'start_line': start, 
285                            'end_line':   end, 
286                            'objtype':    'extended comment',
287                            'text':       text,
288                            'parent':     None,
289                          })
290        return items
291
292    def list_descriptive_comments(self):
293        """
294        Descriptive comments are used to document items that cannot contain
295        extended comments (triple-quoted strings) such as variable declarations
296        or *rdef* or *cdef* macro declarations.  They appear either in-line
297        with the declaration or on the preceding line.
298       
299        Descriptive comment example that documents *tth*, a global variable declaration::
300           
301            global tth    #: two-theta, the scattering angle
302       
303        Descriptive comment example that documents *ccdset_shutter*, a *rdef* declaration::
304       
305            #: clear the ccd shutter handler
306            rdef ccdset_shutter ''
307        """
308        items = []
309        for mo in variable_description_re.finditer(self.buf):
310            start = self.find_pos_in_line_number(mo.start(1))
311            end = self.find_pos_in_line_number(mo.end(1))
312            items.append({
313                            'start_line': start, 
314                            'end_line':   end, 
315                            'objtype':    'descriptive comment',
316                            'text':       mo.group(1),
317                            'parent':     None,
318                          })
319        return items
320
321    def list_variables(self):
322        """
323        parse the internal buffer for local, global, and constant variable declarations
324        """
325        items = []
326        for mo in lgc_variable_sig_re.finditer(self.buf):
327            start = self.find_pos_in_line_number(mo.start(1))
328            end = self.find_pos_in_line_number(mo.end(1))
329            objtype = mo.group(1)
330            content = mo.group(2)
331            p = content.find('#')
332            if p >= 0:                                      # strip off any comment
333                content = content[:p]
334            content = re.sub('[,;]', ' ', content)          # replace , or ; with blank space
335            if content.find('[') >= 0:
336                content = re.sub('\s*?\[', '[', content)    # remove blank space before [
337            if objtype in ('constant'):
338                name = content.strip().split()[0]
339                items.append({
340                                'start_line': start, 
341                                'end_line':   end, 
342                                'objtype':    objtype,
343                                'name':       name,
344                                'parent':     None,
345                              })
346            else:
347                for var in variable_name_re.finditer(content):
348                    name = var.group(1)
349                    if len(name) > 0:
350                        items.append({
351                                        'start_line': start, 
352                                        'end_line':   end, 
353                                        'objtype':    objtype,
354                                        'name':       name,
355                                        'parent':     None,
356                                      })
357        return items
358
359    def list_def_macros(self):
360        """
361        parse the internal buffer for def and rdef macro declarations
362        """
363        items = []
364        for mo in spec_macro_declaration_match_re.finditer(self.buf):
365            objtype = mo.group(1)
366            start = self.find_pos_in_line_number(mo.start(1))
367            end = self.find_pos_in_line_number(mo.end(4))
368            args = mo.group(3)
369            if len(args)>2:
370                m = args_match.search(args)
371                if m is not None:
372                    objtype = 'function ' + objtype
373                    args = m.group(1)
374            # TODO: What if args is multi-line?  flatten.  What if really long?
375            items.append({
376                            'start_line': start, 
377                            'end_line':   end, 
378                            'objtype':    objtype,
379                            'name':       mo.group(2),
380                            'args':       args,
381                            'body':       mo.group(4),
382                            'comment':    mo.group(5),
383                            'parent':     None,
384                          })
385        return items
386
387    def list_cdef_macros(self):
388        """
389        parse the internal buffer for def and rdef macro declarations
390        """
391        # too complicated for a regular expression, just look for the initial part
392        items = []
393        for mo in re.finditer('cdef\s*?\(', self.buf):
394            # look at each potential cdef declaration
395            objtype = 'cdef'
396            start = self.find_pos_in_line_number(mo.start())
397            s = p = mo.end()                # offset s for start of args
398            nesting = 1                     # number of nested parentheses
399            sign = {'(': 1, ')': -1}        # increment or decrement
400            while nesting > 0 and p < len(self.buf):
401                if self.buf[p] in sign.keys():
402                    nesting += sign[self.buf[p]]
403                p += 1
404            e = p
405            text = self.buf[s:e-1]    # carve it out, and remove cdef( ... ) wrapping
406            end = self.find_pos_in_line_number(e)
407            p = text.find(',')
408            name = text[:p].strip('"')
409            if len(name) == 0:
410                name = '<empty name>'
411            args = text[p+1:]
412            # TODO: parse "args" for content
413            # TODO: What if args is multi-line?  convert \n to ;
414            #   args = ';'.join(args.splitlines())  # WRONG: This converts string content, as well
415            # TODO: What if args is really long?
416            items.append({
417                            'start_line': start, 
418                            'end_line':   end, 
419                            'objtype':    objtype,
420                            'name':       name,
421                            'args':       args,
422#                            'body':       mo.group(4),
423#                            'comment':    mo.group(5),
424                            'parent':     None,
425                          })
426        return items
427
428    def find_pos_in_line_number(self, pos):
429        """
430        find the line number that includes *pos*
431       
432        :param int pos: position in the file
433        """
434        # TODO: optimize this straight search using a search by bisection
435        linenumber = None
436        for linenumber, start, end in self.line_positions:
437            if start <= pos < end:
438                break
439        return linenumber
440   
441    #------------------------ reporting section below ----------------------------------
442
443    def ReST(self):
444        """create the ReStructured Text from what has been found"""
445        return self._simple_ReST_renderer()
446
447    def _simple_ReST_renderer(self):
448        """create a simple ReStructured Text rendition of the findings"""
449        declarations = []       # variables and constants
450        macros = []             # def, cdef, and rdef macros
451        functions = []          # def and rdef function macros
452        s = []
453        for r in self.findings:
454            if r['objtype'] == 'extended comment':
455                # TODO: apply rules to suppress reporting under certain circumstances
456                s.append( '' )
457                s.append( '.. %s %s %d %d' % (self.filename, 
458                                              r['objtype'], 
459                                              r['start_line'], 
460                                              r['end_line']) )
461                s.append( '' )
462                s.append(r['text'])
463                s.append( '' )
464            elif r['objtype'] in ('def', 'rdef', 'cdef', ):
465                # TODO: apply rules to suppress reporting under certain circumstances
466                macros.append(r)
467                s.append( '' )
468                s.append( '.. %s %s %s %d %d' % (self.filename, 
469                                              r['objtype'], 
470                                              r['name'], 
471                                              r['start_line'], 
472                                              r['end_line']) )
473                s.append( '.. spec:%s:: %s' % ( r['objtype'], r['name'],) )
474                s.append('')
475                s.append(' '*4 + '*' + r['objtype'] + ' macro declaration*')
476                desc = r.get('description', '')
477                if len(desc) > 0:
478                    s.append('')
479                    for line in desc.splitlines():
480                        s.append(' '*4 + line)
481                s.append( '' )
482            elif r['objtype'] in ('function def', 'function rdef',):
483                # TODO: apply rules to suppress reporting under certain circumstances
484                functions.append(r)
485                objtype = r['objtype'].split()[1]
486                s.append( '' )
487                s.append( '.. %s %s %s %d %d' % (self.filename, 
488                                              objtype, 
489                                              r['name'], 
490                                              r['start_line'], 
491                                              r['end_line']) )
492                s.append( '.. spec:%s:: %s(%s)' % ( objtype, r['name'], r['args']) )
493                s.append('')
494                s.append(' '*4 + '*' + r['objtype'].split()[1] + '() macro function declaration*')
495                desc = r.get('description', '')
496                if len(desc) > 0:
497                    s.append('')
498                    for line in desc.splitlines():
499                        s.append(' '*4 + line)
500                s.append( '' )
501           
502            # Why document local variables in a global scope?
503            elif r['objtype'] in ('global', 'constant'):
504                # TODO: apply rules to suppress reporting under certain circumstances
505                declarations.append(r)
506                if r.get('parent') is None:
507                    s.append( '.. spec:%s:: %s' % ( r['objtype'], r['name']) )
508                    s.append('')
509                    if r['objtype'] in ('constant'):
510                        s.append(' '*4 + '*constant declaration*')
511                    else:
512                        s.append(' '*4 + '*' + r['objtype'] + ' variable declaration*')
513                    desc = r.get('description', '')
514                    if len(desc) > 0:
515                        s.append('')
516                        for line in desc.splitlines():
517                            s.append(' '*4 + line)
518                    s.append( '' )
519
520        s += _report_table('Variable Declarations (%s)' % self.filename, declarations, 
521                          ('objtype', 'name', 'start_line', 'summary', ))
522        s += _report_table('Macro Declarations (%s)' % self.filename, macros, 
523                          ('objtype', 'name', 'start_line', 'end_line', 'summary', ))
524        s += _report_table('Function Macro Declarations (%s)' % self.filename, functions, 
525                          ('objtype', 'name', 'start_line', 'end_line', 'args', 'summary', ))
526        #s += _report_table('Findings from .mac File', self.findings, ('start_line', 'objtype', 'line', 'summary', ))
527
528        return '\n'.join(s)
529
530
531def _report_table(title, itemlist, col_keys = ('objtype', 'start_line', 'end_line', )):
532    """
533    return the itemlist as a reST table
534   
535    :param str title:  section heading above the table
536    :param {str,str} itemlist: database (keyed dictionary) to use for table
537    :param [str] col_keys: column labels (must be keys in the dictionary)
538    :returns [str]: the table (where each list item is a string of reST)
539    """
540    if len(itemlist) == 0:
541        return []
542    rows = []
543    last_line = None
544    for d in itemlist:
545        if d['start_line'] != last_line:
546            rowdata = [str(d.get(key,'')).strip() for key in col_keys]
547            rows.append( tuple(rowdata) )
548        last_line = d['start_line']
549    return make_rest_table(title, col_keys, rows, '=')
550
551
552def make_rest_table(title, labels, rows, titlechar = '='):
553    """
554    build a reST table
555   
556    :param str title: placed in a section heading above the table
557    :param [str] labels: columns labels
558    :param [[str]] rows: 2-D grid of data, len(labels) == len(data[i]) for all i
559    :param str titlechar: character to use when underlining title as reST section heading
560    :returns [str]: each list item is reST
561    """
562    # this is commented out since it causes a warning when building:
563    #  specmacrofileparser.py:docstring of sphinxcontrib.specmacrofileparser.make_rest_table:14: WARNING: Block quote ends without a blank line; unexpected unindent.
564    # -----
565    #    """
566    #    build a reST table
567    #       
568    #    :param str title: placed in a section heading above the table
569    #    :param [str] labels: columns labels
570    #    :param [[str]] rows: 2-D grid of data, len(labels) == len(data[i]) for all i
571    #    :param str titlechar: character to use when underlining title as reST section heading
572    #    :returns [str]: each list item is reST
573    #
574    #    Example::
575    #       
576    #        title = 'This is a reST table'
577    #        labels = ('name', 'phone', 'email')
578    #        rows = [
579    #                ['Snoopy',           '12345', 'dog@house'],
580    #                ['Red Baron',        '65432', 'fokker@triplane'],
581    #                ['Charlie Brown',    '12345', 'main@house'],
582    #        ]
583    #        print '\n'.join(make_rest_table(title, labels, rows, titlechar='~'))
584    #
585    #    This results in this reST::
586    #   
587    #        This is a reST table
588    #        ~~~~~~~~~~~~~~~~~~~~
589    #       
590    #        ============= ===== ===============
591    #        name          phone email         
592    #        ============= ===== ===============
593    #        Snoopy        12345 dog@house     
594    #        Red Baron     65432 fokker@triplane
595    #        Charlie Brown 12345 main@house     
596    #        ============= ===== ===============
597    #   
598    #    """
599    s = []
600    if len(rows) == 0:
601        return s
602    if len(labels) > 0:
603        columns = zip(labels, *rows)
604    else:
605        columns = zip(*rows)
606    widths = [max([len(item) for item in row]) for row in columns]
607    separator = " ".join( ['='*key for key in widths] )
608    fmt = " ".join( '%%-%ds' % key for key in widths )
609    s.append( '' )
610    s.append( title )
611    s.append( titlechar*len(title) )
612    s.append( '' )
613    s.append( separator )
614    if len(labels) > 0:
615        s.append( fmt % labels )
616        s.append( separator )
617    s.extend( [fmt % tuple(row) for row in rows] )
618    s.append( separator )
619    return s
620
621
622TEST_DIR = os.path.join('..', 'macros')
623
624
625if __name__ == '__main__':
626    filelist = [f for f in sorted(os.listdir(TEST_DIR)) if f.endswith('.mac')]
627    for item in filelist:
628        filename = os.path.join(TEST_DIR, item)
629        print filename
630        p = SpecMacrofileParser(filename)
631        print p.ReST()
632        #pprint (p.findings)
Note: See TracBrowser for help on using the repository browser.