diff --git a/docs/changelog/+unreadable-python-version.bugfix.rst b/docs/changelog/+unreadable-python-version.bugfix.rst new file mode 100644 index 0000000..98cbeff --- /dev/null +++ b/docs/changelog/+unreadable-python-version.bugfix.rst @@ -0,0 +1,3 @@ +Stop a ``.python-version`` file that is not valid UTF-8, or that cannot be read, from aborting discovery through a +pyenv, mise or asdf shim; a stray byte no longer hides the versions it names, and an unreadable file is skipped like a +missing one. diff --git a/docs/explanation.rst b/docs/explanation.rst index 125532b..1047892 100644 --- a/docs/explanation.rst +++ b/docs/explanation.rst @@ -52,7 +52,7 @@ detects these shims and resolves them to the actual binary. flowchart TD Shim["Shim detected"] --> EnvVar{"PYENV_VERSION
set?"} EnvVar -->|Yes| Use["Use that version"] - EnvVar -->|No| File{".python-version
file exists?"} + EnvVar -->|No| File{".python-version
names a version?"} File -->|Yes| Use File -->|No| Global{"pyenv global
version exists?"} Global -->|Yes| Use diff --git a/src/python_discovery/_discovery.py b/src/python_discovery/_discovery.py index 65a12de..8511852 100644 --- a/src/python_discovery/_discovery.py +++ b/src/python_discovery/_discovery.py @@ -532,11 +532,10 @@ def _read_python_version_file(start: str | Path, *, search_parents: bool = True) """Read a ``.python-version`` file, optionally searching parent directories.""" current = start while True: - candidate = os.path.join(current, ".python-version") if Path(current).is_dir() else current - if Path(candidate).is_file(): - with Path(candidate).open(encoding="utf-8") as fh: - if versions := [v for line in fh if (v := line.strip()) and not v.startswith("#")]: - return versions + # unlike pathlib before 3.14, os.path answers False when a directory on the way can't be searched + candidate = os.path.join(current, ".python-version") if os.path.isdir(current) else current + if os.path.isfile(candidate) and (versions := _versions_in_file(candidate)): + return versions if not search_parents: return None parent = Path(current).parent @@ -545,6 +544,16 @@ def _read_python_version_file(start: str | Path, *, search_parents: bool = True) current = parent +def _versions_in_file(path: str | Path) -> list[str]: + try: + # pyenv reads raw bytes, so a byte that is not UTF-8 (a Latin-1 comment, say) must not hide the versions + with Path(path).open(encoding="utf-8", errors="replace") as fh: + return [v for line in fh if (v := line.strip()) and not v.startswith("#")] + except OSError: # skip a file we cannot read, the same as one that names no version + _LOGGER.debug("failed to read %s", path, exc_info=True) + return [] + + class PathPythonInfo(PythonInfo): """python info from path.""" diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 5d464ef..89f4d9b 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -260,6 +260,11 @@ def test_discovery_via_version_specifier(session_cache: DiskCache) -> None: assert interpreter.implementation == "CPython" +_POSIX_PERMISSIONS: Final[pytest.MarkDecorator] = pytest.mark.skipif( + IS_WIN or os.geteuid() == 0, reason="POSIX file permissions, which root bypasses" +) + + def _create_version_manager(tmp_path: Path, env_var: str) -> Path: root = tmp_path / env_var.lower() root.mkdir() @@ -358,6 +363,61 @@ def test_shim_uses_python_version_file(tmp_path: Path, monkeypatch: pytest.Monke assert mock_from_exe.call_args_list[0][0][0] == str(real_binary) +def test_shim_python_version_file_not_utf8( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, pyenv_probed: Callable[[], tuple[str, ...]] +) -> None: + (tmp_path / ".python-version").write_bytes(b"# pinned by M\xfcller\n2.7.18\n") + monkeypatch.chdir(tmp_path) + + assert pyenv_probed() == ("versions", "2.7.18") + + +@_POSIX_PERMISSIONS +def test_shim_unreadable_python_version_file_is_skipped( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, pyenv_probed: Callable[[], tuple[str, ...]] +) -> None: + (tmp_path / ".python-version").write_text("2.7.18\n", encoding="utf-8") + (unreadable := tmp_path / "child" / ".python-version").parent.mkdir() + unreadable.write_text("3.12.0\n", encoding="utf-8") + unreadable.chmod(0) + monkeypatch.chdir(unreadable.parent) + + assert pyenv_probed() == ("versions", "2.7.18") + + +@_POSIX_PERMISSIONS +def test_shim_unsearchable_directory_is_skipped( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, pyenv_probed: Callable[[], tuple[str, ...]] +) -> None: + (tmp_path / ".python-version").write_text("2.7.18\n", encoding="utf-8") + (work := tmp_path / "locked" / "work").mkdir(parents=True) + monkeypatch.chdir(work) + work.parent.chmod(0o600) + try: + assert pyenv_probed() == ("versions", "2.7.18") + finally: + work.parent.chmod(0o700) + + +@pytest.fixture +def pyenv_probed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture +) -> Callable[[], tuple[str, ...]]: + root = _create_version_manager(tmp_path, "PYENV_ROOT") + _create_versioned_binary(root, ("versions",), "2.7.18", "python2.7") + (root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7")).touch(mode=0o755) + monkeypatch.setenv("PATH", str(root / "shims")) + monkeypatch.setenv("PYENV_ROOT", str(root)) + monkeypatch.delenv("PYENV_VERSION", raising=False) + from_exe = mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=None) + + def _probed() -> tuple[str, ...]: + get_interpreter("python2.7", []) + return Path(from_exe.call_args_list[0].args[0]).relative_to(root).parts[:2] + + return _probed + + def test_shim_pyenv_version_env_takes_priority_over_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: root = _create_version_manager(tmp_path, "PYENV_ROOT") _create_versioned_binary(root, ("versions",), "2.7.18", "python2.7")