Files
idapython-src/tools/gen_examples_index.py
T
2021-03-30 08:54:44 +02:00

561 lines
18 KiB
Python
Executable File

"""
Generate the index.html for the examples subdirectory,
based on structured docstrings on each Python example
"""
from __future__ import print_function
import ast
import re
import os
import sys
from argparse import ArgumentParser
parser = ArgumentParser()
parser.add_argument("-e", "--examples-dir", required=True)
parser.add_argument("-t", "--template", required=True)
parser.add_argument("-o", "--output", required=True)
parser.add_argument("-v", "--verbose", default=False, action="store_true")
args = parser.parse_args()
def verb(msg):
if args.verbose:
print(msg)
class ProcessException(Exception):
pass
#-------------------------------------------------------------------------------
class Examples(object):
def __init__(self):
self.re_indent = re.compile(r'^ *')
self.re_tag = re.compile(r'^ *([a-zA-Z_][a-zA-Z_0-9]*):')
self.tags = Tags()
self.html = None
def template(self, path):
with open(path, 'r') as f:
self.html = HTML(f.read(), self.tags)
def process(self):
if not self.html:
raise ProcessException('No template!')
self.examples = []
for relpath, path in self._files_with_extension('.py', args.examples_dir):
verb("Processing \"%s\"" % path)
with open(path, 'r') as f:
self._load_ast(relpath, path, ast.parse(f.read(), path))
self.examples = {'sub': sorted(self.examples, \
key=lambda x: x[self.tags.order()])}
self.html.produce(self.examples)
def _load_ast(self, relpath, path, tree):
self.tags.reset()
self.tags.add(Tags.PATH, relpath)
self.tags.add(Tags.NAME, os.path.splitext(os.path.basename(path))[0])
self.tags.add(Tags.USES, self._uses_names(tree))
self.tags.add(Tags.CATEGORY, relpath.split(os.sep)[0])
self._parse_structured_comment(ast.get_docstring(tree, False))
self.examples.append(self.tags.get())
def _parse_structured_comment(self, docstring):
if not docstring:
return
first_line = True
group_tag = None
group_lines = []
def end_group():
if group_tag:
self._remove_common_indentation(group_lines)
if group_tag == Tags.DESCRIPTION:
item = '\n'.join(group_lines).lstrip('\n')
elif group_tag in (Tags.KEYWORDS, Tags.SEE_ALSO):
item = [{group_tag: x.strip()} \
for x in ','.join(group_lines).split(',')]
else:
item = ' '.join(group_lines)
self.tags.add(group_tag, item)
# Blocks of lines are separated by an empty line.
# An attempt is made to preserve the original indentation
# of each block of lines.
#
# If a block of lines wishes to contain a blank line,
# it must represent it with a line containing only a dot ('.')
# (indentation is irrelevant for these 'dot' lines).
# Example:
# description:
# This is one
# big paragraph
# .
# This is another
# big paragraph
# .
# This is the final
# big paragraph
warned = False
for line in docstring.split('\n'):
indent, line = self._indented_line(line.rstrip(), 0)
if line == '':
end_group()
first_line = True
group_tag = None
group_lines = []
continue
if first_line:
first_line = False
m = self.re_tag.match(line)
if not m:
if not warned:
warned = True
path = self.tags.get()[Tags.PATH]
verb('{}:\n No tag in docstring'.format(path))
continue
group_tag = m.group(1)
if not self.tags.is_valid(group_tag) or \
not self.tags.can_occur_in_docstring(group_tag):
path = self.tags.get()[Tags.PATH]
msg = self.tags.error_message(group_tag)
verb('{}:\n {}'.format(path, msg))
group_tag = None
indent, line = self._indented_line(line, m.end())
if group_tag:
group_lines.append( (indent, line) )
end_group()
def _indented_line(self, line, fromPos):
line = line[fromPos:]
m = self.re_indent.match(line)
indent = fromPos + len(m.group())
line = line[m.end():]
if line == '.':
return None, None
return indent, line
def _remove_common_indentation(self, lines):
if not lines:
return
min_indent = min(t[0] for t in lines if t[1])
for i in range(len(lines)):
if lines[i][1]:
lines[i] = ((lines[i][0] - min_indent) * ' ') + lines[i][1]
else:
lines[i] = ''
def _uses_names(self, tree):
names = set()
def callback(name):
names.add(name)
visitor = TreeVisitor(callback, ['idc', 'idautils'])
visitor.generic_visit(tree)
return [{Tags.USES: name} for name in sorted(names)]
def _files_with_extension(self, ext, rootdir):
for path, _, files in os.walk(rootdir):
for filename in files:
if os.path.splitext(filename)[1] == ext:
relpath = os.path.relpath(path, rootdir)
yield os.path.join(relpath, filename), \
os.path.join(path, filename)
#-------------------------------------------------------------------------------
class Tags(object):
NAME = "name"
PATH = "path"
SUMMARY = "summary"
DESCRIPTION = "description"
CATEGORY = "category"
KEYWORDS = "keywords"
USES = "uses"
SEE_ALSO = "see_also"
JSDATA = "jsdata"
# tags that can be used in the template (as a <!--gen:xxx--> comment)
ALL_TAGS = set([NAME, PATH, SUMMARY, DESCRIPTION, CATEGORY, \
KEYWORDS, USES, SEE_ALSO, JSDATA])
# tags that can be used in the examples' docstrings
IN_DOCSTRING = set([SUMMARY, DESCRIPTION, CATEGORY, \
KEYWORDS, USES, SEE_ALSO])
def __init__(self):
self.tags = {}
self.reset()
def order(self):
return self.NAME
def error_message(self, name):
return 'Unrecognized tag: "{}"'.format(name)
def check(self, name):
if name not in self.ALL_TAGS:
raise ProcessException(self.error_message(name))
def is_valid(self, name):
return name in self.ALL_TAGS
def can_occur_in_docstring(self, tag):
return tag in self.IN_DOCSTRING
def reset(self):
self.items = {}
for tag in self.ALL_TAGS:
if tag in (self.KEYWORDS, self.USES, self.SEE_ALSO):
self.items[tag] = []
elif tag != self.JSDATA:
self.items[tag] = ''
def add(self, tag, item):
self.items[tag] = item
def get(self):
return self.items
#-------------------------------------------------------------------------------
class TreeVisitor(ast.NodeVisitor):
def __init__(self, callback, interesting_names, *args):
super(TreeVisitor, self).__init__(*args)
self.my_callback = callback
self.interesting_names = set(interesting_names)
def visit_ImportFrom(self, node):
if self._is_interesting(node.module):
self._add_interesting_names(node.names)
def _add_interesting_names(self, aliases):
for alias in aliases:
if isinstance(alias, ast.alias):
name = alias.asname if alias.asname else alias.name
self.interesting_names.add(name)
def _is_interesting(self, name):
return name.startswith('ida_') or name in self.interesting_names
def visit_Name(self, node):
self._callback_if_interesting(node)
def visit_Attribute(self, node):
self._callback_if_interesting(node)
def _callback_if_interesting(self, node):
names = self._dotted_name(node)
if not names:
return
if self._is_interesting(names[0]) \
and names[-1] != '__init__': # removed calls to base constructors
self.my_callback('.'.join(names))
def _dotted_name(self, node):
if isinstance(node, ast.Name):
return [node.id]
if isinstance(node, ast.Attribute):
names = self._dotted_name(node.value)
if names:
return names + [node.attr]
return None
#-------------------------------------------------------------------------------
class HTML(object):
def __init__(self, template, tags):
self.replacer = TemplateReplacer(tags)
self.replacer.template(template)
def produce(self, examples):
with open(args.output, 'w') as f:
self.replacer.expand(examples, f)
#-------------------------------------------------------------------------------
class SpecialBlock(object):
GROUP = 0
FIRST = 1
LAST = 2
def __init__(self, kind, payload=None):
self.kind = kind
self.payload = payload
#-------------------------------------------------------------------------------
class TemplateReplacer(object):
def __init__(self, tags):
self.tags = tags
self.re_variable = re.compile(r'(?i)<!--gen:([a-z_]+)-->')
self.comment_block = '<!--gen:block-->'
self.comment_end = '<!--gen:end-->'
self.comment_group = re.compile(r'<!--gen:group:([a-z_]+)-->$')
self.comment_first = '<!--gen:first-->'
self.comment_last = '<!--gen:last-->'
def template(self, content):
lines = content.strip().split('\n')
# convert the file content, with block-end repetition blocks,
# into a list of (1) strings and (2) nested lists for blocks
stack = [[]]
for line in lines:
clean = line.strip().lower()
if clean == self.comment_block:
stack.append([])
elif clean == self.comment_end:
if len(stack) == 0:
raise ProcessException('mismatched gen:end')
d = stack.pop()
stack[-1].append(d)
elif clean == self.comment_first:
# open block
stack.append([SpecialBlock(SpecialBlock.FIRST)])
elif clean == self.comment_last:
# open block
stack.append([SpecialBlock(SpecialBlock.LAST)])
else:
m = self.comment_group.match(clean)
if m:
v = m.group(1)
self.tags.check(v)
# open block
stack.append([SpecialBlock(SpecialBlock.GROUP, v)])
else:
stack[-1].append(line)
self.template = stack.pop()
if len(stack) != 0:
raise ProcessException('gen:block/group without gen:end')
def expand(self, data, f):
self._expand(self.template, data, None, f)
def _expand(self, block, data, conditions, f):
self._verify_block(block, data)
if not isinstance(block, list):
raise ProcessException('Expected block')
for line in block:
if isinstance(line, list):
first = False # meaning, restrict/show only the first
last = False
if line and isinstance(line[0], SpecialBlock):
special = line[0]
line = line[1:]
if special.kind == SpecialBlock.GROUP:
gvar = special.payload
subkey = 'sub'
elif special.kind == SpecialBlock.FIRST:
first = True
subkey = None
elif special.kind == SpecialBlock.LAST:
last = True
subkey = None
else:
raise ProcessException('Unknown SpecialBlock: {} {}' \
.format(special.kind,
repr(special.payload)))
else:
# regular block
gvar = None
subkey = self._obtain_subkey(line)
if subkey is None:
# (i.e. first,last): multiple lines but no data repetition
if conditions:
if first and not conditions[0]:
continue
if last and not conditions[1]:
continue
self._expand(line, data, None, f)
continue
subdata = data[subkey]
if not isinstance(subdata, list):
raise ProcessException('Expected multiple data for {}' \
.format('group' if gvar else 'block'))
# repeat for each item
if gvar:
subdata = self._group_by(subdata, gvar)
n_items = len(subdata)
for i in range(n_items):
conditions = i == 0, i == n_items - 1
self._expand(line, subdata[i], conditions, f)
continue
f.write(self._substitute_variables(line, data))
f.write('\n')
def _obtain_subkey(self, block):
# presently (we don't need more at the moment)
# a block contains either:
# - a single variable inside, under the key = variable
# - multiple variables, under a constant key = 'sub'
# (so just one per block)
variables = set()
for line in block:
if isinstance(line, list):
continue # only this level
for key, _, _ in self._variables_in_line(line):
variables.add(key)
for key in variables:
break # get first
return key if len(variables) == 1 else 'sub'
def _group_by(self, data, gvar):
# data assumed already sorted
grouped = {}
for item in data:
value = item[gvar]
if isinstance(value, list):
for obj in value:
subvalue = obj[gvar]
if subvalue not in grouped:
grouped[subvalue] = []
grouped[subvalue].append(item)
else:
if value not in grouped:
grouped[value] = []
grouped[value].append(item)
return [{gvar: key, 'sub': grouped[key]} for key in sorted(grouped)]
def _substitute_variables(self, line, data):
out = ''
last = 0
for key, start, end in self._variables_in_line(line):
sub = self._substitution_string(key, data)
out += line[last:start] + sub
last = end
out += line[last:]
return out
def _substitution_string(self, key, data):
if key != Tags.JSDATA:
value = data[key]
if key == Tags.PATH:
value = "/".join(value.split(os.sep))
return str(value)
if 'sub' not in data:
ProcessException('Please use <!--gen:{}--> at the top level only' \
.format(Tags.JSDATA))
data = data['sub']
# JavaScript representation of the data
js = '// collected data\nexamples = ['
first = True
for item in data:
if first:
first = False
else:
js += ','
js += '\n' + self._js_example(item)
js += '\n];'
return js
def _js_example(self, data):
first = True
js = '{'
for tag in Tags.ALL_TAGS:
if tag in data and data[tag]:
if first:
first = False
else:
js += ','
js += "'" + tag + "': " + self._js_subdata(tag, data[tag])
js += '}'
return js
def _js_subdata(self, tag, subdata):
if isinstance(subdata, list):
# no need to recurse, these are object enclosing a single item
subdata = [x[tag] for x in subdata]
return repr(subdata)
def _verify_block(self, block, data):
# check that all variables at the first 'flat' level of this block
# are present in the data and are not sublists
# (which would require an inner block)
for line in block:
if isinstance(line, list):
continue # check only the flat level
for key, _, _ in self._variables_in_line(line):
if key == Tags.JSDATA:
continue
if key not in data:
raise ProcessException('No keyword "{}" in data' \
.format(key))
if isinstance(data[key], list):
raise ProcessException('Missing block for multiple "{}" data' \
.format(key))
def _variables_in_line(self, line):
pos = 0
while True:
m = self.re_variable.search(line, pos)
if m is None:
break
tag = m.group(1).lower()
self.tags.check(tag)
yield tag, m.start(), m.end()
pos = m.end()
try:
e = Examples()
e.template(args.template)
e.process()
except ProcessException as ex:
print('? {}\n {}'.format(sys.argv[0], ex))
sys.exit(1)
# other exceptions - let them fail