From dd2a2d173ddff26ed158a84ab9034a968e9397b0 Mon Sep 17 00:00:00 2001 From: darrenhuai <60621295+darrenhuai@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:16:22 -0700 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=90=9B=20fix(py=5Finfo):=20stop=20sys?= =?UTF-8?q?tem=5Finclude=20returning=20the=20prefix=20when=20headers=20are?= =?UTF-8?q?=20missing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An interpreter installed without its C headers, such as Ubuntu's python3 without python3-dev, has no include directory. On Python 3.11 and later system_include then returned the interpreter's prefix, which always exists, so a caller that checks the path sees an include directory with no headers in it. The broken packaging fallback joins the parent of the distutils headers path onto the prefix. Without distutils, as on 3.11 and later and on Debian's 3.10, there is no headers path, install_path("headers") is empty and the join gives the prefix itself. The fallback now only runs when there is a headers path to use, so the missing sysconfig include path comes back instead. Tests now cover the branch, so it loses its no cover pragma. --- .../+system-include-fallback.bugfix.rst | 2 ++ src/python_discovery/_py_info.py | 8 ++++--- tests/test_py_info_extra.py | 23 +++++++++++++++---- 3 files changed, 26 insertions(+), 7 deletions(-) create mode 100644 docs/changelog/+system-include-fallback.bugfix.rst diff --git a/docs/changelog/+system-include-fallback.bugfix.rst b/docs/changelog/+system-include-fallback.bugfix.rst new file mode 100644 index 0000000..1531fad --- /dev/null +++ b/docs/changelog/+system-include-fallback.bugfix.rst @@ -0,0 +1,2 @@ +Stop ``PythonInfo.system_include`` returning the interpreter's prefix on Python 3.11 and later when the headers are not +installed; it now returns the sysconfig include path, which does not exist. diff --git a/src/python_discovery/_py_info.py b/src/python_discovery/_py_info.py index 879764f..1221ee9 100644 --- a/src/python_discovery/_py_info.py +++ b/src/python_discovery/_py_info.py @@ -144,7 +144,7 @@ def sysconfig_path(self, key: str, config_var: dict[str, str | int | None] | Non @property def system_include(self) -> str: - """The path to the system include directory for C headers.""" + """The path to the system include directory for C headers, which is missing if the headers are not installed.""" path = self.sysconfig_path( "include", { @@ -152,8 +152,10 @@ 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"))) + # broken packaging fallback: the distutils headers path minus the distribution name; there is no headers path + # without distutils (3.11+), and joining that empty parent would report the prefix as the include directory + 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 17f0427..c2ac7c0 100644 --- a/tests/test_py_info_extra.py +++ b/tests/test_py_info_extra.py @@ -264,11 +264,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: From c851ae304e53b0a7eaee62eb8796075177de1227 Mon Sep 17 00:00:00 2001 From: darrenhuai <60621295+darrenhuai@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:21:40 -0700 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=93=9D=20docs(py=5Finfo):=20name=20De?= =?UTF-8?q?bian's=203.10=20in=20the=20fragment=20and=20shorten=20the=20fal?= =?UTF-8?q?lback=20comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog/+system-include-fallback.bugfix.rst | 5 +++-- src/python_discovery/_py_info.py | 3 +-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/changelog/+system-include-fallback.bugfix.rst b/docs/changelog/+system-include-fallback.bugfix.rst index 1531fad..61d5563 100644 --- a/docs/changelog/+system-include-fallback.bugfix.rst +++ b/docs/changelog/+system-include-fallback.bugfix.rst @@ -1,2 +1,3 @@ -Stop ``PythonInfo.system_include`` returning the interpreter's prefix on Python 3.11 and later when the headers are not -installed; it now returns the sysconfig include path, which does not exist. +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 1221ee9..3705458 100644 --- a/src/python_discovery/_py_info.py +++ b/src/python_discovery/_py_info.py @@ -152,8 +152,7 @@ def system_include(self) -> str: for k, v in self.sysconfig_vars.items() }, ) - # broken packaging fallback: the distutils headers path minus the distribution name; there is no headers path - # without distutils (3.11+), and joining that empty parent would report the prefix as the include directory + # broken packaging fallback; without distutils there is no headers path and the empty join gives the 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): From c2f99b866fce6dbde29b05b73457d6ada6f2e0b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 2 Nov 2026 07:46:04 -0800 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=93=9D=20docs(info):=20clarify=20incl?= =?UTF-8?q?ude=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/python_discovery/_py_info.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/python_discovery/_py_info.py b/src/python_discovery/_py_info.py index 34718af..64acb8f 100644 --- a/src/python_discovery/_py_info.py +++ b/src/python_discovery/_py_info.py @@ -145,7 +145,7 @@ def sysconfig_path(self, key: str, config_var: dict[str, str | int | None] | Non @property def system_include(self) -> str: - """The path to the system include directory for C headers, which is missing if the headers are not installed.""" + """The path to the system include directory for C headers.""" path = self.sysconfig_path( "include", { @@ -153,7 +153,7 @@ def system_include(self) -> str: for k, v in self.sysconfig_vars.items() }, ) - # broken packaging fallback; without distutils there is no headers path and the empty join gives the prefix + # 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):