From 64c202ab149a0637429e750fa81b03f9026622eb Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Thu, 6 Aug 2026 14:13:24 -0400 Subject: [PATCH 1/2] 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 | 134 ++++++++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+), 26 deletions(-) create mode 100644 tests/read_args_test.py diff --git a/pybind11_mkdoc/mkdoc_lib.py b/pybind11_mkdoc/mkdoc_lib.py index 0a553d7..b14ca20 100755 --- a/pybind11_mkdoc/mkdoc_lib.py +++ b/pybind11_mkdoc/mkdoc_lib.py @@ -560,6 +560,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/2] 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 b14ca20..7e5733d 100755 --- a/pybind11_mkdoc/mkdoc_lib.py +++ b/pybind11_mkdoc/mkdoc_lib.py @@ -581,10 +581,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: @@ -614,12 +614,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: @@ -645,6 +646,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 472b276..9424f29 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 = {