blob: 0546b97e1945f5af7a2c91dfa532e2234e2fe2da [file] [edit]
# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
# For details: https://github.com/coveragepy/coveragepy/blob/main/NOTICE.txt
"""Bytecode analysis for coverage.py"""
from __future__ import annotations
import dis
from collections.abc import Iterable, Mapping
from types import CodeType
from coverage.types import TArc, TLineNo, TOffset
class ByteParser:
"""Parse bytecode to understand the structure of code."""
def __init__(
self,
*,
code: CodeType | None = None,
text: str | None = None,
filename: str | None = None,
) -> None:
if code is None:
assert text is not None
code = compile(text, filename or "<string>", "exec", dont_inherit=True)
self.code = code
def _child_parsers(self) -> Iterable[ByteParser]:
"""Iterate over all the code objects nested within this one.
The iteration includes `self` as its first value.
We skip code objects named `__annotate__` since they are deferred
annotations that usually are never run. If there are errors in the
annotations, they will be caught by type checkers or other tools that
use annotations.
"""
return (ByteParser(code=c) for c in self.code_objects() if c.co_name != "__annotate__")
def code_objects(self) -> Iterable[CodeType]:
"""Iterate over all the code objects in `code`."""
stack = [self.code]
while stack:
# We're going to return the code object on the stack, but first
# push its children for later returning.
code = stack.pop()
for c in code.co_consts:
if isinstance(c, CodeType):
stack.append(c)
yield code
def _line_numbers(self) -> Iterable[TLineNo]:
"""Yield the line numbers possible in this code object.
Uses co_lines() to produce a sequence: l0, l1, ...
"""
for _, _, line in self.code.co_lines():
if line:
yield line
def find_statements(self) -> Iterable[TLineNo]:
"""Find the statements in `self.code`.
Produce a sequence of line numbers that start statements. Recurses
into all code objects reachable from `self.code`.
"""
for bp in self._child_parsers():
# Get all of the lineno information from this code.
yield from bp._line_numbers()
def bytes_to_lines(code: CodeType) -> dict[TOffset, TLineNo]:
"""Make a dict mapping byte code offsets to line numbers."""
b2l = {}
for bstart, bend, lineno in code.co_lines():
if lineno is not None:
for boffset in range(bstart, bend, 2):
b2l[boffset] = lineno
return b2l
def op_set(*op_names: str) -> set[int]:
"""Make a set of opcodes from instruction names.
The names might not exist in this version of Python, skip those if not.
"""
ops = {op for name in op_names if (op := dis.opmap.get(name))}
assert ops, f"At least one opcode must exist: {op_names}"
return ops
# Opcodes that are unconditional jumps elsewhere.
ALWAYS_JUMPS = op_set(
"JUMP_BACKWARD",
"JUMP_BACKWARD_NO_INTERRUPT",
"JUMP_FORWARD",
)
# Opcodes that exit from a function.
RETURNS = op_set(
"RETURN_VALUE",
"RETURN_GENERATOR",
)
# CACHE doesn't exist in Python 3.10, but the branch resolver is only used
# on 3.14+, so a placeholder value is fine.
_CACHE = dis.opmap.get("CACHE", -1)
_EXTENDED_ARG = dis.opmap["EXTENDED_ARG"]
# All opcodes with a jump target.
JUMPS = set(dis.hasjrel) | set(dis.hasjabs)
# Opcodes that jump backwards.
BACKWARD_JUMPS = {op for op in JUMPS if "JUMP_BACKWARD" in dis.opname[op]}
class BranchArcResolver:
"""Resolve branch events to line arcs, one (source, dest) pair at a time.
Branch events are one-shot (they are DISABLEd after firing), so each
(source offset, destination offset) pair is resolved at most a couple of
times per code object. Resolving pairs on demand is much cheaper than
precomputing trails for every branch in the code object, most of which
never fire. We walk the raw bytecode bytes so that we never need to
disassemble whole code objects with `dis`.
To resolve one pair:
starting from the destination, follow the trail of instructions (through
unconditional jumps) until we reach an instruction on a new source line
(giving us the arc), a return (an arc to leaving the code object), or
another branch possibility (no arc: that branch will produce its own
events).
"""
def __init__(
self,
code: CodeType,
byte_to_line: Mapping[TOffset, TLineNo],
multiline_map: Mapping[TLineNo, TLineNo],
) -> None:
self.code = code
# co_code re-copies the bytes on each access, so fetch it once.
self.co_code = code.co_code
self.byte_to_line = byte_to_line
self.multiline_map = multiline_map
def line_at(self, offset: TOffset) -> TLineNo | None:
"""The source line of the instruction at `offset`, de-multilined."""
line = self.byte_to_line.get(offset)
if line is not None:
line = self.multiline_map.get(line, line)
return line
def resolve(self, source: TOffset, dest: TOffset) -> TArc | None:
"""Turn a branch event's (source, dest) offsets into an arc, or None."""
from_line = self.line_at(source)
if from_line is None:
return None
co_code = self.co_code
max_offset = len(co_code)
byte_to_line = self.byte_to_line
multiline_map = self.multiline_map
offset = dest
ext_arg = 0
seen: set[TOffset] = set()
while 0 <= offset < max_offset and offset not in seen:
seen.add(offset)
op = co_code[offset]
if op == _CACHE:
offset += 2
continue
if op == _EXTENDED_ARG:
ext_arg = (ext_arg | co_code[offset + 1]) << 8
offset += 2
continue
line = byte_to_line.get(offset)
if line is not None:
line = multiline_map.get(line, line)
if line and line != from_line:
return (from_line, line)
if op in JUMPS:
if op in ALWAYS_JUMPS:
arg = ext_arg | co_code[offset + 1]
# Jump distances are measured from the end of the
# instruction's inline CACHE entries, which appear in
# co_code as CACHE opcodes immediately following it.
next_offset = offset + 2
while next_offset < max_offset and co_code[next_offset] == _CACHE:
next_offset += 2
if op in BACKWARD_JUMPS:
offset = next_offset - 2 * arg
else:
offset = next_offset + 2 * arg
ext_arg = 0
continue
# Another branch possibility: it will get its own events.
return None
if op in RETURNS:
return (from_line, -self.code.co_firstlineno)
ext_arg = 0
offset += 2
return None