DebugClients/Python/coverage/parser.py

changeset 5051
3586ebd9fac8
parent 4491
0d8612e24fef
--- a/DebugClients/Python/coverage/parser.py	Sat Jul 23 13:33:54 2016 +0200
+++ b/DebugClients/Python/coverage/parser.py	Sun Jul 24 12:01:01 2016 +0200
@@ -3,23 +3,30 @@
 
 """Code parsing for coverage.py."""
 
+import ast
 import collections
-import dis
+import os
 import re
 import token
 import tokenize
 
+from coverage import env
 from coverage.backward import range    # pylint: disable=redefined-builtin
-from coverage.backward import bytes_to_ints
-from coverage.bytecode import ByteCodes, CodeObjects
-from coverage.misc import contract, nice_pair, expensive, join_regex
+from coverage.backward import bytes_to_ints, string_class
+from coverage.bytecode import CodeObjects
+from coverage.debug import short_stack
+from coverage.misc import contract, new_contract, nice_pair, join_regex
 from coverage.misc import CoverageException, NoSource, NotPython
-from coverage.phystokens import compile_unicode, generate_tokens
+from coverage.phystokens import compile_unicode, generate_tokens, neuter_encoding_declaration
 
 
 class PythonParser(object):
-    """Parse code to find executable lines, excluded lines, etc."""
+    """Parse code to find executable lines, excluded lines, etc.
 
+    This information is all based on static analysis: no code execution is
+    involved.
+
+    """
     @contract(text='unicode|None')
     def __init__(self, text=None, filename=None, exclude=None):
         """
@@ -42,29 +49,45 @@
 
         self.exclude = exclude
 
-        self.show_tokens = False
-
         # The text lines of the parsed code.
         self.lines = self.text.split('\n')
 
-        # The line numbers of excluded lines of code.
+        # The normalized line numbers of the statements in the code. Exclusions
+        # are taken into account, and statements are adjusted to their first
+        # lines.
+        self.statements = set()
+
+        # The normalized line numbers of the excluded lines in the code,
+        # adjusted to their first lines.
         self.excluded = set()
 
+        # The raw_* attributes are only used in this class, and in
+        # lab/parser.py to show how this class is working.
+
+        # The line numbers that start statements, as reported by the line
+        # number table in the bytecode.
+        self.raw_statements = set()
+
+        # The raw line numbers of excluded lines of code, as marked by pragmas.
+        self.raw_excluded = set()
+
+        # The line numbers of class and function definitions.
+        self.raw_classdefs = set()
+
         # The line numbers of docstring lines.
-        self.docstrings = set()
+        self.raw_docstrings = set()
 
-        # The line numbers of class definitions.
-        self.classdefs = set()
+        # Internal detail, used by lab/parser.py.
+        self.show_tokens = False
 
-        # A dict mapping line numbers to (lo,hi) for multi-line statements.
-        self.multiline = {}
+        # A dict mapping line numbers to lexical statement starts for
+        # multi-line statements.
+        self._multiline = {}
 
-        # The line numbers that start statements.
-        self.statement_starts = set()
-
-        # Lazily-created ByteParser and arc data.
+        # Lazily-created ByteParser, arc data, and missing arc descriptions.
         self._byte_parser = None
         self._all_arcs = None
+        self._missing_arc_fragments = None
 
     @property
     def byte_parser(self):
@@ -81,7 +104,10 @@
         part of it.
 
         """
-        regex_c = re.compile(join_regex(regexes))
+        combined = join_regex(regexes)
+        if env.PY2:
+            combined = combined.decode("utf8")
+        regex_c = re.compile(combined)
         matches = set()
         for i, ltext in enumerate(self.lines, start=1):
             if regex_c.search(ltext):
@@ -91,21 +117,23 @@
     def _raw_parse(self):
         """Parse the source to find the interesting facts about its lines.
 
-        A handful of member fields are updated.
+        A handful of attributes are updated.
 
         """
         # Find lines which match an exclusion pattern.
         if self.exclude:
-            self.excluded = self.lines_matching(self.exclude)
+            self.raw_excluded = self.lines_matching(self.exclude)
 
         # Tokenize, to find excluded suites, to find docstrings, and to find
         # multi-line statements.
         indent = 0
         exclude_indent = 0
         excluding = False
+        excluding_decorators = False
         prev_toktype = token.INDENT
         first_line = None
         empty = True
+        first_on_line = True
 
         tokgen = generate_tokens(self.text)
         for toktype, ttext, (slineno, _), (elineno, _), ltext in tokgen:
@@ -118,32 +146,44 @@
                 indent += 1
             elif toktype == token.DEDENT:
                 indent -= 1
-            elif toktype == token.NAME and ttext == 'class':
-                # Class definitions look like branches in the byte code, so
-                # we need to exclude them.  The simplest way is to note the
-                # lines with the 'class' keyword.
-                self.classdefs.add(slineno)
-            elif toktype == token.OP and ttext == ':':
-                if not excluding and elineno in self.excluded:
-                    # Start excluding a suite.  We trigger off of the colon
-                    # token so that the #pragma comment will be recognized on
-                    # the same line as the colon.
-                    exclude_indent = indent
-                    excluding = True
+            elif toktype == token.NAME:
+                if ttext == 'class':
+                    # Class definitions look like branches in the bytecode, so
+                    # we need to exclude them.  The simplest way is to note the
+                    # lines with the 'class' keyword.
+                    self.raw_classdefs.add(slineno)
+            elif toktype == token.OP:
+                if ttext == ':':
+                    should_exclude = (elineno in self.raw_excluded) or excluding_decorators
+                    if not excluding and should_exclude:
+                        # Start excluding a suite.  We trigger off of the colon
+                        # token so that the #pragma comment will be recognized on
+                        # the same line as the colon.
+                        self.raw_excluded.add(elineno)
+                        exclude_indent = indent
+                        excluding = True
+                        excluding_decorators = False
+                elif ttext == '@' and first_on_line:
+                    # A decorator.
+                    if elineno in self.raw_excluded:
+                        excluding_decorators = True
+                    if excluding_decorators:
+                        self.raw_excluded.add(elineno)
             elif toktype == token.STRING and prev_toktype == token.INDENT:
                 # Strings that are first on an indented line are docstrings.
                 # (a trick from trace.py in the stdlib.) This works for
                 # 99.9999% of cases.  For the rest (!) see:
                 # http://stackoverflow.com/questions/1769332/x/1769794#1769794
