Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/changelog/+unreadable-python-version.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/explanation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ detects these shims and resolves them to the actual binary.
flowchart TD
Shim["Shim detected"] --> EnvVar{"PYENV_VERSION<br>set?"}
EnvVar -->|Yes| Use["Use that version"]
EnvVar -->|No| File{".python-version<br>file exists?"}
EnvVar -->|No| File{".python-version<br>names a version?"}
File -->|Yes| Use
File -->|No| Global{"pyenv global<br>version exists?"}
Global -->|Yes| Use
Expand Down
19 changes: 14 additions & 5 deletions src/python_discovery/_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""

Expand Down
60 changes: 60 additions & 0 deletions tests/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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")
Expand Down
Loading