Skip to content

Commit 1d55081

Browse files
committed
wip
1 parent 1e7e0c4 commit 1d55081

File tree

10 files changed

+594
-67
lines changed

10 files changed

+594
-67
lines changed

ddtrace/contrib/internal/coverage/patch.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ def unpatch():
5252
coverage._datadog_patch = False
5353

5454

55+
def _is_coverage_available():
56+
return coverage is not None
57+
58+
5559
def coverage_report_wrapper(func: Any, instance: Any, args: tuple, kwargs: dict) -> Any:
5660
"""Wrapper to cache percentage when report() is called."""
5761
global _cached_coverage_percentage

ddtrace/contrib/internal/coverage/utils.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
1+
from pathlib import Path
12
import sys
3+
import tempfile
4+
from typing import Callable
25
from typing import List
6+
from typing import Optional
37

48
from ddtrace.contrib.internal.coverage.data import _original_sys_argv_command
9+
from ddtrace.internal.logger import get_logger
510
from ddtrace.internal.settings._config import _get_config
611
from ddtrace.internal.utils.formats import asbool
712

813

14+
log = get_logger(__name__)
15+
16+
917
def is_coverage_loaded() -> bool:
1018
return "coverage" in sys.modules
1119

@@ -25,3 +33,86 @@ def _is_coverage_invoked_by_coverage_run() -> bool:
2533
if _get_config("COVERAGE_RUN", False, asbool):
2634
return True
2735
return _command_invokes_coverage_run(_original_sys_argv_command)
36+
37+
38+
def handle_coverage_report(
39+
session,
40+
upload_func: Callable[[bytes, str], bool],
41+
is_pytest_cov_enabled_func: Callable,
42+
stop_coverage_func: Optional[Callable] = None,
43+
) -> None:
44+
"""
45+
Shared coverage report upload handling for pytest plugins.
46+
47+
Args:
48+
session: pytest session object
49+
upload_func: Function to call for uploading (signature: upload_func(bytes, format) -> bool)
50+
is_pytest_cov_enabled_func: Function to check if pytest-cov is enabled
51+
stop_coverage_func: Optional function to stop coverage collection
52+
"""
53+
try:
54+
from ddtrace.contrib.internal.coverage.patch import generate_lcov_report
55+
from ddtrace.contrib.internal.coverage.patch import is_coverage_running
56+
from ddtrace.contrib.internal.coverage.patch import set_coverage_instance
57+
58+
log.debug("Coverage report upload is enabled, checking for coverage data")
59+
60+
# If pytest-cov is enabled but coverage detection fails, register the pytest-cov instance
61+
if is_pytest_cov_enabled_func(session.config) and not is_coverage_running():
62+
# Try to get coverage from pytest-cov plugin and register it
63+
for plugin in session.config.pluginmanager.list_name_plugin():
64+
_, plugin_instance = plugin
65+
if hasattr(plugin_instance, "cov_controller") and plugin_instance.cov_controller:
66+
set_coverage_instance(plugin_instance.cov_controller.cov)
67+
log.debug("Registered pytest-cov coverage instance with ddtrace")
68+
break
69+
70+
# Now check if coverage is available (either ddtrace started or pytest-cov registered)
71+
if is_coverage_running():
72+
try:
73+
coverage_format = "lcov" # Default to LCOV
74+
# Generate the report in a temporary file
75+
with tempfile.NamedTemporaryFile(mode="wb", suffix=".lcov", delete=False) as tmp_file:
76+
tmp_path = Path(tmp_file.name)
77+
78+
# Generate LCOV report. This returns the percentage and also stores it
79+
# in _coverage_data, so we don't need to generate a second report just for the percentage.
80+
pct_covered = generate_lcov_report(outfile=str(tmp_path))
81+
log.debug("Generated LCOV coverage report: %s (%.1f%% coverage)", tmp_path, pct_covered)
82+
83+
# Read the report file
84+
coverage_report_bytes = tmp_path.read_bytes()
85+
log.debug("Read coverage report: %d bytes", len(coverage_report_bytes))
86+
87+
# Upload the report using provided upload function
88+
upload_success = upload_func(coverage_report_bytes, coverage_format)
89+
90+
if upload_success:
91+
log.info("Successfully uploaded coverage report")
92+
else:
93+
log.warning("Failed to upload coverage report")
94+
95+
# Clean up temporary file
96+
try:
97+
tmp_path.unlink()
98+
except Exception as e:
99+
log.debug("Failed to clean up temporary coverage report file: %s", e)
100+
101+
# Stop coverage AFTER generating and uploading the report (if we started it ourselves)
102+
if stop_coverage_func and not is_pytest_cov_enabled_func(session.config):
103+
log.debug("Stopping coverage.py collection")
104+
stop_coverage_func(save=True)
105+
106+
except Exception as e:
107+
log.exception("Error generating or uploading coverage report: %s", e)
108+
# Still try to stop coverage even if report generation failed
109+
if stop_coverage_func and not is_pytest_cov_enabled_func(session.config):
110+
try:
111+
stop_coverage_func(save=True)
112+
except Exception:
113+
log.debug("Could not stop coverage after error", exc_info=True)
114+
else:
115+
log.debug("Coverage is not running, skipping coverage report upload")
116+
117+
except Exception as e:
118+
log.exception("Error in coverage report upload handling: %s", e)

