blob: ed769e5c0ecaa9df013c8449f61bb69091c22dc8 [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
"""Callback functions and support for sys.monitoring data collection."""
from __future__ import annotations
import collections
import functools
import inspect
import os
import os.path
import sys
import threading
import tokenize
import traceback
from collections.abc import Callable
from dataclasses import dataclass
from types import CodeType
from typing import Any, NewType, Optional, cast
from coverage import env
from coverage.bytecode import BranchArcResolver, bytes_to_lines
from coverage.debug import short_filename, short_stack
from coverage.exceptions import NoSource
from coverage.misc import isolate_module
from coverage.parser import multiline_map_from_text
from coverage.python import get_python_source
from coverage.types import (
AnyCallable,
TFileDisposition,
TLineNo,
TOffset,
Tracer,
TShouldStartContextFn,
TShouldTraceFn,
TTraceData,
TTraceFileData,
TWarnFn,
)
# Only needed for some of the commented-out logging:
# from coverage.debug import ppformat
os = isolate_module(os)
# pylint: disable=unused-argument
# $set_env.py: COVERAGE_SYSMON_LOG - Log sys.monitoring activity
LOG = bool(int(os.getenv("COVERAGE_SYSMON_LOG", 0)))
# $set_env.py: COVERAGE_SYSMON_STATS - Collect sys.monitoring stats
COLLECT_STATS = bool(int(os.getenv("COVERAGE_SYSMON_STATS", 0)))
# This module will be imported in all versions of Python, but only used in 3.12+
# It will be type-checked for 3.12, but not for earlier versions.
sys_monitoring = getattr(sys, "monitoring", None)
DISABLE_TYPE = NewType("DISABLE_TYPE", object)
MonitorReturn = Optional[DISABLE_TYPE]
DISABLE = cast(MonitorReturn, getattr(sys_monitoring, "DISABLE", None))
if LOG: # pragma: debugging
class LoggingWrapper:
"""Wrap a namespace to log all its functions."""
def __init__(self, wrapped: Any, namespace: str) -> None:
self.wrapped = wrapped
self.namespace = namespace
def __getattr__(self, name: str) -> Callable[..., Any]:
def _wrapped(*args: Any, **kwargs: Any) -> Any:
log(f"{self.namespace}.{name}{args}{kwargs}")
return getattr(self.wrapped, name)(*args, **kwargs)
return _wrapped
sys_monitoring = LoggingWrapper(sys_monitoring, "sys.monitoring")
assert sys_monitoring is not None
short_stack = functools.partial(
short_stack,
full=True,
short_filenames=True,
frame_ids=True,
)
seen_threads: set[int] = set()
def log(msg: str) -> None:
"""Write a message to our detailed debugging log(s)."""
# Thread ids are reused across processes?
# Make a shorter number more likely to be unique.
pid = os.getpid()
tid = cast(int, threading.current_thread().ident)
tslug = f"{(pid * tid) % 9_999_991:07d}"
if tid not in seen_threads:
seen_threads.add(tid)
log(f"New thread {tid} {tslug}:\n{short_stack()}")
# log_seq = int(os.getenv("PANSEQ", "0"))
# root = f"/tmp/pan.{log_seq:03d}"
for filename in [
"/tmp/foo.out",
# f"{root}.out",
# f"{root}-{pid}.out",
# f"{root}-{pid}-{tslug}.out",
]:
with open(filename, "a", encoding="utf-8") as f:
try:
print(f"{pid}:{tslug}: {msg}", file=f, flush=True)
except UnicodeError:
print(f"{pid}:{tslug}: {ascii(msg)}", file=f, flush=True)
def arg_repr(arg: Any) -> str:
"""Make a customized repr for logged values."""
if isinstance(arg, CodeType):
return (
f"<code @{id(arg):#x}"
+ f" name={arg.co_name},"
+ f" file={short_filename(arg.co_filename)!r}#{arg.co_firstlineno}>"
)
return repr(arg)
def panopticon(*names: str | None) -> AnyCallable:
"""Decorate a function to log its calls."""
def _decorator(method: AnyCallable) -> AnyCallable:
@functools.wraps(method)
def _wrapped(self: Any, *args: Any) -> Any:
try:
# log(f"{method.__name__}() stack:\n{short_stack()}")
args_reprs = []
for name, arg in zip(names, args):
if name is None:
continue
args_reprs.append(f"{name}={arg_repr(arg)}")
log(f"{id(self):#x}:{method.__name__}({', '.join(args_reprs)})")
ret = method(self, *args)
# log(f" end {id(self):#x}:{method.__name__}({', '.join(args_reprs)})")
return ret
except Exception as exc:
log(f"!!{exc.__class__.__name__}: {exc}")
if 1:
log("".join(traceback.format_exception(exc)))
try:
assert sys_monitoring is not None
sys_monitoring.set_events(sys.monitoring.COVERAGE_ID, 0)
except ValueError:
# We might have already shut off monitoring.
log("oops, shutting off events with disabled tool id")
raise
return _wrapped
return _decorator
else:
def log(msg: str) -> None:
"""Write a message to our detailed debugging log(s), but not really."""
def panopticon(*names: str | None) -> AnyCallable:
"""Decorate a function to log its calls, but not really."""
def _decorator(meth: AnyCallable) -> AnyCallable:
return meth
return _decorator
@dataclass
class CodeInfo:
"""The information we want about each code object."""
tracing: bool
file_data: TTraceFileData | None
byte_to_line: dict[TOffset, TLineNo] | None
# Lazily-created resolver of branch events to arcs, created on the
# first branch event in the code object.
branch_resolver: BranchArcResolver | None
class SysMonitor(Tracer):
"""Python implementation of the raw data tracer for PEP669 implementations."""
# One of these will be used across threads. Be careful.
def __init__(self) -> None:
# Attributes set from the collector:
self.data: TTraceData
self.trace_arcs = False
self.should_trace: TShouldTraceFn
self.should_trace_cache: dict[str, TFileDisposition | None]
# TODO: should_start_context and switch_context are unused!
# Change tests/testenv.py:DYN_CONTEXTS when this is updated.
self.should_start_context: TShouldStartContextFn | None = None
self.switch_context: Callable[[str | None], None] | None = None
self.lock_data: Callable[[], None]
self.unlock_data: Callable[[], None]
# TODO: warn is unused.
self.warn: TWarnFn
assert sys_monitoring is not None
# sys.monitoring pre-allocates tool ids, but it's kind of pointless.
# There's no guarantee that "our" tool id will still be available, so
# we have to search for a usable one in start() anyway.
self.myid = sys_monitoring.COVERAGE_ID
# Map id(code_object) -> CodeInfo
self.code_infos: dict[int, CodeInfo] = {}
# A list of code_objects, just to keep them alive so that id's are
# useful as identity.
self.code_objects: list[CodeType] = []
# Map filename:__name__ -> set(id(code_object))
self.filename_code_ids: dict[str, set[int]] = collections.defaultdict(set)
# Map filename -> multiline map, so each file is parsed at most once.
self.multiline_maps: dict[str, dict[TLineNo, TLineNo]] = {}
self.sysmon_on = False
self.lock = threading.Lock()
self.stats: dict[str, int] | None = None
if COLLECT_STATS:
self.stats = dict.fromkeys(
"starts start_tracing returns line_lines line_arcs branches branch_trails".split(),
0,
)
self._activity = False
def __repr__(self) -> str:
points = sum(len(v) for v in self.data.values())
files = len(self.data)
return f"<SysMonitor at {id(self):#x}: {points} data points in {files} files>"
@panopticon()
def start(self) -> None:
"""Start this Tracer."""
with self.lock:
assert sys_monitoring is not None
while self.myid <= 5:
try:
sys_monitoring.use_tool_id(self.myid, "coverage.py")
break
except ValueError:
self.myid += 1
continue
else:
raise RuntimeError("No sys.monitoring tool id is available")
register = functools.partial(sys_monitoring.register_callback, self.myid)
events = sys.monitoring.events
sys_monitoring.set_events(self.myid, events.PY_START)
register(events.PY_START, self.sysmon_py_start)
if self.trace_arcs:
register(events.PY_RETURN, self.sysmon_py_return)
register(events.LINE, self.sysmon_line_arcs)
if env.PYBEHAVIOR.branch_right_left:
register(events.BRANCH_RIGHT, self.sysmon_branch_either)
register(events.BRANCH_LEFT, self.sysmon_branch_either)
else:
register(events.LINE, self.sysmon_line_lines)
sys_monitoring.restart_events()
self.sysmon_on = True
@panopticon()
def stop(self) -> None:
"""Stop this Tracer."""
with self.lock:
if not self.sysmon_on:
# In forking situations, we might try to stop when we are not
# started. Do nothing in that case.
return
assert sys_monitoring is not None
sys_monitoring.set_events(self.myid, 0)
self.sysmon_on = False
sys_monitoring.free_tool_id(self.myid)
if LOG: # pragma: debugging
items = sorted(
self.filename_code_ids.items(),
key=lambda item: len(item[1]),
reverse=True,
)
code_objs = sum(len(code_ids) for _, code_ids in items)
dupes = code_objs - len(items)
if dupes:
log(f"==== Duplicate code objects: {dupes} duplicates, {code_objs} total")
for filename, code_ids in items:
if len(code_ids) > 1:
log(f"{len(code_ids):>5} objects: {filename}")
else:
log("==== Duplicate code objects: none")
@panopticon()
def post_fork(self) -> None:
"""The process has forked, clean up as needed."""
self.stop()
def activity(self) -> bool:
"""Has there been any activity?"""
return self._activity
def reset_activity(self) -> None:
"""Reset the activity() flag."""
self._activity = False
def get_stats(self) -> dict[str, int] | None:
"""Return a dictionary of statistics, or None."""
return self.stats
@panopticon("code", "@")
def sysmon_py_start(self, code: CodeType, instruction_offset: TOffset) -> MonitorReturn:
"""Handle sys.monitoring.events.PY_START events."""
self._activity = True
if self.stats is not None:
self.stats["starts"] += 1
if code.co_name == "__annotate__":
# Type annotation code objects don't execute, ignore them.
return DISABLE
# Entering a new frame. Decide if we should trace in this file.
code_info = self.code_infos.get(id(code))
tracing_code: bool | None = None
file_data: TTraceFileData | None = None
if code_info is not None:
tracing_code = code_info.tracing
file_data = code_info.file_data
if tracing_code is None:
filename = code.co_filename
disp = self.should_trace_cache.get(filename)
if disp is None:
frame = inspect.currentframe()
if frame is not None:
frame = inspect.currentframe().f_back # type: ignore[union-attr]
if LOG: # pragma: debugging
# @panopticon adds a frame.
frame = frame.f_back # type: ignore[union-attr]
disp = self.should_trace(filename, frame) # type: ignore[arg-type]
self.should_trace_cache[filename] = disp
tracing_code = disp.trace
if tracing_code:
tracename = disp.source_filename
assert tracename is not None
self.lock_data()
try:
if tracename not in self.data:
self.data[tracename] = set()
finally:
self.unlock_data()
file_data = self.data[tracename]
# byte_to_line is only read by the arc callbacks
b2l = bytes_to_lines(code) if self.trace_arcs else None
else:
file_data = None
b2l = None
code_info = CodeInfo(
tracing=tracing_code,
file_data=file_data,
byte_to_line=b2l,
branch_resolver=None,
)
self.code_infos[id(code)] = code_info
self.code_objects.append(code)
if tracing_code:
if self.stats is not None:
self.stats["start_tracing"] += 1
events = sys.monitoring.events
with self.lock:
if self.sysmon_on:
assert sys_monitoring is not None
local_events = events.LINE
if self.trace_arcs:
assert env.PYBEHAVIOR.branch_right_left
local_events |= (
events.PY_RETURN | events.BRANCH_RIGHT | events.BRANCH_LEFT
)
sys_monitoring.set_local_events(self.myid, code, local_events)
if LOG: # pragma: debugging
if code.co_filename not in {"<string>"}:
self.filename_code_ids[f"{code.co_filename}:{code.co_name}"].add(
id(code)
)
return DISABLE
@panopticon("code", "@", None)
def sysmon_py_return(
self,
code: CodeType,
instruction_offset: TOffset,
retval: object,
) -> MonitorReturn:
"""Handle sys.monitoring.events.PY_RETURN events for branch coverage."""
if self.stats is not None:
self.stats["returns"] += 1
code_info = self.code_infos.get(id(code))
# code_info is not None and code_info.file_data is not None, since we
# wouldn't have enabled this event if they were.
last_line = code_info.byte_to_line.get(instruction_offset) # type: ignore
if last_line is not None:
arc = (last_line, -code.co_firstlineno)
code_info.file_data.add(arc) # type: ignore
# log(f"adding {arc=}")
return DISABLE
@panopticon("code", "line")
def sysmon_line_lines(self, code: CodeType, line_number: TLineNo) -> MonitorReturn:
"""Handle sys.monitoring.events.LINE events for line coverage."""
if self.stats is not None:
self.stats["line_lines"] += 1
code_info = self.code_infos.get(id(code))
# It should be true that code_info is not None and code_info.file_data
# is not None, since we wouldn't have enabled this event if they were.
# But somehow code_info can be None here, so we have to check.
if code_info is not None and code_info.file_data is not None:
code_info.file_data.add(line_number) # type: ignore
# log(f"adding {line_number=}")
return DISABLE
@panopticon("code", "line")
def sysmon_line_arcs(self, code: CodeType, line_number: TLineNo) -> MonitorReturn:
"""Handle sys.monitoring.events.LINE events for branch coverage."""
if self.stats is not None:
self.stats["line_arcs"] += 1
code_info = self.code_infos[id(code)]
# code_info is not None and code_info.file_data is not None, since we
# wouldn't have enabled this event if they were.
arc = (line_number, line_number)
code_info.file_data.add(arc) # type: ignore
# log(f"adding {arc=}")
return DISABLE
@panopticon("code", "@", "@")
def sysmon_branch_either(
self, code: CodeType, instruction_offset: TOffset, destination_offset: TOffset
) -> MonitorReturn:
"""Handle BRANCH_RIGHT and BRANCH_LEFT events."""
if self.stats is not None:
self.stats["branches"] += 1
code_info = self.code_infos[id(code)]
# code_info is not None and code_info.file_data is not None, since we
# wouldn't have enabled this event if they were.
resolver = code_info.branch_resolver
if resolver is None:
if self.stats is not None:
self.stats["branch_trails"] += 1
assert code_info.byte_to_line is not None
resolver = code_info.branch_resolver = BranchArcResolver(
code,
code_info.byte_to_line,
self.get_multiline_map(code.co_filename),
)
arc = resolver.resolve(instruction_offset, destination_offset)
if arc is not None:
code_info.file_data.add(arc) # type: ignore
# log(f"adding {arc=}")
else:
# This could be an exception jumping from line to line.
assert code_info.byte_to_line is not None
l1 = code_info.byte_to_line.get(instruction_offset)
if l1 is not None:
l2 = code_info.byte_to_line.get(destination_offset)
if l2 is not None and l1 != l2:
arc = (l1, l2)
code_info.file_data.add(arc) # type: ignore
# log(f"adding unforeseen {arc=}")
return DISABLE
def get_multiline_map(self, filename: str) -> dict[TLineNo, TLineNo]:
"""Get the multiline map for `filename`, computing it at most once."""
multiline_map = self.multiline_maps.get(filename)
if multiline_map is None:
multiline_map = self.multiline_maps[filename] = compute_multiline_map(filename)
return multiline_map
def compute_multiline_map(filename: str) -> dict[TLineNo, TLineNo]:
"""Tokenize `filename` and return its multiline map."""
try:
text = get_python_source(filename)
except (OSError, NoSource):
# This can happen if open() in python.py fails.
return {}
try:
return multiline_map_from_text(text)
except (tokenize.TokenError, IndentationError, SyntaxError):
# The file was not Python. This can happen when the code object refers
# to an original non-Python source file, like a Jinja template.
# In that case, just return an empty map, which might lead to slightly
# wrong branch coverage, but we don't have any better option.
return {}