Skip to content

Commit 2d1949d

Browse files
fix(ci-visibility): fix ITR test skipping [backport 1.17] (#6499)
Backport [ddf8d41](ddf8d41) from #6306 to 1.17. CI Visibility: - Fix for test or suite skipping (controlled by env var to use the latter) - Create spans for ITR-skipped items (as agreed at ITR skipped tests/suites RFC) ## Checklist - [x] Change(s) are motivated and described in the PR description. - [x] Testing strategy is described if automated tests are not included in the PR. - [x] Risk is outlined (performance impact, potential for breakage, maintainability, etc). - [x] Change is maintainable (easy to change, telemetry, documentation). - [x] [Library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) are followed. If no release note is required, add label `changelog/no-changelog`. - [x] Documentation is included (in-code, generated user docs, [public corp docs](https://github.com/DataDog/documentation/)). - [x] Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## Reviewer Checklist - [x] Title is accurate. - [x] No unnecessary changes are introduced. - [x] Description motivates each change. - [x] Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes unless absolutely necessary. - [x] Testing strategy adequately addresses listed risk(s). - [x] Change is maintainable (easy to change, telemetry, documentation). - [x] Release note makes sense to a user of the library. - [x] Reviewer has explicitly acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment. - [x] 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) Co-authored-by: Romain Komorn <[email protected]>
1 parent b9bf673 commit 2d1949d

File tree

12 files changed

+531
-202
lines changed

12 files changed

+531
-202
lines changed

ddtrace/contrib/pytest/plugin.py

Lines changed: 124 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -29,22 +29,26 @@
2929
from ddtrace.ext import SpanTypes
3030
from ddtrace.ext import test
3131
from ddtrace.internal.ci_visibility import CIVisibility as _CIVisibility
32+
from ddtrace.internal.ci_visibility.constants import COVERAGE_TAG_NAME
3233
from ddtrace.internal.ci_visibility.constants import EVENT_TYPE as _EVENT_TYPE
3334
from ddtrace.internal.ci_visibility.constants import MODULE_ID as _MODULE_ID
3435
from ddtrace.internal.ci_visibility.constants import MODULE_TYPE as _MODULE_TYPE
3536
from ddtrace.internal.ci_visibility.constants import SESSION_ID as _SESSION_ID
3637
from ddtrace.internal.ci_visibility.constants import SESSION_TYPE as _SESSION_TYPE
38+
from ddtrace.internal.ci_visibility.constants import SUITE
3739
from ddtrace.internal.ci_visibility.constants import SUITE_ID as _SUITE_ID
3840
from ddtrace.internal.ci_visibility.constants import SUITE_TYPE as _SUITE_TYPE
39-
from ddtrace.internal.ci_visibility.coverage import _coverage_end
40-
from ddtrace.internal.ci_visibility.coverage import _coverage_start
41-
from ddtrace.internal.ci_visibility.coverage import _initialize
41+
from ddtrace.internal.ci_visibility.constants import TEST
42+
from ddtrace.internal.ci_visibility.coverage import _initialize_coverage
43+
from ddtrace.internal.ci_visibility.coverage import build_payload as build_coverage_payload
44+
from ddtrace.internal.ci_visibility.recorder import _get_test_skipping_level
4245
from ddtrace.internal.constants import COMPONENT
4346
from ddtrace.internal.logger import get_logger
4447

4548

4649
SKIPPED_BY_ITR = "Skipped by Datadog Intelligent Test Runner"
4750
PATCH_ALL_HELP_MSG = "Call ddtrace.patch_all before running tests."
51+
4852
log = get_logger(__name__)
4953

5054

@@ -70,6 +74,22 @@ def _store_span(item, span):
7074
setattr(item, "_datadog_span", span)
7175

7276

77+
def _attach_coverage(item):
78+
coverage = _initialize_coverage(str(item.config.rootdir))
79+
setattr(item, "_coverage", coverage)
80+
coverage.start()
81+
82+
83+
def _detach_coverage(item, span):
84+
if not hasattr(item, "_coverage"):
85+
return
86+
span_id = str(span.trace_id)
87+
item._coverage.stop()
88+
span.set_tag(COVERAGE_TAG_NAME, build_coverage_payload(item._coverage, test_id=span_id))
89+
item._coverage.erase()
90+
del item._coverage
91+
92+
7393
def _extract_module_span(item):
7494
"""Extract span from `pytest.Item` instance."""
7595
return getattr(item, "_datadog_span_module", None)
@@ -201,15 +221,14 @@ def _start_test_module_span(pytest_package_item=None, pytest_module_item=None):
201221
return test_module_span, is_package
202222

203223

204-
def _start_test_suite_span(item, test_module_span):
224+
def _start_test_suite_span(item, test_module_span, should_enable_coverage=False):
205225
"""
206226
Starts a test suite span at the start of a new pytest test module.
207227
Note that ``item`` is a ``pytest.Module`` object referencing the test file being run.
208228
"""
209229
test_session_span = _extract_span(item.session)
210230
if test_module_span is None and isinstance(item.parent, pytest.Package):
211231
test_module_span = _extract_span(item.parent)
212-
213232
parent_span = test_module_span
214233
if parent_span is None:
215234
parent_span = test_session_span
@@ -237,6 +256,9 @@ def _start_test_suite_span(item, test_module_span):
237256
test_suite_span.set_tag_str(test.MODULE_PATH, test_module_path)
238257
test_suite_span.set_tag_str(test.SUITE, _get_suite_name(item, test_module_path))
239258
_store_span(item, test_suite_span)
259+
260+
if should_enable_coverage:
261+
_attach_coverage(item)
240262
return test_suite_span
241263

242264

@@ -350,7 +372,7 @@ def pytest_collection_modifyitems(session, config, items):
350372
if _CIVisibility.test_skipping_enabled():
351373
skip = pytest.mark.skip(reason=SKIPPED_BY_ITR)
352374
for item in items:
353-
if _CIVisibility._instance._should_skip_path(str(get_fslocation_from_item(item)[0])):
375+
if _CIVisibility._instance._should_skip_path(str(get_fslocation_from_item(item)[0]), item.name):
354376
item.add_marker(skip)
355377

356378

@@ -366,97 +388,113 @@ def pytest_runtest_protocol(item, nextitem):
366388
if "reason" in marker.kwargs and marker.kwargs["reason"] == SKIPPED_BY_ITR
367389
]
368390

