"""
Executable discovery utilities for system PATH inspection.
This module provides utilities to locate executable files in the system ``PATH``,
similar to the Unix ``which``/``where`` commands and the Windows ``where`` command.
It supports both Unix-like systems and Windows, handling platform-specific search
behavior and executable file extensions.
The module contains the following main public components:
* :func:`where` - Return all matching executable paths in ``PATH``
* :func:`which` - Return the first matching executable path (deprecated)
.. note::
On Windows, the search includes the current directory and checks common
executable extensions (``.bat``, ``.cmd``, ``.com``, ``.exe``) as defined by
the implementation.
Example::
>>> from hbutils.system.os.executable import where
>>> where('python') # doctest: +SKIP
['/usr/bin/python', '/bin/python']
"""
import itertools
import os
from typing import Iterable, Iterator, Optional, List
from deprecation import deprecated
from .type import is_windows
from ...config.meta import __VERSION__
__all__ = [
'where', 'which',
]
[docs]
def where(execfile: str) -> List[str]:
"""
Return all matching file paths for the given executable.
This function searches through all directories in the system ``PATH`` environment
variable and returns a list of all absolute paths where the executable file can
be found. On Windows, it also checks for common executable extensions
(``.bat``, ``.cmd``, ``.com``, ``.exe``) and includes the current directory in
the search path.
:param execfile: Executable file to locate (such as ``python``).
:type execfile: str
:return: A list of normalized absolute paths to executable files.
:rtype: List[str]
Example::
>>> from hbutils.system.os.executable import where
>>> where('bash') # doctest: +SKIP
['/usr/bin/bash', '/bin/bash']
>>> where('not_installed')
[]
"""
return list(_iter_where(execfile))
[docs]
@deprecated(deprecated_in="0.9", removed_in="1.0", current_version=__VERSION__,
details="Use the native :func:`shutil.which` instead")
def which(execfile: str) -> Optional[str]:
"""
Return the first matching executable path in the system ``PATH`` (deprecated).
This function returns the first executable file found in the system ``PATH``,
which is typically the one that would be executed when running the command
in a terminal. Returns ``None`` if no matching executable is found.
.. deprecated:: 0.9
Use the native :func:`shutil.which` instead. This function will be removed
in version 1.0.
:param execfile: Executable file to locate (such as ``python``).
:type execfile: str
:return: Absolute path of the executable file, or ``None`` if not found.
:rtype: Optional[str]
Example::
>>> from hbutils.system.os.executable import which
>>> which('bash') # doctest: +SKIP
'/usr/bin/bash'
>>> which('not_installed') is None
True
"""
try:
return next(_iter_where(execfile))
except StopIteration:
return None
def _iter_where(filename: str) -> Iterator[str]:
"""
Iterate over matching executable paths.
This internal helper yields all matching executable file paths for the given
filename, applying the same logic used by :func:`where`.
:param filename: The executable filename to search for.
:type filename: str
:return: An iterator yielding absolute paths of matching executable files.
:rtype: Iterator[str]
"""
possible_paths = _gen_possible_matches(filename)
existing_file_paths = filter(_is_executable, possible_paths)
return existing_file_paths
def _is_executable(filename: str) -> bool:
"""
Determine whether the given path points to an executable file.
A path is considered executable if it:
* Exists
* Is a file (not a directory)
* Has executable permissions
:param filename: The file path to check.
:type filename: str
:return: ``True`` if the file is executable, ``False`` otherwise.
:rtype: bool
"""
return os.path.exists(filename) and os.path.isfile(filename) and os.access(filename, os.X_OK)
def _normpath(filename: str) -> str:
"""
Normalize a file path to a canonical absolute form.
This function applies both case normalization and path normalization to ensure
consistent path representation across different platforms, which is especially
important on Windows where path representations can vary.
.. note::
``os.path.normcase`` and ``os.path.normpath`` are critical here because
the expression form of a path is not unique, especially on Windows.
:param filename: The file path to normalize.
:type filename: str
:return: The normalized absolute path.
:rtype: str
"""
return os.path.normcase(os.path.normpath(os.path.abspath(filename)))
def _unique_str(siter: Iterable[str]) -> Iterator[str]:
"""
Yield unique strings from an iterable, preserving order.
This function maintains the order of first occurrence while removing duplicates
from the input iterable.
:param siter: An iterable of strings.
:type siter: Iterable[str]
:return: An iterator yielding unique strings in order of first occurrence.
:rtype: Iterator[str]
"""
_exist_str = set()
for s in siter:
if s not in _exist_str:
yield s
_exist_str.add(s)
def _gen_possible_matches(filename: str) -> Iterator[str]:
"""
Generate all possible executable path candidates.
This function generates potential paths by combining the filename with all
directories in the system ``PATH``. On Windows, it also:
* Includes the current directory in the search
* Appends common executable extensions (``.bat``, ``.cmd``, ``.com``, ``.exe``)
All generated paths are normalized to ensure uniqueness and consistency.
:param filename: The executable filename to search for.
:type filename: str
:return: An iterator yielding normalized possible file paths.
:rtype: Iterator[str]
"""
path_parts = os.environ.get("PATH", "").split(os.pathsep)
if is_windows(): # Only in Windows, the executable file in current directory can be called.
path_parts = itertools.chain((os.curdir,), path_parts)
possible_paths = map(lambda x: os.path.join(x, filename), path_parts)
if is_windows():
possible_paths = itertools.chain(
*map(lambda path: (path, f"{path}.bat", f"{path}.cmd", f"{path}.com", f"{path}.exe"), possible_paths))
return _unique_str(map(_normpath, possible_paths))