-                self.docstrings.update(range(slineno, elineno+1))
+                self.raw_docstrings.update(range(slineno, elineno+1))
             elif toktype == token.NEWLINE:
                 if first_line is not None and elineno != first_line:
                     # We're at the end of a line, and we've ended on a
                     # different line than the first line of the statement,
                     # so record a multi-line range.
                     for l in range(first_line, elineno+1):
-                        self.multiline[l] = first_line
+                        self._multiline[l] = first_line
                 first_line = None
+                first_on_line = True
 
             if ttext.strip() and toktype != tokenize.COMMENT:
                 # A non-whitespace token.
@@ -156,21 +196,18 @@
                     if excluding and indent <= exclude_indent:
                         excluding = False
                     if excluding:
-                        self.excluded.add(elineno)
+                        self.raw_excluded.add(elineno)
+                    first_on_line = False
 
             prev_toktype = toktype
 
         # Find the starts of the executable statements.
         if not empty:
-            self.statement_starts.update(self.byte_parser._find_statements())
+            self.raw_statements.update(self.byte_parser._find_statements())
 
     def first_line(self, line):
         """Return the first line number of the statement including `line`."""
-        first_line = self.multiline.get(line)
-        if first_line:
-            return first_line
-        else:
-            return line
+        return self._multiline.get(line, line)
 
     def first_lines(self, lines):
         """Map the line numbers in `lines` to the correct first line of the
@@ -187,20 +224,13 @@
 
     def translate_arcs(self, arcs):
         """Implement `FileReporter.translate_arcs`."""
-        return [
-            (self.first_line(a), self.first_line(b))
-            for (a, b) in arcs
-        ]
+        return [(self.first_line(a), self.first_line(b)) for (a, b) in arcs]
 
-    @expensive
     def parse_source(self):
         """Parse source text to find executable lines, excluded lines, etc.
 
-        Return values are 1) a set of executable line numbers, and 2) a set of
-        excluded line numbers.
-
-        Reported line numbers are normalized to the first line of multi-line
-        statements.
+        Sets the .excluded and .statements attributes, normalized to the first
+        line of multi-line statements.
 
         """
         try:
@@ -211,20 +241,16 @@
             else:
                 lineno = err.args[1][0]     # TokenError
             raise NotPython(
-                "Couldn't parse '%s' as Python source: '%s' at line %d" % (
+                u"Couldn't parse '%s' as Python source: '%s' at line %d" % (
                     self.filename, err.args[0], lineno
                 )
             )
 
-        excluded_lines = self.first_lines(self.excluded)
-        ignore = set()
-        ignore.update(excluded_lines)
-        ignore.update(self.docstrings)
-        starts = self.statement_starts - ignore
-        lines = self.first_lines(starts)
-        lines -= ignore
+        self.excluded = self.first_lines(self.raw_excluded)
 
-        return lines, excluded_lines
+        ignore = self.excluded | self.raw_docstrings
+        starts = self.raw_statements - ignore
+        self.statements = self.first_lines(starts) - ignore
 
     def arcs(self):
         """Get information about the arcs available in the code.
@@ -234,99 +260,98 @@
 
         """
         if self._all_arcs is None:
-            self._all_arcs = set()
-            for l1, l2 in self.byte_parser._all_arcs():
-                fl1 = self.first_line(l1)
-                fl2 = self.first_line(l2)
-                if fl1 != fl2:
-                    self._all_arcs.add((fl1, fl2))
+            self._analyze_ast()
         return self._all_arcs
 
+    def _analyze_ast(self):
+        """Run the AstArcAnalyzer and save its results.
+
+        `_all_arcs` is the set of arcs in the code.
+
+        """
+        aaa = AstArcAnalyzer(self.text, self.raw_statements, self._multiline)
+        aaa.analyze()
+
+        self._all_arcs = set()
+        for l1, l2 in aaa.arcs:
+            fl1 = self.first_line(l1)
+            fl2 = self.first_line(l2)
+            if fl1 != fl2:
+                self._all_arcs.add((fl1, fl2))
+
+        self._missing_arc_fragments = aaa.missing_arc_fragments
+
     def exit_counts(self):
         """Get a count of exits from that each line.
 
         Excluded lines are excluded.
 
         """
-        excluded_lines = self.first_lines(self.excluded)
         exit_counts = collections.defaultdict(int)
         for l1, l2 in self.arcs():
             if l1 < 0:
                 # Don't ever report -1 as a line number
                 continue
-            if l1 in excluded_lines:
+            if l1 in self.excluded:
                 # Don't report excluded lines as line numbers.
                 continue
-            if l2 in excluded_lines:
+            if l2 in self.excluded:
                 # Arcs to excluded lines shouldn't count.
                 continue
             exit_counts[l1] += 1
 
         # Class definitions have one extra exit, so remove one for each:
-        for l in self.classdefs:
+        for l in self.raw_classdefs:
             # Ensure key is there: class definitions can include excluded lines.
             if l in exit_counts:
                 exit_counts[l] -= 1
 
         return exit_counts
 
-
-## Opcodes that guide the ByteParser.
+    def missing_arc_description(self, start, end, executed_arcs=None):
+        """Provide an English sentence describing a missing arc."""
+        if self._missing_arc_fragments is None:
+            self._analyze_ast()
 
-def _opcode(name):
-    """Return the opcode by name from the dis module."""
-    return dis.opmap[name]
-
+        actual_start = start
 
-def _opcode_set(*names):
-    """Return a set of opcodes by the names in `names`."""
-    s = set()
-    for name in names:
-        try:
-            s.add(_opcode(name))
-        except KeyError:
-            pass
-    return s
+        if (
+            executed_arcs and
+            end < 0 and end == -start and
+            (end, start) not in executed_arcs and
+            (end, start) in self._missing_arc_fragments
+        ):
+            # It's a one-line callable, and we never even started it,
+            # and we have a message about not starting it.
+            start, end = end, start
 
-# Opcodes that leave the code object.
-OPS_CODE_END = _opcode_set('RETURN_VALUE')
-
-# Opcodes that unconditionally end the code chunk.
-OPS_CHUNK_END = _opcode_set(
-    'JUMP_ABSOLUTE', 'JUMP_FORWARD', 'RETURN_VALUE', 'RAISE_VARARGS',
-    'BREAK_LOOP', 'CONTINUE_LOOP',
-)
+        fragment_pairs = self._missing_arc_fragments.get((start, end), [(None, None)])
 
-# Opcodes that unconditionally begin a new code chunk.  By starting new chunks
-# with unconditional jump instructions, we neatly deal with jumps to jumps
-# properly.
-OPS_CHUNK_BEGIN = _opcode_set('JUMP_ABSOLUTE', 'JUMP_FORWARD')
-
-# Opcodes that push a block on the block stack.
-OPS_PUSH_BLOCK = _opcode_set(
-    'SETUP_LOOP', 'SETUP_EXCEPT', 'SETUP_FINALLY', 'SETUP_WITH'
-)
-
-# Block types for exception handling.
-OPS_EXCEPT_BLOCKS = _opcode_set('SETUP_EXCEPT', 'SETUP_FINALLY')
+        msgs = []
+        for fragment_pair in fragment_pairs:
+            smsg, emsg = fragment_pair
 
-# Opcodes that pop a block from the block stack.
-OPS_POP_BLOCK = _opcode_set('POP_BLOCK')
-
-# Opcodes that have a jump destination, but aren't really a jump.
-OPS_NO_JUMP = OPS_PUSH_BLOCK
+            if emsg is None:
+                if end < 0:
+                    # Hmm, maybe we have a one-line callable, let's check.
+                    if (-end, end) in self._missing_arc_fragments:
+                        return self.missing_arc_description(-end, end)
+                    emsg = "didn't jump to the function exit"
+                else:
+                    emsg = "didn't jump to line {lineno}"
+            emsg = emsg.format(lineno=end)
 
-# Individual opcodes we need below.
-OP_BREAK_LOOP = _opcode('BREAK_LOOP')
-OP_END_FINALLY = _opcode('END_FINALLY')
-OP_COMPARE_OP = _opcode('COMPARE_OP')
-COMPARE_EXCEPTION = 10  # just have to get this constant from the code.
-OP_LOAD_CONST = _opcode('LOAD_CONST')
-OP_RETURN_VALUE = _opcode('RETURN_VALUE')
+            msg = "line {start} {emsg}".format(start=actual_start, emsg=emsg)
+            if smsg is not None:
+                msg += ", because {smsg}".format(smsg=smsg.format(lineno=actual_start))
+
+            msgs.append(msg)
+
+        return " or ".join(msgs)
 
 
 class ByteParser(object):
-    """Parse byte codes to understand the structure of code."""
+    """Parse bytecode to understand the structure of code."""
 
     @contract(text='unicode')
     def __init__(self, text, code=None, filename=None):
@@ -338,14 +363,14 @@
                 self.code = compile_unicode(text, filename, "exec")
             except SyntaxError as synerr:
                 raise NotPython(
-                    "Couldn't parse '%s' as Python source: '%s' at line %d" % (
+                    u"Couldn't parse '%s' as Python source: '%s' at line %d" % (
                         filename, synerr.msg, synerr.lineno
                     )
                 )
 
         # Alternative Python implementations don't always provide all the
         # attributes on code objects that we need to do the analysis.
-        for attr in ['co_lnotab', 'co_firstlineno', 'co_consts', 'co_code']:
+        for attr in ['co_lnotab', 'co_firstlineno', 'co_consts']:
             if not hasattr(self.code, attr):
                 raise CoverageException(
                     "This implementation of Python doesn't support code analysis.\n"
@@ -400,275 +425,610 @@
             for _, l in bp._bytes_lines():
                 yield l
 
-    def _block_stack_repr(self, block_stack):               # pragma: debugging
-        """Get a string version of `block_stack`, for debugging."""
-        blocks = ", ".join(
-            "(%s, %r)" % (dis.opname[b[0]], b[1]) for b in block_stack
-        )
-        return "[" + blocks + "]"
+
+#
+# AST analysis
+#
+
+class LoopBlock(object):
+    """A block on the block stack representing a `for` or `while` loop."""
+    def __init__(self, start):
+        self.start = start
+        self.break_exits = set()
+
+
+class FunctionBlock(object):
+    """A block on the block stack representing a function definition."""
+    def __init__(self, start, name):
+        self.start = start
+        self.name = name
+
+
+class TryBlock(object):
+    """A block on the block stack representing a `try` block."""
+    def __init__(self, handler_start=None, final_start=None):
+        self.handler_start = handler_start
+        self.final_start = final_start
+        self.break_from = set()
+        self.continue_from = set()
+        self.return_from = set()
+        self.raise_from = set()
+
+
+class ArcStart(collections.namedtuple("Arc", "lineno, cause")):
+    """The information needed to start an arc.
+
+    `lineno` is the line number the arc starts from.  `cause` is a fragment
+    used as the startmsg for AstArcAnalyzer.missing_arc_fragments.
+
+    """
+    def __new__(cls, lineno, cause=None):
+        return super(ArcStart, cls).__new__(cls, lineno, cause)
+
+
+# Define contract words that PyContract doesn't have.
+# ArcStarts is for a list or set of ArcStart's.
+new_contract('ArcStarts', lambda seq: all(isinstance(x, ArcStart) for x in seq))
+
+
+class AstArcAnalyzer(object):
+    """Analyze source text with an AST to find executable code paths."""
+
+    @contract(text='unicode', statements=set)
+    def __init__(self, text, statements, multiline):
+        self.root_node = ast.parse(neuter_encoding_declaration(text))
+        # TODO: I think this is happening in too many places.
+        self.statements = set(multiline.get(l, l) for l in statements)
+        self.multiline = multiline
+
+        if int(os.environ.get("COVERAGE_ASTDUMP", 0)):      # pragma: debugging
+            # Dump the AST so that failing tests have helpful output.
+            print("Statements: {}".format(self.statements))
+            print("Multiline map: {}".format(self.multiline))
+            ast_dump(self.root_node)
+
+        self.arcs = set()
+
+        # A map from arc pairs to a pair of sentence fragments: (startmsg, endmsg).
+        # For an arc from line 17, they should be usable like:
+        #    "Line 17 {endmsg}, because {startmsg}"
+        self.missing_arc_fragments = collections.defaultdict(list)
+        self.block_stack = []
+
+        self.debug = bool(int(os.environ.get("COVERAGE_TRACK_ARCS", 0)))
+
+    def analyze(self):
+        """Examine the AST tree from `root_node` to determine possible arcs.
+
+        This sets the `arcs` attribute to be a set of (from, to) line number
+        pairs.
+
+        """
+        for node in ast.walk(self.root_node):
+            node_name = node.__class__.__name__
+            code_object_handler = getattr(self, "_code_object__" + node_name, None)
+            if code_object_handler is not None:
+                code_object_handler(node)
 
-    def _split_into_chunks(self):
-        """Split the code object into a list of `Chunk` objects.
+    def add_arc(self, start, end, smsg=None, emsg=None):
+        """Add an arc, including message fragments to use if it is missing."""
+        if self.debug:
+            print("\nAdding arc: ({}, {}): {!r}, {!r}".format(start, end, smsg, emsg))
+            print(short_stack(limit=6))
+        self.arcs.add((start, end))
+
+        if smsg is not None or emsg is not None:
+            self.missing_arc_fragments[(start, end)].append((smsg, emsg))
+
+    def nearest_blocks(self):
+        """Yield the blocks in nearest-to-farthest order."""
+        return reversed(self.block_stack)
+
+    @contract(returns=int)
+    def line_for_node(self, node):
+        """What is the right line number to use for this node?
+
+        This dispatches to _line__Node functions where needed.
+
+        """
+        node_name = node.__class__.__name__
+        handler = getattr(self, "_line__" + node_name, None)
+        if handler is not None:
+            return handler(node)
+        else:
+            return node.lineno
+
+    def _line__Assign(self, node):
+        return self.line_for_node(node.value)
+
+    def _line__Dict(self, node):
+        # Python 3.5 changed how dict literals are made.
+        if env.PYVERSION >= (3, 5) and node.keys:
+            if node.keys[0] is not None:
+                return node.keys[0].lineno
+            else:
+                # Unpacked dict literals `{**{'a':1}}` have None as the key,
+                # use the value in that case.
+                return node.values[0].lineno
+        else:
+            return node.lineno
 