ddtrace/contrib/internal/pytest/_plugin_v2.py

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77

88
from ddtrace import DDTraceDeprecationWarning
99
from ddtrace import config as dd_config
10+
from ddtrace.contrib.internal.coverage.patch import _is_coverage_available
1011
from ddtrace.contrib.internal.coverage.patch import get_coverage_percentage
1112
from ddtrace.contrib.internal.coverage.patch import patch as patch_coverage
1213
from ddtrace.contrib.internal.coverage.patch import run_coverage_report
14+
from ddtrace.contrib.internal.coverage.patch import start_coverage
1315
from ddtrace.contrib.internal.coverage.utils import _is_coverage_invoked_by_coverage_run
1416
from ddtrace.contrib.internal.coverage.utils import _is_coverage_patched
1517
from ddtrace.contrib.internal.pytest._benchmark_utils import _set_benchmark_data_from_item
@@ -171,6 +173,30 @@ def _handle_itr_xdist_skipped_suite(item, suite_id) -> bool:
171173
return True
172174

173175

176+
def _is_coverage_report_upload_enabled() -> bool:
177+
"""
178+
Check if coverage report upload is enabled, following V3 pattern:
179+
1. First, get setting from API (via CI visibility service)
180+
2. Then, allow environment variable to override
181+
182+
This should only be called after pytest_sessionstart when settings are available.
183+
"""
184+
# Get API setting (should be available after InternalTestSession.discover())
185+
coverage_report_upload_enabled = False
186+
try:
187+
ci_visibility_service = require_ci_visibility_service()
188+
if hasattr(ci_visibility_service, "_api_settings") and ci_visibility_service._api_settings:
189+
coverage_report_upload_enabled = ci_visibility_service._api_settings.coverage_report_upload_enabled
190+
except Exception:
191+
log.debug("Unable to check if coverage report upload is enabled from settings", exc_info=True)
192+
193+
# Allow environment variable to override (same pattern as V3)
194+
if asbool(os.getenv("DD_CIVISIBILITY_CODE_COVERAGE_REPORT_UPLOAD_ENABLED", "false")):
195+
coverage_report_upload_enabled = True
196+
197+
return coverage_report_upload_enabled
198+
199+
174200
def _handle_test_management(item, test_id):
175201
"""Add a user property to identify quarantined tests, and mark them for skipping if quarantine is enabled in
176202
skipping mode.
@@ -381,6 +407,32 @@ def _is_pytest_cov_enabled(config) -> bool:
381407
return cov_option
382408

383409

410+
def _handle_coverage_patch_early(config):
411+
"""
412+
Handle coverage patching in pytest_configure (must be early for pytest-cov).
413+
Coverage start decision is deferred to pytest_sessionstart when API settings are available.
414+
"""
415+
pytest_cov_enabled = _is_pytest_cov_enabled(config)
416+
417+
# Check environment variable (API settings not available yet)
418+
env_coverage_upload = asbool(os.getenv("DD_CIVISIBILITY_CODE_COVERAGE_REPORT_UPLOAD_ENABLED", "false"))
419+
420+
# Patch if pytest-cov is enabled OR env var suggests we might need coverage
421+
if not (pytest_cov_enabled or env_coverage_upload):
422+
return
423+
424+
# Patch coverage.py early (needed for pytest-cov to work)
425+
patch_coverage()
426+
427+
# Verify patching worked
428+
if not _is_coverage_available():
429+
log.warning("Coverage requested but coverage.py not available - install with 'pip install coverage'")
430+
return
431+
432+
if pytest_cov_enabled:
433+
log.debug("pytest-cov detected, coverage.py patched successfully")
434+
435+
384436
def pytest_configure(config: pytest_Config) -> None:
385437
global skip_pytest_runtest_protocol
386438

@@ -399,8 +451,8 @@ def pytest_configure(config: pytest_Config) -> None:
399451
if is_enabled(config):
400452
unpatch_unittest()
401453
enable_test_visibility(config=dd_config.pytest)
402-
if _is_pytest_cov_enabled(config):
403-
patch_coverage()
454+
455+
_handle_coverage_patch_early(config)
404456

405457
skip_pytest_runtest_protocol = False
406458

@@ -475,6 +527,19 @@ def pytest_sessionstart(session: pytest.Session) -> None:
475527

476528
InternalTestSession.set_library_capabilities(library_capabilities)
477529

530+
# Start coverage if needed (API settings are now available after discover())
531+
if (
532+
not _is_pytest_cov_enabled(session.config)
533+
and _is_coverage_available()
534+
and _is_coverage_report_upload_enabled()
535+
):
536+
workspace_path = InternalTestSession.get_workspace_path()
537+
if workspace_path:
538+
start_coverage(source=[str(workspace_path)])
539+
log.debug("Started coverage.py for report upload")
540+
else:
541+
log.warning("Coverage report upload enabled but workspace path not available")
542+
478543
extracted_context = None
479544
distributed_children = False
480545
if hasattr(session.config, "workerinput"):
@@ -755,6 +820,33 @@ def _pytest_run_one_test(item, nextitem):
755820
item.ihook.pytest_runtest_logfinish(nodeid=item.nodeid, location=item.location)
756821

757822

823+
def _handle_coverage_report_upload(session: pytest.Session) -> None:
824+
"""
825+
Handle coverage report upload if enabled using shared implementation.
826+
"""
827+
from ddtrace.contrib.internal.coverage.utils import handle_coverage_report
828+
829+
def upload_func(coverage_report_bytes: bytes, coverage_format: str) -> bool:
830+
"""Upload coverage report using native V2 writer/recorder infrastructure."""
831+
try:
832+
# Get the CI visibility service (recorder)
833+
ci_visibility_service = require_ci_visibility_service()
834+
835+
# Use the recorder's native upload method which integrates with writer infrastructure
836+
return ci_visibility_service.upload_coverage_report(coverage_report_bytes, coverage_format)
837+
838+
except Exception as e:
839+
log.exception("Error in native coverage upload: %s", e)
840+
return False
841+
842+
handle_coverage_report(
843+
session=session,
844+
upload_func=upload_func,
845+
is_pytest_cov_enabled_func=_is_pytest_cov_enabled,
846+
stop_coverage_func=None, # V2 plugin doesn't need to stop coverage manually
847+
)
848+
849+
758850
def _process_reports_dict(item, reports) -> _TestOutcome:
759851
final_outcome = None
760852

@@ -963,6 +1055,8 @@ def _pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
9631055

9641056
invoked_by_coverage_run_status = _is_coverage_invoked_by_coverage_run()
9651057
pytest_cov_status = _is_pytest_cov_enabled(session.config)
1058+
coverage_report_upload_enabled = _is_coverage_report_upload_enabled()
1059+
9661060
if _is_coverage_patched() and (pytest_cov_status or invoked_by_coverage_run_status):
9671061
if invoked_by_coverage_run_status and not pytest_cov_status:
9681062
run_coverage_report()
@@ -981,6 +1075,10 @@ def _pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
9811075
else:
9821076
InternalTestSession.set_covered_lines_pct(lines_pct_value)
9831077

1078+
# Handle coverage report upload if enabled
1079+
if coverage_report_upload_enabled:
1080+
_handle_coverage_report_upload(session)
1081+
9841082
if ModuleCodeCollector.is_installed():
9851083
ModuleCodeCollector.uninstall()
9861084

0 commit comments

Comments
 (0)