diff --git a/cuda_pathfinder/cuda/pathfinder/__init__.py b/cuda_pathfinder/cuda/pathfinder/__init__.py index 53f2527c71..d931a264cf 100644 --- a/cuda_pathfinder/cuda/pathfinder/__init__.py +++ b/cuda_pathfinder/cuda/pathfinder/__init__.py @@ -1,13 +1,25 @@ # SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 +"""cuda.pathfinder public APIs""" + from cuda.pathfinder._dynamic_libs.load_dl_common import DynamicLibNotFoundError as DynamicLibNotFoundError from cuda.pathfinder._dynamic_libs.load_dl_common import LoadedDL as LoadedDL from cuda.pathfinder._dynamic_libs.load_nvidia_dynamic_lib import load_nvidia_dynamic_lib as load_nvidia_dynamic_lib from cuda.pathfinder._dynamic_libs.supported_nvidia_libs import ( SUPPORTED_LIBNAMES as SUPPORTED_NVIDIA_LIBNAMES, # noqa: F401 ) -from cuda.pathfinder._headers.find_nvidia_headers import ( - find_nvidia_header_directory as _find_nvidia_header_directory, # noqa: F401 -) +from cuda.pathfinder._headers.find_nvidia_headers import find_nvidia_header_directory as find_nvidia_header_directory +from cuda.pathfinder._headers.supported_nvidia_headers import SUPPORTED_HEADERS_CTK as _SUPPORTED_HEADERS_CTK from cuda.pathfinder._version import __version__ as __version__ + +# Indirection to help Sphinx find the docstring. +#: Mapping from short CUDA Toolkit (CTK) library names to their canonical +#: header basenames (used to validate a discovered include directory). +#: Example: ``"cublas" → "cublas.h"``. The key set is platform-aware +#: (e.g., ``"cufile"`` may be Linux-only). +SUPPORTED_HEADERS_CTK = _SUPPORTED_HEADERS_CTK + +# Backward compatibility: _find_nvidia_header_directory was added in release 1.2.2. +# It will be removed in release 1.2.4. +_find_nvidia_header_directory = find_nvidia_header_directory diff --git a/cuda_pathfinder/cuda/pathfinder/_dynamic_libs/find_nvidia_dynamic_lib.py b/cuda_pathfinder/cuda/pathfinder/_dynamic_libs/find_nvidia_dynamic_lib.py index 18708a2b3e..d9567207ea 100644 --- a/cuda_pathfinder/cuda/pathfinder/_dynamic_libs/find_nvidia_dynamic_lib.py +++ b/cuda_pathfinder/cuda/pathfinder/_dynamic_libs/find_nvidia_dynamic_lib.py @@ -14,6 +14,7 @@ SITE_PACKAGES_LIBDIRS_WINDOWS, is_suppressed_dll_file, ) +from cuda.pathfinder._utils.env_vars import get_cuda_home_or_path from cuda.pathfinder._utils.find_sub_dirs import find_sub_dirs, find_sub_dirs_all_sitepackages @@ -79,15 +80,8 @@ def _find_dll_using_nvidia_bin_dirs( return None -def _get_cuda_home() -> Optional[str]: - cuda_home = os.environ.get("CUDA_HOME") - if cuda_home is None: - cuda_home = os.environ.get("CUDA_PATH") - return cuda_home - - def _find_lib_dir_using_cuda_home(libname: str) -> Optional[str]: - cuda_home = _get_cuda_home() + cuda_home = get_cuda_home_or_path() if cuda_home is None: return None subdirs_list: tuple[tuple[str, ...], ...] diff --git a/cuda_pathfinder/cuda/pathfinder/_headers/find_nvidia_headers.py b/cuda_pathfinder/cuda/pathfinder/_headers/find_nvidia_headers.py index cc2c8654cb..f97f12c06a 100644 --- a/cuda_pathfinder/cuda/pathfinder/_headers/find_nvidia_headers.py +++ b/cuda_pathfinder/cuda/pathfinder/_headers/find_nvidia_headers.py @@ -6,16 +6,24 @@ import os from typing import Optional -from cuda.pathfinder._dynamic_libs.supported_nvidia_libs import IS_WINDOWS +from cuda.pathfinder._headers import supported_nvidia_headers +from cuda.pathfinder._headers.supported_nvidia_headers import IS_WINDOWS +from cuda.pathfinder._utils.env_vars import get_cuda_home_or_path from cuda.pathfinder._utils.find_sub_dirs import find_sub_dirs_all_sitepackages -@functools.cache -def find_nvidia_header_directory(libname: str) -> Optional[str]: - if libname != "nvshmem": - raise RuntimeError(f"UNKNOWN {libname=}") +def _abs_norm(path: Optional[str]) -> Optional[str]: + if path: + return os.path.normpath(os.path.abspath(path)) + return None + + +def _joined_isfile(dirpath: str, basename: str) -> bool: + return os.path.isfile(os.path.join(dirpath, basename)) - if libname == "nvshmem" and IS_WINDOWS: + +def _find_nvshmem_header_directory() -> Optional[str]: + if IS_WINDOWS: # nvshmem has no Windows support. return None @@ -23,20 +31,120 @@ def find_nvidia_header_directory(libname: str) -> Optional[str]: nvidia_sub_dirs = ("nvidia", "nvshmem", "include") hdr_dir: str # help mypy for hdr_dir in find_sub_dirs_all_sitepackages(nvidia_sub_dirs): - nvshmem_h_path = os.path.join(hdr_dir, "nvshmem.h") - if os.path.isfile(nvshmem_h_path): + if _joined_isfile(hdr_dir, "nvshmem.h"): return hdr_dir conda_prefix = os.environ.get("CONDA_PREFIX") if conda_prefix and os.path.isdir(conda_prefix): hdr_dir = os.path.join(conda_prefix, "include") - nvshmem_h_path = os.path.join(hdr_dir, "nvshmem.h") - if os.path.isfile(nvshmem_h_path): + if _joined_isfile(hdr_dir, "nvshmem.h"): return hdr_dir for hdr_dir in sorted(glob.glob("/usr/include/nvshmem_*"), reverse=True): - nvshmem_h_path = os.path.join(hdr_dir, "nvshmem.h") - if os.path.isfile(nvshmem_h_path): + if _joined_isfile(hdr_dir, "nvshmem.h"): return hdr_dir return None + + +def _find_based_on_ctk_layout(libname: str, h_basename: str, anchor_point: str) -> Optional[str]: + parts = [anchor_point] + if libname == "nvvm": + parts.append(libname) + parts.append("include") + idir = os.path.join(*parts) + if libname == "cccl": + cdir = os.path.join(idir, "cccl") # CTK 13 + if _joined_isfile(cdir, h_basename): + return cdir + if _joined_isfile(idir, h_basename): + return idir + return None + + +def _find_based_on_conda_layout(libname: str, h_basename: str, conda_prefix: str) -> Optional[str]: + if IS_WINDOWS: + anchor_point = os.path.join(conda_prefix, "Library") + if not os.path.isdir(anchor_point): + return None + else: + targets_include_path = glob.glob(os.path.join(conda_prefix, "targets", "*", "include")) + if not targets_include_path: + return None + if len(targets_include_path) != 1: + # Conda does not support multiple architectures. + # QUESTION(PR#956): Do we want to issue a warning? + return None + anchor_point = os.path.dirname(targets_include_path[0]) + return _find_based_on_ctk_layout(libname, h_basename, anchor_point) + + +def _find_ctk_header_directory(libname: str) -> Optional[str]: + h_basename = supported_nvidia_headers.SUPPORTED_HEADERS_CTK[libname] + candidate_dirs = supported_nvidia_headers.SUPPORTED_SITE_PACKAGE_HEADER_DIRS_CTK[libname] + + # Installed from a wheel + for cdir in candidate_dirs: + hdr_dir: str # help mypy + for hdr_dir in find_sub_dirs_all_sitepackages(tuple(cdir.split("/"))): + if _joined_isfile(hdr_dir, h_basename): + return hdr_dir + + conda_prefix = os.getenv("CONDA_PREFIX") + if conda_prefix: # noqa: SIM102 + if result := _find_based_on_conda_layout(libname, h_basename, conda_prefix): + return result + + cuda_home = get_cuda_home_or_path() + if cuda_home: # noqa: SIM102 + if result := _find_based_on_ctk_layout(libname, h_basename, cuda_home): + return result + + return None + + +@functools.cache +def find_nvidia_header_directory(libname: str) -> Optional[str]: + """Locate the header directory for a supported NVIDIA library. + + Args: + libname (str): The short name of the library whose headers are needed + (e.g., ``"nvrtc"``, ``"cusolver"``, ``"nvshmem"``). + + Returns: + str or None: Absolute path to the discovered header directory, or ``None`` + if the headers cannot be found. + + Raises: + RuntimeError: If ``libname`` is not in the supported set. + + Search order: + 1. **NVIDIA Python wheels** + + - Scan installed distributions (``site-packages``) for header layouts + shipped in NVIDIA wheels (e.g., ``cuda-toolkit[nvrtc]``). + + 2. **Conda environments** + + - Check Conda-style installation prefixes, which use platform-specific + include directory layouts. + + 3. **CUDA Toolkit environment variables** + + - Use ``CUDA_HOME`` or ``CUDA_PATH`` (in that order). + + Notes: + - The ``SUPPORTED_HEADERS_CTK`` dictionary maps each supported CUDA Toolkit + (CTK) library to the name of its canonical header (e.g., ``"cublas" → + "cublas.h"``). This is used to verify that the located directory is valid. + + - The only supported non-CTK library at present is ``nvshmem``. + """ + + if libname == "nvshmem": + return _abs_norm(_find_nvshmem_header_directory()) + + if libname in supported_nvidia_headers.SUPPORTED_HEADERS_CTK: + return _abs_norm(_find_ctk_header_directory(libname)) + + raise RuntimeError(f"UNKNOWN {libname=}") diff --git a/cuda_pathfinder/cuda/pathfinder/_headers/supported_nvidia_headers.py b/cuda_pathfinder/cuda/pathfinder/_headers/supported_nvidia_headers.py new file mode 100644 index 0000000000..afd9067de2 --- /dev/null +++ b/cuda_pathfinder/cuda/pathfinder/_headers/supported_nvidia_headers.py @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import sys +from typing import Final + +IS_WINDOWS = sys.platform == "win32" + +SUPPORTED_HEADERS_CTK_COMMON = { + "cccl": "cuda/std/version", + "cublas": "cublas.h", + "cudart": "cuda_runtime.h", + "cufft": "cufft.h", + "curand": "curand.h", + "cusolver": "cusolverDn.h", + "cusparse": "cusparse.h", + "npp": "npp.h", + "nvcc": "fatbinary_section.h", + "nvfatbin": "nvFatbin.h", + "nvjitlink": "nvJitLink.h", + "nvjpeg": "nvjpeg.h", + "nvrtc": "nvrtc.h", + "nvvm": "nvvm.h", +} + +SUPPORTED_HEADERS_CTK_LINUX_ONLY = { + "cufile": "cufile.h", +} +SUPPORTED_HEADERS_CTK_LINUX = SUPPORTED_HEADERS_CTK_COMMON | SUPPORTED_HEADERS_CTK_LINUX_ONLY + +SUPPORTED_HEADERS_CTK_WINDOWS_ONLY: dict[str, str] = {} +SUPPORTED_HEADERS_CTK_WINDOWS = SUPPORTED_HEADERS_CTK_COMMON | SUPPORTED_HEADERS_CTK_WINDOWS_ONLY + +SUPPORTED_HEADERS_CTK_ALL = ( + SUPPORTED_HEADERS_CTK_COMMON | SUPPORTED_HEADERS_CTK_LINUX_ONLY | SUPPORTED_HEADERS_CTK_WINDOWS_ONLY +) +SUPPORTED_HEADERS_CTK: Final[dict[str, str]] = ( + SUPPORTED_HEADERS_CTK_WINDOWS if IS_WINDOWS else SUPPORTED_HEADERS_CTK_LINUX +) + +SUPPORTED_SITE_PACKAGE_HEADER_DIRS_CTK = { + "cccl": ( + "nvidia/cu13/include/cccl", # cuda-toolkit[cccl]==13.* + "nvidia/cuda_cccl/include", # cuda-toolkit[cccl]==12.* + ), + "cublas": ("nvidia/cu13/include", "nvidia/cublas/include"), + "cudart": ("nvidia/cu13/include", "nvidia/cuda_runtime/include"), + "cufft": ("nvidia/cu13/include", "nvidia/cufft/include"), + "cufile": ("nvidia/cu13/include", "nvidia/cufile/include"), + "curand": ("nvidia/cu13/include", "nvidia/curand/include"), + "cusolver": ("nvidia/cu13/include", "nvidia/cusolver/include"), + "cusparse": ("nvidia/cu13/include", "nvidia/cusparse/include"), + "npp": ("nvidia/cu13/include", "nvidia/npp/include"), + "nvcc": ("nvidia/cu13/include", "nvidia/cuda_nvcc/include"), + "nvfatbin": ("nvidia/cu13/include", "nvidia/nvfatbin/include"), + "nvjitlink": ("nvidia/cu13/include", "nvidia/nvjitlink/include"), + "nvjpeg": ("nvidia/cu13/include", "nvidia/nvjpeg/include"), + "nvrtc": ("nvidia/cu13/include", "nvidia/cuda_nvrtc/include"), + "nvvm": ("nvidia/cu13/include", "nvidia/cuda_nvcc/nvvm/include"), +} diff --git a/cuda_pathfinder/cuda/pathfinder/_utils/env_vars.py b/cuda_pathfinder/cuda/pathfinder/_utils/env_vars.py new file mode 100644 index 0000000000..3a7de992c0 --- /dev/null +++ b/cuda_pathfinder/cuda/pathfinder/_utils/env_vars.py @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import os +import warnings +from typing import Optional + + +def _paths_differ(a: str, b: str) -> bool: + """ + Return True if paths are observably different. + + Strategy: + 1) Compare os.path.normcase(os.path.normpath(...)) for quick, robust textual equality. + - Handles trailing slashes and case-insensitivity on Windows. + 2) If still different AND both exist, use os.path.samefile to resolve symlinks/junctions. + 3) Otherwise (nonexistent paths or samefile unavailable), treat as different. + """ + norm_a = os.path.normcase(os.path.normpath(a)) + norm_b = os.path.normcase(os.path.normpath(b)) + if norm_a == norm_b: + return False + + try: + if os.path.exists(a) and os.path.exists(b): + # samefile raises on non-existent paths; only call when both exist. + return not os.path.samefile(a, b) + except OSError: + # Fall through to "different" if samefile isn't applicable/available. + pass + + # If normalized strings differ and we couldn't prove they're the same entry, treat as different. + return True + + +def get_cuda_home_or_path() -> Optional[str]: + cuda_home = os.environ.get("CUDA_HOME") + cuda_path = os.environ.get("CUDA_PATH") + + if cuda_home and cuda_path and _paths_differ(cuda_home, cuda_path): + warnings.warn( + "Both CUDA_HOME and CUDA_PATH are set but differ:\n" + f" CUDA_HOME={cuda_home}\n" + f" CUDA_PATH={cuda_path}\n" + "Using CUDA_HOME (higher priority).", + UserWarning, + stacklevel=2, + ) + + if cuda_home is not None: + return cuda_home + return cuda_path diff --git a/cuda_pathfinder/cuda/pathfinder/_version.py b/cuda_pathfinder/cuda/pathfinder/_version.py index 70aa6255c8..001da9389f 100644 --- a/cuda_pathfinder/cuda/pathfinder/_version.py +++ b/cuda_pathfinder/cuda/pathfinder/_version.py @@ -1,4 +1,4 @@ # SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -__version__ = "1.2.2" +__version__ = "1.2.3" diff --git a/cuda_pathfinder/docs/nv-versions.json b/cuda_pathfinder/docs/nv-versions.json index eb5b96a0a1..9fcc3f0ab1 100644 --- a/cuda_pathfinder/docs/nv-versions.json +++ b/cuda_pathfinder/docs/nv-versions.json @@ -3,6 +3,10 @@ "version": "latest", "url": "https://nvidia.github.io/cuda-python/cuda-pathfinder/latest/" }, + { + "version": "1.2.3", + "url": "https://nvidia.github.io/cuda-python/cuda-pathfinder/1.2.3/" + }, { "version": "1.2.2", "url": "https://nvidia.github.io/cuda-python/cuda-pathfinder/1.2.2/" diff --git a/cuda_pathfinder/docs/source/api.rst b/cuda_pathfinder/docs/source/api.rst index 1870711a1e..3cae4b6f70 100644 --- a/cuda_pathfinder/docs/source/api.rst +++ b/cuda_pathfinder/docs/source/api.rst @@ -6,10 +6,8 @@ ``cuda.pathfinder`` API Reference ================================= -The ``cuda.pathfinder`` module provides utilities for loading NVIDIA dynamic libraries. - -Public API ------------ +The ``cuda.pathfinder`` module provides utilities for loading NVIDIA dynamic libraries, +and experimental APIs for locating NVIDIA C/C++ header directories. .. autosummary:: :toctree: generated/ @@ -18,3 +16,6 @@ Public API load_nvidia_dynamic_lib LoadedDL DynamicLibNotFoundError + + SUPPORTED_HEADERS_CTK + find_nvidia_header_directory diff --git a/cuda_pathfinder/docs/source/release.rst b/cuda_pathfinder/docs/source/release.rst index b7c0ff6e19..62dbf7ad6a 100644 --- a/cuda_pathfinder/docs/source/release.rst +++ b/cuda_pathfinder/docs/source/release.rst @@ -7,6 +7,7 @@ Release Notes .. toctree:: :maxdepth: 3 + 1.2.3 1.2.2 1.2.1 1.2.0 diff --git a/cuda_pathfinder/docs/source/release/1.2.3-notes.rst b/cuda_pathfinder/docs/source/release/1.2.3-notes.rst new file mode 100644 index 0000000000..93128b2341 --- /dev/null +++ b/cuda_pathfinder/docs/source/release/1.2.3-notes.rst @@ -0,0 +1,17 @@ +.. SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +.. SPDX-License-Identifier: Apache-2.0 + +.. module:: cuda.pathfinder + +``cuda-pathfinder`` 1.2.3 Release notes +======================================= + +Released on Sep 17, 2025 + + +Highlights +---------- + +* Extend experimental ``cuda.pathfinder._find_nvidia_headers`` API + to support CTK library headers + (`PR #956 `_) diff --git a/cuda_pathfinder/pyproject.toml b/cuda_pathfinder/pyproject.toml index 96ad7fb6aa..adfff29bb8 100644 --- a/cuda_pathfinder/pyproject.toml +++ b/cuda_pathfinder/pyproject.toml @@ -15,7 +15,7 @@ test = [ "pytest>=6.2.4", ] test_nvidia_wheels_cu12 = [ - "cuda-toolkit[nvcc,cublas,nvrtc,cudart,cufft,curand,cusolver,cusparse,npp,nvfatbin,nvjitlink,nvjpeg]==12.*", + "cuda-toolkit[nvcc,cublas,nvrtc,cudart,cufft,curand,cusolver,cusparse,npp,nvfatbin,nvjitlink,nvjpeg,cccl]==12.*", "cuda-toolkit[cufile]==12.*; sys_platform != 'win32'", "nvidia-cudss-cu12", "nvidia-cufftmp-cu12; sys_platform != 'win32'", @@ -24,7 +24,7 @@ test_nvidia_wheels_cu12 = [ "nvidia-nvshmem-cu12; sys_platform != 'win32'", ] test_nvidia_wheels_cu13 = [ - "cuda-toolkit[nvcc,cublas,nvrtc,cudart,cufft,curand,cusolver,cusparse,npp,nvfatbin,nvjitlink,nvjpeg,nvvm]==13.*", + "cuda-toolkit[nvcc,cublas,nvrtc,cudart,cufft,curand,cusolver,cusparse,npp,nvfatbin,nvjitlink,nvjpeg,cccl,nvvm]==13.*", "cuda-toolkit[cufile]==13.*; sys_platform != 'win32'", "nvidia-nccl-cu13; sys_platform != 'win32'", "nvidia-nvshmem-cu13; sys_platform != 'win32'", diff --git a/cuda_pathfinder/tests/test_find_nvidia_headers.py b/cuda_pathfinder/tests/test_find_nvidia_headers.py index 2d432b0f21..da0f0e01e5 100644 --- a/cuda_pathfinder/tests/test_find_nvidia_headers.py +++ b/cuda_pathfinder/tests/test_find_nvidia_headers.py @@ -20,8 +20,13 @@ import pytest -from cuda.pathfinder import _find_nvidia_header_directory as find_nvidia_header_directory -from cuda.pathfinder._dynamic_libs.supported_nvidia_libs import IS_WINDOWS +from cuda.pathfinder import find_nvidia_header_directory +from cuda.pathfinder._headers.supported_nvidia_headers import ( + IS_WINDOWS, + SUPPORTED_HEADERS_CTK, + SUPPORTED_HEADERS_CTK_ALL, + SUPPORTED_SITE_PACKAGE_HEADER_DIRS_CTK, +) STRICTNESS = os.environ.get("CUDA_PATHFINDER_TEST_FIND_NVIDIA_HEADERS_STRICTNESS", "see_what_works") assert STRICTNESS in ("see_what_works", "all_must_work") @@ -58,3 +63,19 @@ def test_find_libname_nvshmem(info_summary_append): assert hdr_dir.startswith(conda_prefix) else: assert hdr_dir.startswith("/usr/include/nvshmem_") + + +def test_supported_headers_site_packages_ctk_consistency(): + assert tuple(sorted(SUPPORTED_HEADERS_CTK_ALL)) == tuple(sorted(SUPPORTED_SITE_PACKAGE_HEADER_DIRS_CTK.keys())) + + +@pytest.mark.parametrize("libname", SUPPORTED_HEADERS_CTK.keys()) +def test_find_ctk_headers(info_summary_append, libname): + hdr_dir = find_nvidia_header_directory(libname) + info_summary_append(f"{hdr_dir=!r}") + if hdr_dir: + assert os.path.isdir(hdr_dir) + h_filename = SUPPORTED_HEADERS_CTK[libname] + assert os.path.isfile(os.path.join(hdr_dir, h_filename)) + if STRICTNESS == "all_must_work": + assert hdr_dir is not None diff --git a/cuda_pathfinder/tests/test_utils_env_vars.py b/cuda_pathfinder/tests/test_utils_env_vars.py new file mode 100644 index 0000000000..40c7d4930d --- /dev/null +++ b/cuda_pathfinder/tests/test_utils_env_vars.py @@ -0,0 +1,181 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import os +import pathlib +import sys +import warnings + +import pytest + +from cuda.pathfinder._utils.env_vars import _paths_differ, get_cuda_home_or_path + +skip_symlink_tests = pytest.mark.skipif( + sys.platform == "win32", + reason="Exercising symlinks intentionally omitted for simplicity", +) + + +def unset_env(monkeypatch): + """Helper to clear both env vars for each test.""" + monkeypatch.delenv("CUDA_HOME", raising=False) + monkeypatch.delenv("CUDA_PATH", raising=False) + + +def test_returns_none_when_unset(monkeypatch): + unset_env(monkeypatch) + assert get_cuda_home_or_path() is None + + +def test_empty_cuda_home_preserved(monkeypatch): + # empty string is returned as-is if set. + monkeypatch.setenv("CUDA_HOME", "") + monkeypatch.setenv("CUDA_PATH", "/does/not/matter") + assert get_cuda_home_or_path() == "" + + +def test_prefers_cuda_home_over_cuda_path(monkeypatch, tmp_path): + unset_env(monkeypatch) + home = tmp_path / "home" + path = tmp_path / "path" + home.mkdir() + path.mkdir() + + monkeypatch.setenv("CUDA_HOME", str(home)) + monkeypatch.setenv("CUDA_PATH", str(path)) + + # Different directories -> warning + prefer CUDA_HOME + with pytest.warns(UserWarning, match="Both CUDA_HOME and CUDA_PATH are set but differ"): + result = get_cuda_home_or_path() + assert pathlib.Path(result) == home + + +def test_uses_cuda_path_if_home_missing(monkeypatch, tmp_path): + unset_env(monkeypatch) + only_path = tmp_path / "path" + only_path.mkdir() + monkeypatch.setenv("CUDA_PATH", str(only_path)) + assert pathlib.Path(get_cuda_home_or_path()) == only_path + + +def test_no_warning_when_textually_equal_after_normalization(monkeypatch, tmp_path): + """ + Trailing slashes should not trigger a warning, thanks to normpath. + This works cross-platform. + """ + unset_env(monkeypatch) + d = tmp_path / "cuda" + d.mkdir() + + with_slash = str(d) + ("/" if os.sep == "/" else "\\") + monkeypatch.setenv("CUDA_HOME", str(d)) + monkeypatch.setenv("CUDA_PATH", with_slash) + + # No warning; same logical directory + with warnings.catch_warnings(record=True) as record: + result = get_cuda_home_or_path() + assert pathlib.Path(result) == d + assert len(record) == 0 + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific case-folding check") +def test_no_warning_on_windows_case_only_difference(monkeypatch, tmp_path): + """ + On Windows, paths differing only by case should not warn because normcase collapses case. + """ + unset_env(monkeypatch) + d = tmp_path / "Cuda" + d.mkdir() + + upper = str(d).upper() + lower = str(d).lower() + monkeypatch.setenv("CUDA_HOME", upper) + monkeypatch.setenv("CUDA_PATH", lower) + + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + result = get_cuda_home_or_path() + assert pathlib.Path(result).samefile(d) + assert len(record) == 0 + + +def test_warning_when_both_exist_and_are_different(monkeypatch, tmp_path): + unset_env(monkeypatch) + a = tmp_path / "a" + b = tmp_path / "b" + a.mkdir() + b.mkdir() + + monkeypatch.setenv("CUDA_HOME", str(a)) + monkeypatch.setenv("CUDA_PATH", str(b)) + + # Different actual dirs -> warning + with pytest.warns(UserWarning, match="Both CUDA_HOME and CUDA_PATH are set but differ"): + result = get_cuda_home_or_path() + assert pathlib.Path(result) == a + + +def test_nonexistent_paths_fall_back_to_text_comparison(monkeypatch, tmp_path): + """ + If one or both paths don't exist, we compare normalized strings. + Different strings should warn. + """ + unset_env(monkeypatch) + a = tmp_path / "does_not_exist_a" + b = tmp_path / "does_not_exist_b" + + monkeypatch.setenv("CUDA_HOME", str(a)) + monkeypatch.setenv("CUDA_PATH", str(b)) + + with pytest.warns(UserWarning, match="Both CUDA_HOME and CUDA_PATH are set but differ"): + result = get_cuda_home_or_path() + assert pathlib.Path(result) == a + + +@skip_symlink_tests +def test_samefile_equivalence_via_symlink_when_possible(monkeypatch, tmp_path): + """ + If both paths exist and one is a symlink/junction to the other, we should NOT warn. + """ + unset_env(monkeypatch) + real_dir = tmp_path / "real" + real_dir.mkdir() + + link_dir = tmp_path / "alias" + + os.symlink(str(real_dir), str(link_dir), target_is_directory=True) + + # Set env vars to real and alias + monkeypatch.setenv("CUDA_HOME", str(real_dir)) + monkeypatch.setenv("CUDA_PATH", str(link_dir)) + + # Because they resolve to the same entry, no warning should be raised + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + result = get_cuda_home_or_path() + assert pathlib.Path(result) == real_dir + assert len(record) == 0 + + +# --- unit tests for the helper itself (optional but nice to have) --- + + +def test_paths_differ_text_only(tmp_path): + a = tmp_path / "x" + b = tmp_path / "x" / ".." / "x" # normalizes to same + assert _paths_differ(str(a), str(b)) is False + + a = tmp_path / "x" + b = tmp_path / "y" + assert _paths_differ(str(a), str(b)) is True + + +@skip_symlink_tests +def test_paths_differ_samefile(tmp_path): + real_dir = tmp_path / "r" + real_dir.mkdir() + alias = tmp_path / "alias" + os.symlink(str(real_dir), str(alias), target_is_directory=True) + + # Should detect equivalence via samefile + assert _paths_differ(str(real_dir), str(alias)) is False diff --git a/toolshed/setup-docs-env.sh b/toolshed/setup-docs-env.sh index 9d4768156e..16378725e9 100755 --- a/toolshed/setup-docs-env.sh +++ b/toolshed/setup-docs-env.sh @@ -65,4 +65,4 @@ echo "Build docs with e.g.:" echo " conda activate ${ENV_NAME}" echo " cd cuda_pathfinder/" echo " pip install -e ." -echo " (cd docs/ && rm -rf build && ./build_docs.sh)" +echo " (cd docs/ && rm -rf build source/generated && ./build_docs.sh)"