-        Each chunk is only entered at its first instruction, though there can
-        be many exits from a chunk.
+    def _line__List(self, node):
+        if node.elts:
+            return self.line_for_node(node.elts[0])
+        else:
+            return node.lineno
+
+    def _line__Module(self, node):
+        if node.body:
+            return self.line_for_node(node.body[0])
+        else:
+            # Modules have no line number, they always start at 1.
+            return 1
+
+    OK_TO_DEFAULT = set([
+        "Assign", "Assert", "AugAssign", "Delete", "Exec", "Expr", "Global",
+        "Import", "ImportFrom", "Nonlocal", "Pass", "Print",
+    ])
+
+    @contract(returns='ArcStarts')
+    def add_arcs(self, node):
+        """Add the arcs for `node`.
 
-        Returns a list of `Chunk` objects.
+        Return a set of ArcStarts, exits from this node to the next.
+
+        """
+        node_name = node.__class__.__name__
+        handler = getattr(self, "_handle__" + node_name, None)
+        if handler is not None:
+            return handler(node)
+
+        if 0:
+            node_name = node.__class__.__name__
+            if node_name not in self.OK_TO_DEFAULT:
+                print("*** Unhandled: {0}".format(node))
+        return set([ArcStart(self.line_for_node(node), cause=None)])
+
+    @contract(returns='ArcStarts')
+    def add_body_arcs(self, body, from_start=None, prev_starts=None):
+        """Add arcs for the body of a compound statement.
+
+        `body` is the body node.  `from_start` is a single `ArcStart` that can
+        be the previous line in flow before this body.  `prev_starts` is a set
+        of ArcStarts that can be the previous line.  Only one of them should be
+        given.
+
+        Returns a set of ArcStarts, the exits from this body.
 
         """
-        # The list of chunks so far, and the one we're working on.
-        chunks = []
-        chunk = None
-
-        # A dict mapping byte offsets of line starts to the line numbers.
-        bytes_lines_map = dict(self._bytes_lines())
+        if prev_starts is None:
+            prev_starts = set([from_start])
+        for body_node in body:
+            lineno = self.line_for_node(body_node)
+            first_line = self.multiline.get(lineno, lineno)
+            if first_line not in self.statements:
+                continue
+            for prev_start in prev_starts:
+                self.add_arc(prev_start.lineno, lineno, prev_start.cause)
+            prev_starts = self.add_arcs(body_node)
+        return prev_starts
 
-        # The block stack: loops and try blocks get pushed here for the
-        # implicit jumps that can occur.
-        # Each entry is a tuple: (block type, destination)
-        block_stack = []
+    def is_constant_expr(self, node):
+        """Is this a compile-time constant?"""
+        node_name = node.__class__.__name__
+        if node_name in ["NameConstant", "Num"]:
+            return True
+        elif node_name == "Name":
+            if env.PY3 and node.id in ["True", "False", "None"]:
+                return True
+        return False
 
-        # Some op codes are followed by branches that should be ignored.  This
-        # is a count of how many ignores are left.
-        ignore_branch = 0
+    # tests to write:
+    # TODO: while EXPR:
+    # TODO: while False:
+    # TODO: listcomps hidden deep in other expressions
+    # TODO: listcomps hidden in lists: x = [[i for i in range(10)]]
+    # TODO: nested function definitions
 
-        # We have to handle the last two bytecodes specially.
-        ult = penult = None
+    @contract(exits='ArcStarts')
+    def process_break_exits(self, exits):
+        """Add arcs due to jumps from `exits` being breaks."""
+        for block in self.nearest_blocks():
+            if isinstance(block, LoopBlock):
+                block.break_exits.update(exits)
+                break
+            elif isinstance(block, TryBlock) and block.final_start is not None:
+                block.break_from.update(exits)
+                break
 
-        # Get a set of all of the jump-to points.
-        jump_to = set()
-        bytecodes = list(ByteCodes(self.code.co_code))
-        for bc in bytecodes:
-            if bc.jump_to >= 0:
-                jump_to.add(bc.jump_to)
-
-        chunk_lineno = 0
+    @contract(exits='ArcStarts')
+    def process_continue_exits(self, exits):
+        """Add arcs due to jumps from `exits` being continues."""
+        for block in self.nearest_blocks():
+            if isinstance(block, LoopBlock):
+                for xit in exits:
+                    self.add_arc(xit.lineno, block.start, xit.cause)
+                break
+            elif isinstance(block, TryBlock) and block.final_start is not None:
+                block.continue_from.update(exits)
+                break
 
-        # Walk the byte codes building chunks.
-        for bc in bytecodes:
-            # Maybe have to start a new chunk.
-            start_new_chunk = False
-            first_chunk = False
-            if bc.offset in bytes_lines_map:
-                # Start a new chunk for each source line number.
-                start_new_chunk = True
-                chunk_lineno = bytes_lines_map[bc.offset]
-                first_chunk = True
-            elif bc.offset in jump_to:
-                # To make chunks have a single entrance, we have to make a new
-                # chunk when we get to a place some bytecode jumps to.
-                start_new_chunk = True
-            elif bc.op in OPS_CHUNK_BEGIN:
-                # Jumps deserve their own unnumbered chunk.  This fixes
-                # problems with jumps to jumps getting confused.
-                start_new_chunk = True
+    @contract(exits='ArcStarts')
+    def process_raise_exits(self, exits):
+        """Add arcs due to jumps from `exits` being raises."""
+        for block in self.nearest_blocks():
+            if isinstance(block, TryBlock):
+                if block.handler_start is not None:
+                    for xit in exits:
+                        self.add_arc(xit.lineno, block.handler_start, xit.cause)
+                    break
+                elif block.final_start is not None:
+                    block.raise_from.update(exits)
+                    break
+            elif isinstance(block, FunctionBlock):
+                for xit in exits:
+                    self.add_arc(
+                        xit.lineno, -block.start, xit.cause,
+                        "didn't except from function '{0}'".format(block.name),
+                    )
+                break
+
+    @contract(exits='ArcStarts')
+    def process_return_exits(self, exits):
+        """Add arcs due to jumps from `exits` being returns."""
+        for block in self.nearest_blocks():
+            if isinstance(block, TryBlock) and block.final_start is not None:
+                block.return_from.update(exits)
+                break
+            elif isinstance(block, FunctionBlock):
+                for xit in exits:
+                    self.add_arc(
+                        xit.lineno, -block.start, xit.cause,
+                        "didn't return from function '{0}'".format(block.name),
+                    )
+                break
+
+    ## Handlers
 
-            if not chunk or start_new_chunk:
-                if chunk:
-                    chunk.exits.add(bc.offset)
-                chunk = Chunk(bc.offset, chunk_lineno, first_chunk)
-                if not chunks:
-                    # The very first chunk of a code object is always an
-                    # entrance.
-                    chunk.entrance = True
-                chunks.append(chunk)
+    @contract(returns='ArcStarts')
+    def _handle__Break(self, node):
+        here = self.line_for_node(node)
+        break_start = ArcStart(here, cause="the break on line {lineno} wasn't executed")
+        self.process_break_exits([break_start])
+        return set()
 
-            # Look at the opcode.
-            if bc.jump_to >= 0 and bc.op not in OPS_NO_JUMP:
-                if ignore_branch:
-                    # Someone earlier wanted us to ignore this branch.
-                    ignore_branch -= 1
-                else:
-                    # The opcode has a jump, it's an exit for this chunk.
-                    chunk.exits.add(bc.jump_to)
+    @contract(returns='ArcStarts')
+    def _handle_decorated(self, node):
+        """Add arcs for things that can be decorated (classes and functions)."""
+        last = self.line_for_node(node)
+        if node.decorator_list:
+            for dec_node in node.decorator_list:
+                dec_start = self.line_for_node(dec_node)
+                if dec_start != last:
+                    self.add_arc(last, dec_start)
+                    last = dec_start
+            # The definition line may have been missed, but we should have it
+            # in `self.statements`.  For some constructs, `line_for_node` is
+            # not what we'd think of as the first line in the statement, so map
+            # it to the first one.
+            body_start = self.line_for_node(node.body[0])
+            body_start = self.multiline.get(body_start, body_start)
+            for lineno in range(last+1, body_start):
+                if lineno in self.statements:
+                    self.add_arc(last, lineno)
+                    last = lineno
+        # The body is handled in collect_arcs.
+        return set([ArcStart(last, cause=None)])
+
+    _handle__ClassDef = _handle_decorated
+
+    @contract(returns='ArcStarts')
+    def _handle__Continue(self, node):
+        here = self.line_for_node(node)
+        continue_start = ArcStart(here, cause="the continue on line {lineno} wasn't executed")
+        self.process_continue_exits([continue_start])
+        return set()
 
-            if bc.op in OPS_CODE_END:
-                # The opcode can exit the code object.
-                chunk.exits.add(-self.code.co_firstlineno)
-            if bc.op in OPS_PUSH_BLOCK:
-                # The opcode adds a block to the block_stack.
-                block_stack.append((bc.op, bc.jump_to))
-            if bc.op in OPS_POP_BLOCK:
-                # The opcode pops a block from the block stack.
-                block_stack.pop()
-            if bc.op in OPS_CHUNK_END:
-                # This opcode forces the end of the chunk.
-                if bc.op == OP_BREAK_LOOP:
-                    # A break is implicit: jump where the top of the
-                    # block_stack points.
-                    chunk.exits.add(block_stack[-1][1])
-                chunk = None
-            if bc.op == OP_END_FINALLY:
-                # For the finally clause we need to find the closest exception
-                # block, and use its jump target as an exit.
-                for block in reversed(block_stack):
-                    if block[0] in OPS_EXCEPT_BLOCKS:
-                        chunk.exits.add(block[1])
-                        break
-            if bc.op == OP_COMPARE_OP and bc.arg == COMPARE_EXCEPTION:
-                # This is an except clause.  We want to overlook the next
-                # branch, so that except's don't count as branches.
-                ignore_branch += 1
+    @contract(returns='ArcStarts')
+    def _handle__For(self, node):
+        start = self.line_for_node(node.iter)
+        self.block_stack.append(LoopBlock(start=start))
+        from_start = ArcStart(start, cause="the loop on line {lineno} never started")
+        exits = self.add_body_arcs(node.body, from_start=from_start)
+        # Any exit from the body will go back to the top of the loop.
+        for xit in exits:
+            self.add_arc(xit.lineno, start, xit.cause)
+        my_block = self.block_stack.pop()
+        exits = my_block.break_exits
+        from_start = ArcStart(start, cause="the loop on line {lineno} didn't complete")
+        if node.orelse:
+            else_exits = self.add_body_arcs(node.orelse, from_start=from_start)
+            exits |= else_exits
+        else:
+            # no else clause: exit from the for line.
+            exits.add(from_start)
+        return exits
+
+    _handle__AsyncFor = _handle__For
+
+    _handle__FunctionDef = _handle_decorated
+    _handle__AsyncFunctionDef = _handle_decorated
 
-            penult = ult
-            ult = bc
+    @contract(returns='ArcStarts')
+    def _handle__If(self, node):
+        start = self.line_for_node(node.test)
+        from_start = ArcStart(start, cause="the condition on line {lineno} was never true")
+        exits = self.add_body_arcs(node.body, from_start=from_start)
+        from_start = ArcStart(start, cause="the condition on line {lineno} was never false")
+        exits |= self.add_body_arcs(node.orelse, from_start=from_start)
+        return exits
+
+    @contract(returns='ArcStarts')
+    def _handle__Raise(self, node):
+        here = self.line_for_node(node)
+        raise_start = ArcStart(here, cause="the raise on line {lineno} wasn't executed")
+        self.process_raise_exits([raise_start])
+        # `raise` statement jumps away, no exits from here.
+        return set()
+
+    @contract(returns='ArcStarts')
+    def _handle__Return(self, node):
+        here = self.line_for_node(node)
+        return_start = ArcStart(here, cause="the return on line {lineno} wasn't executed")
+        self.process_return_exits([return_start])
+        # `return` statement jumps away, no exits from here.
+        return set()
 
-        if chunks:
-            # The last two bytecodes could be a dummy "return None" that
-            # shouldn't be counted as real code. Every Python code object seems
-            # to end with a return, and a "return None" is inserted if there
-            # isn't an explicit return in the source.
-            if ult and penult:
-                if penult.op == OP_LOAD_CONST and ult.op == OP_RETURN_VALUE:
-                    if self.code.co_consts[penult.arg] is None:
-                        # This is "return None", but is it dummy?  A real line
-                        # would be a last chunk all by itself.
-                        if chunks[-1].byte != penult.offset:
-                            ex = -self.code.co_firstlineno
-                            # Split the last chunk
-                            last_chunk = chunks[-1]
-                            last_chunk.exits.remove(ex)
-                            last_chunk.exits.add(penult.offset)
-                            chunk = Chunk(
-                                penult.offset, last_chunk.line, False
-                            )
-                            chunk.exits.add(ex)
-                            chunks.append(chunk)
+    @contract(returns='ArcStarts')
+    def _handle__Try(self, node):
+        if node.handlers:
+            handler_start = self.line_for_node(node.handlers[0])
+        else:
+            handler_start = None
+
+        if node.finalbody:
+            final_start = self.line_for_node(node.finalbody[0])
+        else:
+            final_start = None
+
+        try_block = TryBlock(handler_start=handler_start, final_start=final_start)
+        self.block_stack.append(try_block)
+
+        start = self.line_for_node(node)
+        exits = self.add_body_arcs(node.body, from_start=ArcStart(start, cause=None))
 
-            # Give all the chunks a length.
-            chunks[-1].length = bc.next_offset - chunks[-1].byte
-            for i in range(len(chunks)-1):
-                chunks[i].length = chunks[i+1].byte - chunks[i].byte
+        # We're done with the `try` body, so this block no longer handles
+        # exceptions. We keep the block so the `finally` clause can pick up
+        # flows from the handlers and `else` clause.
+        if node.finalbody:
+            try_block.handler_start = None
+            if node.handlers:
+                # If there are `except` clauses, then raises in the try body
+                # will already jump to them.  Start this set over for raises in
+                # `except` and `else`.
+                try_block.raise_from = set([])
+        else:
+            self.block_stack.pop()
+
+        handler_exits = set()
+
+        if node.handlers:
+            last_handler_start = None
+            for handler_node in node.handlers:
+                handler_start = self.line_for_node(handler_node)
+                if last_handler_start is not None:
+                    self.add_arc(last_handler_start, handler_start)
+                last_handler_start = handler_start
+                from_cause = "the exception caught by line {lineno} didn't happen"
+                from_start = ArcStart(handler_start, cause=from_cause)
+                handler_exits |= self.add_body_arcs(handler_node.body, from_start=from_start)
 
