Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 40 additions & 18 deletions cuda_python_test_helpers/cuda_python_test_helpers/_pytest_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,34 +10,56 @@
install path is covered too.
"""

import itertools

import pytest

from cuda_python_test_helpers.marks import _cuda_headers_available

_PACKAGE_MARKERS = {
"cuda_pathfinder": "pathfinder",
"cuda_bindings": "bindings",
"cuda_core": "core",
}


def _segments(item) -> tuple[str, ...]:
"""Path segments for ``item``, preferring the real filesystem path.

``item.nodeid`` is relative to pytest's *rootdir*, and each subpackage
ships its own pytest.ini -- so ``pushd ./cuda_core && pytest tests/``,
which is exactly what ci/tools/run-tests does, makes rootdir ``cuda_core/``
and every nodeid start at ``tests/``. Matching a package name against the
nodeid therefore never fires there. ``item.path`` is absolute and does not
move with rootdir.
"""
path = getattr(item, "path", None)
if path is not None:
return tuple(path.parts)
return tuple(item.nodeid.replace("\\", "/").split("/"))


def _followed_by(segments: tuple[str, ...], first: str, second: str) -> bool:
"""True if ``first`` appears immediately before ``second``."""
return any(a == first and b == second for a, b in itertools.pairwise(segments))


def pytest_collection_modifyitems(config, items): # noqa: ARG001
have_headers = _cuda_headers_available()
for item in items:
nodeid = item.nodeid.replace("\\", "/")

# Package markers by path
if nodeid.startswith("cuda_pathfinder/tests/") or "/cuda_pathfinder/tests/" in nodeid:
item.add_marker(pytest.mark.pathfinder)
if nodeid.startswith("cuda_bindings/tests/") or "/cuda_bindings/tests/" in nodeid:
item.add_marker(pytest.mark.bindings)
if nodeid.startswith("cuda_core/tests/") or "/cuda_core/tests/" in nodeid:
item.add_marker(pytest.mark.core)

# Smoke tests
if nodeid.startswith("tests/integration/") or "/tests/integration/" in nodeid:
segments = _segments(item)

# Package markers by path: "<package>/tests/..."
for package, marker in _PACKAGE_MARKERS.items():
if _followed_by(segments, package, "tests"):
item.add_marker(getattr(pytest.mark, marker))

# Smoke tests: "tests/integration/..."
if _followed_by(segments, "tests", "integration"):
item.add_marker(pytest.mark.smoke)

# Cython tests (any tests/cython subtree)
if (
"/tests/cython/" in nodeid
or nodeid.endswith("/tests/cython")
or ("/cython/" in nodeid and "/tests/" in nodeid)
):
# Cython tests: any "tests/cython/..." subtree
if _followed_by(segments, "tests", "cython"):
item.add_marker(pytest.mark.cython)

# Gate core cython tests on CUDA_PATH
Expand Down
120 changes: 120 additions & 0 deletions cuda_python_test_helpers/tests/test_pytest_plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Package/cython markers must survive the way CI actually invokes pytest.

``ci/tools/run-tests`` runs ``pushd ./cuda_core && pytest tests/``. Each
subpackage ships its own pytest.ini, so rootdir becomes that subpackage and
every nodeid starts at ``tests/`` -- never ``cuda_core/tests/``. The node ids
below are taken verbatim from NVIDIA CI job logs.
"""

from __future__ import annotations

import os
import pathlib
import sys

import pytest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from cuda_python_test_helpers import _pytest_plugin

REPO = pathlib.Path("/home/runner/work/cuda-python/cuda-python")


class FakeItem:
"""Minimal stand-in for a collected pytest item."""

def __init__(self, relpath: str, nodeid: str):
self.path = REPO / relpath
self.nodeid = nodeid
self.own_markers = []
self.keywords = set()

def add_marker(self, marker):
self.own_markers.append(marker)
self.keywords.add(marker.name)

@property
def marker_names(self):
return {m.name for m in self.own_markers}


def collect(items, *, have_headers=True, monkeypatch=None):
monkeypatch.setattr(_pytest_plugin, "_cuda_headers_available", lambda: have_headers)
_pytest_plugin.pytest_collection_modifyitems(None, items)
return items


# (relative path, nodeid as pytest -v prints it in CI, expected marker)
CI_ITEMS = [
("cuda_core/tests/test_memory.py", "tests/test_memory.py::test_buffer", "core"),
("cuda_core/tests/graph/test_graph_builder.py", "tests/graph/test_graph_builder.py::test_build", "core"),
("cuda_bindings/tests/test_cuda.py", "tests/test_cuda.py::test_x", "bindings"),
("cuda_pathfinder/tests/test_search_steps.py", "tests/test_search_steps.py::test_y", "pathfinder"),
]


@pytest.mark.agent_authored(model="claude-opus-5")
@pytest.mark.parametrize(("relpath", "nodeid", "expected"), CI_ITEMS)
def test_package_marker_applied_for_ci_node_ids(monkeypatch, relpath, nodeid, expected):
(item,) = collect([FakeItem(relpath, nodeid)], monkeypatch=monkeypatch)

assert expected in item.marker_names


@pytest.mark.agent_authored(model="claude-opus-5")
def test_package_marker_applied_for_repo_root_node_ids(monkeypatch):
"""The repo-root invocation must keep working too."""
item = FakeItem("cuda_core/tests/test_memory.py", "cuda_core/tests/test_memory.py::test_buffer")

(item,) = collect([item], monkeypatch=monkeypatch)

assert "core" in item.marker_names


@pytest.mark.agent_authored(model="claude-opus-5")
def test_cython_marker_applied_for_ci_node_ids(monkeypatch):
item = FakeItem("cuda_core/tests/cython/test_cython.py", "tests/cython/test_cython.py::test_ccuda_memcpy")

(item,) = collect([item], monkeypatch=monkeypatch)

assert {"core", "cython"} <= item.marker_names


@pytest.mark.agent_authored(model="claude-opus-5")
def test_core_cython_tests_are_skipped_without_cuda_headers(monkeypatch):
"""The gate this plugin exists for: no CUDA headers means no core cython."""
item = FakeItem("cuda_core/tests/cython/test_cython.py", "tests/cython/test_cython.py::test_ccuda_memcpy")

(item,) = collect([item], have_headers=False, monkeypatch=monkeypatch)

assert "skip" in item.marker_names


@pytest.mark.agent_authored(model="claude-opus-5")
def test_core_cython_tests_run_when_headers_are_present(monkeypatch):
item = FakeItem("cuda_core/tests/cython/test_cython.py", "tests/cython/test_cython.py::test_ccuda_memcpy")

(item,) = collect([item], have_headers=True, monkeypatch=monkeypatch)

assert "skip" not in item.marker_names


@pytest.mark.agent_authored(model="claude-opus-5")
def test_integration_tests_are_marked_smoke(monkeypatch):
item = FakeItem("tests/integration/test_smoke.py", "tests/integration/test_smoke.py::test_z")

(item,) = collect([item], monkeypatch=monkeypatch)

assert "smoke" in item.marker_names


@pytest.mark.agent_authored(model="claude-opus-5")
def test_unrelated_paths_get_no_package_marker(monkeypatch):
item = FakeItem("toolshed/tests/test_thing.py", "toolshed/tests/test_thing.py::test_w")

(item,) = collect([item], monkeypatch=monkeypatch)

assert item.marker_names & {"core", "bindings", "pathfinder"} == set()
Loading