From 774cd7e283a93a61c30600f2d2579aac00cbda7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtor=20De=20Ara=C3=BAjo?= Date: Mon, 30 Jun 2025 18:01:12 +0000 Subject: [PATCH] fix(ci_visibility): handle PYTEST_ADDOPTS for enabling code coverage collection (#13820) This fix resolves an issue where code coverage would not be enabled if ddtrace was enabled via the ``PYTEST_ADDOPTS`` environment variable. ## Checklist - [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)) ## Reviewer Checklist - [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 b0c1496a3ab1fc32a657e6e265c998649d273171) --- ddtrace/contrib/internal/pytest/_plugin_v1.py | 2 +- ddtrace/contrib/internal/pytest/_plugin_v2.py | 2 +- ddtrace/contrib/internal/pytest/_utils.py | 15 ++-- ...t-addopts-early-init-74fb4e207dee18e4.yaml | 5 ++ tests/contrib/pytest/test_pytest.py | 35 ++++++++ .../pytest/test_pytest_early_config.py | 80 +++++++++++++++++++ 6 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 releasenotes/notes/ci_visibility-fix-pytest-addopts-early-init-74fb4e207dee18e4.yaml create mode 100644 tests/contrib/pytest/test_pytest_early_config.py diff --git a/ddtrace/contrib/internal/pytest/_plugin_v1.py b/ddtrace/contrib/internal/pytest/_plugin_v1.py index ffc291afb80..c121bbddf6b 100644 --- a/ddtrace/contrib/internal/pytest/_plugin_v1.py +++ b/ddtrace/contrib/internal/pytest/_plugin_v1.py @@ -417,7 +417,7 @@ def _get_test_class_hierarchy(item): def pytest_load_initial_conftests(early_config, parser, args): - if _is_enabled_early(early_config): + if _is_enabled_early(early_config, args): # Enables experimental use of ModuleCodeCollector for coverage collection. from ddtrace.internal.ci_visibility.coverage import USE_DD_COVERAGE from ddtrace.internal.logger import get_logger diff --git a/ddtrace/contrib/internal/pytest/_plugin_v2.py b/ddtrace/contrib/internal/pytest/_plugin_v2.py index 1dd15619a81..d58b76e4069 100644 --- a/ddtrace/contrib/internal/pytest/_plugin_v2.py +++ b/ddtrace/contrib/internal/pytest/_plugin_v2.py @@ -209,7 +209,7 @@ def _pytest_load_initial_conftests_pre_yield(early_config, parser, args): ModuleCodeCollector has a tangible impact on the time it takes to load modules, so it should only be installed if coverage collection is requested by the backend. """ - if not _is_enabled_early(early_config): + if not _is_enabled_early(early_config, args): return try: diff --git a/ddtrace/contrib/internal/pytest/_utils.py b/ddtrace/contrib/internal/pytest/_utils.py index e83548e2d58..4ab62bf614d 100644 --- a/ddtrace/contrib/internal/pytest/_utils.py +++ b/ddtrace/contrib/internal/pytest/_utils.py @@ -211,7 +211,7 @@ def _extract_span(item): return getattr(item, "_datadog_span", None) -def _is_enabled_early(early_config): +def _is_enabled_early(early_config, args): """Checks if the ddtrace plugin is enabled before the config is fully populated. This is necessary because the module watchdog for coverage collection needs to be enabled as early as possible. @@ -222,15 +222,14 @@ def _is_enabled_early(early_config): if not _pytest_version_supports_itr(): return False - if ( - "--no-ddtrace" in early_config.invocation_params.args - or early_config.getini("no-ddtrace") - or "ddtrace" in early_config.inicfg - and early_config.getini("ddtrace") is False - ): + if _is_option_true("no-ddtrace", early_config, args): return False - return "--ddtrace" in early_config.invocation_params.args or early_config.getini("ddtrace") + return _is_option_true("ddtrace", early_config, args) + + +def _is_option_true(option, early_config, args): + return early_config.getoption(option) or early_config.getini(option) or f"--{option}" in args class _TestOutcome(t.NamedTuple): diff --git a/releasenotes/notes/ci_visibility-fix-pytest-addopts-early-init-74fb4e207dee18e4.yaml b/releasenotes/notes/ci_visibility-fix-pytest-addopts-early-init-74fb4e207dee18e4.yaml new file mode 100644 index 00000000000..d0ef7d15690 --- /dev/null +++ b/releasenotes/notes/ci_visibility-fix-pytest-addopts-early-init-74fb4e207dee18e4.yaml @@ -0,0 +1,5 @@ +--- +fixes: + - | + This fix resolves an issue where code coverage would not be enabled if ddtrace was enabled via the + ``PYTEST_ADDOPTS`` environment variable. diff --git a/tests/contrib/pytest/test_pytest.py b/tests/contrib/pytest/test_pytest.py index 2e7836e97d4..5667be4f5a8 100644 --- a/tests/contrib/pytest/test_pytest.py +++ b/tests/contrib/pytest/test_pytest.py @@ -218,6 +218,41 @@ def test_ini(ddspan): assert len(spans) == 4 + def test_pytest_addopts_env_var(self): + """Test enabling ddtrace via the PYTEST_ADDOPTS environment variable.""" + py_file = self.testdir.makepyfile( + """ + import pytest + + def test_trace(ddspan): + assert ddspan is not None + """ + ) + file_name = os.path.basename(py_file.strpath) + rec = self.inline_run(file_name, extra_env={"PYTEST_ADDOPTS": "--ddtrace"}) + rec.assertoutcome(passed=1) + spans = self.pop_spans() + + assert len(spans) == 4 + + def test_pytest_addopts_ini(self): + """Test enabling ddtrace via the `addopts` option in the ini file.""" + self.testdir.makefile(".ini", pytest="[pytest]\naddopts = --ddtrace\n") + py_file = self.testdir.makepyfile( + """ + import pytest + + def test_ini(ddspan): + assert ddspan is not None + """ + ) + file_name = os.path.basename(py_file.strpath) + rec = self.inline_run(file_name) + rec.assertoutcome(passed=1) + spans = self.pop_spans() + + assert len(spans) == 4 + def test_pytest_command(self): """Test that the pytest run command is stored on a test span.""" py_file = self.testdir.makepyfile( diff --git a/tests/contrib/pytest/test_pytest_early_config.py b/tests/contrib/pytest/test_pytest_early_config.py new file mode 100644 index 00000000000..b2c21a62ad5 --- /dev/null +++ b/tests/contrib/pytest/test_pytest_early_config.py @@ -0,0 +1,80 @@ +from unittest import mock + +import pytest + +from ddtrace.contrib.internal.pytest._utils import _pytest_version_supports_itr +from ddtrace.internal.ci_visibility._api_client import TestVisibilityAPISettings +from ddtrace.internal.ci_visibility.constants import COVERAGE_TAG_NAME +from tests.contrib.pytest.test_pytest import PytestTestCaseBase +from tests.contrib.pytest.test_pytest import _get_spans_from_list + + +_TEST_PASS_CONTENT = """ +def test_func_pass(): + assert True +""" + +pytestmark = pytest.mark.skipif(not _pytest_version_supports_itr(), reason="pytest version does not support coverage") + + +class PytestEarlyConfigTestCase(PytestTestCaseBase): + """ + Test that code coverage is enabled in the `pytest_load_initial_conftests` hook, regardless of the method used to + enable ddtrace (command line, env var, or ini file). + """ + + @pytest.fixture(autouse=True, scope="function") + def set_up_features(self): + with mock.patch( + "ddtrace.internal.ci_visibility.recorder.CIVisibility._check_enabled_features", + return_value=TestVisibilityAPISettings(coverage_enabled=True), + ): + yield + + def test_coverage_not_enabled(self): + self.testdir.makepyfile(test_pass=_TEST_PASS_CONTENT) + self.inline_run() + spans = self.pop_spans() + assert not spans + + def test_coverage_enabled_via_command_line_option(self): + self.testdir.makepyfile(test_pass=_TEST_PASS_CONTENT) + self.inline_run("--ddtrace") + spans = self.pop_spans() + [suite_span] = _get_spans_from_list(spans, "suite") + [test_span] = _get_spans_from_list(spans, "test") + assert ( + suite_span.get_struct_tag(COVERAGE_TAG_NAME) is not None or test_span.get_tag(COVERAGE_TAG_NAME) is not None + ) + + def test_coverage_enabled_via_pytest_addopts_env_var(self): + self.testdir.makepyfile(test_pass=_TEST_PASS_CONTENT) + self.inline_run(extra_env={"PYTEST_ADDOPTS": "--ddtrace"}) + spans = self.pop_spans() + [suite_span] = _get_spans_from_list(spans, "suite") + [test_span] = _get_spans_from_list(spans, "test") + assert ( + suite_span.get_struct_tag(COVERAGE_TAG_NAME) is not None or test_span.get_tag(COVERAGE_TAG_NAME) is not None + ) + + def test_coverage_enabled_via_addopts_ini_file_option(self): + self.testdir.makepyfile(test_pass=_TEST_PASS_CONTENT) + self.testdir.makefile(".ini", pytest="[pytest]\naddopts = --ddtrace\n") + self.inline_run() + spans = self.pop_spans() + [suite_span] = _get_spans_from_list(spans, "suite") + [test_span] = _get_spans_from_list(spans, "test") + assert ( + suite_span.get_struct_tag(COVERAGE_TAG_NAME) is not None or test_span.get_tag(COVERAGE_TAG_NAME) is not None + ) + + def test_coverage_enabled_via_ddtrace_ini_file_option(self): + self.testdir.makepyfile(test_pass=_TEST_PASS_CONTENT) + self.testdir.makefile(".ini", pytest="[pytest]\nddtrace = 1\n") + self.inline_run() + spans = self.pop_spans() + [suite_span] = _get_spans_from_list(spans, "suite") + [test_span] = _get_spans_from_list(spans, "test") + assert ( + suite_span.get_struct_tag(COVERAGE_TAG_NAME) is not None or test_span.get_tag(COVERAGE_TAG_NAME) is not None + )