diff --git a/docs/changelog/+system-include-fallback.bugfix.rst b/docs/changelog/+system-include-fallback.bugfix.rst new file mode 100644 index 0000000..61d5563 --- /dev/null +++ b/docs/changelog/+system-include-fallback.bugfix.rst @@ -0,0 +1,3 @@ +Stop ``PythonInfo.system_include`` reporting the interpreter's prefix as the include directory when the C headers are +not installed, which happened on Python 3.11 and later and on Debian's 3.10; the missing sysconfig include path is +returned instead. diff --git a/src/python_discovery/_py_info.py b/src/python_discovery/_py_info.py index 476d53f..64acb8f 100644 --- a/src/python_discovery/_py_info.py +++ b/src/python_discovery/_py_info.py @@ -153,8 +153,9 @@ def system_include(self) -> str: for k, v in self.sysconfig_vars.items() }, ) - if not os.path.exists(path): # pragma: no cover # broken packaging fallback - fallback = os.path.join(self.prefix, os.path.dirname(self.install_path("headers"))) + # An empty headers path would make the fallback resolve to the existing prefix. + if not os.path.exists(path) and (headers := os.path.dirname(self.install_path("headers"))): + fallback = os.path.join(self.prefix, headers) if os.path.exists(fallback): path = fallback return path diff --git a/tests/test_py_info_extra.py b/tests/test_py_info_extra.py index b421a05..84d75af 100644 --- a/tests/test_py_info_extra.py +++ b/tests/test_py_info_extra.py @@ -276,11 +276,26 @@ def test_system_include() -> None: assert isinstance(result, str) -def test_system_include_fallback(mocker: MockerFixture) -> None: +@pytest.mark.parametrize( + ("distutils_install", "existing", "expected"), + [ + pytest.param({}, [], "include", id="no-headers-path"), + pytest.param({"headers": "headers/UNKNOWN"}, ["headers"], "headers", id="headers"), + pytest.param({"headers": "headers/UNKNOWN"}, [], "include", id="headers-missing"), + pytest.param({"headers": "headers/UNKNOWN"}, ["include", "headers"], "include", id="include-present"), + ], +) +def test_system_include_fallback( + tmp_path: Path, distutils_install: dict[str, str], existing: list[str], expected: str +) -> None: info = copy.deepcopy(CURRENT) - mocker.patch("os.path.exists", side_effect=lambda p: "include" not in p or "dist" in p.lower()) - result = info.system_include - assert isinstance(result, str) + info.prefix, info.base_prefix, info.real_prefix = str(tmp_path), None, None + info.sysconfig_vars = {"base": str(tmp_path)} + info.sysconfig_paths = {"include": "{base}/include"} + info.distutils_install = distutils_install + for name in existing: + (tmp_path / name).mkdir() + assert info.system_include == str(tmp_path / expected) def test_sysconfig_path_missing_key() -> None: