Source code for spatial_smooth._deps

"""Lazy, actionable dependency handling.

Heavy or optional third-party packages are never imported at module import time.
:func:`require` imports on demand and, when the package is absent, raises an
``ImportError`` that names the package, says what it is needed for, and gives the
exact ``pip install`` line.
"""
from __future__ import annotations

import importlib
from typing import Dict, Optional, Tuple

__all__ = ["require", "have", "check_dependencies", "DEPENDENCIES", "OPTIONAL"]

#: ``package -> (import name, pip target, what it is used for)``
DEPENDENCIES: Dict[str, Tuple[str, str, str]] = {
    "numpy": ("numpy", "numpy>=1.23", "core arrays (required)"),
    "pandas": ("pandas", "pandas>=1.5", "core tables (required)"),
    "scipy": ("scipy", "scipy>=1.10", "kd-tree neighbours + grid interpolation (required)"),
    "anndata": ("anndata", "anndata>=0.9", "the AnnData container (required)"),
    "kompot": ("kompot", "kompot>=0.7.0", "GP smoothing over a cell-state embedding (step `KompotGP`)"),
    "palantir": ("palantir", "palantir>=1.4", "diffusion-map embedding (`compute_diffusion_map`)"),
    "scanpy": ("scanpy", "scanpy>=1.9", "plotting backend `scanpy` (`spatial_smooth.pl`)"),
    "squidpy": ("squidpy", "squidpy>=1.4", "plotting backend `squidpy` (`spatial_smooth.pl`)"),
    "KDEpy": ("KDEpy", "KDEpy>=1.1", "fine-grid FFT-KDE smoother (step `Kde`)"),
    "matplotlib": ("matplotlib", "matplotlib>=3.6", "figure canvas for the plotting backends"),
}

#: Dependencies whose absence is not an error until the feature that needs them is used.
OPTIONAL = frozenset({"kompot", "palantir", "scanpy", "squidpy", "KDEpy", "matplotlib"})


def require(pkg: str):
    """Import ``pkg``, or raise an ``ImportError`` that says exactly how to fix it.

    Parameters
    ----------
    pkg
        Key into :data:`DEPENDENCIES`.

    Raises
    ------
    ImportError
        With the package name, its purpose, and the ``pip install`` line.
    """
    try:
        import_name, pip_target, purpose = DEPENDENCIES[pkg]
    except KeyError:  # pragma: no cover - programming error
        raise KeyError(f"{pkg!r} is not a known spatial_smooth dependency") from None
    try:
        return importlib.import_module(import_name)
    except ImportError as exc:
        raise ImportError(
            f"spatial_smooth needs `{pkg}` for: {purpose}.\n"
            f'    Install it with:  pip install "{pip_target}"\n'
            f"(original error: {exc})"
        ) from exc


def _version_of(module, import_name: str) -> str:
    """Distribution version, preferring the metadata over a (sometimes deprecated) attribute."""
    from importlib.metadata import PackageNotFoundError, version

    try:
        return version(import_name)
    except PackageNotFoundError:
        return str(getattr(module, "__version__", "?"))


def have(pkg: str) -> bool:
    """``True`` if ``pkg`` can be imported right now (never raises)."""
    import_name = DEPENDENCIES[pkg][0]
    try:
        importlib.import_module(import_name)
    except ImportError:
        return False
    return True


[docs] def check_dependencies(verbose: bool = True) -> Dict[str, Optional[str]]: """Report which dependencies are importable and at which version. Prints a table and returns ``{package: version or None}``. Call it at the top of a script or notebook to surface gaps *before* the analysis starts, each with the exact ``pip install`` line that fixes it. """ from . import __version__ status: Dict[str, Optional[str]] = {} rows = [] for pkg, (import_name, pip_target, purpose) in DEPENDENCIES.items(): try: mod = importlib.import_module(import_name) ver = _version_of(mod, import_name) status[pkg] = ver rows.append((pkg, ver, "ok", purpose)) except ImportError: status[pkg] = None state = f'missing: pip install "{pip_target}"' rows.append((pkg, "-", state, purpose)) if verbose: width = max(len(r[0]) for r in rows) print(f"spatial_smooth v{__version__} -- dependency check") for pkg, ver, state, _purpose in rows: tag = "" if pkg not in OPTIONAL else " (optional)" print(f" {pkg:<{width}} {ver:<10} {state}{tag}") missing_required = [p for p, v in status.items() if v is None and p not in OPTIONAL] missing_optional = [p for p, v in status.items() if v is None and p in OPTIONAL] print() if missing_required: print(f" Required packages missing: {', '.join(missing_required)}.") elif missing_optional: print(f" All required packages present (optional missing: {', '.join(missing_optional)}).") else: print(" All dependencies present.") return status