Files
scylladb/test/pylib/skip_reason_plugin.py
Avi Kivity 0ae22a09d4 LICENSE: Update to version 1.1
Updated terms of non-commercial use (must be a never-customer).
2026-04-12 19:46:33 +03:00

158 lines
5.8 KiB
Python

#
# Copyright (C) 2026-present ScyllaDB
#
# SPDX-License-Identifier: LicenseRef-ScyllaDB-Source-Available-1.1
#
"""Pytest plugin for typed skip markers.
Framework-agnostic: the concrete skip types are provided by the
project's ``conftest.py`` via :class:`SkipReasonPlugin`.
Usage as decorator (after conftest.py registers the plugin)::
@pytest.mark.skip_bug(reason="scylladb/scylladb#12345")
@pytest.mark.skip_not_implemented(reason="no per tablet support yet")
Usage at runtime (inside test body or fixture) — prefer the
convenience wrappers from :mod:`test.pylib.skip_types`::
from test.pylib.skip_types import skip_env
skip_env("need --runveryslow option")
"""
from __future__ import annotations
import warnings
from collections.abc import Callable
from enum import StrEnum
import pytest
# StashKeys to carry skip metadata from collection to reporting.
SKIP_TYPE_KEY = pytest.StashKey[str]()
SKIP_REASON_KEY = pytest.StashKey[str]()
def skip(reason: str, skip_type: StrEnum):
"""Skip the current test at runtime with a typed reason.
Use instead of bare ``pytest.skip()``.
The *skip_type* should be a :class:`~enum.StrEnum` member defined
by the project (e.g. ``SkipType.SKIP_ENV``).
"""
pytest.skip(f"[{skip_type}] {reason}")
def skip_marker(item: pytest.Item, reason: str, skip_type: str) -> None:
"""Add a typed skip marker to an item during collection.
Use from ``pytest_collection_modifyitems`` hooks (e.g. runner.py's
``skip_mode`` processing) to skip a test with full type metadata.
The caller does not need to know how the metadata is stored.
"""
item.add_marker(pytest.mark.skip(reason=f"[{skip_type}] {reason}"))
item.stash[SKIP_TYPE_KEY] = skip_type
item.stash[SKIP_REASON_KEY] = reason
class SkipReasonPlugin:
"""Pytest plugin that converts typed skip markers into real skips
and enriches reports with skip metadata.
Args:
skip_types: A :class:`~enum.StrEnum` whose members define the
typed skip markers. Each member's ``marker_name`` property
becomes the pytest marker name, and its value becomes the
tag written to reports.
report_callback: Optional callback invoked with
``(skip_type, reason)`` for every skipped test that has
type metadata.
"""
def __init__(
self,
skip_types: type[StrEnum],
*,
report_callback: Callable[[str, str], None] | None = None,
) -> None:
self._skip_types = skip_types
self._report_callback = report_callback
@staticmethod
def _get_reason(mark: pytest.Mark) -> str:
"""Extract reason from a marker (keyword or positional)."""
return mark.kwargs.get("reason") or (mark.args[0] if mark.args else "")
@staticmethod
def _parse_skip_type(longrepr) -> tuple[str, str] | None:
"""Try to extract ``(skip_type, reason)`` from a ``[type] reason`` message.
Returns ``None`` when *longrepr* does not match the expected format.
"""
text = longrepr[-1] if isinstance(longrepr, tuple) else str(longrepr)
text = text.removeprefix("Skipped: ")
if text.startswith("[") and "]" in text:
tag, _, reason = text[1:].partition("]")
return tag, reason.lstrip()
return None
@pytest.hookimpl(trylast=True)
def pytest_collection_modifyitems(self, items: list[pytest.Item]) -> None:
"""Apply typed markers and warn on bare skips."""
for item in items:
# Convert typed skip markers to real pytest.mark.skip.
for st in self._skip_types:
for mark in item.iter_markers(st.marker_name):
reason = self._get_reason(mark)
if not reason:
raise pytest.UsageError(
f"Marker @pytest.mark.{st.marker_name} on {item.nodeid} "
f"requires a 'reason' argument."
)
item.add_marker(pytest.mark.skip(reason=f"[{st}] {reason}"))
item.stash[SKIP_TYPE_KEY] = str(st)
item.stash[SKIP_REASON_KEY] = reason
# Warn on bare pytest.mark.skip not added by typed markers.
# skip_mode sets SKIP_TYPE_KEY before this hook runs (trylast).
if SKIP_TYPE_KEY not in item.stash:
bare = [self._get_reason(m) for m in item.iter_markers("skip")]
if bare:
alternatives = ", ".join(
f"@pytest.mark.{st.marker_name}" for st in self._skip_types)
# TODO: Change to pytest.fail() after full migration.
warnings.warn(
f"Untyped skip on {item.nodeid}: {'; '.join(bare)}. "
f"Use {alternatives} instead.",
)
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(self, item: pytest.Item):
"""Enrich JUnit XML reports (and optionally custom reporters) with skip metadata."""
outcome = yield
report = outcome.get_result()
if not report.skipped:
return
skip_type = item.stash.get(SKIP_TYPE_KEY, None)
reason = item.stash.get(SKIP_REASON_KEY, "")
# Runtime skips (via skip()) don't set stash keys — parse from message.
if skip_type is None and report.longrepr:
parsed = self._parse_skip_type(report.longrepr)
if parsed is None:
return
skip_type, reason = parsed
if skip_type is None:
return
item.user_properties.append(("skip_type", skip_type))
if reason:
item.user_properties.append(("skip_reason", reason))
if self._report_callback is not None:
self._report_callback(skip_type, reason)