diff --git a/docs/changelog/211.security.rst b/docs/changelog/211.security.rst new file mode 100644 index 0000000..d4ea828 --- /dev/null +++ b/docs/changelog/211.security.rst @@ -0,0 +1,2 @@ +Tcl/Tk library detection no longer opens an X display, so an unresponsive display cannot block interpreter discovery. +It can also find the Tk library on headless systems. diff --git a/src/python_discovery/_py_info_collect.py b/src/python_discovery/_py_info_collect.py index a1bdf2d..7764827 100644 --- a/src/python_discovery/_py_info_collect.py +++ b/src/python_discovery/_py_info_collect.py @@ -212,9 +212,12 @@ def _resolve_tk_lib(tcl, tcl_lib): # pragma: no cover tk_lib = PythonInfoCollector._query_tk_library(tcl) if tk_lib is not None: return tk_lib - tk_version = tcl.eval("package require Tk") + import _tkinter # ruff:ignore[import-outside-top-level,import-private-name] # novermin # linked Tk version + + # Loading Tk can block on DISPLAY; the linked version is enough to locate its library. + tk_version = _tkinter.TK_VERSION tcl_parent = os.path.dirname(tcl_lib) - for version in (tk_version, ".".join(tk_version.split(".")[:2]), tk_version.split(".")[0]): + for version in (tk_version, tk_version.split(".")[0]): tk_lib_path = os.path.join(tcl_parent, "tk{}".format(version)) if os.path.isdir(tk_lib_path) and os.path.exists(os.path.join(tk_lib_path, "tk.tcl")): return tk_lib_path diff --git a/tests/test_py_info_collect.py b/tests/test_py_info_collect.py index 5385cea..34a2d26 100644 --- a/tests/test_py_info_collect.py +++ b/tests/test_py_info_collect.py @@ -319,6 +319,28 @@ def test_tcl_tk_libs_queried_with_env(tmp_path: Path, mocker: MockerFixture) -> assert (info.tcl_lib, info.tk_lib) == ("/tcl-lib", str(tk_dir)) +@pytest.mark.parametrize("folder", [pytest.param("tk8.6", id="minor"), pytest.param("tk8", id="major")]) +def test_tk_lib_resolved_without_loading_tk(tmp_path: Path, mocker: MockerFixture, folder: str) -> None: + tcl_dir, tk_dir = tmp_path / "tcl8.6", tmp_path / folder + tk_dir.mkdir() + (tk_dir / "tk.tcl").touch() + + def evaluate(script: str) -> str: + if script == "info library": + return str(tcl_dir) + if script == "set tk_library": + raise _TclError(script) + raise AssertionError + + _fake_tkinter(mocker, evaluate) + mocker.patch.dict(sys.modules, {"_tkinter": mocker.MagicMock(TK_VERSION="8.6")}) + mocker.patch.dict(os.environ, {"TCL_LIBRARY": str(tcl_dir)}) + + info = PythonInfo() + + assert (info.tcl_lib, info.tk_lib) == (str(tcl_dir), str(tk_dir)) + + def test_tcl_tk_libs_none_on_tcl_error(tmp_path: Path, mocker: MockerFixture) -> None: _fake_tkinter(mocker, _TclError("fail")) mocker.patch.dict(os.environ, {"TCL_LIBRARY": str(tmp_path)})