diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9c57a7e..9df6d5a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,7 +19,7 @@ jobs: strategy: matrix: os: [ ubuntu-latest, macos-latest, windows-latest ] - python: [ '3.8', '3.9', 'pypy-3.9', '3.10', '3.11' ] + python: [ '3.8', '3.9', 'pypy-3.9', '3.10', '3.11', '3.12' ] steps: - uses: actions/checkout@v3 @@ -71,7 +71,7 @@ jobs: container: ${{ matrix.container }} strategy: matrix: - python_version: ['3.8', '3.9', '3.10', '3.11'] + python_version: ['3.8', '3.9', '3.10', '3.11', '3.12'] include: - os: ubuntu-latest container: quay.io/pypa/manylinux2014_x86_64 # https://github.com/pypa/manylinux diff --git a/Makefile b/Makefile index 1392474..d26b5b2 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ all: HOMEBREW_PYTHON=/opt/homebrew/opt/python@ test: - rm -f .coverage - @ for V in 3.8 3.9 3.10 3.11; do \ + @ for V in 3.8 3.9 3.10 3.11 3.12; do \ P=$$(command -v ${HOMEBREW_PYTHON}$$V/bin/python3 || command -v python$$V); \ if ! [ -z $$P ]; then \ $$P --version; \ diff --git a/setup.py b/setup.py index 7aee0f7..d756140 100644 --- a/setup.py +++ b/setup.py @@ -100,7 +100,7 @@ def limited_api_args(): packages=['slipcover'], package_dir={'': 'src'}, ext_modules=([probe]), - python_requires=">=3.8,<3.12", + python_requires=">=3.8,<3.13", install_requires=[ "tabulate" ], diff --git a/src/slipcover/branch.py b/src/slipcover/branch.py index d3c73a5..02c9002 100644 --- a/src/slipcover/branch.py +++ b/src/slipcover/branch.py @@ -4,6 +4,20 @@ BRANCH_NAME = "_slipcover_branches" +if sys.version_info[0:2] >= (3,12): + def is_branch(line): + return (line & (1<<30)) != 0 + + def encode_branch(from_line, to_line): + # FIXME anything bigger, and we get an overflow... encode to_line as relative number? + assert from_line <= 0x7FFF, f"Line number {from_line} too high, unable to add branch tracking" + assert to_line <= 0x7FFF, f"Line number {to_line} too high, unable to add branch tracking" + return (1<<30)|((from_line & 0x7FFF)<<15)|(to_line&0x7FFF) + + def decode_branch(line): + return ((line>>15)&0x7FFF, line&0x7FFF) + + def preinstrument(tree: ast.AST) -> ast.AST: """Prepares an AST for Slipcover instrumentation, inserting assignments indicating where branches happen.""" @@ -15,9 +29,15 @@ def _mark_branch(self, from_line: int, to_line: int) -> List[ast.stmt]: mark = ast.Assign([ast.Name(BRANCH_NAME, ast.Store())], ast.Tuple([ast.Constant(from_line), ast.Constant(to_line)], ast.Load())) - for node in ast.walk(mark): - # we ignore line 0, so this avoids generating extra line probes - node.lineno = 0 if sys.version_info >= (3,11) else from_line + if sys.version_info[0:2] == (3,12): + for node in ast.walk(mark): + node.lineno = node.end_lineno = encode_branch(from_line, to_line) + elif sys.version_info[0:2] == (3,11): + for node in ast.walk(mark): + node.lineno = 0 # we ignore line 0, so this avoids generating extra line probes + else: + for node in ast.walk(mark): + node.lineno = from_line return [mark] diff --git a/src/slipcover/bytecode.py b/src/slipcover/bytecode.py index 291b8db..b376849 100644 --- a/src/slipcover/bytecode.py +++ b/src/slipcover/bytecode.py @@ -590,56 +590,6 @@ def disable_inserted_function(self, offset): self.patch[offset] = op_JUMP_FORWARD - def replace_global_with_const(self, global_name, const_index): - """Replaces a global name lookup by a constant load.""" - assert not self.finished - - if self.patch is None: - self.patch = bytearray(self.orig_code.co_code) - - if self.branches is None: - self.branches = Branch.from_code(self.orig_code) - self.ex_table = ExceptionTableEntry.from_code(self.orig_code) - self.lines = LineEntry.from_code(self.orig_code) - - if global_name in self.orig_code.co_names: - name_index = self.orig_code.co_names.index(global_name) - - def find_load_globals(): - for op_off, op_len, op, op_arg in unpack_opargs(self.patch): - if op == op_LOAD_GLOBAL: - if sys.version_info >= (3,11): - if (op_arg>>1) == name_index: - yield (op_off, op_len, op, op_arg) - else: - if op_arg == name_index: - yield (op_off, op_len, op, op_arg) - - delta = 0 - # read from pre-computed list() below so we can modify on the fly - for op_off, op_len, op, op_arg in list(find_load_globals()): - repl = bytearray() - if sys.version_info[0:2] >= (3,11) and op_arg&1: - repl.extend(opcode_arg(dis.opmap['PUSH_NULL'], 0)) - repl.extend(opcode_arg(op_LOAD_CONST, const_index)) - - op_off += delta # adjust for any other changes - self.patch[op_off:op_off+op_len] = repl - - change = len(repl) - op_len - if change: - for l in self.lines: - l.adjust(op_off, change) - - for b in self.branches: - b.adjust(op_off, change) - - for e in self.ex_table: - e.adjust(op_off, change) - - delta += change - - def _finish(self): if not self.finished: self.finished = True diff --git a/src/slipcover/importer.py b/src/slipcover/importer.py index 1407ed9..d8fb2bc 100644 --- a/src/slipcover/importer.py +++ b/src/slipcover/importer.py @@ -1,7 +1,6 @@ from typing import Any from .slipcover import Slipcover, VERSION from . import branch as br -from . import bytecode as bc from pathlib import Path import sys @@ -93,6 +92,7 @@ def matches(self, filename : Path): return self.cwd in filename.parents + class MatchEverything: def __init__(self): pass @@ -100,6 +100,7 @@ def __init__(self): def matches(self, filename : Path): return True + class SlipcoverMetaPathFinder(MetaPathFinder): def __init__(self, sci, file_matcher, debug=False): self.debug = debug @@ -156,22 +157,59 @@ def __exit__(self, *args: Any) -> None: def wrap_pytest(sci: Slipcover, file_matcher: FileMatcher): - def exec_wrapper(obj, g): - if hasattr(obj, 'co_filename') and file_matcher.matches(obj.co_filename): - obj = sci.instrument(obj) - exec(obj, g) + def redirect_calls(module, funcName, funcWrapperName): + """Redirects calls to the given function to a wrapper function in the same module.""" + import ast + import types + + assert funcWrapperName not in module.__dict__, f"function {funcWrapperName} already defined" + + with open(module.__file__) as f: + t = ast.parse(f.read()) + + funcNames = set() # names of the functions we modified + for n in ast.walk(t): + if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)): + for s in ast.walk(n): + if isinstance(s, ast.Call) and isinstance(s.func, ast.Name) and s.func.id == funcName: + s.func.id = funcWrapperName + funcNames.add(n.name) + + code = compile(t, module.__file__, "exec") + + # It's tempting to just exec(code, module.__dict__) here, but the code often times has side effects... + # So instead of we find the new code object(s) and replace them in the loaded module. + + replacement = dict() # replacement code objects + def find_replacements(co): + for c in co.co_consts: + if isinstance(c, types.CodeType): + if c.co_name in funcNames: + replacement[c.co_name] = c + find_replacements(c) + + find_replacements(code) + + visited = set() + for f in Slipcover.find_functions(module.__dict__.values(), visited): + if (repl := replacement.get(f.__code__.co_name, None)): + assert f.__code__.co_firstlineno == repl.co_firstlineno # sanity check + f.__code__ = repl + try: import _pytest.assertion.rewrite as pyrewrite except ModuleNotFoundError: return - for f in Slipcover.find_functions(pyrewrite.__dict__.values(), set()): - if 'exec' in f.__code__.co_names: - ed = bc.Editor(f.__code__) - wrapper_index = ed.add_const(exec_wrapper) - ed.replace_global_with_const('exec', wrapper_index) - f.__code__ = ed.finish() + redirect_calls(pyrewrite, "exec", "_Slipcover_exec_wrapper") + + def exec_wrapper(obj, g): + if hasattr(obj, 'co_filename') and file_matcher.matches(obj.co_filename): + obj = sci.instrument(obj) + exec(obj, g) + + pyrewrite._Slipcover_exec_wrapper = exec_wrapper if sci.branch: import inspect diff --git a/src/slipcover/slipcover.py b/src/slipcover/slipcover.py index 14bf74e..2c03ca1 100644 --- a/src/slipcover/slipcover.py +++ b/src/slipcover/slipcover.py @@ -5,10 +5,13 @@ from typing import Dict, Set, List from collections import defaultdict, Counter import threading -from . import probe -from . import bytecode as bc -from . import branch as br + +if sys.version_info[0:2] < (3,12): + from . import probe + from . import bytecode as bc + from pathlib import Path +from . import branch as br VERSION = "0.3.2" @@ -32,7 +35,6 @@ def simplify(self, path : str) -> str: except ValueError: return path - class Slipcover: def __init__(self, immediate: bool = False, d_miss_threshold: int = 50, branch: bool = False, skip_covered: bool = False, @@ -46,23 +48,36 @@ def __init__(self, immediate: bool = False, # mutex protecting this state self.lock = threading.RLock() - # maps to guide CodeType replacements - self.replace_map: Dict[types.CodeType, types.CodeType] = dict() - self.instrumented: Dict[str, set] = defaultdict(set) - # notes which code lines have been instrumented self.code_lines: Dict[str, set] = defaultdict(set) self.code_branches: Dict[str, set] = defaultdict(set) - # provides an index (line_or_branch -> offset) for each code object - self.code2index: Dict[types.CodeType, list] = dict() - # notes which lines and branches have been seen. self.all_seen: Dict[str, set] = defaultdict(set) # notes lines/branches seen since last de-instrumentation self._get_newly_seen() + if sys.version_info[0:2] >= (3,12): + def handle_line(code, line): + if br.is_branch(line): + self.newly_seen[code.co_filename].add(br.decode_branch(line)) + elif line: + self.newly_seen[code.co_filename].add(line) + return sys.monitoring.DISABLE + + if sys.monitoring.get_tool(sys.monitoring.COVERAGE_ID) != "SlipCover": + sys.monitoring.use_tool_id(sys.monitoring.COVERAGE_ID, "SlipCover") # FIXME add free_tool_id + sys.monitoring.register_callback(sys.monitoring.COVERAGE_ID, + sys.monitoring.events.LINE, handle_line) + else: + # maps to guide CodeType replacements + self.replace_map: Dict[types.CodeType, types.CodeType] = dict() + self.instrumented: Dict[str, set] = defaultdict(set) + + # provides an index (line_or_branch -> offset) for each code object + self.code2index: Dict[types.CodeType, list] = dict() + self.modules = [] def _get_newly_seen(self): @@ -80,99 +95,132 @@ def _get_newly_seen(self): return newly_seen - def instrument(self, co: types.CodeType, parent: types.CodeType = 0) -> types.CodeType: - """Instruments a code object for coverage detection. + if sys.version_info[0:2] >= (3,12): + def instrument(self, co: types.CodeType, parent: types.CodeType = 0) -> types.CodeType: + """Instruments a code object for coverage detection. - If invoked on a function, instruments its code. - """ + If invoked on a function, instruments its code. + """ - if isinstance(co, types.FunctionType): - co.__code__ = self.instrument(co.__code__) - return co.__code__ + if isinstance(co, types.FunctionType): + co = co.__code__ - assert isinstance(co, types.CodeType) - # print(f"instrumenting {co.co_name}") + assert isinstance(co, types.CodeType) + # print(f"instrumenting {co.co_name}") - ed = bc.Editor(co) + sys.monitoring.set_local_events(sys.monitoring.COVERAGE_ID, co, sys.monitoring.events.LINE) - # handle functions-within-functions - for i, c in enumerate(co.co_consts): - if isinstance(c, types.CodeType): - ed.set_const(i, self.instrument(c, co)) - - probe_signal_index = ed.add_const(probe.signal) - - off_list = list(dis.findlinestarts(co)) - if self.branch: - off_list.extend(list(ed.find_const_assignments(br.BRANCH_NAME))) - # sort line probes (2-tuples) before branch probes (3-tuples) because - # line probes don't overwrite bytecode like branch probes do, so if there - # are two being inserted at the same offset, the accumulated offset 'delta' applies - off_list.sort(key = lambda x: (x[0], len(x))) - - branch_set = set() - insert_labels = [] - probes = [] - - delta = 0 - for off_item in off_list: - if len(off_item) == 2: # from findlinestarts - offset, lineno = off_item - if lineno == 0 or co.co_code[offset] == bc.op_RESUME: - continue + # handle functions-within-functions + for c in co.co_consts: + if isinstance(c, types.CodeType): + self.instrument(c, co) - # Can't insert between an EXTENDED_ARG and the final opcode - if (offset >= 2 and co.co_code[offset-2] == bc.op_EXTENDED_ARG): - while (offset < len(co.co_code) and co.co_code[offset-2] == bc.op_EXTENDED_ARG): - offset += 2 # TODO will we overtake the next offset from findlinestarts? + op_RESUME = dis.opmap["RESUME"] - insert_labels.append(lineno) + with self.lock: + # Python 3.11 generates a 0th line; 3.11+ generates a line just for RESUME + self.code_lines[co.co_filename].update(line for off, line in dis.findlinestarts(co) \ + if line != 0 and not br.is_branch(line) \ + and co.co_code[off] != op_RESUME) - tr = probe.new(self, co.co_filename, lineno, self.d_miss_threshold) - probes.append(tr) - tr_index = ed.add_const(tr) + self.code_branches[co.co_filename].update(br.decode_branch(line) for off, line in dis.findlinestarts(co) \ + if br.is_branch(line) and co.co_code[off] != op_RESUME) + return co - delta += ed.insert_function_call(offset+delta, probe_signal_index, (tr_index,)) + else: + def instrument(self, co: types.CodeType, parent: types.CodeType = 0) -> types.CodeType: + """Instruments a code object for coverage detection. - else: # from find_const_assignments - begin_off, end_off, branch_index = off_item - branch = co.co_consts[branch_index] + If invoked on a function, instruments its code. + """ - branch_set.add(branch) - insert_labels.append(branch) + if isinstance(co, types.FunctionType): + co.__code__ = self.instrument(co.__code__) + return co.__code__ - tr = probe.new(self, co.co_filename, branch, self.d_miss_threshold) - probes.append(tr) - ed.set_const(branch_index, tr) + assert isinstance(co, types.CodeType) + # print(f"instrumenting {co.co_name}") - delta += ed.insert_function_call(begin_off+delta, probe_signal_index, (branch_index,), - repl_length = end_off-begin_off) + ed = bc.Editor(co) - ed.add_const('__slipcover__') # mark instrumented - new_code = ed.finish() + # handle functions-within-functions + for i, c in enumerate(co.co_consts): + if isinstance(c, types.CodeType): + ed.set_const(i, self.instrument(c, co)) - if self.disassemble: - dis.dis(new_code) + probe_signal_index = ed.add_const(probe.signal) - if self.immediate: - for tr, off in zip(probes, ed.get_inserts()): - probe.set_immediate(tr, new_code.co_code, off) - else: - index = list(zip(ed.get_inserts(), insert_labels)) + off_list = list(dis.findlinestarts(co)) + if self.branch: + off_list.extend(list(ed.find_const_assignments(br.BRANCH_NAME))) + # sort line probes (2-tuples) before branch probes (3-tuples) because + # line probes don't overwrite bytecode like branch probes do, so if there + # are two being inserted at the same offset, the accumulated offset 'delta' applies + off_list.sort(key = lambda x: (x[0], len(x))) - with self.lock: - # Python 3.11 generates a 0th line; 3.11+ generates a line just for RESUME - self.code_lines[co.co_filename].update(line for off, line in dis.findlinestarts(co) \ - if line != 0 and co.co_code[off] != bc.op_RESUME) - self.code_branches[co.co_filename].update(branch_set) + branch_set = set() + insert_labels = [] + probes = [] - if not parent: - self.instrumented[co.co_filename].add(new_code) + delta = 0 + for off_item in off_list: + if len(off_item) == 2: # from findlinestarts + offset, lineno = off_item + if lineno == 0 or co.co_code[offset] == bc.op_RESUME: + continue - if not self.immediate: - self.code2index[new_code] = index + # Can't insert between an EXTENDED_ARG and the final opcode + if (offset >= 2 and co.co_code[offset-2] == bc.op_EXTENDED_ARG): + while (offset < len(co.co_code) and co.co_code[offset-2] == bc.op_EXTENDED_ARG): + offset += 2 # TODO will we overtake the next offset from findlinestarts? - return new_code + insert_labels.append(lineno) + + tr = probe.new(self, co.co_filename, lineno, self.d_miss_threshold) + probes.append(tr) + tr_index = ed.add_const(tr) + + delta += ed.insert_function_call(offset+delta, probe_signal_index, (tr_index,)) + + else: # from find_const_assignments + begin_off, end_off, branch_index = off_item + branch = co.co_consts[branch_index] + + branch_set.add(branch) + insert_labels.append(branch) + + tr = probe.new(self, co.co_filename, branch, self.d_miss_threshold) + probes.append(tr) + ed.set_const(branch_index, tr) + + delta += ed.insert_function_call(begin_off+delta, probe_signal_index, (branch_index,), + repl_length = end_off-begin_off) + + ed.add_const('__slipcover__') # mark instrumented + new_code = ed.finish() + + if self.disassemble: + dis.dis(new_code) + + if self.immediate: + for tr, off in zip(probes, ed.get_inserts()): + probe.set_immediate(tr, new_code.co_code, off) + else: + index = list(zip(ed.get_inserts(), insert_labels)) + + with self.lock: + # Python 3.11 generates a 0th line; 3.11+ generates a line just for RESUME + self.code_lines[co.co_filename].update(line for off, line in dis.findlinestarts(co) \ + if line != 0 and co.co_code[off] != bc.op_RESUME) + self.code_branches[co.co_filename].update(branch_set) + + if not parent: + self.instrumented[co.co_filename].add(new_code) + + if not self.immediate: + self.code2index[new_code] = index + + return new_code def deinstrument(self, co, lines: set) -> types.CodeType: diff --git a/tests/test_branch.py b/tests/test_branch.py index 93d4bcf..15a25b1 100644 --- a/tests/test_branch.py +++ b/tests/test_branch.py @@ -13,8 +13,8 @@ def ast_parse(s): def get_branches(code): """Extracts a list of all branches marked up in bytecode.""" - import slipcover.bytecode as bc import types + import dis branches = [] @@ -23,9 +23,17 @@ def get_branches(code): if isinstance(c, types.CodeType): branches.extend(get_branches(c)) - ed = bc.Editor(code) - for _, _, br_index in ed.find_const_assignments(br.BRANCH_NAME): - branches.append(code.co_consts[br_index]) + const = None + + for inst in dis.get_instructions(code): + if inst.opname == 'LOAD_CONST': + const = inst.argval + + elif const is not None: + if inst.opname in ('STORE_NAME', 'STORE_GLOBAL') and inst.argval == br.BRANCH_NAME: + branches.append(const) + + const = None return sorted(branches) diff --git a/tests/test_bytecode.py b/tests/test_bytecode.py index d3100d1..5f98b91 100644 --- a/tests/test_bytecode.py +++ b/tests/test_bytecode.py @@ -1,13 +1,16 @@ import pytest +import sys + +PYTHON_VERSION = sys.version_info[0:2] + +if PYTHON_VERSION >= (3,12): + pytest.skip(allow_module_level=True) + import slipcover.bytecode as bc import types import dis -import sys import inspect - -PYTHON_VERSION = sys.version_info[0:2] - def current_line(): import inspect as i return i.getframeinfo(i.currentframe().f_back).lineno diff --git a/tests/test_coverage.py b/tests/test_coverage.py index d320f56..789ef9c 100644 --- a/tests/test_coverage.py +++ b/tests/test_coverage.py @@ -1,6 +1,5 @@ import pytest import slipcover.slipcover as sc -import slipcover.bytecode as bc import slipcover.branch as br import types import dis diff --git a/tests/test_instrumentation.py b/tests/test_instrumentation.py index af7a6cb..c794086 100644 --- a/tests/test_instrumentation.py +++ b/tests/test_instrumentation.py @@ -1,16 +1,19 @@ import pytest +import sys + +PYTHON_VERSION = sys.version_info[0:2] + +if PYTHON_VERSION >= (3,12): + pytest.skip(allow_module_level=True) + import slipcover.slipcover as sc import slipcover.bytecode as bc import slipcover.branch as br import types import dis -import sys import platform import re - -PYTHON_VERSION = sys.version_info[0:2] - def current_line(): import inspect as i return i.getframeinfo(i.currentframe().f_back).lineno