From 85e83b9d25862a0efc0e8888e5a7947b24ae185d Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Thu, 6 Aug 2026 14:13:24 -0400 Subject: [PATCH 1/5] fix: honor LIBCLANG_PATH and Command Line Tools on macOS, LIBCLANG_PATH on Linux Addresses findings 3-5 from the code review in #59: - Linux: LIBCLANG_PATH is now consulted before raising when no /usr/lib*/llvm-* directory is found; llvm_dir-derived include paths are skipped when llvm_dir is unknown. - macOS: honor LIBCLANG_PATH, and fall back to the Command Line Tools location when Xcode.app is absent (both libclang and the SDK dir). - macOS: SDK selection is now deterministic, preferring MacOSX.sdk and otherwise the newest version, instead of os.walk ordering. Assisted-by: ClaudeCode:claude-fable-5 --- pybind11_mkdoc/mkdoc_lib.py | 77 +++++++++++++------- tests/read_args_test.py | 139 +++++++++++++++++++++++++++++++++++- 2 files changed, 187 insertions(+), 29 deletions(-) diff --git a/pybind11_mkdoc/mkdoc_lib.py b/pybind11_mkdoc/mkdoc_lib.py index e706455..735889d 100755 --- a/pybind11_mkdoc/mkdoc_lib.py +++ b/pybind11_mkdoc/mkdoc_lib.py @@ -577,6 +577,10 @@ def _extract_file(filename, parameters): return output +def _folder_version(d): + return [int(ver) for ver in re.findall(r"(? Date: Fri, 7 Aug 2026 08:56:19 -0400 Subject: [PATCH 2/5] fix: validate LIBCLANG_PATH on Linux and report the path in errors Assisted-by: ClaudeCode:claude-opus-5 --- pybind11_mkdoc/mkdoc_lib.py | 24 +++++++++++++++--------- tests/read_args_test.py | 25 +++++++++++++++++++------ 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/pybind11_mkdoc/mkdoc_lib.py b/pybind11_mkdoc/mkdoc_lib.py index 735889d..e1b13ba 100755 --- a/pybind11_mkdoc/mkdoc_lib.py +++ b/pybind11_mkdoc/mkdoc_lib.py @@ -598,10 +598,10 @@ def read_args(args): if "LIBCLANG_PATH" in os.environ: library_file = os.environ["LIBCLANG_PATH"] if not os.path.isfile(library_file): - msg = ( - "Failed to find libclang.dylib! Set the LIBCLANG_PATH environment variable to provide a path to it." + raise FileNotFoundError( + f"LIBCLANG_PATH points to {library_file!r}, which is not a file. " + "Set it to the path of libclang.dylib." ) - raise FileNotFoundError(msg) if not cindex.Config.loaded: cindex.Config.set_library_file(library_file) else: @@ -631,12 +631,13 @@ def read_args(args): elif platform.system() == "Windows": if "LIBCLANG_PATH" in os.environ: library_file = os.environ["LIBCLANG_PATH"] - if os.path.isfile(library_file): - if not cindex.Config.loaded: - cindex.Config.set_library_file(library_file) - else: - msg = "Failed to find libclang.dll! Set the LIBCLANG_PATH environment variable to provide a path to it." - raise FileNotFoundError(msg) + if not os.path.isfile(library_file): + raise FileNotFoundError( + f"LIBCLANG_PATH points to {library_file!r}, which is not a file. " + "Set it to the path of libclang.dll." + ) + if not cindex.Config.loaded: + cindex.Config.set_library_file(library_file) else: library_file = ctypes.util.find_library("libclang.dll") if library_file is not None and not cindex.Config.loaded: @@ -662,6 +663,11 @@ def read_args(args): if "LIBCLANG_PATH" in os.environ: libclang_file = os.environ["LIBCLANG_PATH"] + if not os.path.isfile(libclang_file): + raise FileNotFoundError( + f"LIBCLANG_PATH points to {libclang_file!r}, which is not a file. " + "Set it to the path of libclang.so.1." + ) elif llvm_dir is not None: libclang_file = os.path.join(llvm_dir, "lib", "libclang.so.1") else: diff --git a/tests/read_args_test.py b/tests/read_args_test.py index 6604aab..c61c06e 100644 --- a/tests/read_args_test.py +++ b/tests/read_args_test.py @@ -40,24 +40,28 @@ def fake_walk(subdirs): @pytest.mark.usefixtures("linux", "clean_env") -def test_linux_libclang_path_without_llvm_dir(monkeypatch, config_calls): +def test_linux_libclang_path_without_llvm_dir(monkeypatch, config_calls, tmp_path): + lib = tmp_path / "libclang.so.1" + lib.touch() monkeypatch.setattr(mkdoc_lib, "glob", lambda _pattern: []) - monkeypatch.setenv("LIBCLANG_PATH", "/opt/lib/libclang.so.1") + monkeypatch.setenv("LIBCLANG_PATH", str(lib)) _, filenames = mkdoc_lib.read_args(["foo.h"]) - assert config_calls["file"] == "/opt/lib/libclang.so.1" + assert config_calls["file"] == str(lib) assert filenames == ["foo.h"] @pytest.mark.usefixtures("linux", "clean_env") -def test_linux_libclang_path_without_llvm_dir_libcpp(monkeypatch, config_calls): +def test_linux_libclang_path_without_llvm_dir_libcpp(monkeypatch, config_calls, tmp_path): + lib = tmp_path / "libclang.so.1" + lib.touch() monkeypatch.setattr(mkdoc_lib, "glob", lambda _pattern: []) - monkeypatch.setenv("LIBCLANG_PATH", "/opt/lib/libclang.so.1") + monkeypatch.setenv("LIBCLANG_PATH", str(lib)) parameters, _ = mkdoc_lib.read_args(["-stdlib=libc++", "foo.h"]) - assert config_calls["file"] == "/opt/lib/libclang.so.1" + assert config_calls["file"] == str(lib) assert "-stdlib=libc++" in parameters @@ -69,6 +73,15 @@ def test_linux_no_llvm_dir_no_env_raises(monkeypatch): mkdoc_lib.read_args(["foo.h"]) +@pytest.mark.usefixtures("linux", "clean_env", "config_calls") +def test_linux_libclang_path_missing_raises(monkeypatch, tmp_path): + monkeypatch.setattr(mkdoc_lib, "glob", lambda _pattern: []) + monkeypatch.setenv("LIBCLANG_PATH", str(tmp_path / "does_not_exist.so.1")) + + with pytest.raises(FileNotFoundError): + mkdoc_lib.read_args(["foo.h"]) + + @pytest.mark.usefixtures("darwin", "clean_env") def test_macos_xcode_preferred(monkeypatch, config_calls): existing = { From eaffcecbd1d97c647f7a1c94ad36ce9f9a316ed0 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Sat, 8 Aug 2026 23:26:00 -0400 Subject: [PATCH 3/5] style: assign exception messages to a variable to satisfy ruff Also add Windows unit tests for LIBCLANG_PATH handling. Co-authored-by: Carl Leake <46822212+leakec@users.noreply.github.com> Assisted-by: ClaudeCode:claude-fable-5 --- pybind11_mkdoc/mkdoc_lib.py | 9 ++++++--- tests/read_args_test.py | 25 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/pybind11_mkdoc/mkdoc_lib.py b/pybind11_mkdoc/mkdoc_lib.py index e1b13ba..0a1504b 100755 --- a/pybind11_mkdoc/mkdoc_lib.py +++ b/pybind11_mkdoc/mkdoc_lib.py @@ -598,10 +598,11 @@ def read_args(args): if "LIBCLANG_PATH" in os.environ: library_file = os.environ["LIBCLANG_PATH"] if not os.path.isfile(library_file): - raise FileNotFoundError( + msg = ( f"LIBCLANG_PATH points to {library_file!r}, which is not a file. " "Set it to the path of libclang.dylib." ) + raise FileNotFoundError(msg) if not cindex.Config.loaded: cindex.Config.set_library_file(library_file) else: @@ -632,10 +633,11 @@ def read_args(args): if "LIBCLANG_PATH" in os.environ: library_file = os.environ["LIBCLANG_PATH"] if not os.path.isfile(library_file): - raise FileNotFoundError( + msg = ( f"LIBCLANG_PATH points to {library_file!r}, which is not a file. " "Set it to the path of libclang.dll." ) + raise FileNotFoundError(msg) if not cindex.Config.loaded: cindex.Config.set_library_file(library_file) else: @@ -664,10 +666,11 @@ def read_args(args): if "LIBCLANG_PATH" in os.environ: libclang_file = os.environ["LIBCLANG_PATH"] if not os.path.isfile(libclang_file): - raise FileNotFoundError( + msg = ( f"LIBCLANG_PATH points to {libclang_file!r}, which is not a file. " "Set it to the path of libclang.so.1." ) + raise FileNotFoundError(msg) elif llvm_dir is not None: libclang_file = os.path.join(llvm_dir, "lib", "libclang.so.1") else: diff --git a/tests/read_args_test.py b/tests/read_args_test.py index c61c06e..b536ad0 100644 --- a/tests/read_args_test.py +++ b/tests/read_args_test.py @@ -35,6 +35,11 @@ def darwin(monkeypatch): monkeypatch.setattr(mkdoc_lib.platform, "system", lambda: "Darwin") +@pytest.fixture +def windows(monkeypatch): + monkeypatch.setattr(mkdoc_lib.platform, "system", lambda: "Windows") + + def fake_walk(subdirs): return lambda top: iter([(top, list(subdirs), [])]) @@ -134,6 +139,26 @@ def test_macos_libclang_path_env_missing_raises(monkeypatch, tmp_path): mkdoc_lib.read_args(["foo.h"]) +@pytest.mark.usefixtures("windows", "clean_env") +def test_windows_libclang_path_env(monkeypatch, config_calls, tmp_path): + lib = tmp_path / "libclang.dll" + lib.touch() + monkeypatch.setenv("LIBCLANG_PATH", str(lib)) + + _, filenames = mkdoc_lib.read_args(["foo.h"]) + + assert config_calls["file"] == str(lib) + assert filenames == ["foo.h"] + + +@pytest.mark.usefixtures("windows", "clean_env", "config_calls") +def test_windows_libclang_path_env_missing_raises(monkeypatch, tmp_path): + monkeypatch.setenv("LIBCLANG_PATH", str(tmp_path / "does_not_exist.dll")) + + with pytest.raises(FileNotFoundError): + mkdoc_lib.read_args(["foo.h"]) + + @pytest.mark.usefixtures("darwin", "clean_env", "config_calls") def test_macos_sdk_newest_numeric(monkeypatch): existing = {CLT + "usr/lib/libclang.dylib", CLT + "SDKs"} From 08ffba8b3f3568a938c2563c859c9963edcdc86c Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Sat, 8 Aug 2026 23:28:58 -0400 Subject: [PATCH 4/5] ci: re-enable Windows tests using the runner's preinstalled LLVM Assisted-by: ClaudeCode:claude-fable-5 --- .github/workflows/ci.yml | 53 +++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4135cf8..658de93 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,36 +42,29 @@ jobs: - name: Test package run: uv run --with "clang<19" --group test pytest -# Commented for now -- msys2 Clang (v15) and the clang Python package (v14) are incompatible -# -# checks_windows: -# strategy: -# fail-fast: false -# matrix: -# python-version: -# - "3.8" -# - "3.9" -# runs-on: -# - windows-latest -# runs-on: ${{ matrix.runs-on }} -# name: Test • 🐍 ${{ matrix.python-version }} • ${{matrix.runs-on}} -# steps: -# - uses: actions/checkout@v7 -# - uses: actions/setup-python@v7 -# with: -# python-version: ${{ matrix.python-version }} -# -# - name: Install package -# run: python -m pip install -e. --group test -# -# - name: Install clang -# run: C:\msys64\usr\bin\pacman.exe -S clang64/mingw-w64-clang-x86_64-clang --noconfirm -# -# - name: Test package -# env: -# LIBCLANG_PATH: C:\msys64\clang64\bin\libclang.dll -# run: python -m pytest -n2 - + checks_windows: + strategy: + fail-fast: false + matrix: + python-version: + - "3.9" + - "3.14" + runs-on: windows-latest + name: Test • 🐍 ${{ matrix.python-version }} • windows-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + - uses: astral-sh/setup-uv@v9.0.0 + + - name: Test package + env: + LIBCLANG_PATH: C:\Program Files\LLVM\bin\libclang.dll + run: uv run --with "clang<19" --group test pytest + + dist: runs-on: ubuntu-latest name: Build distribution From 74e6a8f8852dff02eb9944abb9668d0b11d99599 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Sun, 9 Aug 2026 15:40:15 -0400 Subject: [PATCH 5/5] fix: build POSIX paths with PurePosixPath so tests pass on Windows os.path.join uses backslashes on Windows, which broke the macOS-mocked SDK tests on the Windows CI runners. Assisted-by: ClaudeCode:claude-fable-5 --- pybind11_mkdoc/mkdoc_lib.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/pybind11_mkdoc/mkdoc_lib.py b/pybind11_mkdoc/mkdoc_lib.py index 0a1504b..90bd583 100755 --- a/pybind11_mkdoc/mkdoc_lib.py +++ b/pybind11_mkdoc/mkdoc_lib.py @@ -17,6 +17,7 @@ from concurrent.futures import ThreadPoolExecutor from glob import glob from itertools import repeat +from pathlib import PurePosixPath from clang import cindex from clang.cindex import CursorKind @@ -627,7 +628,7 @@ def read_args(args): sdk = sdks[-1] else: continue - parameters.extend(["-isysroot", os.path.join(sdk_dir, sdk)]) + parameters.extend(["-isysroot", str(PurePosixPath(sdk_dir) / sdk)]) break elif platform.system() == "Windows": if "LIBCLANG_PATH" in os.environ: @@ -653,7 +654,7 @@ def read_args(args): path for libdir in ["lib64", "lib", "lib32"] for path in glob(f"/usr/{libdir}/llvm-*") - if os.path.exists(os.path.join(path, "lib", "libclang.so.1")) + if os.path.exists(str(PurePosixPath(path) / "lib" / "libclang.so.1")) ), default=None, key=_folder_version, @@ -672,7 +673,7 @@ def read_args(args): ) raise FileNotFoundError(msg) elif llvm_dir is not None: - libclang_file = os.path.join(llvm_dir, "lib", "libclang.so.1") + libclang_file = str(PurePosixPath(llvm_dir) / "lib" / "libclang.so.1") else: msg = ( "Failed to find a LLVM installation providing the file " @@ -698,13 +699,17 @@ def read_args(args): max(glob(f"/usr/include/{platform.machine()}-linux-gnu/c++/*"), default=None, key=_folder_version) ) elif llvm_dir is not None: - cpp_dirs.append(os.path.join(llvm_dir, "include", "c++", "v1")) + cpp_dirs.append(str(PurePosixPath(llvm_dir) / "include" / "c++" / "v1")) if "CLANG_INCLUDE_DIR" in os.environ: cpp_dirs.append(os.environ["CLANG_INCLUDE_DIR"]) elif llvm_dir is not None: cpp_dirs.append( - max(glob(os.path.join(llvm_dir, "lib", "clang", "*", "include")), default=None, key=_folder_version) + max( + glob(str(PurePosixPath(llvm_dir) / "lib" / "clang" / "*" / "include")), + default=None, + key=_folder_version, + ) ) cpp_dirs.append(f"/usr/include/{platform.machine()}-linux-gnu")