369-
if is_skipped_by_itr:
370-
yield
371-
else:
372-
test_session_span = _extract_span(item.session)
391+
test_session_span = _extract_span(item.session)
373392

374-
pytest_module_item = _find_pytest_item(item, pytest.Module)
375-
pytest_package_item = _find_pytest_item(pytest_module_item, pytest.Package)
393+
pytest_module_item = _find_pytest_item(item, pytest.Module)
394+
pytest_package_item = _find_pytest_item(pytest_module_item, pytest.Package)
395+
396+
module_is_package = True
397+
398+
test_module_span = _extract_span(pytest_package_item)
399+
if not test_module_span:
400+
test_module_span = _extract_module_span(pytest_module_item)
401+
if test_module_span:
402+
module_is_package = False
403+
404+
if test_module_span is None:
405+
test_module_span, module_is_package = _start_test_module_span(pytest_package_item, pytest_module_item)
406+
407+
test_suite_span = _extract_span(pytest_module_item)
408+
if pytest_module_item is not None and test_suite_span is None:
409+
# Start coverage for the test suite if coverage is enabled
410+
test_suite_span = _start_test_suite_span(
411+
pytest_module_item,
412+
test_module_span,
413+
should_enable_coverage=(
414+
_get_test_skipping_level() == SUITE
415+
and _CIVisibility._instance._collect_coverage_enabled
416+
and not is_skipped_by_itr
417+
),
418+
)
376419

377-
module_is_package = True
420+
with _CIVisibility._instance.tracer._start_span(
421+
ddtrace.config.pytest.operation_name,
422+
service=_CIVisibility._instance._service,
423+
resource=item.nodeid,
424+
span_type=SpanTypes.TEST,
425+
activate=True,
426+
) as span:
427+
span.set_tag_str(COMPONENT, "pytest")
428+
span.set_tag_str(SPAN_KIND, KIND)
429+
span.set_tag_str(test.FRAMEWORK, FRAMEWORK)
430+
span.set_tag_str(_EVENT_TYPE, SpanTypes.TEST)
431+
span.set_tag_str(test.NAME, item.name)
432+
span.set_tag_str(test.COMMAND, _get_pytest_command(item.config))
433+
span.set_tag_str(_SESSION_ID, str(test_session_span.span_id))
434+
435+
span.set_tag_str(_MODULE_ID, str(test_module_span.span_id))
436+
span.set_tag_str(test.MODULE, test_module_span.get_tag(test.MODULE))
437+
span.set_tag_str(test.MODULE_PATH, test_module_span.get_tag(test.MODULE_PATH))
438+
439+
span.set_tag_str(_SUITE_ID, str(test_suite_span.span_id))
440+
test_class_hierarchy = _get_test_class_hierarchy(item)
441+
if test_class_hierarchy:
442+
span.set_tag_str(test.CLASS_HIERARCHY, test_class_hierarchy)
443+
if hasattr(item, "dtest") and isinstance(item.dtest, DocTest):
444+
span.set_tag_str(test.SUITE, "{}.py".format(item.dtest.globs["__name__"]))
445+
else:
446+
span.set_tag_str(test.SUITE, test_suite_span.get_tag(test.SUITE))
378447

