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")