"""This module contains functions to assess compatibility and optional dependencies."""
from __future__ import annotations
import importlib
import shutil
import sys
import types
import warnings
from packaging.version import parse as parse_version
__all__ = ["check_for_optional_program", "import_optional_dependency"]
_MINIMUM_VERSIONS: dict[str, str] = {}
"""Dict[str, str]: A mapping from packages to their minimum versions."""
_IMPORT_TO_PACKAGE_NAME: dict[str, str] = {}
"""Dict[str, str]: A mapping from import name to package name (on PyPI) for packages
where these two names are different."""
def _get_version(module: types.ModuleType) -> str:
"""Get version from a package."""
version = getattr(module, "__version__", None)
if version is None:
raise ImportError(f"Can't determine version for {module.__name__}")
return version
[docs]
def import_optional_dependency(
name: str,
extra: str = "",
errors: str = "raise",
min_version: str | None = None,
caller: str = "pytask",
) -> types.ModuleType | None:
"""Import an optional dependency.
By default, if a dependency is missing an ImportError with a nice message will be
raised. If a dependency is present, but too old, we raise.
Parameters
----------
name
The module name.
extra
Additional text to include in the ImportError message.
errors
What to do when a dependency is not found or its version is too old.
* raise : Raise an ImportError
* warn : Only applicable when a module's version is to old. Warns that the
version is too old and returns None
* ignore: If the module is not installed, return None, otherwise, return the
module, even if the version is too old. It's expected that users validate the
version locally when using ``errors="ignore"`` (see. ``io/html.py``)
min_version
Specify a minimum version that is different from the global pandas minimum
version required.
caller
The caller of the function.
Returns
-------
types.ModuleType | None
The imported module, when found and the version is correct. None is returned
when the package is not found and `errors` is False, or when the package's
version is too old and `errors` is ``'warn'``.
"""
if errors not in ("warn", "raise", "ignore"): # pragma: no cover
raise ValueError("'errors' must be one of 'warn', 'raise' or 'ignore'.")
package_name = _IMPORT_TO_PACKAGE_NAME.get(name)
install_name = package_name if package_name is not None else name
if extra and not extra.endswith(" "):
extra += " "
msg = (
f"{caller} requires the optional dependency {install_name!r}. {extra}"
f"Use pip or conda to install {install_name!r}."
)
try:
module = importlib.import_module(name)
except ImportError:
if errors == "raise":
raise ImportError(msg) from None
return None
# Handle submodules: if we have submodule, grab parent module from sys.modules
parent = name.split(".")[0]
if parent != name:
install_name = parent
module_to_get = sys.modules[install_name]
else:
module_to_get = module
minimum_version = (
min_version if min_version is not None else _MINIMUM_VERSIONS.get(parent)
)
if minimum_version:
version = _get_version(module_to_get)
if parse_version(version) < parse_version(minimum_version):
msg = (
f"{caller} requires version {minimum_version!r} or newer of "
f"{parent!r} (version {version!r} currently installed)."
)
if errors == "warn":
warnings.warn(msg, UserWarning, stacklevel=2)
return None
if errors == "raise":
raise ImportError(msg)
return module
[docs]
def check_for_optional_program(
name: str,
extra: str = "",
errors: str = "raise",
caller: str = "pytask",
) -> bool | None:
"""Check whether an optional program exists."""
if errors not in ("warn", "raise", "ignore"):
raise ValueError(
f"'errors' must be one of 'warn', 'raise' or 'ignore' and not {errors!r}."
)
msg = f"{caller} requires the optional program {name!r}. {extra}"
program_exists = shutil.which(name) is not None
if not program_exists:
if errors == "raise":
raise RuntimeError(msg)
if errors == "warn":
warnings.warn(msg, UserWarning, stacklevel=2)
return program_exists