379-
test_module_span = _extract_span(pytest_package_item)
380-
if not test_module_span:
381-
test_module_span = _extract_module_span(pytest_module_item)
382-
if test_module_span:
383-
module_is_package = False
448+
span.set_tag_str(test.TYPE, SpanTypes.TEST)
449+
span.set_tag_str(test.FRAMEWORK_VERSION, pytest.__version__)
384450

385-
if test_module_span is None:
386-
test_module_span, module_is_package = _start_test_module_span(pytest_package_item, pytest_module_item)
451+
if item.location and item.location[0]:
452+
_CIVisibility.set_codeowners_of(item.location[0], span=span)
387453

388-
test_suite_span = _extract_span(pytest_module_item)
389-
if pytest_module_item is not None and test_suite_span is None:
390-
test_suite_span = _start_test_suite_span(pytest_module_item, test_module_span)
391-
# Start coverage for the test suite if coverage is enabled
392-
if _CIVisibility._instance._collect_coverage_enabled:
393-
_initialize(str(item.config.rootdir))
394-
_coverage_start()
454+
# We preemptively set FAIL as a status, because if pytest_runtest_makereport is not called
455+
# (where the actual test status is set), it means there was a pytest error
456+
span.set_tag_str(test.STATUS, test.Status.FAIL.value)
395457

396-
with _CIVisibility._instance.tracer._start_span(
397-
ddtrace.config.pytest.operation_name,
398-
service=_CIVisibility._instance._service,
399-
resource=item.nodeid,
400-
span_type=SpanTypes.TEST,
401-
activate=True,
402-
) as span:
403-
span.set_tag_str(COMPONENT, "pytest")
404-
span.set_tag_str(SPAN_KIND, KIND)
405-
span.set_tag_str(test.FRAMEWORK, FRAMEWORK)
406-
span.set_tag_str(_EVENT_TYPE, SpanTypes.TEST)
407-
span.set_tag_str(test.NAME, item.name)
408-
span.set_tag_str(test.COMMAND, _get_pytest_command(item.config))
409-
span.set_tag_str(_SESSION_ID, str(test_session_span.span_id))
410-
411-
span.set_tag_str(_MODULE_ID, str(test_module_span.span_id))
412-
span.set_tag_str(test.MODULE, test_module_span.get_tag(test.MODULE))
413-
span.set_tag_str(test.MODULE_PATH, test_module_span.get_tag(test.MODULE_PATH))
414-
415-
span.set_tag_str(_SUITE_ID, str(test_suite_span.span_id))
416-
test_class_hierarchy = _get_test_class_hierarchy(item)
417-
if test_class_hierarchy:
418-
span.set_tag_str(test.CLASS_HIERARCHY, test_class_hierarchy)
419-
if hasattr(item, "dtest") and isinstance(item.dtest, DocTest):
420-
span.set_tag_str(test.SUITE, "{}.py".format(item.dtest.globs["__name__"]))
421-
else:
422-
span.set_tag_str(test.SUITE, test_suite_span.get_tag(test.SUITE))
423-
424-
span.set_tag_str(test.TYPE, SpanTypes.TEST)
425-
span.set_tag_str(test.FRAMEWORK_VERSION, pytest.__version__)
426-
427-
if item.location and item.location[0]:
428-
_CIVisibility.set_codeowners_of(item.location[0], span=span)
429-
430-
# We preemptively set FAIL as a status, because if pytest_runtest_makereport is not called
431-
# (where the actual test status is set), it means there was a pytest error
432-
span.set_tag_str(test.STATUS, test.Status.FAIL.value)
433-
434-
# Parameterized test cases will have a `callspec` attribute attached to the pytest Item object.
435-
# Pytest docs: https://docs.pytest.org/en/6.2.x/reference.html#pytest.Function
436-
if getattr(item, "callspec", None):
437-
parameters = {"arguments": {}, "metadata": {}} # type: Dict[str, Dict[str, str]]
438-
for param_name, param_val in item.callspec.params.items():
439-
try:
440-
parameters["arguments"][param_name] = encode_test_parameter(param_val)
441-
except Exception:
442-
parameters["arguments"][param_name] = "Could not encode"
443-
log.warning("Failed to encode %r", param_name, exc_info=True)
444-
span.set_tag_str(test.PARAMETERS, json.dumps(parameters))
445-
446-
markers = [marker.kwargs for marker in item.iter_markers(name="dd_tags")]
447-
for tags in markers:
448-
span.set_tags(tags)
449-
_store_span(item, span)
450-
451-
# Run the actual test
452-
yield
458+
# Parameterized test cases will have a `callspec` attribute attached to the pytest Item object.
459+
# Pytest docs: https://docs.pytest.org/en/6.2.x/reference.html#pytest.Function
460+
if getattr(item, "callspec", None):
461+
parameters = {"arguments": {}, "metadata": {}} # type: Dict[str, Dict[str, str]]
462+
for param_name, param_val in item.callspec.params.items():
463+
try:
464+
parameters["arguments"][param_name] = encode_test_parameter(param_val)
465+
except Exception:
466+
parameters["arguments"][param_name] = "Could not encode"
467+
log.warning("Failed to encode %r", param_name, exc_info=True)
468+
span.set_tag_str(test.PARAMETERS, json.dumps(parameters))
469+
470+
markers = [marker.kwargs for marker in item.iter_markers(name="dd_tags")]
471+
for tags in markers:
472+
span.set_tags(tags)
473+
_store_span(item, span)
474+
475+
coverage_per_test = (
476+
_get_test_skipping_level() == TEST
477+
and _CIVisibility._instance._collect_coverage_enabled
478+
and not is_skipped_by_itr
479+
)
480+
if coverage_per_test:
481+
_attach_coverage(item)
482+
# Run the actual test
483+
yield
484+
# Finish coverage for the test suite if coverage is enabled
485+
if coverage_per_test:
486+
_detach_coverage(item, span)
453487

