From 1238661cdbcfd90541758741b0209b676f6a78ec Mon Sep 17 00:00:00 2001 From: Federico Mon Date: Thu, 28 May 2026 16:39:32 +0000 Subject: [PATCH 01/10] fix(ci_visibility): unblock ddtrace import on Python 3.15 for dd_coverage Replace the module-level NotImplementedError guards in the bytecode-wrapping subsystem with no-op stubs so that ddtrace can be imported on Python 3.15 without crashing. Wrapping functionality remains intentionally unimplemented until #17849 (wrapping context for 3.15) lands. Changes: - ddtrace/internal/wrapping/asyncs.py: pass + wrap_async no-op on >=3.15 - ddtrace/internal/wrapping/generators.py: pass + wrap_generator no-op on >=3.15 - ddtrace/internal/wrapping/context.py: pass + CONTEXT_RETURN_CONST stub + _UniversalWrappingContext.wrap/unwrap no-ops on >=3.15 - ddtrace/internal/bytecode_injection/__init__.py: pass on >=3.15 (HookType type alias still works; inject/eject not used by the sys.monitoring path) - tests/conftest.py: gate auto_enable_crashtracking on crashtracking.is_available (crashtracker feature is intentionally excluded from the 3.15 build in setup.py) Verified: all 85 dd_coverage tests pass on Python 3.15-dev. Co-Authored-By: Claude Sonnet 4.6 --- ddtrace/internal/bytecode_injection/__init__.py | 2 +- ddtrace/internal/wrapping/asyncs.py | 6 +++++- ddtrace/internal/wrapping/context.py | 16 ++++++++++++++-- ddtrace/internal/wrapping/generators.py | 5 ++++- tests/conftest.py | 2 +- 5 files changed, 25 insertions(+), 6 deletions(-) diff --git a/ddtrace/internal/bytecode_injection/__init__.py b/ddtrace/internal/bytecode_injection/__init__.py index 661ca193904..97e3cc23341 100644 --- a/ddtrace/internal/bytecode_injection/__init__.py +++ b/ddtrace/internal/bytecode_injection/__init__.py @@ -30,7 +30,7 @@ class InvalidLine(Exception): INJECTION_ASSEMBLY = Assembly() if PY >= (3, 15): - raise NotImplementedError("Python >= 3.15 is not supported yet") + pass # AIDEV-TODO: Populate assembly for 3.15 bytecode (tracked in #17849). elif PY >= (3, 13): INJECTION_ASSEMBLY.parse( r""" diff --git a/ddtrace/internal/wrapping/asyncs.py b/ddtrace/internal/wrapping/asyncs.py index 06dc4507f12..020733d59e5 100644 --- a/ddtrace/internal/wrapping/asyncs.py +++ b/ddtrace/internal/wrapping/asyncs.py @@ -36,7 +36,7 @@ ASYNC_HEAD_ASSEMBLY = None if PY >= (3, 15): - raise NotImplementedError("This version of CPython is not supported yet") + pass # AIDEV-TODO: Populate assemblies for 3.15 bytecode (tracked in #17849). elif PY >= (3, 14): ASYNC_HEAD_ASSEMBLY = Assembly() @@ -712,6 +712,10 @@ def wrap_async(instrs: list[bc.Instr], code: CodeType, lineno: int) -> None: + if PY >= (3, 15): + # AIDEV-TODO: Async wrapping not yet implemented for 3.15; no-op until + # #17849 lands. Remove this guard once the assemblies above are populated. + return if (bc.CompilerFlags.ASYNC_GENERATOR | bc.CompilerFlags.COROUTINE) & code.co_flags: if ASYNC_HEAD_ASSEMBLY is not None: instrs[0:0] = ASYNC_HEAD_ASSEMBLY.bind(lineno=lineno) diff --git a/ddtrace/internal/wrapping/context.py b/ddtrace/internal/wrapping/context.py index 62043667412..6cc8c54831a 100644 --- a/ddtrace/internal/wrapping/context.py +++ b/ddtrace/internal/wrapping/context.py @@ -79,7 +79,9 @@ CONTEXT_FOOT = Assembly() if sys.version_info >= (3, 15): - raise NotImplementedError("Python >= 3.15 is not supported yet") + # AIDEV-TODO: Populate assemblies for 3.15 bytecode (tracked in #17849). + # Define the stub so references below don't NameError. + CONTEXT_RETURN_CONST: Assembly = Assembly() elif sys.version_info >= (3, 13): CONTEXT_HEAD.parse( r""" @@ -587,7 +589,17 @@ def extract(cls, f: FunctionType) -> "_UniversalWrappingContext": raise ValueError("Function is not wrapped") return t.cast(_UniversalWrappingContext, t.cast(ContextWrappedFunction, f).__dd_context_wrapped__) - if sys.version_info >= (3, 11): + if sys.version_info >= (3, 15): + + def wrap(self) -> None: + # AIDEV-TODO: No-op until #17849 implements wrapping for 3.15. + pass + + def unwrap(self) -> None: + # AIDEV-TODO: No-op until #17849 implements wrapping for 3.15. + pass + + elif sys.version_info >= (3, 11): def wrap(self) -> None: f = self.__wrapped__ diff --git a/ddtrace/internal/wrapping/generators.py b/ddtrace/internal/wrapping/generators.py index 7406f978cbc..8b12b472254 100644 --- a/ddtrace/internal/wrapping/generators.py +++ b/ddtrace/internal/wrapping/generators.py @@ -34,7 +34,7 @@ GENERATOR_HEAD_ASSEMBLY = None if PY >= (3, 15): - raise NotImplementedError("This version of CPython is not supported yet") + pass # AIDEV-TODO: Populate assemblies for 3.15 bytecode (tracked in #17849). elif PY >= (3, 14): GENERATOR_HEAD_ASSEMBLY = Assembly() @@ -485,6 +485,9 @@ def wrap_generator(instrs: list[bc.Instr], code: CodeType, lineno: int) -> None: + if PY >= (3, 15): + # AIDEV-TODO: No-op until #17849 populates the assemblies above. + return if GENERATOR_HEAD_ASSEMBLY is not None: instrs[0:0] = GENERATOR_HEAD_ASSEMBLY.bind(lineno=lineno) diff --git a/tests/conftest.py b/tests/conftest.py index ae6c346b181..57df25e04f5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -210,7 +210,7 @@ def use_dummy_writer(): def auto_enable_crashtracking(): # Crashtracking is only supported on linux right now # TODO: Default to `True` when Windows and Darwin are supported - yield platform.system() == "Linux" + yield platform.system() == "Linux" and crashtracking.is_available @pytest.fixture(autouse=True) From 2b348cd3e76a346e6fb5a5a38362aacab6c0e3e6 Mon Sep 17 00:00:00 2001 From: Federico Mon Date: Fri, 29 May 2026 10:06:52 +0200 Subject: [PATCH 02/10] empty From 4bd039c706c715c49201bd6860f4fa5c9199cf55 Mon Sep 17 00:00:00 2001 From: Federico Mon Date: Fri, 29 May 2026 09:55:56 +0000 Subject: [PATCH 03/10] fix(ci): skip profiling smoke test on Python 3.15+ The profiling Cython extensions (_lock, _task, etc.) are not built for Python 3.15 (setup.py gates them behind sys.version_info < (3, 15), tracked in #17817). Mirror that guard in smoke_test.py so the create-venv job doesn't fail before dd_coverage tests get a chance to run. Co-Authored-By: Claude Sonnet 4.6 --- tests/smoke_test.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/smoke_test.py b/tests/smoke_test.py index ddd71fbeea2..a06da19ca50 100644 --- a/tests/smoke_test.py +++ b/tests/smoke_test.py @@ -76,7 +76,8 @@ def emit(self, record): print("Skipping test, 32-bit DDWAF not ready yet") # Profiling smoke test - if platform.system() in ("Linux", "Darwin") and sys.maxsize > (1 << 32): + # Profiling extensions are not built for Python 3.15+ (see setup.py, tracked in #17817) + if platform.system() in ("Linux", "Darwin") and sys.maxsize > (1 << 32) and sys.version_info < (3, 15): print("Running profiling smoke test...") profiling_cmd = [sys.executable, "-c", "import ddtrace.profiling.auto"] result = subprocess.run(profiling_cmd, capture_output=True, text=True) From 71ef2d01c3fee851c4d76bcfd7f8af7cbb96cdaa Mon Sep 17 00:00:00 2001 From: Federico Mon Date: Mon, 1 Jun 2026 09:09:43 +0000 Subject: [PATCH 04/10] ci(pydantic_ai): cap pydantic==2.12.0a1 venvs at Python 3.14 pydantic==2.12.0a1 requires pydantic-core~=2.37.x, which bundles PyO3 0.25.1. PyO3 0.25.1 rejects Python 3.15 at build time with "maximum supported version (3.14)". The pydantic==2.13+ releases use pydantic-core 2.46.x (PyO3 0.28) which builds fine on 3.15. Testing an old pydantic alpha against Python 3.15 is not meaningful, so cap the affected sub-venvs at max_version="3.14". The unpinned pydantic-ai-slim==1.63.0 venv continues to cover 3.15 using the latest pydantic/pydantic-core. Pruned stale 3.15 lockfiles: 14a7a5f.txt, 1f96562.txt. Co-Authored-By: Claude Sonnet 4.6 --- .riot/requirements/14a7a5f.txt | 53 ---------------------------------- .riot/requirements/1f96562.txt | 53 ---------------------------------- riotfile.py | 4 ++- tested_versions.json | 8 ----- 4 files changed, 3 insertions(+), 115 deletions(-) delete mode 100644 .riot/requirements/14a7a5f.txt delete mode 100644 .riot/requirements/1f96562.txt diff --git a/.riot/requirements/14a7a5f.txt b/.riot/requirements/14a7a5f.txt deleted file mode 100644 index 47926643d1d..00000000000 --- a/.riot/requirements/14a7a5f.txt +++ /dev/null @@ -1,53 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.15 -# by the following command: -# -# pip-compile --allow-unsafe --no-annotate .riot/requirements/14a7a5f.in -# -annotated-types==0.7.0 -anyio==4.13.0 -attrs==26.1.0 -certifi==2026.5.20 -colorama==0.4.6 -coverage[toml]==7.14.0 -distro==1.9.0 -eval-type-backport==0.3.1 -genai-prices==0.0.61 -griffe==2.0.2 -griffecli==2.0.2 -griffelib==2.0.2 -h11==0.16.0 -httpcore==1.0.9 -httpx==0.28.1 -hypothesis==6.45.0 -idna==3.15 -iniconfig==2.3.0 -jiter==0.15.0 -logfire-api==4.33.0 -mock==5.2.0 -multidict==6.7.1 -openai==2.37.0 -opentelemetry-api==1.42.0 -opentracing==2.4.0 -packaging==26.2 -pluggy==1.6.0 -propcache==0.5.2 -pydantic==2.12.0a1 -pydantic-ai-slim[openai]==0.8.1 -pydantic-core==2.37.2 -pydantic-graph==0.8.1 -pygments==2.20.0 -pytest==9.0.3 -pytest-asyncio==1.3.0 -pytest-cov==7.1.0 -pytest-mock==3.15.1 -pyyaml==6.0.3 -sniffio==1.3.1 -sortedcontainers==2.4.0 -tqdm==4.67.3 -typing-extensions==4.15.0 -typing-inspection==0.4.2 -urllib3==2.7.0 -vcrpy==7.0.0 -wrapt==2.1.2 -yarl==1.24.2 diff --git a/.riot/requirements/1f96562.txt b/.riot/requirements/1f96562.txt deleted file mode 100644 index cb0e3c5dec7..00000000000 --- a/.riot/requirements/1f96562.txt +++ /dev/null @@ -1,53 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.15 -# by the following command: -# -# pip-compile --allow-unsafe --no-annotate .riot/requirements/1f96562.in -# -annotated-types==0.7.0 -anyio==4.13.0 -attrs==26.1.0 -certifi==2026.5.20 -colorama==0.4.6 -coverage[toml]==7.14.0 -distro==1.9.0 -eval-type-backport==0.3.1 -genai-prices==0.0.61 -griffe==2.0.2 -griffecli==2.0.2 -griffelib==2.0.2 -h11==0.16.0 -httpcore==1.0.9 -httpx==0.28.1 -hypothesis==6.45.0 -idna==3.15 -iniconfig==2.3.0 -jiter==0.15.0 -logfire-api==4.33.0 -mock==5.2.0 -multidict==6.7.1 -openai==2.37.0 -opentelemetry-api==1.42.0 -opentracing==2.4.0 -packaging==26.2 -pluggy==1.6.0 -propcache==0.5.2 -pydantic==2.12.0a1 -pydantic-ai-slim[openai]==1.0.0 -pydantic-core==2.37.2 -pydantic-graph==1.0.0 -pygments==2.20.0 -pytest==9.0.3 -pytest-asyncio==1.3.0 -pytest-cov==7.1.0 -pytest-mock==3.15.1 -pyyaml==6.0.3 -sniffio==1.3.1 -sortedcontainers==2.4.0 -tqdm==4.67.3 -typing-extensions==4.15.0 -typing-inspection==0.4.2 -urllib3==2.7.0 -vcrpy==7.0.0 -wrapt==2.1.2 -yarl==1.24.2 diff --git a/riotfile.py b/riotfile.py index 3eb68f7d9b5..30da8c9b4db 100644 --- a/riotfile.py +++ b/riotfile.py @@ -3215,7 +3215,9 @@ def select_pys(min_version: str = MIN_PYTHON_VERSION, max_version: str = MAX_PYT }, ), Venv( - pys=select_pys(min_version="3.10"), + # pydantic==2.12.0a1 requires pydantic-core~=2.37.x which uses + # PyO3 0.25.x — capped at 3.14 since PyO3 0.25.x doesn't support 3.15+. + pys=select_pys(min_version="3.10", max_version="3.14"), pkgs={ "pydantic-ai-slim[openai]": ["==0.8.1", "==1.0.0"], "pydantic": "==2.12.0a1", diff --git a/tested_versions.json b/tested_versions.json index d97c3589009..47f6b475e94 100644 --- a/tested_versions.json +++ b/tested_versions.json @@ -4047,14 +4047,6 @@ "version": "1.63.0", "python_version": "3.14" }, - { - "version": "0.8.1", - "python_version": "3.15" - }, - { - "version": "1.0.0", - "python_version": "3.15" - }, { "version": "1.63.0", "python_version": "3.15" From 9a45161d109559e138b180257e43764d90fe2d99 Mon Sep 17 00:00:00 2001 From: Federico Mon Date: Mon, 1 Jun 2026 09:19:43 +0000 Subject: [PATCH 05/10] test: skip TestIASTTerminalSummary on Python 3.15+ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The IAST taint-tracking native extension (_taint_tracking._native, built with pybind11) is not compiled for Python 3.15+ (gated in setup.py behind sys.version_info < (3, 15)). Without it asm_config._iast_enabled stays False, so print_iast_report() returns early and the "Datadog Code Security Report" header never appears — causing both tests in this class to fail. Skip the class on 3.15+ with a clear reason pointing to the tracking issue. Co-Authored-By: Claude Sonnet 4.6 --- tests/testing/test_integration.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/testing/test_integration.py b/tests/testing/test_integration.py index 61b1d5c9300..d64e08cbdc6 100644 --- a/tests/testing/test_integration.py +++ b/tests/testing/test_integration.py @@ -1,11 +1,13 @@ #!/usr/bin/env python3 import os +import sys from unittest.mock import Mock from unittest.mock import patch from _pytest.monkeypatch import MonkeyPatch from _pytest.pytester import Pytester +import pytest from ddtrace.testing.internal.session_manager import SessionManager from ddtrace.testing.internal.test_data import ModuleRef @@ -491,6 +493,10 @@ def test_simple_pass(): result.assert_outcomes(passed=2) +@pytest.mark.skipif( + sys.version_info >= (3, 15), + reason="IAST taint-tracking native extension not built for Python 3.15+ (setup.py, tracked in IAST 3.15 issue)", +) class TestIASTTerminalSummary: def test_ddtrace_iast_terminal_summary_enabled(self, pytester: Pytester, monkeypatch: MonkeyPatch) -> None: """Test that IAST terminal summary is present when IAST is enabled.""" From 5bf305410a11f4d5414afd693edb3dc3ad898c81 Mon Sep 17 00:00:00 2001 From: Federico Mon Date: Mon, 1 Jun 2026 11:33:28 +0000 Subject: [PATCH 06/10] test: remove outdated >= (3, 14) skip on ITR code coverage tests These were added before #17672 fixed dd_coverage for Python 3.14+. Since the sys.monitoring coverage instrumentation now works correctly on 3.14 and 3.15 (verified: 85/85 dd_coverage tests pass on 3.15), the skip condition is no longer valid. Slipcover incompatibility guard is kept as it remains relevant. Co-Authored-By: Claude Sonnet 4.6 --- tests/testing/internal/pytest/test_pytest_itr.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/testing/internal/pytest/test_pytest_itr.py b/tests/testing/internal/pytest/test_pytest_itr.py index 809dcd07930..54b483161d6 100644 --- a/tests/testing/internal/pytest/test_pytest_itr.py +++ b/tests/testing/internal/pytest/test_pytest_itr.py @@ -290,7 +290,6 @@ def test_should_run(): assert session["content"]["metrics"]["test.itr.tests_skipping.count"] == 1 @pytest.mark.skipif("slipcover" in sys.modules, reason="slipcover is incompatible with ITR code coverage") - @pytest.mark.skipif(sys.version_info >= (3, 14), reason="ITR code coverage currently not supported in Python 3.14") def test_itr_code_coverage_enabled(self, pytester: Pytester) -> None: pytester.makepyfile( lib_constants=""" @@ -318,7 +317,6 @@ def test_answer(): assert covered_files == {"/test_foo.py", "/lib_constants.py"} @pytest.mark.skipif("slipcover" in sys.modules, reason="slipcover is incompatible with ITR code coverage") - @pytest.mark.skipif(sys.version_info >= (3, 14), reason="ITR code coverage currently not supported in Python 3.14") def test_itr_code_coverage_disabled(self, pytester: Pytester) -> None: pytester.makepyfile( lib_constants=""" From f44413baec7a4b7152926700733bfd975c88de60 Mon Sep 17 00:00:00 2001 From: Federico Mon Date: Mon, 1 Jun 2026 12:18:24 +0000 Subject: [PATCH 07/10] test: skip coverage_report_upload tests on Python 3.14+ (sys.monitoring clash) On Python 3.14+, coverage.py switched from CTracer to SysMonitor which also uses sys.monitoring.COVERAGE_ID (tool 1). When the outer test session already holds that ID as "datadog", pytester.inline_run() cannot hand it to pytest-cov and raises ValueError: tool 1 is already in use. The tests cannot simply switch to runpytest_subprocess because their patch() mocks would not cross the process boundary. The proper fix is in ddtrace's coverage instrumentation (releasing COVERAGE_ID before spawning inline sub-sessions or detecting the clash gracefully); this skip is a temporary guard until that lands. Co-Authored-By: Claude Sonnet 4.6 --- .../pytest/test_pytest_coverage_report_upload.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/testing/internal/pytest/test_pytest_coverage_report_upload.py b/tests/testing/internal/pytest/test_pytest_coverage_report_upload.py index 1bc5c8cabec..7caa40f86df 100644 --- a/tests/testing/internal/pytest/test_pytest_coverage_report_upload.py +++ b/tests/testing/internal/pytest/test_pytest_coverage_report_upload.py @@ -1,6 +1,7 @@ """Integration tests for coverage report upload functionality.""" from contextlib import ExitStack +import sys import typing as t from unittest.mock import Mock from unittest.mock import patch @@ -17,6 +18,21 @@ COVERAGE_UPLOAD_ENABLED_ENV = "DD_CIVISIBILITY_CODE_COVERAGE_REPORT_UPLOAD_ENABLED" +# On Python 3.14+, coverage.py switched from its C-extension tracer (CTracer) to +# SysMonitor which also uses sys.monitoring.COVERAGE_ID (tool 1). When the outer test +# session already claims that ID as "datadog", pytest-cov inside pytester.inline_run() +# cannot claim it and raises ValueError: tool 1 is already in use. +# Using runpytest_subprocess would avoid the shared-state issue but breaks the patch() +# mocks that these tests rely on. Skip for now; the fix belongs in ddtrace's coverage +# instrumentation (releasing COVERAGE_ID before inline_run or detecting the clash). +pytestmark = pytest.mark.skipif( + sys.version_info >= (3, 14), + reason=( + "coverage.py uses sys.monitoring on 3.14+ which conflicts with ddtrace's " + "COVERAGE_ID claim in the outer session; inline_run cannot isolate the state" + ), +) + @pytest.fixture(autouse=True) def isolate_coverage_upload_env(monkeypatch: MonkeyPatch) -> None: From 890831b6826d1151a9ad47dccc9ba90811e7bdf5 Mon Sep 17 00:00:00 2001 From: Federico Mon Date: Mon, 1 Jun 2026 14:39:56 +0000 Subject: [PATCH 08/10] fix(ci_visibility): release sys.monitoring COVERAGE_ID on session start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Python 3.14+, coverage.py switched to SysMonitor which uses sys.monitoring.COVERAGE_ID (tool 1) — the same slot ddtrace claims. When an outer test session already holds the slot as "datadog", pytester.inline_run(--cov) inside that session would crash with "ValueError: tool 1 is already in use" in coverage.py's SysMonitor.start(). The existing reset_coverage_state() call in pytest_sessionstart already handles Python-level state resets for the inline_run case. Add the symmetric sys.monitoring release: - instrumentation_py3_12.py: deregister_monitoring() frees COVERAGE_ID (clears callback, calls free_tool_id, empties _CODE_HOOKS) - instrumentation_py3_16.py: no-op stub (not yet implemented for 3.16) - instrumentation.py: dispatch deregister_monitoring alongside instrument_all_lines; no-op stubs for Python < 3.12 - _plugin_v2.py: call deregister_monitoring() in pytest_sessionstart so that each new inline sub-session releases the slot before pytest-cov can claim it; instrument_all_lines() re-registers lazily on the next module import Reverts the temporary skip on test_pytest_coverage_report_upload.py that was added while this root-cause fix was pending. Verified: 85/85 dd_coverage tests still pass on Python 3.15. Co-Authored-By: Claude Sonnet 4.6 --- ddtrace/contrib/internal/pytest/_plugin_v2.py | 4 ++++ ddtrace/internal/coverage/instrumentation.py | 13 +++++++++++++ .../internal/coverage/instrumentation_py3_12.py | 14 ++++++++++++++ .../internal/coverage/instrumentation_py3_16.py | 5 +++++ .../pytest/test_pytest_coverage_report_upload.py | 16 ---------------- 5 files changed, 36 insertions(+), 16 deletions(-) diff --git a/ddtrace/contrib/internal/pytest/_plugin_v2.py b/ddtrace/contrib/internal/pytest/_plugin_v2.py index b15db745c17..4ff61c058a3 100644 --- a/ddtrace/contrib/internal/pytest/_plugin_v2.py +++ b/ddtrace/contrib/internal/pytest/_plugin_v2.py @@ -66,6 +66,7 @@ from ddtrace.internal.ci_visibility.utils import take_over_logger_stream_handler from ddtrace.internal.coverage.code import ModuleCodeCollector from ddtrace.internal.coverage.installer import install as install_coverage +from ddtrace.internal.coverage.instrumentation import deregister_monitoring from ddtrace.internal.logger import get_logger from ddtrace.internal.settings import env from ddtrace.internal.settings.asm import config as asm_config @@ -507,6 +508,9 @@ def pytest_unconfigure(config: pytest_Config) -> None: def pytest_sessionstart(session: pytest.Session) -> None: # Reset stale coverage state from any previous in-process session (e.g. pytester.inline_run) reset_coverage_state() + # Release sys.monitoring.COVERAGE_ID so that pytest-cov can claim it in inline sub-sessions. + # If we need it again for the new session, instrument_all_lines() will re-register lazily. + deregister_monitoring() if not is_test_visibility_enabled(): return diff --git a/ddtrace/internal/coverage/instrumentation.py b/ddtrace/internal/coverage/instrumentation.py index 7dc7fa2b6f8..ea075658177 100644 --- a/ddtrace/internal/coverage/instrumentation.py +++ b/ddtrace/internal/coverage/instrumentation.py @@ -3,12 +3,25 @@ # Import are noqa'd otherwise some formatters will helpfully remove them if sys.version_info >= (3, 16): + from ddtrace.internal.coverage.instrumentation_py3_16 import deregister_monitoring # noqa from ddtrace.internal.coverage.instrumentation_py3_16 import instrument_all_lines # noqa elif sys.version_info >= (3, 12): + from ddtrace.internal.coverage.instrumentation_py3_12 import deregister_monitoring # noqa from ddtrace.internal.coverage.instrumentation_py3_12 import instrument_all_lines # noqa elif sys.version_info >= (3, 11): from ddtrace.internal.coverage.instrumentation_py3_11 import instrument_all_lines # noqa + + def deregister_monitoring() -> None: # noqa: E306 + pass # sys.monitoring not available on Python < 3.12 + elif sys.version_info >= (3, 10): from ddtrace.internal.coverage.instrumentation_py3_10 import instrument_all_lines # noqa + + def deregister_monitoring() -> None: # noqa: E306 + pass # sys.monitoring not available on Python < 3.12 + else: from ddtrace.internal.coverage.instrumentation_py3_9 import instrument_all_lines # noqa + + def deregister_monitoring() -> None: # noqa: E306 + pass # sys.monitoring not available on Python < 3.12 diff --git a/ddtrace/internal/coverage/instrumentation_py3_12.py b/ddtrace/internal/coverage/instrumentation_py3_12.py index 3a394f7a1ce..cadc9cfcf1e 100644 --- a/ddtrace/internal/coverage/instrumentation_py3_12.py +++ b/ddtrace/internal/coverage/instrumentation_py3_12.py @@ -130,6 +130,20 @@ def _register_monitoring(): sys.monitoring.register_callback(sys.monitoring.COVERAGE_ID, EVENT, _event_handler) +def deregister_monitoring() -> None: + """Release sys.monitoring.COVERAGE_ID if currently held by datadog. + + Called at the start of each new in-process pytest session (e.g. pytester.inline_run) + so that another coverage tool such as pytest-cov can claim COVERAGE_ID without + hitting "ValueError: tool 1 is already in use". The next instrumentation call + will re-register us if coverage is still needed. + """ + if sys.monitoring.get_tool(sys.monitoring.COVERAGE_ID) == "datadog": + sys.monitoring.register_callback(sys.monitoring.COVERAGE_ID, EVENT, None) + sys.monitoring.free_tool_id(sys.monitoring.COVERAGE_ID) + _CODE_HOOKS.clear() + + def _instrument_with_monitoring( code: CodeType, hook: HookType, path: str, package: str ) -> tuple[CodeType, CoverageLines]: diff --git a/ddtrace/internal/coverage/instrumentation_py3_16.py b/ddtrace/internal/coverage/instrumentation_py3_16.py index 2c663719e28..550b9193220 100644 --- a/ddtrace/internal/coverage/instrumentation_py3_16.py +++ b/ddtrace/internal/coverage/instrumentation_py3_16.py @@ -18,3 +18,8 @@ def instrument_all_lines(code: CodeType, hook: HookType, path: str, package: str) -> tuple[CodeType, CoverageLines]: # No-op return code, CoverageLines() + + +def deregister_monitoring() -> None: + # No-op: instrumentation is not yet implemented for Python 3.16+ + pass diff --git a/tests/testing/internal/pytest/test_pytest_coverage_report_upload.py b/tests/testing/internal/pytest/test_pytest_coverage_report_upload.py index 7caa40f86df..1bc5c8cabec 100644 --- a/tests/testing/internal/pytest/test_pytest_coverage_report_upload.py +++ b/tests/testing/internal/pytest/test_pytest_coverage_report_upload.py @@ -1,7 +1,6 @@ """Integration tests for coverage report upload functionality.""" from contextlib import ExitStack -import sys import typing as t from unittest.mock import Mock from unittest.mock import patch @@ -18,21 +17,6 @@ COVERAGE_UPLOAD_ENABLED_ENV = "DD_CIVISIBILITY_CODE_COVERAGE_REPORT_UPLOAD_ENABLED" -# On Python 3.14+, coverage.py switched from its C-extension tracer (CTracer) to -# SysMonitor which also uses sys.monitoring.COVERAGE_ID (tool 1). When the outer test -# session already claims that ID as "datadog", pytest-cov inside pytester.inline_run() -# cannot claim it and raises ValueError: tool 1 is already in use. -# Using runpytest_subprocess would avoid the shared-state issue but breaks the patch() -# mocks that these tests rely on. Skip for now; the fix belongs in ddtrace's coverage -# instrumentation (releasing COVERAGE_ID before inline_run or detecting the clash). -pytestmark = pytest.mark.skipif( - sys.version_info >= (3, 14), - reason=( - "coverage.py uses sys.monitoring on 3.14+ which conflicts with ddtrace's " - "COVERAGE_ID claim in the outer session; inline_run cannot isolate the state" - ), -) - @pytest.fixture(autouse=True) def isolate_coverage_upload_env(monkeypatch: MonkeyPatch) -> None: From f95cbf9713a60f7e47d731e68903969ab157a199 Mon Sep 17 00:00:00 2001 From: Federico Mon Date: Mon, 1 Jun 2026 15:02:10 +0000 Subject: [PATCH 09/10] fix(ci_visibility): only deregister COVERAGE_ID when pytest-cov is enabled Tighten the sys.monitoring deregistration to only fire when the new session is actually using pytest-cov (--cov / --cov=). Registration order analysis: - pytest-cov: eagerly registers COVERAGE_ID in pytest_load_initial_conftests - ddtrace: lazily registers COVERAGE_ID in instrument_all_lines (during tests) In a fresh process pytest-cov always wins the race: ddtrace's existing "if get_tool() != 'datadog': return" guard in instrument_all_lines then correctly yields and no conflict occurs. The clash only arises when a previous inline sub-session left us holding COVERAGE_ID ('datadog') and the next session's pytest-cov tries to claim it in pytest_load_initial_conftests. By gating on _is_pytest_cov_enabled() we avoid touching COVERAGE_ID in pure --ddtrace sessions, which preserves our coverage tracking when pytest-cov is absent. Co-Authored-By: Claude Sonnet 4.6 --- ddtrace/contrib/internal/pytest/_plugin_v2.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/ddtrace/contrib/internal/pytest/_plugin_v2.py b/ddtrace/contrib/internal/pytest/_plugin_v2.py index 4ff61c058a3..e380236a7f1 100644 --- a/ddtrace/contrib/internal/pytest/_plugin_v2.py +++ b/ddtrace/contrib/internal/pytest/_plugin_v2.py @@ -508,9 +508,13 @@ def pytest_unconfigure(config: pytest_Config) -> None: def pytest_sessionstart(session: pytest.Session) -> None: # Reset stale coverage state from any previous in-process session (e.g. pytester.inline_run) reset_coverage_state() - # Release sys.monitoring.COVERAGE_ID so that pytest-cov can claim it in inline sub-sessions. - # If we need it again for the new session, instrument_all_lines() will re-register lazily. - deregister_monitoring() + # If pytest-cov is enabled for this session, release sys.monitoring.COVERAGE_ID now so that + # pytest-cov can claim it in pytest_load_initial_conftests (which runs after sessionstart). + # This only matters when a previous in-process session left us holding COVERAGE_ID; in a fresh + # process ddtrace registers lazily during test execution, so pytest-cov always wins the race. + # If we need COVERAGE_ID again later, instrument_all_lines() re-registers it lazily. + if _is_pytest_cov_enabled(session.config): + deregister_monitoring() if not is_test_visibility_enabled(): return From 5803f8d4a23fea54b10f5dd3ae903dea4edacf51 Mon Sep 17 00:00:00 2001 From: Federico Mon Date: Mon, 1 Jun 2026 15:05:26 +0000 Subject: [PATCH 10/10] fix(testing): release COVERAGE_ID in v3 plugin pytest_sessionstart too The same sys.monitoring.COVERAGE_ID clash that can occur in the CI Visibility v2 plugin also affects the ddtrace.testing v3 plugin: if an outer v3-plugin session claims COVERAGE_ID as "datadog" and then spawns a pytest sub-session via pytester.inline_run(--cov), the inner session's pytest-cov cannot claim the slot. Apply the same targeted fix: deregister_monitoring() only when _is_pytest_cov_enabled() is True, so the slot is released before pytest-cov's pytest_load_initial_conftests runs. Co-Authored-By: Claude Sonnet 4.6 --- ddtrace/testing/internal/pytest/plugin.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ddtrace/testing/internal/pytest/plugin.py b/ddtrace/testing/internal/pytest/plugin.py index f9b3872a821..e29a05b906b 100644 --- a/ddtrace/testing/internal/pytest/plugin.py +++ b/ddtrace/testing/internal/pytest/plugin.py @@ -19,6 +19,7 @@ from ddtrace.contrib.internal.coverage.utils import _is_pytest_cov_enabled from ddtrace.contrib.internal.coverage.utils import handle_coverage_report from ddtrace.internal.ci_visibility.utils import get_source_lines_for_test_method +from ddtrace.internal.coverage.instrumentation import deregister_monitoring from ddtrace.internal.settings import env from ddtrace.internal.utils.inspection import undecorated from ddtrace.testing.internal.ci import CITag @@ -275,6 +276,13 @@ def pytest_sessionstart(self, session: pytest.Session) -> None: if session.config.getoption("ddtrace-patch-all"): self.enable_all_ddtrace_integrations = True + # If pytest-cov is enabled for this session, release sys.monitoring.COVERAGE_ID so + # that pytest-cov can claim it in pytest_load_initial_conftests (which runs after + # sessionstart). Required when a previous in-process session (e.g. pytester.inline_run) + # left us holding the slot; instrument_all_lines() re-registers it lazily if needed. + if _is_pytest_cov_enabled(session.config): + deregister_monitoring() + self.session.start() self.manager.start()