Source code for geoprior.utils.deps_utils

# SPDX-License-Identifier: Apache-2.0
# Author: LKouadio <etanoyau@gmail.com>
# Adapted from: earthai-tech/fusionlab-learn — https://github.com/earthai-tech/fusionlab-learn
# Modified for GeoPrior-v3 API.

"""
Dependency utilities providing functions to handle package installation,
checking, and ensuring that optional dependencies are available.
"""

from __future__ import annotations

import functools
import inspect
import subprocess
import sys
import warnings
from collections.abc import Callable
from typing import Any

from ..api.types import _T
from ..core.handlers import delegate_on_error
from ..decorators import Deprecated
from ..logging import get_logger
from ._dependency import import_optional_dependency

_logger = get_logger(__name__)

import importlib

try:
    from importlib import metadata as importlib_metadata
except ImportError:
    try:
        # Backport package for Python < 3.8
        import importlib_metadata
    except ImportError:
        importlib_metadata = None

__all__ = [
    "ensure_pkg",
    "ensure_pkgs",
    "install_package",
    "is_installing",
    "get_installation_name",
    "is_module_installed",
    "import_optional_dependency",
    "ensure_module_installed",
    "get_versions",
]


[docs] def get_versions(extras=None, distribution_mapping=None): """ Retrieve a dictionary containing version information for common libraries, as well as any user-specified packages and distribution name mappings. Parameters ---------- extras : list of str, optional Additional packages for which to attempt version retrieval. By default, None, which means no extra packages beyond the defaults. distribution_mapping : dict, optional Mapping from import-like names to actual distribution names. For example, the import name ``'sklearn'`` corresponds to the distribution name ``'scikit-learn'``. Default is None, which uses a built-in mapping for scikit-learn and any user-provided dictionary overrides or additions. Returns ------- dict Dictionary of the form: .. code-block:: python { "__version__": { "numpy": "1.24.2", "pandas": "1.5.0", "sklearn": "1.3.2", ... } } Notes ----- - By default, this function attempts to retrieve versions for the following packages: ``['numpy', 'pandas', 'sklearn', 'joblib', 'tensorflow', 'keras', 'torch']``. - If a package is not installed, it is skipped (no error is raised). - If `<distribution_mapping>` is provided, it merges with the built-in mapping (for ``"sklearn"`` → ``"scikit-learn"``), allowing users to specify additional name differences. - Python 3.8+ is recommended to ensure ``importlib.metadata`` is available. Examples -------- >>> get_versions() { "__version__": { "numpy": "1.24.2", "pandas": "1.5.0", ... } } >>> # Add custom package and distribution mapping: >>> get_versions( ... extras=["spacy"], ... distribution_mapping={"spacy": "spacy-legacy"} ... ) { "__version__": { "numpy": "1.24.2", "pandas": "1.5.0", "spacy": "3.5.1" } } """ if extras is None: extras = [] # Default packages to check default_pkgs = [ "numpy", "pandas", "sklearn", # we expect distribution 'scikit-learn' "joblib", "tensorflow", "keras", "torch", ] # Base distribution mapping for known discrepancies base_mapping = { "sklearn": "scikit-learn", "geoprior": "geoprior-learn", } # Merge user-provided distribution mapping, if any if distribution_mapping is not None: base_mapping.update(distribution_mapping) all_pkgs = default_pkgs + list(extras) version_dict = {} if ( importlib_metadata is None ): # Check if metadata module is available warnings.warn( "Version retrieval skipped: 'importlib.metadata' (or its backport " "'importlib_metadata') not found. This typically occurs in " "Python versions older than 3.8. Consider upgrading Python or " "installing 'importlib-metadata' if you are using an older Python.", stacklevel=2, ) return { "__version__": version_dict } # Return empty if no way to get versions for pkg in all_pkgs: # Determine the actual distribution name for version lookup dist_name = base_mapping.get(pkg, pkg) try: # Check if the package is findable spec = importlib.util.find_spec(pkg) if spec is None: # Not installed or can't be found continue # Attempt to retrieve version from distribution name metadata = importlib.metadata version = metadata.version(dist_name) # Store the version under the original pkg key version_dict[pkg] = version except ( importlib.metadata.PackageNotFoundError, ModuleNotFoundError, ): # Not installed or cannot detect version continue except Exception as e: # Catch other unexpected issues, warn and skip warnings.warn( f"Could not retrieve version for '{pkg}': {e}", stacklevel=2, ) continue # After collecting versions in version_dict, # fix distribution names if needed. for import_name, dist_name in base_mapping.items(): if import_name in version_dict: # Move the version from import_name => dist_name version_dict[dist_name] = version_dict.pop( import_name ) return {"__version__": version_dict}
[docs] def ensure_module_installed( module_name: str, auto_install: bool = False, version: str | None = None, package_manager: str = "pip", dist_name: str | None = None, extra_install_args: list[str] | None = None, ) -> bool: """ Ensure that the required module is installed, optionally installing it if missing. Parameters ---------- module_name : str The name of the module to check and install if necessary. auto_install : bool, optional If ``True``, automatically install the module using the specified package manager if it is not already installed (default is ``False``). version : Optional[str], optional Specify a version or version range for the module. For example, ">=1.0.0" or "==2.0.1". If ``None``, no version constraints are applied (default is ``None``). package_manager : str, optional The package manager to use for installation. Currently, only ``"pip"`` is supported. Future versions may support other package managers like ``"conda"`` (default is ``"pip"``). dist_name : Optional[str], optional Sometimes the module name used for importing is different from the distribution package name. This parameter allows specifying the distribution package name (default is ``None``). extra_install_args : Optional[List[str]], optional A list of additional arguments to pass to the package manager during installation. For example, ``["--upgrade"]`` to upgrade the package. If ``None``, no extra arguments are passed (default is ``None``). Returns ------- bool Returns ``True`` if the module is installed or successfully installed, ``False`` otherwise. Raises ------ ImportError If the module is not installed and ``auto_install`` is ``False``, or if the installation fails. ValueError If an unsupported package manager is specified. Examples -------- >>> from geoprior.utils.deps_utils import ensure_module_installed >>> # Ensure that 'numpy' is installed >>> ensure_module_installed("numpy") >>> # Ensure that 'pandas' is installed, automatically installing if missing >>> ensure_module_installed("pandas", auto_install=True) >>> # Ensure that 'scipy' version >=1.5.0 is installed >>> ensure_module_installed("scipy", version=">=1.5.0", auto_install=True) >>> # Install with additional arguments >>> ensure_module_installed( ... "requests", ... auto_install=True, ... extra_install_args=["--upgrade"] ... ) Notes ----- This function currently supports only ``"pip"`` as the package manager. When specifying a version, ensure that the version string is compatible with the package manager's version specification syntax. Packages that require system-level dependencies may still need manual installation steps. See Also -------- subprocess : For spawning new processes. sys : System-specific parameters and functions. """ try: # Attempt to import the module using the module_name if dist_name: __import__(dist_name) else: __import__(module_name) return True except ImportError: if not auto_install: raise ImportError( f"``{module_name}`` is required but not installed." ) if package_manager.lower() != "pip": raise ValueError( f"Unsupported package manager ``'{package_manager}'``. " f"Only ``'pip'`` is supported." ) # If auto-install is true, create the install command install_cmd = [sys.executable, "-m", "pip", "install"] # Append the module_name and version if provided install_cmd.append(module_name) if version: install_cmd.append(version) # Include any additional installation arguments if extra_install_args: install_cmd.extend(extra_install_args) # Attempt to install the module try: subprocess.check_call(install_cmd) if dist_name: __import__(dist_name) else: __import__(module_name) return True except subprocess.CalledProcessError as e: raise ImportError( f"Failed to install ``{module_name}``" f" using ``{package_manager}``: {e}" ) except ImportError: raise ImportError( f"Module ``{module_name}`` was installed but could not be imported." )
[docs] def install_package( name: str, dist_name: str | None = None, infer_dist_name: bool = False, version: str | None = None, extra: str = "", use_conda: bool = False, verbose: bool = True, ) -> None: r""" Install a Python package at runtime, optionally specifying a version constraint or other parameters, using either conda or pip. If conda is unavailable or disabled, pip is used by default. The function includes a check for pre-existing installations, allowing users to skip redundant installs. Parameters ---------- name : str Base name of the package to install (e.g., ``'requests'``). dist_name : str, optional Distribution name, if different from the import name. For example, scikit-learn's import name ``sklearn`` differs from its distribution name ``'scikit-learn'``. infer_dist_name : bool, optional If True, calls :meth:`get_installation_name` to infer the distribution name automatically. Defaults to False. version : str, optional Version string or comparator. Examples include ``'1.2.0'`` interpreted as ``'>=1.2.0'``, ``'==1.2.0'``, ``'<2.0'``, and ``'>=1.5.3'``. If ``None``, no version constraint is applied. extra : str, optional Additional install specifiers or command-line flags passed to the installation command. For instance, ``' --no-cache-dir'`` or ``'[extra]'``. Default is ``''``. use_conda : bool, optional If True, attempts installation via conda first. If conda is unavailable or fails, falls back to pip. Defaults to False. verbose : bool, optional If True, prints detailed logs throughout the installation. Defaults to True. Returns ------- None On success, the specified package is installed (or is already present). If the installer fails, raises a RuntimeError. Raises ------ RuntimeError If the installation cannot be completed using either conda or pip, or if conda is requested but unavailable. Notes ----- If the package is already installed, as determined by :func:`is_module_installed`, no further action is taken. When using pip, a progress bar is displayed if ``tqdm`` is installed. For conda, no progress bar is shown because of console I/O capture limitations. Conceptually, this function assembles an install spec of the form: .. math:: \text{install\_str} = \langle \text{name} \rangle + \langle \text{version\_spec} \rangle + \langle \text{extra} \rangle where :math:`\langle \text{name} \rangle` is the package name, :math:`\langle \text{version\_spec} \rangle` is a version comparator (e.g., ``>=1.2.0``), and :math:`\langle \text{extra} \rangle` is any additional flags or arguments. Examples -------- >>> from geoprior.utils.deps_utils import install_package >>> # Install requests with no version constraint, default pip: >>> install_package('requests', verbose=True) >>> # Install a specific version via conda (fallback to pip if conda fails): >>> install_package( ... 'pandas', ... version='==1.2.0', ... use_conda=True, ... verbose=True ... ) See Also -------- is_module_installed : Check whether a Python module or corresponding distribution is already installed. get_installation_name : Infer a distribution name for the given module name when needed. """ # Check if tqdm is available try: from tqdm import tqdm TQDM_AVAILABLE = True except ImportError: TQDM_AVAILABLE = False # --- Helper Functions --- def _format_version_spec(ver_str: str) -> str: """ If `ver_str` starts with a comparator (>, <, ==, !=, >=, <=, ~=, ^), return it unchanged. Otherwise, interpret it as '>=ver_str'. """ # List of recognized version operators operators = ( ">=", "<=", "==", "!=", ">", "<", "~=", "^", ) if any( ver_str.strip().startswith(op) for op in operators ): return ver_str # Default to >= if user just gave a plain version return f">={ver_str}" def execute_command( command: list, show_progress: bool = False ) -> None: """ Execute a system command with optional progress bar for output lines. Raises RuntimeError if the command fails. """ if TQDM_AVAILABLE and show_progress: with ( subprocess.Popen( command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, ) as process, tqdm(desc="Installing", unit="line") as pbar, ): for line in process.stdout: if verbose: print(line, end="") pbar.update(1) if process.wait() != 0: raise RuntimeError( f"Installation failed for package '{command[-1]}'." ) else: # No tqdm available or progress not requested with subprocess.Popen( command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, ) as process: for line in process.stdout: if verbose: print(line, end="") if process.wait() != 0: raise RuntimeError( f"Installation failed for package '{command[-1]}'." ) # --- Main Logic --- # Check if already installed if is_module_installed(name, dist_name): if verbose: print(f"[INFO] '{name}' is already installed.") return # Potentially infer the distribution name for pip/conda usage if infer_dist_name: name = get_installation_name(name, dist_name) # Format version string if provided version_spec = "" if version is not None: version_spec = _format_version_spec(version) # Combine the name + version spec + extra into a single string for pip/conda # e.g. "pandas>=1.2.0" or "numpy==1.23.5 --no-cache-dir" install_str = name if version_spec: install_str += version_spec if extra.strip(): # If user wrote extra like "==1.2.0", we typically expect them # to put version in "version" param. But let's not override. We'll just # append it with a space if it doesn't start with '='. # For safety, always add a space. The user might specify advanced pip flags: # e.g. extra=" --no-cache-dir" install_str += f"{extra}" conda_available = _check_conda_installed() if use_conda and conda_available: if verbose: print( f"[INFO] Attempting to install '{install_str}' using conda..." ) try: # conda install <package>[version spec, etc.] -y execute_command( ["conda", "install", install_str, "-y"], show_progress=False, ) if verbose: print( f"[INFO] Package '{install_str}' installed via conda." ) return except Exception as e: if verbose: print( f"[WARN] Conda installation failed: {str(e)}." ) print("[INFO] Falling back to pip...") elif use_conda and not conda_available: if verbose: print( "[WARN] Conda is not available. Falling back to pip..." ) # Fallback to pip if verbose: print( f"[INFO] Attempting to install '{install_str}' using pip..." ) try: execute_command( [ sys.executable, "-m", "pip", "install", install_str, ], show_progress=True, ) if verbose: print( f"[INFO] Package '{install_str}' was successfully installed." ) except Exception as e: raise RuntimeError( f"Failed to install '{install_str}' via pip: {e}" ) from e
@Deprecated( "'Install_pkg' is deprecated, fallback to `install_package'" " which implement the more robust approach." ) @delegate_on_error( transfer=install_package, delegate_params_mapping={ "name": "name", "dist_name": "dist_name", "infer_dist_name": "infer_dist_name", "version": "version", "use_conda": "use_conda", "verbose": "verbose", }, ) def install_pkg( name: str, dist_name: str | None = None, infer_dist_name: bool = False, version: str = "", use_conda: bool = False, verbose: bool = True, ) -> None: """ Install a Python package using either conda or pip, with an option to display installation progress and fallback mechanism. This function dynamically chooses between conda and pip for installing Python packages, based on user preference and system configuration. It supports a verbose mode for detailed operation logging and utilizes a progress bar for pip installations. Parameters ---------- name : str Name of the package to install. Version specification can be included. dist_name : str, optional The distribution name of the package. Useful for packages where the import name differs from the distribution name. infer_dist_name : bool, optional If True, attempt to infer the distribution name for pip installation, defaults to False. version : str, optional Additional options or version specifier for the package, by default ''. use_conda : bool, optional Prefer conda over pip for installation, by default False. verbose : bool, optional Enable detailed output during the installation process, by default True. Raises ------ RuntimeError If installation fails via both conda and pip, or if the specified installer is not available. Examples -------- Install a package using pip without version specification: >>> from geoprior.utils.deps_utils import install_package >>> install_package('requests', verbose=True) Install a specific version of a package using conda: >>> install_package('pandas', extra='==1.2.0', use_conda=True, verbose=True) Notes ----- Conda installations do not display a progress bar due to limitations in capturing conda command line output. Pip installations will show a progress bar indicating the number of processed output lines from the installation command. """ def execute_command( command: list, progress_bar: bool = False ) -> None: """ Execute a system command with optional progress bar for output lines. Parameters ---------- command : list Command and arguments to execute as a list. progress_bar : bool, optional Enable a progress bar that tracks the command's output lines, by default False. """ from tqdm import tqdm with ( subprocess.Popen( command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, ) as process, tqdm( desc="Installing", unit="line", disable=not progress_bar, ) as pbar, ): for line in process.stdout: if verbose: print(line, end="") pbar.update(1) if ( process.wait() != 0 ): # Non-zero exit code indicates failure raise RuntimeError( f"Installation failed for package '{name}{version}'." ) # If the module is installed don't install again. if is_module_installed(name, distribution_name=dist_name): if verbose: print(f"{name} is already installed.") return True # If the distribution to pkg name if the pkg name # is different to distribution name . if infer_dist_name: name = get_installation_name(name, dist_name) conda_available = _check_conda_installed() try: if use_conda and conda_available: if verbose: print( f"Attempting to install '{name}{version}' using conda..." ) execute_command( [ "conda", "install", f"{name}{version}", "-y", ], progress_bar=False, ) elif use_conda and not conda_available: if verbose: print( "Conda is not available. Falling back to pip..." ) execute_command( [ sys.executable, "-m", "pip", "install", f"{name}{version}", ], progress_bar=True, ) else: if verbose: print( f"Attempting to install '{name}{version}' using pip..." ) execute_command( [ sys.executable, "-m", "pip", "install", f"{name}{version}", ], progress_bar=True, ) if verbose: print( f"Package '{name}{version}' was successfully installed." ) except Exception as e: raise RuntimeError( f"Failed to install '{name}{version}': {e}" ) from e def _check_conda_installed() -> bool: """ Check if conda is installed and available in the system's PATH. Returns ------- bool True if conda is found, False otherwise. """ try: subprocess.check_call( ["conda", "--version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) return True except (subprocess.CalledProcessError, FileNotFoundError): return False
[docs] def ensure_pkg( name: str, extra: str = "", error: str = "raise", min_version: str | None = None, exception: Exception = None, dist_name: str | None = None, infer_dist_name: bool = False, auto_install: bool = False, use_conda: bool = False, partial_check: bool = False, condition: Any = None, verbose: bool = False, ) -> Callable[[_T], _T]: """ Decorator to ensure a Python package is installed before function execution. If the specified package is not installed, or if its installed version does not meet the minimum version requirement, this decorator can optionally install or upgrade the package automatically using either pip or conda. Parameters ---------- name : str The name of the package. extra : str, optional Additional specification for the package, such as version or extras. error : str, optional Error handling strategy if the package is missing: 'raise', 'ignore', or 'warn'. min_version : str or None, optional The minimum required version of the package. If not met, triggers installation. exception : Exception, optional A custom exception to raise if the package is missing and `errors` is 'raise'. dist_name : str, optional The distribution name of the package as known by package managers (e.g., pip). If provided and the module import fails, an additional check based on the distribution name is performed. This parameter is useful for packages where the distribution name differs from the importable module name. infer_dist_name : bool, optional If True, attempt to infer the distribution name for pip installation, defaults to False. auto_install : bool, optional Whether to automatically install the package if missing. Defaults to False. use_conda : bool, optional Prefer conda over pip for automatic installation. Defaults to False. partial_check : bool, optional If True, checks the existence of the package only if the `condition` is met. This allows for conditional package checking based on the function's arguments or other criteria. If `False`, the check is always performed. Defaults to False. condition : Any, optional A condition that determines whether to check for the package's existence. This can be a callable that takes the same arguments as the decorated function and returns a boolean, a specific argument name to check for truthiness, or any other value that will be evaluated as a boolean. If `None`, the package check is performed unconditionally unless `partial_check` is False. verbose : bool, optional Enable verbose output during the installation process. Defaults to False. Returns ------- Callable A decorator that wraps functions to ensure the specified package is installed. Examples -------- >>> from geoprior.utils.deps_utils import ensure_pkg >>> @ensure_pkg("numpy", auto_install=True) ... def use_numpy(): ... import numpy as np ... return np.array([1, 2, 3]) >>> @ensure_pkg("pandas", min_version="1.1.0", errors="warn", use_conda=True) ... def use_pandas(): ... import pandas as pd ... return pd.DataFrame([[1, 2], [3, 4]]) >>> @ensure_pkg("matplotlib", partial_check=True, condition=lambda x, y: x > 0) ... def plot_data(x, y): ... import matplotlib.pyplot as plt ... plt.plot(x, y) ... plt.show() >>> @ensure_pkg("skimage", partial_check=True, condition=( ... lambda *args, **kwargs: 'method' in kwargs and kwargs['method'] == 'hog') ... ) >>> def check_package_installed(data, method='hog', **kwargs): ... extractor_function = None ... if method == 'hog': ... from skimage.feature import hog ... extractor_function = lambda image: hog(image, **kwargs) ... return extractor_function """ # def decorator(func: _T) -> _T: # @functools.wraps(func) # def wrapper(*args, **kwargs): # # Determine if this is a method or a function based on the first argument # bound_method = hasattr(args[0], func.__name__) if args else False # # # If partial_check is True, check condition before performing actions # if not partial_check or _should_check_condition( # condition, *args, **kwargs): # try: # # Attempt to import the package, handling installation # # if necessary and permitted # import_optional_dependency( # name, extra=extra, errors=error, # min_version=min_version, # exception=exception # ) # except (ModuleNotFoundError, ImportError): # if auto_install: # # Install the package if auto-install is enabled # install_package( # name, dist_name=dist_name, # infer_dist_name=infer_dist_name, # extra=extra, # version=min_version, # use_conda=use_conda, # verbose=verbose # ) # elif exception is not None: # raise exception # else: # raise # # If the function is a bound method, call it with 'self' or 'cls' # if bound_method: # return func(args[0], *args[1:], **kwargs) # else: # return func(*args, **kwargs) # # return wrapper # return decorator def decorator(func): is_method_def = ( "." in func.__qualname__ and "<locals>" not in func.__qualname__ ) if is_method_def: @functools.wraps(func) def wrapper(self, *args, **kwargs): # --- your existing dependency check block --- if ( not partial_check or _should_check_condition( condition, self, *args, **kwargs ) ): import_optional_dependency( name, extra=extra, errors=error, min_version=min_version, exception=exception, ) return func(self, *args, **kwargs) else: @functools.wraps(func) def wrapper(*args, **kwargs): if ( not partial_check or _should_check_condition( condition, *args, **kwargs ) ): import_optional_dependency( name, extra=extra, errors=error, min_version=min_version, exception=exception, ) return func(*args, **kwargs) # keep inspect.signature() nice (sklearn/etc.) wrapper.__signature__ = inspect.signature(func) return wrapper return decorator
def _should_check_condition( condition: Any, *args, **kwargs ) -> bool: """ Determines whether the condition(s) for checking a package's existence are met, based on the provided arguments and keyword arguments of a decorated function. This function offers enhanced flexibility by allowing conditions to be specified as callable functions, tuples for positional argument checks, strings for keyword argument checks, or a list combining any of these types for multiple conditions. Parameters ---------- condition : Any The condition(s) that determine whether to perform the package check. Can be: - A callable that takes `*args` and `**kwargs` and returns a boolean. - A string specifying a keyword argument name that should be truthy. - A tuple `(index, value)` for checking a specific value of a positional argument. - A list of any combination of the above to specify multiple conditions. *args : tuple Positional arguments passed to the decorated function. **kwargs : dict Keyword arguments passed to the decorated function. Returns ------- bool `True` if the package check should be performed based on the evaluation of `condition`, `False` otherwise. Examples -------- Checking with a single callable condition for partial_check is ``True``: >>> _should_check_condition(lambda x, y: x > y, 5, 3) True Checking with a string condition (keyword argument name): >>> _should_check_condition('method', method='hog') True Checking with a tuple for positional argument value: >>> _should_check_condition((0, 'data'), 'data', method='hog') True Checking with multiple conditions: >>> conditions = [(1, 'hog'), lambda *args, **kwargs: kwargs.get('filter', False)] >>> _should_check_condition( conditions, 'data', 'hog', filter=True) True In the last example, the package check is performed because both conditions are met: the second positional argument equals 'hog', and the 'filter' keyword argument is `True`. """ def eval_condition(cond): # Callable condition with direct application if callable(cond): return cond(*args, **kwargs) # String condition indicating a key in kwargs elif isinstance(cond, str) and cond in kwargs: return bool(kwargs[cond]) # Tuple condition indicating positional argument check elif isinstance(cond, tuple) and len(cond) == 2: index, value = cond return index < len(args) and args[index] == value return False # Support for list of conditions: all must be True if isinstance(condition, list): return all(eval_condition(cond) for cond in condition) else: return eval_condition(condition)
[docs] def ensure_pkgs( names: str | list[str], extra: str = "", error: str = "raise", min_versions: str | list[str | None] | None = None, exception: Exception = None, dist_names: str | list[str | None] | None = None, infer_dist_name: bool = False, auto_install: bool = False, use_conda: bool = False, partial_check: bool = False, condition: Any = None, verbose: bool = False, ) -> Callable[[_T], _T]: """ Decorator to ensure Python packages are installed before function execution. If the specified packages are not installed, or if their installed versions do not meet the minimum version requirements, this decorator can optionally install or upgrade the packages automatically using either pip or conda. Parameters ---------- names : str or list of str The name(s) of the package(s). Can be a single string with package names separated by commas, or a list of package names. extra : str, optional Additional specification for the package(s), such as version or extras. error : {'raise', 'ignore', 'warn'}, optional Error handling strategy if a package is missing: 'raise', 'ignore', or 'warn'. Defaults to 'raise'. min_version : str or list of str, optional The minimum required version(s) of the package(s). If not met, triggers installation. Can be a single version string applied to all packages or a list matching the `names` list. exception : Exception, optional A custom exception to raise if a package is missing and `errors` is 'raise'. dist_name : str or list of str, optional The distribution name(s) of the package(s) as known by package managers (e.g., pip). Useful when the distribution name differs from the importable module name. Can be a single string or a list matching the `names` list. infer_dist_name : bool, optional If True, attempt to infer the distribution name for pip installation. Defaults to False. auto_install : bool, optional Whether to automatically install missing packages. Defaults to False. use_conda : bool, optional Prefer conda over pip for automatic installation. Defaults to False. partial_check : bool, optional If True, checks the existence of the packages only if the `condition` is met. Allows for conditional package checking based on the function's arguments or other criteria. If False, the check is always performed. Defaults to False. condition : Any, optional A condition that determines whether to check for the packages' existence. Can be a callable that takes the same arguments as the decorated function and returns a boolean, a specific argument name to check for truthiness, or any other value that will be evaluated as a boolean. If None, the package check is performed unconditionally unless `partial_check` is False. verbose : bool, optional Enable verbose output during the installation process. Defaults to False. Returns ------- Callable A decorator that wraps functions to ensure the specified packages are installed. Examples -------- >>> from geoprior.utils.deps_utils import ensure_pkgs >>> @ensure_pkgs("numpy, pandas", auto_install=True) ... def use_numpy_pandas(): ... import numpy as np ... import pandas as pd ... return np.array([1, 2, 3]), pd.DataFrame([[1, 2], [3, 4]]) >>> @ensure_pkgs(["matplotlib", "seaborn"], min_version=["3.0.0", "0.11.0"]) ... def plot_data(x, y): ... import matplotlib.pyplot as plt ... import seaborn as sns ... sns.scatterplot(x=x, y=y) ... plt.show() >>> @ensure_pkgs("skimage", partial_check=True, condition=( ... lambda *args, **kwargs: 'method' in kwargs and kwargs['method'] == 'hog') ... ) ... def process_image(data, method='hog', **kwargs): ... if method == 'hog': ... from skimage.feature import hog ... return hog(data, **kwargs) ... else: ... # Other processing ... pass """ def decorator(func: _T) -> _T: @functools.wraps(func) def wrapper(*args, **kwargs): # Determine if this is a method or a function based on the first argument bound_method = ( hasattr(args[0], func.__name__) if args else False ) # If partial_check is True, check condition before performing actions if not partial_check or _should_check_condition( condition, *args, **kwargs ): # Parse names into a list if isinstance(names, str): pkg_list = [ pkg.strip() for pkg in names.split(",") ] else: pkg_list = names # Ensure min_version and dist_name are lists matching pkg_list if isinstance(min_versions, str | type(None)): min_version_list = [min_versions] * len( pkg_list ) else: min_version_list = min_versions if isinstance(dist_names, str | type(None)): dist_name_list = [dist_names] * len( pkg_list ) else: dist_name_list = dist_names # Iterate over the packages for idx, pkg_name in enumerate(pkg_list): pkg_min_version = ( min_version_list[idx] if min_version_list else None ) pkg_dist_name = ( dist_name_list[idx] if dist_name_list else None ) try: # Attempt to import the package import_optional_dependency( pkg_name, extra=extra, errors=error, min_version=pkg_min_version, exception=exception, ) except (ModuleNotFoundError, ImportError): if auto_install: # Install the package if auto-install is enabled install_package( pkg_name, dist_name=pkg_dist_name, infer_dist_name=infer_dist_name, version=pkg_min_version, use_conda=use_conda, verbose=verbose, ) elif exception is not None: raise exception else: raise # Call the original function if bound_method: return func(args[0], *args[1:], **kwargs) else: return func(*args, **kwargs) return wrapper return decorator
[docs] def is_module_installed( module_name: str, distribution_name: str = None ) -> bool: """ Check if a Python module is installed by attempting to import it. Optionally, a distribution name can be provided if it differs from the module name. Parameters ---------- module_name : str The import name of the module to check. distribution_name : str, optional The distribution name of the package as known by package managers (e.g., pip). If provided and the module import fails, an additional check based on the distribution name is performed. This parameter is useful for packages where the distribution name differs from the importable module name. Returns ------- bool True if the module can be imported or the distribution package is installed, False otherwise. Examples -------- >>> is_module_installed("sklearn") True >>> is_module_installed("scikit-learn", "scikit-learn") True >>> is_module_installed("some_nonexistent_module") False """ if _try_import_module(module_name): return True if distribution_name and _check_distribution_installed( distribution_name ): return True return False
def _try_import_module(module_name: str) -> bool: """ Attempt to import a module by its name. Parameters ---------- module_name : str The import name of the module. Returns ------- bool True if the module can be imported, False otherwise. """ # import importlib.util # module_spec = importlib.util.find_spec(module_name) # return module_spec is not None import importlib try: importlib.import_module(module_name) return True except ImportError: return False def _check_distribution_installed( distribution_name: str, ) -> bool: """ Check if a distribution package is installed by its name. Parameters ---------- distribution_name : str The distribution name of the package. Returns ------- bool True if the distribution package is installed, False otherwise. """ try: # Prefer importlib.metadata for Python 3.8 and newer from importlib.metadata import distribution distribution(distribution_name) return True except ImportError: # Fallback to pkg_resources for older Python versions try: from pkg_resources import ( DistributionNotFound, get_distribution, ) get_distribution(distribution_name) return True except DistributionNotFound: return False except Exception: return False
[docs] def get_installation_name( module_name: str, distribution_name: str | None = None, return_bool: bool = False, ) -> str | bool: """ Determines the appropriate name for installing a package, considering potential discrepancies between the distribution name and the module import name. Optionally, returns a boolean indicating if the distribution name matches the import name. Parameters ---------- module_name : str The import name of the module. distribution_name : str, optional The distribution name of the package. If None, the function attempts to infer the distribution name from the module name. return_bool : bool, optional If True, returns a boolean indicating whether the distribution name matches the module import name. Otherwise, returns the name recommended for installation. Returns ------- Union[str, bool] Depending on `return_bool`, returns either a boolean indicating if the distribution name matches the module name, or the name (distribution or module) recommended for installation. """ inferred_name = _infer_distribution_name(module_name) # If a distribution name is provided, check if it matches the inferred name if distribution_name: if return_bool: return ( distribution_name.lower() == inferred_name.lower() ) return distribution_name # If no distribution name is provided, return the inferred name or module name if return_bool: return inferred_name.lower() == module_name.lower() return inferred_name or module_name
def _infer_distribution_name(module_name: str) -> str: """ Attempts to infer the distribution name of a package from its module name by querying the metadata of installed packages. Parameters ---------- module_name : str The import name of the module. Returns ------- str The inferred distribution name. If no specific inference is made, returns the module name. """ try: # Use importlib.metadata for Python 3.8+; use importlib_metadata for older versions from importlib.metadata import distributions except ImportError: from importlib_metadata import distributions # Loop through all installed distributions for distribution in distributions(): # Check if the module name matches the distribution name directly if module_name == distribution.metadata.get( "Name" ).replace("-", "_"): return distribution.metadata["Name"] # Safely attempt to read and split 'top_level.txt' top_level_txt = distribution.read_text( "top_level.txt" ) if top_level_txt: top_level_packages = top_level_txt.split() if any( module_name == pkg.split(".")[0] for pkg in top_level_packages ): return distribution.metadata["Name"] return module_name
[docs] def is_installing( module: str, upgrade: bool = True, action: bool = True, DEVNULL: bool = False, verbose: int = 0, **subpkws, ) -> bool: """Install or uninstall a module/package using the subprocess under the hood. Parameters ------------ module: str, the module or library name to install using Python Index Package `PIP` upgrade: bool, install the lastest version of the package. *default* is ``True``. DEVNULL:bool, decline the stdoutput the message in the console action: str,bool Action to perform. 'install' or 'uninstall' a package. *default* is ``True`` which means 'intall'. verbose: int, Optional Control the verbosity i.e output a message. High level means more messages. *default* is ``0``. subpkws: dict, additional subprocess keywords arguments Returns --------- success: bool whether the package is sucessfully installed or not. Example -------- >>> from gofast import is_installing >>> is_installing( 'tqdm', action ='install', DEVNULL=True, verbose =1) >>> is_installing( 'tqdm', action ='uninstall', verbose =1) """ # implement pip as subprocess # refer to https://pythongeeks.org/subprocess-in-python/ if not action: if verbose > 0: print( "---> No action `install`or `uninstall`" f" of the module {module!r} performed." ) return action # DO NOTHING success = False action_msg = ( "uninstallation" if action == "uninstall" else "installation" ) if ( action in ("install", "uninstall", True) and verbose > 0 ): print( f"---> Module {module!r} {action_msg} will take a while," " please be patient..." ) cmdg = ( f"<pip install {module}> | <python -m pip install {module}>" if action in (True, "install") else "".join( [ f"<pip uninstall {module} -y> or <pip3 uninstall {module} -y ", f"or <python -m pip uninstall {module} -y>.", ] ) ) upgrade = "--upgrade" if upgrade else "" if action == "uninstall": upgrade = "-y" # Don't ask for confirmation of uninstall deletions. elif action in ("install", True): action = "install" cmd = [ "-m", "pip", f"{action}", f"{module}", f"{upgrade}", ] try: STDOUT = subprocess.DEVNULL if DEVNULL else None STDERR = subprocess.STDOUT if DEVNULL else None subprocess.check_call( [sys.executable] + cmd, stdout=STDOUT, stderr=STDERR, **subpkws, ) if action in (True, "install"): # freeze the dependancies reqs = subprocess.check_output( [sys.executable, "-m", "pip", "freeze"] ) [r.decode().split("==")[0] for r in reqs.split()] success = True except: if verbose > 0: print( f"---> Module {module!r} {action_msg} failed. Please use" f" the following command: {cmdg} to manually do it." ) else: if verbose > 0: print( f"{action_msg.capitalize()} of `{module}` " "and dependancies was successfully done!" ) return success