454488
nextitem_pytest_module_item = _find_pytest_item(nextitem, pytest.Module)
455489
if nextitem is None or nextitem_pytest_module_item != pytest_module_item and not test_suite_span.finished:
456490
_mark_test_status(pytest_module_item, test_suite_span)
457491
# Finish coverage for the test suite if coverage is enabled
458-
if _CIVisibility._instance._collect_coverage_enabled:
459-
_coverage_end(test_suite_span)
492+
if (
493+
_get_test_skipping_level() == SUITE
494+
and _CIVisibility._instance._collect_coverage_enabled
495+
and not is_skipped_by_itr
496+
):
497+
_detach_coverage(pytest_module_item, test_suite_span)
460498
test_suite_span.finish()
461499

462500
if not module_is_package:

ddtrace/internal/ci_visibility/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
from enum import IntEnum
22

33

4+
SUITE = "suite"
5+
TEST = "test"
6+
47
EVENT_TYPE = "type"
58

69

ddtrace/internal/ci_visibility/coverage.py

Lines changed: 17 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
from itertools import groupby
22
import json
33
import os
4-
from typing import Dict
5-
from typing import Iterable
6-
from typing import List
7-
from typing import Optional
8-
from typing import Tuple
4+
from typing import TYPE_CHECKING
95

106
from ddtrace.internal.logger import get_logger
117

12-
from .constants import COVERAGE_TAG_NAME
138

9+
if TYPE_CHECKING: # pragma: no cover
10+
from typing import Dict
11+
from typing import Iterable
12+
from typing import List
13+
from typing import Optional
14+
from typing import Tuple
1415

1516
log = get_logger(__name__)
1617

@@ -24,42 +25,27 @@
2425
Coverage = None # type: ignore[misc,assignment]
2526
EXECUTE_ATTR = ""
2627

27-
COVERAGE_SINGLETON = None
2828
ROOT_DIR = None
2929

3030

3131
def is_coverage_available():
3232
return Coverage is not None
3333

3434

35-
def _initialize(root_dir):
35+
def _initialize_coverage(root_dir):
3636
global ROOT_DIR
3737
if ROOT_DIR is None:
3838
ROOT_DIR = root_dir
3939

40-
global COVERAGE_SINGLETON
41-
if COVERAGE_SINGLETON is None:
42-
coverage_kwargs = {
43-
"data_file": None,
44-
"source": [root_dir],
45-
"config_file": False,
46-
"omit": [
47-
"*/site-packages/*",
48-
],
49-
}
50-
COVERAGE_SINGLETON = Coverage(**coverage_kwargs)
51-
52-
53-
def _coverage_start():
54-
COVERAGE_SINGLETON.start()
55-
56-
57-
def _coverage_end(span):
58-
span_id = str(span.trace_id)
59-
COVERAGE_SINGLETON.stop()
60-
span.set_tag(COVERAGE_TAG_NAME, build_payload(COVERAGE_SINGLETON, test_id=span_id))
61-
COVERAGE_SINGLETON._collector._clear_data()
62-
COVERAGE_SINGLETON._collector.data.clear()
40+
coverage_kwargs = {
41+
"data_file": None,
42+
"source": [ROOT_DIR],
43+
"config_file": False,
44+
"omit": [
45+
"*/site-packages/*",
46+
],
47+
}
48+
return Coverage(**coverage_kwargs)
6349

6450

6551
def segments(lines):

0 commit comments

Comments
 (0)