"""
Optional dependencies management for the Tracker Component Library.
This module provides a unified system for handling optional dependencies,
including lazy imports, availability checks, and helpful error messages.
The system supports:
- Lazy imports that only load modules when accessed
- Availability flags for conditional code paths
- Decorators for functions requiring optional dependencies
- Helpful error messages with installation instructions
Examples
--------
Check if a dependency is available:
>>> from pytcl.core.optional_deps import is_available
>>> if is_available("plotly"):
... import plotly.graph_objects as go
Use a decorator to require a dependency:
>>> from pytcl.core.optional_deps import requires
>>> @requires("plotly", extra="visualization")
... def create_3d_plot(data):
... import plotly.graph_objects as go
... return go.Figure(data)
Import with a helpful error on failure:
>>> from pytcl.core.optional_deps import import_optional
>>> go = import_optional("plotly.graph_objects", package="plotly", extra="visualization")
"""
import importlib
import logging
from functools import wraps
from types import ModuleType
from typing import Any, Callable, Optional, TypeVar
from pytcl.core.exceptions import DependencyError
# Module logger
_logger = logging.getLogger("pytcl.core.optional_deps")
# Type variable for generic function signatures
F = TypeVar("F", bound=Callable[..., Any])
# =============================================================================
# Package Configuration
# =============================================================================
# Mapping of package names to their pip install extras
# Format: package_name -> (extra_name, pip_package_name)
PACKAGE_EXTRAS: dict[str, tuple[str, str]] = {
# Visualization
"plotly": ("visualization", "plotly"),
# Astronomy
"astropy": ("astronomy", "astropy"),
"jplephem": ("astronomy", "jplephem"),
# Geodesy
"pyproj": ("geodesy", "pyproj"),
"geographiclib": ("geodesy", "geographiclib"),
# Optimization
"cvxpy": ("optimization", "cvxpy"),
# Signal processing
"pywt": ("signal", "pywavelets"),
"pywavelets": ("signal", "pywavelets"),
# Terrain data
"netCDF4": ("terrain", "netCDF4"),
# GPU acceleration
"cupy": ("gpu", "cupy-cuda12x"),
# Apple Silicon GPU acceleration
"mlx": ("gpu-apple", "mlx"),
}
# Friendly names for features provided by each package
PACKAGE_FEATURES: dict[str, str] = {
"plotly": "interactive visualization",
"astropy": "astronomical calculations",
"jplephem": "JPL ephemeris access",
"pyproj": "coordinate transformations",
"geographiclib": "geodetic calculations",
"cvxpy": "convex optimization",
"pywt": "wavelet transforms",
"pywavelets": "wavelet transforms",
"netCDF4": "NetCDF file reading",
"cupy": "GPU acceleration",
"mlx": "Apple Silicon GPU acceleration",
}
# =============================================================================
# Availability Cache
# =============================================================================
# Cache of package availability checks
_availability_cache: dict[str, bool] = {}
def _clear_cache() -> None:
"""Clear the availability cache. Mainly for testing."""
_availability_cache.clear()
[docs]
def is_available(package: str) -> bool:
"""
Check if an optional package is available.
Parameters
----------
package : str
Name of the package to check (e.g., "plotly", "pywt").
Returns
-------
bool
True if the package is installed and can be imported.
Examples
--------
>>> from pytcl.core.optional_deps import is_available
>>> if is_available("plotly"):
... from plotly import graph_objects as go
... # use plotly
... else:
... print("Plotly not available")
Notes
-----
Results are cached for performance. Use ``_clear_cache()`` if you
need to re-check availability (e.g., after installing a package).
"""
if package in _availability_cache:
return _availability_cache[package]
try:
importlib.import_module(package)
available = True
_logger.debug("Optional package '%s' is available", package)
except ImportError:
available = False
_logger.debug("Optional package '%s' is not available", package)
_availability_cache[package] = available
return available
# =============================================================================
# Import Helpers
# =============================================================================
def _get_install_command(package: str, extra: Optional[str] = None) -> str:
"""Generate the pip install command for a package."""
if extra:
return f"pip install pytcl[{extra}]"
# Check if we know the extra for this package
if package in PACKAGE_EXTRAS:
extra_name, _ = PACKAGE_EXTRAS[package]
return f"pip install pytcl[{extra_name}]"
# Default to direct package install
pip_package = package
if package in PACKAGE_EXTRAS:
_, pip_package = PACKAGE_EXTRAS[package]
return f"pip install {pip_package}"
def _get_feature_name(package: str) -> str:
"""Get a friendly feature name for a package."""
return PACKAGE_FEATURES.get(package, f"{package} functionality")
[docs]
def import_optional(
module_name: str,
*,
package: Optional[str] = None,
extra: Optional[str] = None,
feature: Optional[str] = None,
) -> ModuleType:
"""
Import an optional module with a helpful error message on failure.
Parameters
----------
module_name : str
Full module path to import (e.g., "plotly.graph_objects").
package : str, optional
Package name for error message. If not provided, extracted from
module_name.
extra : str, optional
Name of the pytcl extra that provides this dependency
(e.g., "visualization", "astronomy").
feature : str, optional
Description of the feature requiring this dependency.
Returns
-------
module : ModuleType
The imported module.
Raises
------
DependencyError
If the module cannot be imported.
Examples
--------
>>> go = import_optional(
... "plotly.graph_objects",
... package="plotly",
... extra="visualization",
... feature="3D plotting"
... )
"""
if package is None:
package = module_name.split(".")[0]
try:
module = importlib.import_module(module_name)
_logger.debug("Successfully imported optional module '%s'", module_name)
return module
except ImportError as e:
if feature is None:
feature = _get_feature_name(package)
install_cmd = _get_install_command(package, extra)
msg = f"{package} is required for {feature}. " f"Install with: {install_cmd}"
_logger.warning("Failed to import optional module '%s': %s", module_name, e)
raise DependencyError(
msg,
package=package,
feature=feature,
install_command=install_cmd,
) from e
# =============================================================================
# Decorator for Optional Dependencies
# =============================================================================
[docs]
def requires(
*packages: str,
extra: Optional[str] = None,
feature: Optional[str] = None,
) -> Callable[[F], F]:
"""
Decorator to mark a function as requiring optional dependencies.
When the decorated function is called, it checks if the required
packages are available. If not, it raises a DependencyError with
a helpful message.
Parameters
----------
*packages : str
One or more package names required by the function.
extra : str, optional
Name of the pytcl extra that provides these dependencies.
feature : str, optional
Description of the feature provided by the function.
Returns
-------
decorator : callable
Decorator that wraps the function with dependency checking.
Examples
--------
>>> from pytcl.core.optional_deps import requires
>>>
>>> @requires("plotly", extra="visualization")
... def create_plot(data):
... import plotly.graph_objects as go
... return go.Figure(data)
>>>
>>> # This will raise DependencyError if plotly is not installed
>>> create_plot([1, 2, 3])
Multiple packages:
>>> @requires("astropy", "jplephem", extra="astronomy")
... def compute_ephemeris(body, time):
... from astropy.time import Time
... import jplephem
... # ...
Notes
-----
The decorator checks availability at call time, not at definition
time. This allows the module to be imported even if the optional
dependencies are not installed.
"""
def decorator(func: F) -> F:
@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
missing = [pkg for pkg in packages if not is_available(pkg)]
if missing:
# Get feature name from first package if not provided
feat = feature or _get_feature_name(missing[0])
if len(missing) == 1:
pkg_str = missing[0]
install_cmd = _get_install_command(missing[0], extra)
else:
pkg_str = ", ".join(missing)
install_cmd = _get_install_command(missing[0], extra)
msg = (
f"{pkg_str} {'is' if len(missing) == 1 else 'are'} required "
f"for {feat}. Install with: {install_cmd}"
)
raise DependencyError(
msg,
package=missing[0],
feature=feat,
install_command=install_cmd,
)
return func(*args, **kwargs)
return wrapper
return decorator
# =============================================================================
# Availability Flags (for backward compatibility)
# =============================================================================
# These flags are computed lazily when first accessed
class _AvailabilityFlags:
"""Lazy availability flags for common optional packages."""
@property
def HAS_PLOTLY(self) -> bool:
"""True if plotly is available."""
return is_available("plotly")
@property
def HAS_PYWT(self) -> bool:
"""True if pywavelets is available."""
return is_available("pywt")
@property
def PYWT_AVAILABLE(self) -> bool:
"""True if pywavelets is available (alias)."""
return is_available("pywt")
@property
def HAS_JPLEPHEM(self) -> bool:
"""True if jplephem is available."""
return is_available("jplephem")
@property
def HAS_ASTROPY(self) -> bool:
"""True if astropy is available."""
return is_available("astropy")
@property
def HAS_PYPROJ(self) -> bool:
"""True if pyproj is available."""
return is_available("pyproj")
@property
def HAS_CVXPY(self) -> bool:
"""True if cvxpy is available."""
return is_available("cvxpy")
@property
def HAS_NETCDF4(self) -> bool:
"""True if netCDF4 is available."""
return is_available("netCDF4")
@property
def HAS_CUPY(self) -> bool:
"""True if cupy is available."""
return is_available("cupy")
@property
def HAS_MLX(self) -> bool:
"""True if mlx is available (Apple Silicon)."""
return is_available("mlx")
# Create singleton instance
_flags = _AvailabilityFlags()
# Export individual flags for convenient access
HAS_PLOTLY = property(lambda self: _flags.HAS_PLOTLY)
HAS_PYWT = property(lambda self: _flags.HAS_PYWT)
PYWT_AVAILABLE = property(lambda self: _flags.PYWT_AVAILABLE)
HAS_JPLEPHEM = property(lambda self: _flags.HAS_JPLEPHEM)
HAS_ASTROPY = property(lambda self: _flags.HAS_ASTROPY)
HAS_PYPROJ = property(lambda self: _flags.HAS_PYPROJ)
HAS_CVXPY = property(lambda self: _flags.HAS_CVXPY)
HAS_NETCDF4 = property(lambda self: _flags.HAS_NETCDF4)
HAS_CUPY = property(lambda self: _flags.HAS_CUPY)
HAS_MLX = property(lambda self: _flags.HAS_MLX)
# =============================================================================
# Lazy Module Loader
# =============================================================================
[docs]
class LazyModule:
"""
A lazy module loader that imports the module on first access.
This allows optional dependencies to be "imported" at module level
without triggering an import error until they're actually used.
Parameters
----------
module_name : str
Full module path to import.
package : str, optional
Package name for error messages.
extra : str, optional
pytcl extra that provides this dependency.
feature : str, optional
Feature description for error messages.
Examples
--------
>>> from pytcl.core.optional_deps import LazyModule
>>> go = LazyModule("plotly.graph_objects", package="plotly")
>>> # No import yet...
>>> fig = go.Figure() # Import happens here
"""
[docs]
def __init__(
self,
module_name: str,
*,
package: Optional[str] = None,
extra: Optional[str] = None,
feature: Optional[str] = None,
):
self._module_name = module_name
self._package = package or module_name.split(".")[0]
self._extra = extra
self._feature = feature
self._module: Optional[ModuleType] = None
def _load(self) -> ModuleType:
"""Load the module if not already loaded."""
if self._module is None:
self._module = import_optional(
self._module_name,
package=self._package,
extra=self._extra,
feature=self._feature,
)
return self._module
[docs]
def __getattr__(self, name: str) -> Any:
"""Delegate attribute access to the loaded module."""
module = self._load()
return getattr(module, name)
[docs]
def __dir__(self) -> list[str]:
"""Return module attributes for tab completion."""
try:
module = self._load()
return dir(module)
except DependencyError:
return []
# =============================================================================
# Convenience Functions
# =============================================================================
[docs]
def check_dependencies(*packages: str, extra: Optional[str] = None) -> None:
"""
Check that all required packages are available.
Parameters
----------
*packages : str
Package names to check.
extra : str, optional
pytcl extra for installation hint.
Raises
------
DependencyError
If any package is not available.
Examples
--------
>>> from pytcl.core.optional_deps import check_dependencies
>>> check_dependencies("plotly", extra="visualization")
>>> # Raises DependencyError if plotly is not installed
"""
missing = [pkg for pkg in packages if not is_available(pkg)]
if missing:
feature = _get_feature_name(missing[0])
install_cmd = _get_install_command(missing[0], extra)
if len(missing) == 1:
msg = f"{missing[0]} is required. Install with: {install_cmd}"
else:
msg = f"{', '.join(missing)} are required. Install with: {install_cmd}"
raise DependencyError(
msg,
package=missing[0],
feature=feature,
install_command=install_cmd,
)
__all__ = [
# Core functions
"is_available",
"import_optional",
"requires",
"check_dependencies",
# Lazy loading
"LazyModule",
# Configuration
"PACKAGE_EXTRAS",
"PACKAGE_FEATURES",
# Availability flags (backward compatibility)
"HAS_PLOTLY",
"HAS_PYWT",
"PYWT_AVAILABLE",
"HAS_JPLEPHEM",
"HAS_ASTROPY",
"HAS_PYPROJ",
"HAS_CVXPY",
"HAS_NETCDF4",
"HAS_CUPY",
"HAS_MLX",
# Internal (for testing)
"_clear_cache",
"_flags",
]