From 7ee06f48a053fb6ad1ac290cc7e66f848e471089 Mon Sep 17 00:00:00 2001 From: LeSingh1 Date: Sat, 8 Aug 2026 19:21:29 -0700 Subject: [PATCH] fix(test-helpers): match package markers on path segments, not on the nodeid pytest_collection_modifyitems tags every collected item by matching its nodeid against a repo-root-relative prefix: if nodeid.startswith("cuda_core/tests/") or "/cuda_core/tests/" in nodeid: item.add_marker(pytest.mark.core) but item.nodeid is relative to pytest's *rootdir*, and each subpackage ships its own pytest.ini. ci/tools/run-tests runs pushd ./cuda_core pytest -rxXs -v --durations=0 --randomly-dont-reorganize tests/ so rootdir is cuda_core/ and every nodeid starts at "tests/". Confirmed against real NVIDIA CI job logs (run 31274048063): job 93145314849: 9543 node ids matching tests/...py:: 0 node ids matching cuda_core/tests/...py:: job 93145314844: 9519 / 0 job 93145314853: 10447 / 0, including tests/cython/test_cython.py::test_ccuda_memcpy So the core/bindings/pathfinder markers are never applied in CI. The same is true of the cython marker: for "tests/cython/test_cython.py::test_x", none of "/tests/cython/" in nodeid, nodeid.endswith("/tests/cython"), or ("/cython/" in nodeid and "/tests/" in nodeid) is true, because each needs a leading slash that a rootdir-relative nodeid does not have. That last one matters beyond labelling: the CUDA-header gate is nested inside the cython branch, so if "core" in item.keywords and not have_headers: ... skip ... can never fire. Core cython tests are never skipped when CUDA_PATH is unset; they run and fail on a missing header instead. "core" in item.keywords is also unreachable for a second reason -- the core marker it depends on is one of the ones that never got applied. Match on path segments from item.path, which is absolute and does not move with rootdir, falling back to the nodeid when an item has no path. A package marker is applied when "" is immediately followed by "tests", and cython/smoke when "tests" is immediately followed by "cython"/"integration". The repo-root invocation keeps working; "toolshed/tests/..." still gets no package marker. --- .../_pytest_plugin.py | 58 ++++++--- .../tests/test_pytest_plugin.py | 120 ++++++++++++++++++ 2 files changed, 160 insertions(+), 18 deletions(-) create mode 100644 cuda_python_test_helpers/tests/test_pytest_plugin.py diff --git a/cuda_python_test_helpers/cuda_python_test_helpers/_pytest_plugin.py b/cuda_python_test_helpers/cuda_python_test_helpers/_pytest_plugin.py index e1da55dcaf0..eb2d15be9ab 100644 --- a/cuda_python_test_helpers/cuda_python_test_helpers/_pytest_plugin.py +++ b/cuda_python_test_helpers/cuda_python_test_helpers/_pytest_plugin.py @@ -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: "/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 diff --git a/cuda_python_test_helpers/tests/test_pytest_plugin.py b/cuda_python_test_helpers/tests/test_pytest_plugin.py new file mode 100644 index 00000000000..322beeffb78 --- /dev/null +++ b/cuda_python_test_helpers/tests/test_pytest_plugin.py @@ -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()