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

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

fixes #32

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