Source code for scitex_dev._core.imports

"""Optional-import helper for SciTeX standalone packages.

Use in a `__init__.py` to replace inline `try/except ImportError` blocks:

    from scitex_dev import try_import_optional

    h5py = try_import_optional("h5py", extra="hdf5", pkg="scitex-io")
    ndarray = try_import_optional("numpy", attr="ndarray")  # always installed
    rel_mod = try_import_optional(".sub.thing", package="scitex_io")

Returns the imported object on success and ``None`` on ``ImportError``.
The original failure (module name + extra/pkg metadata) is recorded on the
returned ``None`` is impossible — instead, callers can read the registry via
``last_install_hint(name)`` to construct precise error messages at the
use-site without re-raising.

The `NotInstalled` sentinel proposal (see python-api skill TODO.md) is
deferred — Pattern A (`X = None` always present in `__all__`) plus a
companion `XXX_AVAILABLE = X is not None` flag covers all current use cases.
"""

from __future__ import annotations

import importlib
from dataclasses import dataclass
from typing import Any

__all__ = ["try_import_optional", "last_install_hint", "InstallHint"]


[docs] @dataclass(frozen=True) class InstallHint: """Metadata to help a caller surface a useful install message.""" module: str extra: str | None pkg: str | None # The REAL exception text when the dependency is PRESENT but failed to # import cleanly (e.g. a numpy-ABI-broken C-extension). None for a plain # "module absent" miss. Preserved so a degraded import stays diagnosable # instead of being silently masked. cause: str | None = None def message(self) -> str: if self.pkg and self.extra: base = ( f"`{self.module}` is required. " f"Install with: pip install '{self.pkg}[{self.extra}]'" ) elif self.extra: base = ( f"`{self.module}` is required. " f"Re-install with the '{self.extra}' extra." ) else: base = ( f"`{self.module}` is required. " f"Install with: pip install {self.module}" ) if self.cause: base += f"\n underlying import error: {self.cause}" return base
_HINTS: dict[str, InstallHint] = {}
[docs] def last_install_hint(name: str) -> InstallHint | None: """Return the most recently recorded install hint for ``name`` (or ``None``).""" return _HINTS.get(name)
_NUMPY_ABI_SIGNATURES = ("_ARRAY_API not found", "Failed to initialize NumPy") def _numpy_abi_cause(exc: BaseException) -> str | None: """Return an actionable hint if ``exc`` looks like a numpy ABI mismatch. A C-extension (torch, etc.) built against a different numpy MAJOR fails to import with a non-``ImportError`` (often ``RuntimeError``) whose text carries numpy's array-API signature — the SAME string numpy emits in its ``UserWarning``. Matching the RAISED exception text catches ANY numpy-ABI- broken extension without a per-package functional probe. Returns ``None`` when ``exc`` carries no such signature. """ text = str(exc) if not any(sig in text for sig in _NUMPY_ABI_SIGNATURES): return None try: import numpy as _np npv = _np.__version__ except Exception: # noqa: BLE001 — numpy itself absent/broken npv = "unknown" return ( f"{type(exc).__name__}: {exc} -- likely a numpy ABI mismatch " f"(installed numpy {npv}; the C-extension was built for a different " f"numpy major). Reinstall the extension with a build matching this " f"numpy (e.g. a numpy-2-compatible wheel)." )
[docs] def try_import_optional( module_path: str, attr: str | None = None, *, extra: str | None = None, pkg: str | None = None, package: str | None = None, ) -> Any: """Import ``module_path``; return ``None`` on ANY import failure. A dependency that is absent (``ImportError``) OR present-but-broken (any other exception at import time, e.g. a numpy-ABI-incompatible C-extension raising ``RuntimeError``) degrades to ``None`` rather than propagating. The real failure is recorded on the install hint (``cause``) so it stays diagnosable via :func:`last_install_hint`. Parameters ---------- module_path : str Module to import. Leading ``.`` triggers relative resolution against ``package`` (mirrors :func:`importlib.import_module`). attr : str, optional If given, return ``getattr(module, attr)`` instead of the module. A missing attribute is treated identically to a failed import. extra : str, optional Name of the pip extra that pulls this dependency (used for the install hint surfaced via :func:`last_install_hint`). pkg : str, optional Distribution name owning the extra (e.g. ``"scitex-io"``). package : str, optional Anchor for relative imports. Returns ------- object or None The imported module/attribute, or ``None`` on failure. """ if module_path.startswith(".") and package is None: raise ValueError( "relative imports require an explicit `package=` argument " f"(got module_path={module_path!r})" ) try: mod = importlib.import_module(module_path, package=package) except ImportError as exc: # Dependency genuinely absent / unresolved — the normal optional-miss # path. Record an ABI cause only if the text carries the signature # (a plain "No module named X" leaves cause=None, message unchanged). _HINTS[module_path] = InstallHint( module=module_path, extra=extra, pkg=pkg, cause=_numpy_abi_cause(exc) ) return None except Exception as exc: # noqa: BLE001 # The dependency IS installed but fails to import CLEANLY (e.g. a torch # built for numpy<2 raising RuntimeError under numpy>=2). Without this # branch the RuntimeError escapes and crashes EVERY feature that lazily # touches the dep (neurovista: stx.io.save via a stack->getmodule import # of torch). Degrade to None like any optional miss, but RECORD the real # exception so the failure is diagnosable via last_install_hint() — never # silently masked. _HINTS[module_path] = InstallHint( module=module_path, extra=extra, pkg=pkg, cause=_numpy_abi_cause(exc) or f"{type(exc).__name__}: {exc}", ) return None if attr is None: return mod try: return getattr(mod, attr) except AttributeError: _HINTS[module_path] = InstallHint(module=module_path, extra=extra, pkg=pkg) return None