-        #self.validate_chunks(chunks)
-        return chunks
+        if node.orelse:
+            exits = self.add_body_arcs(node.orelse, prev_starts=exits)
+
+        exits |= handler_exits
 
-    def validate_chunks(self, chunks):                      # pragma: debugging
-        """Validate the rule that chunks have a single entrance."""
-        # starts is the entrances to the chunks
-        starts = set(ch.byte for ch in chunks)
-        for ch in chunks:
-            assert all((ex in starts or ex < 0) for ex in ch.exits)
-
-    def _arcs(self):
-        """Find the executable arcs in the code.
+        if node.finalbody:
+            self.block_stack.pop()
+            final_from = (                  # You can get to the `finally` clause from:
+                exits |                         # the exits of the body or `else` clause,
+                try_block.break_from |          # or a `break`,
+                try_block.continue_from |       # or a `continue`,
+                try_block.raise_from |          # or a `raise`,
+                try_block.return_from           # or a `return`.
+            )
 
-        Yields pairs: (from,to).  From and to are integer line numbers.  If
-        from is < 0, then the arc is an entrance into the code object.  If to
-        is < 0, the arc is an exit from the code object.
+            exits = self.add_body_arcs(node.finalbody, prev_starts=final_from)
+            if try_block.break_from:
+                break_exits = self._combine_finally_starts(try_block.break_from, exits)
+                self.process_break_exits(break_exits)
+            if try_block.continue_from:
+                continue_exits = self._combine_finally_starts(try_block.continue_from, exits)
+                self.process_continue_exits(continue_exits)
+            if try_block.raise_from:
+                raise_exits = self._combine_finally_starts(try_block.raise_from, exits)
+                self.process_raise_exits(raise_exits)
+            if try_block.return_from:
+                return_exits = self._combine_finally_starts(try_block.return_from, exits)
+                self.process_return_exits(return_exits)
 
-        """
-        chunks = self._split_into_chunks()
+        return exits
 
-        # A map from byte offsets to the chunk starting at that offset.
-        byte_chunks = dict((c.byte, c) for c in chunks)
+    def _combine_finally_starts(self, starts, exits):
+        """Helper for building the cause of `finally` branches."""
+        causes = []
+        for lineno, cause in sorted(starts):
+            if cause is not None:
+                causes.append(cause.format(lineno=lineno))
+        cause = " or ".join(causes)
+        exits = set(ArcStart(ex.lineno, cause) for ex in exits)
+        return exits
 
-        # Traverse from the first chunk in each line, and yield arcs where
-        # the trace function will be invoked.
-        for chunk in chunks:
-            if chunk.entrance:
-                yield (-1, chunk.line)
+    @contract(returns='ArcStarts')
+    def _handle__TryExcept(self, node):
+        # Python 2.7 uses separate TryExcept and TryFinally nodes. If we get
+        # TryExcept, it means there was no finally, so fake it, and treat as
+        # a general Try node.
+        node.finalbody = []
+        return self._handle__Try(node)
 
-            if not chunk.first:
-                continue
+    @contract(returns='ArcStarts')
+    def _handle__TryFinally(self, node):
+        # Python 2.7 uses separate TryExcept and TryFinally nodes. If we get
+        # TryFinally, see if there's a TryExcept nested inside. If so, merge
+        # them. Otherwise, fake fields to complete a Try node.
+        node.handlers = []
+        node.orelse = []
 
-            chunks_considered = set()
-            chunks_to_consider = [chunk]
-            while chunks_to_consider:
-                # Get the chunk we're considering, and make sure we don't
-                # consider it again.
-                this_chunk = chunks_to_consider.pop()
-                chunks_considered.add(this_chunk)
+        first = node.body[0]
+        if first.__class__.__name__ == "TryExcept" and node.lineno == first.lineno:
+            assert len(node.body) == 1
+            node.body = first.body
+            node.handlers = first.handlers
+            node.orelse = first.orelse
+
+        return self._handle__Try(node)
 
-                # For each exit, add the line number if the trace function
-                # would be triggered, or add the chunk to those being
-                # considered if not.
-                for ex in this_chunk.exits:
-                    if ex < 0:
-                        yield (chunk.line, ex)
-                    else:
-                        next_chunk = byte_chunks[ex]
-                        if next_chunk in chunks_considered:
-                            continue
+    @contract(returns='ArcStarts')
+    def _handle__While(self, node):
+        constant_test = self.is_constant_expr(node.test)
+        start = to_top = self.line_for_node(node.test)
+        if constant_test:
+            to_top = self.line_for_node(node.body[0])
+        self.block_stack.append(LoopBlock(start=start))
+        from_start = ArcStart(start, cause="the condition on line {lineno} was never true")
+        exits = self.add_body_arcs(node.body, from_start=from_start)
+        for xit in exits:
+            self.add_arc(xit.lineno, to_top, xit.cause)
+        exits = set()
+        my_block = self.block_stack.pop()
+        exits.update(my_block.break_exits)
+        from_start = ArcStart(start, cause="the condition on line {lineno} was never false")
+        if node.orelse:
+            else_exits = self.add_body_arcs(node.orelse, from_start=from_start)
+            exits |= else_exits
+        else:
+            # No `else` clause: you can exit from the start.
+            if not constant_test:
+                exits.add(from_start)
+        return exits
+
+    @contract(returns='ArcStarts')
+    def _handle__With(self, node):
+        start = self.line_for_node(node)
+        exits = self.add_body_arcs(node.body, from_start=ArcStart(start))
+        return exits
 
-                        # The trace function is invoked if visiting the first
-                        # bytecode in a line, or if the transition is a
-                        # backward jump.
-                        backward_jump = next_chunk.byte < this_chunk.byte
-                        if next_chunk.first or backward_jump:
-                            if next_chunk.line != chunk.line:
-                                yield (chunk.line, next_chunk.line)
-                        else:
-                            chunks_to_consider.append(next_chunk)
+    _handle__AsyncWith = _handle__With
 
