diff --git a/cuda_bindings/cuda/bindings/_utils/__init__.py b/cuda_bindings/cuda/bindings/_utils/__init__.py new file mode 100644 index 0000000000..830c1bf0de --- /dev/null +++ b/cuda_bindings/cuda/bindings/_utils/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: LicenseRef-NVIDIA-SOFTWARE-LICENSE diff --git a/cuda_core/cuda/core/_utils/driver_cu_result_explanations.py b/cuda_bindings/cuda/bindings/_utils/driver_cu_result_explanations.py similarity index 100% rename from cuda_core/cuda/core/_utils/driver_cu_result_explanations.py rename to cuda_bindings/cuda/bindings/_utils/driver_cu_result_explanations.py diff --git a/cuda_core/cuda/core/_utils/runtime_cuda_error_explanations.py b/cuda_bindings/cuda/bindings/_utils/runtime_cuda_error_explanations.py similarity index 100% rename from cuda_core/cuda/core/_utils/runtime_cuda_error_explanations.py rename to cuda_bindings/cuda/bindings/_utils/runtime_cuda_error_explanations.py diff --git a/cuda_bindings/tests/test_enum_explanations.py b/cuda_bindings/tests/test_enum_explanations.py new file mode 100644 index 0000000000..c4e99daa3b --- /dev/null +++ b/cuda_bindings/tests/test_enum_explanations.py @@ -0,0 +1,258 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: LicenseRef-NVIDIA-SOFTWARE-LICENSE + +import importlib +import importlib.metadata +import re + +import pytest + +from cuda.bindings import driver, runtime + +_EXPLANATION_MODULES = [ + ("driver_cu_result_explanations", "DRIVER_CU_RESULT_EXPLANATIONS", driver.CUresult), + ("runtime_cuda_error_explanations", "RUNTIME_CUDA_ERROR_EXPLANATIONS", runtime.cudaError_t), +] + +# Explanation dicts are maintained for the same toolkit as cuda-bindings; enum members +# carry docstrings from code generation (reportedly aligned since cuda-bindings 13.2.0). +_MIN_BINDING_VERSION_FOR_DOCSTRING_COMPARE = (13, 2) + + +def _get_binding_version(): + try: + major_minor = importlib.metadata.version("cuda-bindings").split(".")[:2] + except importlib.metadata.PackageNotFoundError: + major_minor = importlib.metadata.version("cuda-python").split(".")[:2] + return tuple(int(v) for v in major_minor) + + +def _explanation_text_from_dict_value(value): + """Flatten a dict entry to a single str (entries are str or tuple of str fragments).""" + if isinstance(value, tuple): + return "".join(value) + return value + + +def _strip_doxygen_double_colon_prefixes(s: str) -> str: + """Remove Doxygen-style ``::`` before CUDA identifiers in header-comment text. + + Matches ``::`` only when it *starts* a reference (not C++ scope between two names): + use a negative lookbehind so ``Foo::Bar`` keeps the inner ``::``. + + Applied repeatedly so ``::a ::b`` becomes ``a b``. + """ + prev = None + while prev != s: + prev = s + s = re.sub(r"(? str: + """Normalize hand-maintained dict text to compare with ``clean_enum_member_docstring`` output. + + Dicts use Doxygen ``::Symbol`` for APIs, types, and constants; cleaned enum ``__doc__`` + uses plain names after Sphinx role stripping. Strip those ``::`` prefixes on the fly, + then collapse whitespace like ``clean_enum_member_docstring``. + """ + s = _explanation_text_from_dict_value(value) + s = _strip_doxygen_double_colon_prefixes(s) + s = re.sub(r"\s+", " ", s).strip() + s = _fix_hyphenation_wordwrap_spacing(s) + # Manpage token only in dict text; cleaned __doc__ cites the section title alone. + s = s.replace("CUDART_DRIVER ", "") + return s + + +def _fix_hyphenation_wordwrap_spacing(s: str) -> str: + """Remove spaces around hyphens introduced by line wrapping in generated ``__doc__`` text. + + Sphinx/reflow often splits hyphenated words as ``non- linear`` or ``word -word``. + The explanation dicts are usually single-line and do not contain these splits; the + mismatch shows up on the cleaned enum side, so this runs inside + ``clean_enum_member_docstring`` (and the same transform is applied to dict text for + comparison parity). + + Patterns (all lowercase ASCII letters as in the CUDA blurbs): ``[a-z]- [a-z]`` and + ``[a-z] -[a-z]``. Applied repeatedly until stable. + """ + prev = None + while prev != s: + prev = s + s = re.sub(r"([a-z])- ([a-z])", r"\1-\2", s) + s = re.sub(r"([a-z]) -([a-z])", r"\1-\2", s) + return s + + +def clean_enum_member_docstring(doc: str | None) -> str | None: + """Turn a FastEnum member ``__doc__`` into plain text for display or fallback logic. + + Always: collapse all whitespace (including newlines) to single spaces and strip ends. + + Best-effort: remove common Sphinx/reST inline markup seen in generated CUDA docs, + e.g. ``:py:obj:`~.cudaGetLastError()` `` -> ``cudaGetLastError()`` (relative ``~.`` is + dropped). Does not aim for perfect reST parsing—only patterns that appear on these + enums in practice. + + After whitespace collapse, removes spurious spaces around hyphens from line wrapping + (``[a-z]- [a-z]`` and ``[a-z] -[a-z]``) so ``non- linear`` matches dict ``non-linear``. + + Returns ``None`` if ``doc`` is ``None``; otherwise returns a non-empty or empty str. + """ + if doc is None: + return None + s = doc + # Work around codegen bug (cudaErrorIncompatibleDriverContext): + # malformed :py:obj before `with`. Please remove after fix. + s = s.replace("\n:py:obj:`~.Interactions`", ' "Interactions ') + # Sphinx roles with a single backtick-delimited target (most common on these enums). + # Strip the role and keep the inner text; drop leading ~. used for same-module refs. + s = re.sub( + r":(?:py:)?(?:obj|func|meth|class|mod|data|const|exc):`([^`]+)`", + lambda m: re.sub(r"^~?\.", "", m.group(1)), + s, + ) + # Inline emphasis / strong (rare in error blurbs) + s = re.sub(r"\*\*([^*]+)\*\*", r"\1", s) + s = re.sub(r"\*([^*]+)\*", r"\1", s) + # Collapse whitespace (newlines -> spaces) and trim + s = re.sub(r"\s+", " ", s).strip() + s = _fix_hyphenation_wordwrap_spacing(s) + return s + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + pytest.param("a\nb c", "a b c", id="collapse_whitespace"), + pytest.param(" x \n ", "x", id="strip_padding"), + pytest.param( + "see\n:py:obj:`~.cuInit()` or :py:obj:`cuCtxDestroy()`", + "see cuInit() or cuCtxDestroy()", + id="sphinx_py_obj_roles", + ), + pytest.param( + "x :py:func:`~.cudaMalloc()` y", + "x cudaMalloc() y", + id="sphinx_py_func_role", + ), + pytest.param("**Note:** text", "Note: text", id="strip_bold"), + pytest.param("[Deprecated]\n", "[Deprecated]", id="deprecated_line"), + pytest.param("non- linear", "non-linear", id="hyphen_space_after"), + pytest.param("word -word", "word-word", id="hyphen_space_before"), + pytest.param( + 'Please see\n:py:obj:`~.Interactions`with the CUDA Driver API" for more information.', + 'Please see "Interactions with the CUDA Driver API" for more information.', + id="codegen_broken_interactions_role", + ), + ], +) +def test_clean_enum_member_docstring_examples(raw, expected): + assert clean_enum_member_docstring(raw) == expected + + +def test_clean_enum_member_docstring_none_input(): + assert clean_enum_member_docstring(None) is None + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + pytest.param("see ::CUDA_SUCCESS", "see CUDA_SUCCESS", id="type_ref"), + pytest.param("Foo::Bar unchanged", "Foo::Bar unchanged", id="cpp_scope_preserved"), + pytest.param("::cuInit() and ::CUstream", "cuInit() and CUstream", id="multiple_prefixes"), + ], +) +def test_strip_doxygen_double_colon_prefixes(raw, expected): + assert _strip_doxygen_double_colon_prefixes(raw) == expected + + +def _enum_docstring_parity_cases(): + for module_name, dict_name, enum_type in _EXPLANATION_MODULES: + for error in enum_type: + yield pytest.param( + module_name, + dict_name, + error, + id=f"{enum_type.__name__}.{error.name}", + ) + + +@pytest.mark.xfail( + reason=( + "Some members still differ after clean_enum_member_docstring and dict-side " + "::/whitespace alignment (wording drift, etc.). [Deprecated] stubs are skipped. " + "Remove xfail when dicts and generated docstrings share one source." + ), + strict=False, +) +@pytest.mark.parametrize( + "module_name,dict_name,error", + list(_enum_docstring_parity_cases()), +) +def test_explanations_dict_matches_cleaned_enum_docstrings(module_name, dict_name, error): + """Hand-maintained explanation dict entries should match cleaned enum ``__doc__`` text. + + cuda-bindings 13.2+ attaches per-member documentation on driver ``CUresult`` and + runtime ``cudaError_t``. This compares ``clean_enum_member_docstring(member.__doc__)`` + to dict text normalized with ``_explanation_dict_text_for_cleaned_doc_compare`` (same + whitespace rules; strip Doxygen ``::`` before ``name(`` to align with Sphinx output). + + Members whose ``__doc__`` is the ``[Deprecated]`` stub alone, or ends with + ``[Deprecated]`` after stripping whitespace, are skipped (dicts may keep longer + text; we do not compare those). + + ``cudaErrorLaunchTimeout`` is skipped: cleaned ``__doc__`` is considered authoritative; + the hand dict still carries a Doxygen-style ``cudaDeviceAttr::…`` fragment that we do + not normalize to match. + + Marked xfail while any non-skipped member still mismatches; many cases already match + (reported as xpassed when this mark is present). + """ + if _get_binding_version() < _MIN_BINDING_VERSION_FOR_DOCSTRING_COMPARE: + pytest.skip( + "Enum __doc__ vs explanation dict compare is only run for " + f"cuda-bindings >= {_MIN_BINDING_VERSION_FOR_DOCSTRING_COMPARE[0]}.{_MIN_BINDING_VERSION_FOR_DOCSTRING_COMPARE[1]}" + ) + + if error is runtime.cudaError_t.cudaErrorLaunchTimeout: + pytest.skip("Known good __doc__, bad explanations dict value") + + mod = importlib.import_module(f"cuda.bindings._utils.{module_name}") + expl_dict = getattr(mod, dict_name) + + code = int(error) + assert code in expl_dict + + raw_doc = error.__doc__ + if raw_doc is not None and raw_doc.strip().endswith("[Deprecated]"): + pytest.skip(f"SKIPPED: {error.name} is deprecated (__doc__ is or ends with [Deprecated])") + + if raw_doc is None: + pytest.skip(f"SKIPPED: {error.name} has no __doc__") + + expected = _explanation_dict_text_for_cleaned_doc_compare(expl_dict[code]) + actual = clean_enum_member_docstring(raw_doc) + if expected != actual: + pytest.fail( + f"normalized dict != cleaned __doc__ for {error!r}:\n" + f" dict (normalized for compare): {expected!r}\n" + f" cleaned __doc__: {actual!r}" + ) + + +@pytest.mark.parametrize("module_name,dict_name,enum_type", _EXPLANATION_MODULES) +def test_explanations_health(module_name, dict_name, enum_type): + mod = importlib.import_module(f"cuda.bindings._utils.{module_name}") + expl_dict = getattr(mod, dict_name) + + known_codes = set() + for error in enum_type: + code = int(error) + assert code in expl_dict + known_codes.add(code) + + if _get_binding_version() >= (13, 0): + extra_expl = sorted(set(expl_dict.keys()) - known_codes) + assert not extra_expl diff --git a/cuda_core/cuda/core/_utils/cuda_utils.pyx b/cuda_core/cuda/core/_utils/cuda_utils.pyx index 867d066ce2..726f8b4aba 100644 --- a/cuda_core/cuda/core/_utils/cuda_utils.pyx +++ b/cuda_core/cuda/core/_utils/cuda_utils.pyx @@ -26,8 +26,15 @@ from cpython.buffer cimport PyObject_GetBuffer, PyBuffer_Release, Py_buffer, PyB from cuda.bindings cimport cynvrtc, cynvvm, cynvjitlink -from cuda.core._utils.driver_cu_result_explanations import DRIVER_CU_RESULT_EXPLANATIONS -from cuda.core._utils.runtime_cuda_error_explanations import RUNTIME_CUDA_ERROR_EXPLANATIONS +try: + from cuda.bindings._utils.driver_cu_result_explanations import DRIVER_CU_RESULT_EXPLANATIONS +except ModuleNotFoundError: + DRIVER_CU_RESULT_EXPLANATIONS = {} + +try: + from cuda.bindings._utils.runtime_cuda_error_explanations import RUNTIME_CUDA_ERROR_EXPLANATIONS +except ModuleNotFoundError: + RUNTIME_CUDA_ERROR_EXPLANATIONS = {} class CUDAError(Exception): diff --git a/cuda_core/tests/test_cuda_utils.py b/cuda_core/tests/test_cuda_utils.py index f218182766..59b6369815 100644 --- a/cuda_core/tests/test_cuda_utils.py +++ b/cuda_core/tests/test_cuda_utils.py @@ -11,42 +11,6 @@ from cuda.core._utils.clear_error_support import assert_type_str_or_bytes_like, raise_code_path_meant_to_be_unreachable -def test_driver_cu_result_explanations_health(): - expl_dict = cuda_utils.DRIVER_CU_RESULT_EXPLANATIONS - - # Ensure all CUresult enums are in expl_dict - known_codes = set() - for error in driver.CUresult: - code = int(error) - assert code in expl_dict - known_codes.add(code) - - from cuda.core._utils.version import binding_version - - if binding_version() >= (13, 0, 0): - # Ensure expl_dict has no codes not known as a CUresult enum - extra_expl = sorted(set(expl_dict.keys()) - known_codes) - assert not extra_expl - - -def test_runtime_cuda_error_explanations_health(): - expl_dict = cuda_utils.RUNTIME_CUDA_ERROR_EXPLANATIONS - - # Ensure all cudaError_t enums are in expl_dict - known_codes = set() - for error in runtime.cudaError_t: - code = int(error) - assert code in expl_dict - known_codes.add(code) - - from cuda.core._utils.version import binding_version - - if binding_version() >= (13, 0, 0): - # Ensure expl_dict has no codes not known as a cudaError_t enum - extra_expl = sorted(set(expl_dict.keys()) - known_codes) - assert not extra_expl - - def test_check_driver_error(): num_unexpected = 0 for error in driver.CUresult: