From b75d672dfc3cf736569b40d959e604e57ec14cdb Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Tue, 9 Jun 2026 00:28:49 -0400 Subject: [PATCH] Port Pipenv pythonfinder behavior --- news/3855.feature.rst | 1 + news/4898.bugfix.rst | 1 + src/pythonfinder/pythonfinder.py | 72 ++++++++++++++++------------ src/pythonfinder/utils/path_utils.py | 16 +++---- tests/test_finder.py | 13 +++++ tests/test_path_utils.py | 17 +++---- 6 files changed, 69 insertions(+), 51 deletions(-) create mode 100644 news/3855.feature.rst create mode 100644 news/4898.bugfix.rst diff --git a/news/3855.feature.rst b/news/3855.feature.rst new file mode 100644 index 0000000..2e3f1e3 --- /dev/null +++ b/news/3855.feature.rst @@ -0,0 +1 @@ +Add ``Finder(pyenv_only=True)`` to restrict discovery to pyenv-managed Python installations. diff --git a/news/4898.bugfix.rst b/news/4898.bugfix.rst new file mode 100644 index 0000000..0683178 --- /dev/null +++ b/news/4898.bugfix.rst @@ -0,0 +1 @@ +Treat any ``Path.exists()`` ``OSError`` as an inaccessible path during discovery, allowing FUSE and network mount failures to be skipped cleanly. diff --git a/src/pythonfinder/pythonfinder.py b/src/pythonfinder/pythonfinder.py index f55dae5..c653c97 100644 --- a/src/pythonfinder/pythonfinder.py +++ b/src/pythonfinder/pythonfinder.py @@ -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. @@ -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: """ diff --git a/src/pythonfinder/utils/path_utils.py b/src/pythonfinder/utils/path_utils.py index 8a633e6..12d57ec 100644 --- a/src/pythonfinder/utils/path_utils.py +++ b/src/pythonfinder/utils/path_utils.py @@ -1,6 +1,5 @@ from __future__ import annotations -import errno import os import re from pathlib import Path @@ -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. @@ -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: diff --git a/tests/test_finder.py b/tests/test_finder.py index c959e11..69745a8 100644 --- a/tests/test_finder.py +++ b/tests/test_finder.py @@ -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) diff --git a/tests/test_path_utils.py b/tests/test_path_utils.py index ac4d424..47944ce 100644 --- a/tests/test_path_utils.py +++ b/tests/test_path_utils.py @@ -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, @@ -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): @@ -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():