-    def _all_chunks(self):
-        """Returns a list of `Chunk` objects for this code and its children.
+    def _code_object__Module(self, node):
+        start = self.line_for_node(node)
+        if node.body:
+            exits = self.add_body_arcs(node.body, from_start=ArcStart(-start))
+            for xit in exits:
+                self.add_arc(xit.lineno, -start, xit.cause, "didn't exit the module")
+        else:
+            # Empty module.
+            self.add_arc(-start, start)
+            self.add_arc(start, -start)
 
-        See `_split_into_chunks` for details.
+    def _code_object__FunctionDef(self, node):
+        start = self.line_for_node(node)
+        self.block_stack.append(FunctionBlock(start=start, name=node.name))
+        exits = self.add_body_arcs(node.body, from_start=ArcStart(-start))
+        self.process_return_exits(exits)
+        self.block_stack.pop()
+
+    _code_object__AsyncFunctionDef = _code_object__FunctionDef
 
-        """
-        chunks = []
-        for bp in self.child_parsers():
-            chunks.extend(bp._split_into_chunks())
-
-        return chunks
+    def _code_object__ClassDef(self, node):
+        start = self.line_for_node(node)
+        self.add_arc(-start, start)
+        exits = self.add_body_arcs(node.body, from_start=ArcStart(start))
+        for xit in exits:
+            self.add_arc(
+                xit.lineno, -start, xit.cause,
+                "didn't exit the body of class '{0}'".format(node.name),
+            )
 
-    def _all_arcs(self):
-        """Get the set of all arcs in this code object and its children.
-
-        See `_arcs` for details.
+    def _make_oneline_code_method(noun):     # pylint: disable=no-self-argument
+        """A function to make methods for online callable _code_object__ methods."""
+        def _code_object__oneline_callable(self, node):
+            start = self.line_for_node(node)
+            self.add_arc(-start, start, None, "didn't run the {0} on line {1}".format(noun, start))
+            self.add_arc(
+                start, -start, None,
+                "didn't finish the {0} on line {1}".format(noun, start),
+            )
+        return _code_object__oneline_callable
 
-        """
-        arcs = set()
-        for bp in self.child_parsers():
-            arcs.update(bp._arcs())
-
-        return arcs
+    _code_object__Lambda = _make_oneline_code_method("lambda")
+    _code_object__GeneratorExp = _make_oneline_code_method("generator expression")
+    _code_object__DictComp = _make_oneline_code_method("dictionary comprehension")
+    _code_object__SetComp = _make_oneline_code_method("set comprehension")
+    if env.PY3:
+        _code_object__ListComp = _make_oneline_code_method("list comprehension")
 
 
-class Chunk(object):
-    """A sequence of byte codes with a single entrance.
-
-    To analyze byte code, we have to divide it into chunks, sequences of byte
-    codes such that each chunk has only one entrance, the first instruction in
-    the block.
-
-    This is almost the CS concept of `basic block`_, except that we're willing
-    to have many exits from a chunk, and "basic block" is a more cumbersome
-    term.
+SKIP_DUMP_FIELDS = ["ctx"]
 
-    .. _basic block: http://en.wikipedia.org/wiki/Basic_block
-
-    `byte` is the offset to the bytecode starting this chunk.
-
-    `line` is the source line number containing this chunk.
+def _is_simple_value(value):
+    """Is `value` simple enough to be displayed on a single line?"""
+    return (
+        value in [None, [], (), {}, set()] or
+        isinstance(value, (string_class, int, float))
+    )
 
-    `first` is true if this is the first chunk in the source line.
+# TODO: a test of ast_dump?
+def ast_dump(node, depth=0):
+    """Dump the AST for `node`.
 
-    An exit < 0 means the chunk can leave the code (return).  The exit is
-    the negative of the starting line number of the code block.
-
-    The `entrance` attribute is a boolean indicating whether the code object
-    can be entered at this chunk.
+    This recursively walks the AST, printing a readable version.
 
     """
-    def __init__(self, byte, line, first):
-        self.byte = byte
-        self.line = line
-        self.first = first
-        self.length = 0
-        self.entrance = False
-        self.exits = set()
+    indent = " " * depth
+    if not isinstance(node, ast.AST):
+        print("{0}<{1} {2!r}>".format(indent, node.__class__.__name__, node))
+        return
+
+    lineno = getattr(node, "lineno", None)
+    if lineno is not None:
+        linemark = " @ {0}".format(node.lineno)
+    else:
+        linemark = ""
+    head = "{0}<{1}{2}".format(indent, node.__class__.__name__, linemark)
 
-    def __repr__(self):
-        return "<%d+%d @%d%s%s %r>" % (
-            self.byte,
-            self.length,
-            self.line,
-            "!" if self.first else "",
-            "v" if self.entrance else "",
-            list(self.exits),
-        )
+    named_fields = [
+        (name, value)
+        for name, value in ast.iter_fields(node)
+        if name not in SKIP_DUMP_FIELDS
+    ]
+    if not named_fields:
+        print("{0}>".format(head))
+    elif len(named_fields) == 1 and _is_simple_value(named_fields[0][1]):
+        field_name, value = named_fields[0]
+        print("{0} {1}: {2!r}>".format(head, field_name, value))
+    else:
+        print(head)
+        if 0:
+            print("{0}# mro: {1}".format(
+                indent, ", ".join(c.__name__ for c in node.__class__.__mro__[1:]),
+            ))
+        next_indent = indent + "    "
+        for field_name, value in named_fields:
+            prefix = "{0}{1}:".format(next_indent, field_name)
+            if _is_simple_value(value):
+                print("{0} {1!r}".format(prefix, value))
+            elif isinstance(value, list):
+                print("{0} [".format(prefix))
+                for n in value:
+                    ast_dump(n, depth + 8)
+                print("{0}]".format(next_indent))
+            else:
+                print(prefix)
+                ast_dump(value, depth + 8)
+
+        print("{0}>".format(indent))
 
 #
 # eflag: FileType = Python2

eric ide

mercurial