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
1 change: 1 addition & 0 deletions news/3855.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add ``Finder(pyenv_only=True)`` to restrict discovery to pyenv-managed Python installations.
1 change: 1 addition & 0 deletions news/4898.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Treat any ``Path.exists()`` ``OSError`` as an inaccessible path during discovery, allowing FUSE and network mount failures to be skipped cleanly.
72 changes: 41 additions & 31 deletions src/pythonfinder/pythonfinder.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ def __init__(
global_search: bool = True,
ignore_unsupported: bool = True,
sort_by_path: bool = False,
pyenv_only: bool = False,
):
"""
Initialize a new Finder.
Expand All @@ -41,54 +42,63 @@ def __init__(
system: Whether to include the system Python.
global_search: Whether to search in the system PATH.
ignore_unsupported: Whether to ignore unsupported Python versions.
pyenv_only: Whether to restrict searches to pyenv-managed Pythons.
"""
self.path = path
self.system = system
self.global_search = global_search
self.ignore_unsupported = ignore_unsupported
self.sort_by_path = sort_by_path
self.pyenv_only = pyenv_only

# Initialize finders
self.system_finder = SystemFinder(
paths=[path] if path else None,
global_search=global_search,
system=system,
ignore_unsupported=ignore_unsupported,
)

self.pyenv_finder = PyenvFinder(
ignore_unsupported=ignore_unsupported,
)

self.asdf_finder = AsdfFinder(
ignore_unsupported=ignore_unsupported,
)

# Initialize Windows-specific finders if on Windows
self.py_launcher_finder = None
self.windows_finder = None
if os.name == "nt":
self.py_launcher_finder = PyLauncherFinder(
if pyenv_only:
self.system_finder = None
self.asdf_finder = None
self.py_launcher_finder = None
self.windows_finder = None
self.finders: list[BaseFinder] = [self.pyenv_finder]
else:
self.system_finder = SystemFinder(
paths=[path] if path else None,
global_search=global_search,
system=system,
ignore_unsupported=ignore_unsupported,
)
self.windows_finder = WindowsRegistryFinder(

self.asdf_finder = AsdfFinder(
ignore_unsupported=ignore_unsupported,
)

# List of all finders
self.finders: list[BaseFinder] = [
self.pyenv_finder,
self.asdf_finder,
]

# Add Windows-specific finders if on Windows
if self.py_launcher_finder:
self.finders.append(self.py_launcher_finder)
if self.windows_finder:
self.finders.append(self.windows_finder)

# Add system finder last
self.finders.append(self.system_finder)
# Initialize Windows-specific finders if on Windows
self.py_launcher_finder = None
self.windows_finder = None
if os.name == "nt":
self.py_launcher_finder = PyLauncherFinder(
ignore_unsupported=ignore_unsupported,
)
self.windows_finder = WindowsRegistryFinder(
ignore_unsupported=ignore_unsupported,
)

# List of all finders
self.finders: list[BaseFinder] = [
self.pyenv_finder,
self.asdf_finder,
]

# Add Windows-specific finders if on Windows
if self.py_launcher_finder:
self.finders.append(self.py_launcher_finder)
if self.windows_finder:
self.finders.append(self.windows_finder)

# Add system finder last
self.finders.append(self.system_finder)

def which(self, executable: str) -> Path | None:
"""
Expand Down
16 changes: 7 additions & 9 deletions src/pythonfinder/utils/path_utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations

import errno
import os
import re
from pathlib import Path
Expand Down Expand Up @@ -230,6 +229,11 @@ def exists_and_is_accessible(path: Path) -> bool:
"""
Check if a path exists and is accessible.

Catches all ``OSError`` subclasses (including ``PermissionError``) and
returns ``False`` so that network mounts, FUSE filesystems, and other
special paths that raise unexpected error codes are silently skipped
instead of propagating an unhandled exception.

Args:
path: The path to check.

Expand All @@ -238,14 +242,8 @@ def exists_and_is_accessible(path: Path) -> bool:
"""
try:
return path.exists()
except PermissionError as error:
if error.errno == errno.EACCES or getattr(error, "winerror", None) == 5:
return False
raise
except OSError as error:
if error.errno == errno.EACCES or getattr(error, "winerror", None) == 5:
return False
raise
except OSError:
return False


def is_in_path(path: str | Path, parent_path: str | Path) -> bool:
Expand Down
13 changes: 13 additions & 0 deletions tests/test_finder.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,19 @@ def test_finder_initialization():
assert finder.windows_finder is not None


def test_finder_pyenv_only_initialization():
"""Test that pyenv_only restricts the finder list to pyenv."""
finder = Finder(system=True, global_search=True, pyenv_only=True)

assert finder.pyenv_only is True
assert finder.pyenv_finder is not None
assert finder.system_finder is None
assert finder.asdf_finder is None
assert finder.py_launcher_finder is None
assert finder.windows_finder is None
assert finder.finders == [finder.pyenv_finder]


def test_which_method():
"""Test the which method to find an executable."""
finder = Finder(system=True, global_search=True)
Expand Down
17 changes: 6 additions & 11 deletions tests/test_path_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@
from pathlib import Path
from unittest import mock

import pytest

from pythonfinder.utils.path_utils import (
ensure_path,
exists_and_is_accessible,
Expand Down Expand Up @@ -234,12 +232,10 @@ def test_exists_and_is_accessible():
):
assert not exists_and_is_accessible(Path("/usr/bin/python"))

# Test with other error
with pytest.raises(PermissionError):
with mock.patch(
"pathlib.Path.exists", side_effect=PermissionError(1, "Other error")
):
exists_and_is_accessible(Path("/usr/bin/python"))
with mock.patch(
"pathlib.Path.exists", side_effect=PermissionError(1, "Other error")
):
assert not exists_and_is_accessible(Path("/usr/bin/python"))

class WindowsAccessDenied(OSError):
def __init__(self):
Expand All @@ -250,9 +246,8 @@ def __init__(self):
with mock.patch("pathlib.Path.exists", side_effect=WindowsAccessDenied()):
assert not exists_and_is_accessible(Path("/usr/bin/python"))

with pytest.raises(OSError):
with mock.patch("pathlib.Path.exists", side_effect=OSError(1, "Other error")):
exists_and_is_accessible(Path("/usr/bin/python"))
with mock.patch("pathlib.Path.exists", side_effect=OSError(1, "Other error")):
assert not exists_and_is_accessible(Path("/usr/bin/python"))


def test_is_in_path():
Expand Down
Loading