Skip to content

Commit 36a3e0f

Browse files
fix(ci_visibility): avoid clash with other sys.monitoring tools (#12558)
On Python 3.12+, CI Visibility / Test Optimization uses `sys.monitoring` to gather code coverage information. The problem is that if other tools, such as `pyest-cov`, also try to register a `sys.monitoring` tool for coverage, an exception will be raised because Python only allows one handler per tool type (see issue #11256). This PR postpones tool registering until the first call to `instrument_all_lines()`, giving other tools the opportunity to register themselves; in this case, however, we do not gather code coverage information. - [x] PR author has checked that all the criteria below are met - The PR description includes an overview of the change - The PR description articulates the motivation for the change - The change includes tests OR the PR description describes a testing strategy - The PR description notes risks associated with the change, if any - Newly-added code is easy to change - The change follows the [library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) - The change includes or references documentation updates if necessary - Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) - [x] Reviewer has checked that all the criteria below are met - Title is accurate - All changes are related to the pull request's stated goal - Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes - Testing strategy adequately addresses listed risks - Newly-added code is easy to change - Release note makes sense to a user of the library - If necessary, author has acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment - Backport labels are set in a manner that is consistent with the [release branch maintenance policy](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting) (cherry picked from commit 7074375)
1 parent 1e454f1 commit 36a3e0f

File tree

3 files changed

+179
-111
lines changed

3 files changed

+179
-111
lines changed

ddtrace/internal/coverage/instrumentation_py3_12.py

Lines changed: 124 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,13 @@
44
import typing as t
55

66
from ddtrace.internal.injection import HookType
7+
from ddtrace.internal.logger import get_logger
78
from ddtrace.internal.test_visibility.coverage_lines import CoverageLines
89

910

11+
log = get_logger(__name__)
12+
13+
1014
# This is primarily to make mypy happy without having to nest the rest of this module behind a version check
1115
assert sys.version_info >= (3, 12) # nosec
1216

@@ -17,126 +21,135 @@
1721
RETURN_CONST = dis.opmap["RETURN_CONST"]
1822
EMPTY_MODULE_BYTES = bytes([RESUME, 0, RETURN_CONST, 0])
1923

20-
# Register the coverage tool with the low-impact monitoring system
21-
try:
22-
sys.monitoring.use_tool_id(sys.monitoring.COVERAGE_ID, "datadog") # noqa
23-
except ValueError:
24-
# TODO: Another coverage tool is already in use. Either warn the user
25-
# or free the tool and register ours.
26-
def instrument_all_lines(
27-
code: CodeType, hook: HookType, path: str, package: str
28-
) -> t.Tuple[CodeType, CoverageLines]:
29-
# No-op
24+
_CODE_HOOKS: t.Dict[CodeType, t.Tuple[HookType, str, t.Dict[int, t.Tuple[str, t.Optional[t.Tuple[str]]]]]] = {}
25+
26+
27+
def instrument_all_lines(code: CodeType, hook: HookType, path: str, package: str) -> t.Tuple[CodeType, CoverageLines]:
28+
coverage_tool = sys.monitoring.get_tool(sys.monitoring.COVERAGE_ID)
29+
if coverage_tool is not None and coverage_tool != "datadog":
30+
log.debug("Coverage tool '%s' already registered, not gathering coverage", coverage_tool)
3031
return code, CoverageLines()
3132

32-
else:
33-
_CODE_HOOKS: t.Dict[CodeType, t.Tuple[HookType, str, t.Dict[int, t.Tuple[str, t.Optional[t.Tuple[str]]]]]] = {}
33+
if coverage_tool is None:
34+
log.debug("Registering code coverage tool")
35+
_register_monitoring()
36+
37+
return _instrument_all_lines_with_monitoring(code, hook, path, package)
38+
39+
40+
def _line_event_handler(code: CodeType, line: int) -> t.Any:
41+
hook, path, import_names = _CODE_HOOKS[code]
42+
import_name = import_names.get(line, None)
43+
return hook((line, path, import_name))
44+
3445

35-
def _line_event_handler(code: CodeType, line: int) -> t.Any:
36-
hook, path, import_names = _CODE_HOOKS[code]
37-
import_name = import_names.get(line, None)
38-
return hook((line, path, import_name))
46+
def _register_monitoring():
47+
"""
48+
Register the coverage tool with the low-impact monitoring system.
49+
"""
50+
sys.monitoring.use_tool_id(sys.monitoring.COVERAGE_ID, "datadog")
3951

4052
# Register the line callback
4153
sys.monitoring.register_callback(
4254
sys.monitoring.COVERAGE_ID, sys.monitoring.events.LINE, _line_event_handler
4355
) # noqa
4456

45-
def instrument_all_lines(
46-
code: CodeType, hook: HookType, path: str, package: str
47-
) -> t.Tuple[CodeType, CoverageLines]:
48-
# Enable local line events for the code object
49-
sys.monitoring.set_local_events(sys.monitoring.COVERAGE_ID, code, sys.monitoring.events.LINE) # noqa
50-
51-
# Collect all the line numbers in the code object
52-
linestarts = dict(dis.findlinestarts(code))
53-
54-
lines = CoverageLines()
55-
import_names: t.Dict[int, t.Tuple[str, t.Optional[t.Tuple[str, ...]]]] = {}
56-
57-
# The previous two arguments are kept in order to track the depth of the IMPORT_NAME
58-
# For example, from ...package import module
59-
current_arg: int = 0
60-
previous_arg: int = 0
61-
_previous_previous_arg: int = 0
62-
current_import_name: t.Optional[str] = None
63-
current_import_package: t.Optional[str] = None
64-
65-
line = 0
66-
67-
ext: list[bytes] = []
68-
code_iter = iter(enumerate(code.co_code))
69-
try:
70-
while True:
71-
offset, opcode = next(code_iter)
72-
_, arg = next(code_iter)
73-
74-
if opcode == RESUME:
75-
continue
76-
77-
if offset in linestarts:
78-
line = linestarts[offset]
79-
lines.add(line)
80-
81-
# Make sure that the current module is marked as depending on its own package by instrumenting the
82-
# first executable line
83-
if code.co_name == "<module>" and len(lines) == 1 and package is not None:
84-
import_names[line] = (package, ("",))
85-
86-
if opcode is EXTENDED_ARG:
87-
ext.append(arg)
88-
continue
57+
58+
def _instrument_all_lines_with_monitoring(
59+
code: CodeType, hook: HookType, path: str, package: str
60+
) -> t.Tuple[CodeType, CoverageLines]:
61+
# Enable local line events for the code object
62+
sys.monitoring.set_local_events(sys.monitoring.COVERAGE_ID, code, sys.monitoring.events.LINE) # noqa
63+
64+
# Collect all the line numbers in the code object
65+
linestarts = dict(dis.findlinestarts(code))
66+
67+
lines = CoverageLines()
68+
import_names: t.Dict[int, t.Tuple[str, t.Optional[t.Tuple[str, ...]]]] = {}
69+
70+
# The previous two arguments are kept in order to track the depth of the IMPORT_NAME
71+
# For example, from ...package import module
72+
current_arg: int = 0
73+
previous_arg: int = 0
74+
_previous_previous_arg: int = 0
75+
current_import_name: t.Optional[str] = None
76+
current_import_package: t.Optional[str] = None
77+
78+
line = 0
79+
80+
ext: list[bytes] = []
81+
code_iter = iter(enumerate(code.co_code))
82+
try:
83+
while True:
84+
offset, opcode = next(code_iter)
85+
_, arg = next(code_iter)
86+
87+
if opcode == RESUME:
88+
continue
89+
90+
if offset in linestarts:
91+
line = linestarts[offset]
92+
lines.add(line)
93+
94+
# Make sure that the current module is marked as depending on its own package by instrumenting the
95+
# first executable line
96+
if code.co_name == "<module>" and len(lines) == 1 and package is not None:
97+
import_names[line] = (package, ("",))
98+
99+
if opcode is EXTENDED_ARG:
100+
ext.append(arg)
101+
continue
102+
else:
103+
_previous_previous_arg = previous_arg
104+
previous_arg = current_arg
105+
current_arg = int.from_bytes([*ext, arg], "big", signed=False)
106+
ext.clear()
107+
108+
if opcode == IMPORT_NAME:
109+
import_depth: int = code.co_consts[_previous_previous_arg]
110+
current_import_name: str = code.co_names[current_arg]
111+
# Adjust package name if the import is relative and a parent (ie: if depth is more than 1)
112+
current_import_package: str = (
113+
".".join(package.split(".")[: -import_depth + 1]) if import_depth > 1 else package
114+
)
115+
116+
if line in import_names:
117+
import_names[line] = (
118+
current_import_package,
119+
tuple(list(import_names[line][1]) + [current_import_name]),
120+
)
89121
else:
90-
_previous_previous_arg = previous_arg
91-
previous_arg = current_arg
92-
current_arg = int.from_bytes([*ext, arg], "big", signed=False)
93-
ext.clear()
94-
95-
if opcode == IMPORT_NAME:
96-
import_depth: int = code.co_consts[_previous_previous_arg]
97-
current_import_name: str = code.co_names[current_arg]
98-
# Adjust package name if the import is relative and a parent (ie: if depth is more than 1)
99-
current_import_package: str = (
100-
".".join(package.split(".")[: -import_depth + 1]) if import_depth > 1 else package
122+
import_names[line] = (current_import_package, (current_import_name,))
123+
124+
# Also track import from statements since it's possible that the "from" target is a module, eg:
125+
# from my_package import my_module
126+
# Since the package has not changed, we simply extend the previous import names with the new value
127+
if opcode == IMPORT_FROM:
128+
import_from_name = f"{current_import_name}.{code.co_names[current_arg]}"
129+
if line in import_names:
130+
import_names[line] = (
131+
current_import_package,
132+
tuple(list(import_names[line][1]) + [import_from_name]),
101133
)
134+
else:
135+
import_names[line] = (current_import_package, (import_from_name,))
136+
137+
except StopIteration:
138+
pass
139+
140+
# Recursively instrument nested code objects
141+
for nested_code in (_ for _ in code.co_consts if isinstance(_, CodeType)):
142+
_, nested_lines = instrument_all_lines(nested_code, hook, path, package)
143+
lines.update(nested_lines)
144+
145+
# Register the hook and argument for the code object
146+
_CODE_HOOKS[code] = (hook, path, import_names)
147+
148+
# Special case for empty modules (eg: __init__.py ):
149+
# Make sure line 0 is marked as executable, and add package dependency
150+
if not lines and code.co_name == "<module>" and code.co_code == EMPTY_MODULE_BYTES:
151+
lines.add(0)
152+
if package is not None:
153+
import_names[0] = (package, ("",))
102154

103-
if line in import_names:
104-
import_names[line] = (
105-
current_import_package,
106-
tuple(list(import_names[line][1]) + [current_import_name]),
107-
)
108-
else:
109-
import_names[line] = (current_import_package, (current_import_name,))
110-
111-
# Also track import from statements since it's possible that the "from" target is a module, eg:
112-
# from my_package import my_module
113-
# Since the package has not changed, we simply extend the previous import names with the new value
114-
if opcode == IMPORT_FROM:
115-
import_from_name = f"{current_import_name}.{code.co_names[current_arg]}"
116-
if line in import_names:
117-
import_names[line] = (
118-
current_import_package,
119-
tuple(list(import_names[line][1]) + [import_from_name]),
120-
)
121-
else:
122-
import_names[line] = (current_import_package, (import_from_name,))
123-
124-
except StopIteration:
125-
pass
126-
127-
# Recursively instrument nested code objects
128-
for nested_code in (_ for _ in code.co_consts if isinstance(_, CodeType)):
129-
_, nested_lines = instrument_all_lines(nested_code, hook, path, package)
130-
lines.update(nested_lines)
131-
132-
# Register the hook and argument for the code object
133-
_CODE_HOOKS[code] = (hook, path, import_names)
134-
135-
# Special case for empty modules (eg: __init__.py ):
136-
# Make sure line 0 is marked as executable, and add package dependency
137-
if not lines and code.co_name == "<module>" and code.co_code == EMPTY_MODULE_BYTES:
138-
lines.add(0)
139-
if package is not None:
140-
import_names[0] = (package, ("",))
141-
142-
return code, lines
155+
return code, lines
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
fixes:
3+
- |
4+
CI Visibility: This fix resolves an issue where ddtrace's own sys.monitoring coverage tool in Python 3.12+ would
5+
block other sys.monitoring tools such as pytest-cov from being used.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import sys
2+
3+
import pytest
4+
5+
6+
@pytest.mark.skipif(sys.version_info < (3, 12), reason="sys.monitoring coverage is only used in Python 3.12+")
7+
@pytest.mark.subprocess
8+
def test_code_coverage_tool_clash():
9+
"""If another tool is already registered as the `sys.monitoring` coverage tool, do not collect coverage."""
10+
import os
11+
from pathlib import Path
12+
import sys
13+
14+
from ddtrace.internal.coverage.code import ModuleCodeCollector
15+
from ddtrace.internal.coverage.installer import install
16+
from tests.coverage.utils import _get_relpath_dict
17+
18+
cwd_path = os.getcwd()
19+
include_path = Path(cwd_path + "/tests/coverage/included_path/")
20+
21+
sys.monitoring.use_tool_id(sys.monitoring.COVERAGE_ID, "something_else")
22+
23+
install(include_paths=[include_path], collect_import_time_coverage=True)
24+
25+
from tests.coverage.included_path.async_code import call_async_function_and_report_line_number
26+
27+
ModuleCodeCollector.start_coverage()
28+
line_number = call_async_function_and_report_line_number()
29+
ModuleCodeCollector.stop_coverage()
30+
31+
executable = _get_relpath_dict(cwd_path, ModuleCodeCollector._instance.lines)
32+
covered = _get_relpath_dict(cwd_path, ModuleCodeCollector._instance._get_covered_lines(include_imported=False))
33+
covered_with_imports = _get_relpath_dict(
34+
cwd_path, ModuleCodeCollector._instance._get_covered_lines(include_imported=True)
35+
)
36+
37+
expected_executable = {
38+
"tests/coverage/included_path/async_code.py": set(),
39+
}
40+
expected_covered = {}
41+
expected_covered_with_imports = {}
42+
43+
assert (
44+
executable == expected_executable
45+
), f"Executable lines mismatch: expected={expected_executable} vs actual={executable}"
46+
assert covered == expected_covered, f"Covered lines mismatch: expected={expected_covered} vs actual={covered}"
47+
assert (
48+
covered_with_imports == expected_covered_with_imports
49+
), f"Covered lines with imports mismatch: expected={expected_covered_with_imports} vs actual={covered_with_imports}"
50+
assert line_number == 7

0 commit comments

Comments
 (0)