"""Process-level runtime cache settings for BCMP."""
from __future__ import annotations
import os
from pathlib import Path
import sys
from threading import Lock
import warnings
from typing import Any, Literal
__all__ = ["set_cache_dir", "cache_info"]
CacheSource = Literal[
"api", "environment", "mixed", "partial_environment", "unconfigured"
]
_NUMBA_ENV = "NUMBA_CACHE_DIR"
_MPL_ENV = "MPLCONFIGDIR"
_XDG_ENV = "XDG_CACHE_HOME"
_ENV_KEYS = (_NUMBA_ENV, _MPL_ENV, _XDG_ENV)
_PRIMARY_ENV_KEYS = (_NUMBA_ENV, _MPL_ENV)
_INFO_FIELDS = {
_NUMBA_ENV: "numba_cache_dir",
_MPL_ENV: "matplotlib_config_dir",
_XDG_ENV: "xdg_cache_home",
}
_CACHE_SUBDIRS = {
_NUMBA_ENV: "bcmp_numba_cache",
_MPL_ENV: "bcmp_mpl_config",
_XDG_ENV: "bcmp_xdg_cache",
}
_SENSITIVE_MODULES = {
_NUMBA_ENV: ("numba", "umap"),
_MPL_ENV: ("matplotlib",),
_XDG_ENV: ("matplotlib",),
}
_CACHE_LOCK = Lock()
_CONFIG: dict[str, Any] | None = None
_CONFIG_REQUESTED_BASE: Path | None = None
def _info_matches_desired(info: dict[str, Any], desired_paths: dict[str, Path]) -> bool:
for key in _ENV_KEYS:
configured_path = info.get(_INFO_FIELDS[key])
if (
configured_path is None
or _resolve_path(str(configured_path)) != desired_paths[key]
):
return False
return True
def _raise_conflicting_cache_dir(
*,
requested_base: Path,
requested_paths: dict[str, Path],
current_info: dict[str, Any],
) -> None:
expected = ", ".join(f"{key}={str(requested_paths[key])!r}" for key in _ENV_KEYS)
current = ", ".join(
f"{key}={current_info.get(_INFO_FIELDS[key])!r}" for key in _ENV_KEYS
)
raise RuntimeError(
"Conflict detected in BCMP runtime cache configuration. "
f"set_cache_dir({str(requested_base)!r}) expects {expected}, "
f"but the current process has {current}. "
"To safely isolate BCMP, clear the existing shell environment first, "
f"or call set_cache_dir({str(requested_base)!r}, force=True) before "
"importing numba, umap, or matplotlib."
)
def _resolve_path(path: str | Path) -> Path:
return Path(path).expanduser().resolve()
def _path_from_env(key: str) -> Path | None:
raw = os.environ.get(key)
if raw is None or not str(raw).strip():
return None
return _resolve_path(raw)
def _cache_paths(base_dir: str | Path) -> dict[str, Path]:
base = _resolve_path(base_dir)
return {"base_dir": base, **{key: base / _CACHE_SUBDIRS[key] for key in _ENV_KEYS}}
def _infer_base_dir(cache_paths: dict[str, Path | None]) -> Path | None:
if any(cache_paths[key] is None for key in _ENV_KEYS):
return None
parents: set[Path] = set()
for key in _ENV_KEYS:
path = cache_paths[key]
if path is None or path.name != _CACHE_SUBDIRS[key]:
return None
parents.add(path.parent)
return parents.pop() if len(parents) == 1 else None
def _loaded_sensitive_modules(key: str) -> list[str]:
loaded: list[str] = []
for module_name in _SENSITIVE_MODULES[key]:
if module_name in sys.modules:
loaded.append(module_name)
return loaded
def _require_safe_to_write(keys: list[str]) -> None:
blocked = {
key: loaded for key in keys if (loaded := _loaded_sensitive_modules(key))
}
if not blocked:
return
details = "; ".join(
f"{key} after modules already loaded: {', '.join(modules)}"
for key, modules in blocked.items()
)
raise RuntimeError(
"BCMP runtime cache environment must be configured before importing "
f"cache-sensitive runtime modules ({details})"
)
def _ensure_dirs(paths: list[Path]) -> None:
for path in paths:
try:
path.mkdir(parents=True, exist_ok=True)
except OSError as exc:
raise RuntimeError(
"Unable to create BCMP runtime cache directory "
f"{path}. Check path permissions, disk quota, and available space."
) from exc
def _warn_mixed_cache_config(
*,
requested_base: Path,
final_paths: dict[str, Path | None],
keys_to_write: list[str],
) -> None:
external_parts: list[str] = []
routed_parts: list[str] = []
for key in _ENV_KEYS:
path = final_paths[key]
if path is None:
continue
if key in keys_to_write:
routed_parts.append(f"{key}={str(path)!r}")
else:
external_parts.append(f"{key}={str(path)!r}")
warnings.warn(
"External runtime cache environment detected: "
f"{'; '.join(external_parts)}. "
"Only the missing cache variables were routed under requested "
f"cache_dir {str(requested_base)!r}: {'; '.join(routed_parts)}. "
"Use force=True to override all BCMP runtime cache paths.",
UserWarning,
stacklevel=3,
)
def _build_info(
*,
source: CacheSource,
cache_paths: dict[str, Path | None],
base_dir: Path | None = None,
) -> dict[str, Any]:
inferred_base = base_dir if base_dir is not None else _infer_base_dir(cache_paths)
return {
"configured": all(cache_paths[key] is not None for key in _ENV_KEYS),
"source": source,
"base_dir": None if inferred_base is None else str(inferred_base),
**{
_INFO_FIELDS[key]: None
if cache_paths[key] is None
else str(cache_paths[key])
for key in _ENV_KEYS
},
}
def _environment_info() -> dict[str, Any]:
cache_paths = {key: _path_from_env(key) for key in _ENV_KEYS}
configured_count = sum(cache_paths[key] is not None for key in _ENV_KEYS)
if configured_count == len(_ENV_KEYS):
source: CacheSource = "environment"
elif configured_count:
source = "partial_environment"
else:
source = "unconfigured"
return _build_info(source=source, cache_paths=cache_paths)
[docs]
def set_cache_dir(cache_dir: str | Path, *, force: bool = False) -> dict[str, Any]:
"""Configure BCMP runtime caches for the current process.
Existing shell-provided ``NUMBA_CACHE_DIR``, ``MPLCONFIGDIR``, and
``XDG_CACHE_HOME`` values are respected by default. Pass ``force=True`` to
replace them.
"""
global _CONFIG, _CONFIG_REQUESTED_BASE
desired = _cache_paths(cache_dir)
desired_base = desired["base_dir"]
desired_paths = {key: desired[key] for key in _ENV_KEYS}
with _CACHE_LOCK:
env_paths = {key: _path_from_env(key) for key in _ENV_KEYS}
if not force and _CONFIG is not None:
env_matches_config = True
for key in _ENV_KEYS:
config_path = _CONFIG.get(_INFO_FIELDS[key])
if config_path is None or env_paths[key] != _resolve_path(
str(config_path)
):
env_matches_config = False
break
if env_matches_config:
if _CONFIG_REQUESTED_BASE == desired_base:
return dict(_CONFIG)
_raise_conflicting_cache_dir(
requested_base=desired_base,
requested_paths=desired_paths,
current_info=_CONFIG,
)
if not force and all(env_paths[key] is not None for key in _ENV_KEYS):
info = _build_info(source="environment", cache_paths=env_paths)
if not _info_matches_desired(info, desired_paths):
_raise_conflicting_cache_dir(
requested_base=desired_base,
requested_paths=desired_paths,
current_info=info,
)
_ensure_dirs([path for path in env_paths.values() if path is not None])
_CONFIG = dict(info)
_CONFIG_REQUESTED_BASE = desired_base
return dict(info)
if not force and all(env_paths[key] is not None for key in _PRIMARY_ENV_KEYS):
primary_paths_match_request = all(
env_paths[key] == desired_paths[key] for key in _PRIMARY_ENV_KEYS
)
if not primary_paths_match_request:
info = _build_info(source="partial_environment", cache_paths=env_paths)
_raise_conflicting_cache_dir(
requested_base=desired_base,
requested_paths=desired_paths,
current_info=info,
)
keys_to_write: list[str] = []
final_paths = dict(env_paths)
source: CacheSource
if force:
final_paths = dict(desired_paths)
keys_to_write = list(_ENV_KEYS)
source = "api"
else:
for key in _ENV_KEYS:
if final_paths[key] is None:
final_paths[key] = desired_paths[key]
keys_to_write.append(key)
source = (
"api"
if all(final_paths[key] == desired_paths[key] for key in _ENV_KEYS)
else "mixed"
)
_require_safe_to_write(keys_to_write)
if source == "mixed":
_warn_mixed_cache_config(
requested_base=desired_base,
final_paths=final_paths,
keys_to_write=keys_to_write,
)
_ensure_dirs([path for path in final_paths.values() if path is not None])
for key in keys_to_write:
os.environ[key] = str(final_paths[key])
base = desired_base if source == "api" else None
info = _build_info(source=source, cache_paths=final_paths, base_dir=base)
_CONFIG = dict(info)
_CONFIG_REQUESTED_BASE = desired_base
return dict(info)
[docs]
def cache_info() -> dict[str, Any]:
"""Return the currently observed BCMP runtime cache configuration."""
with _CACHE_LOCK:
if _CONFIG is not None:
env_paths = {key: _path_from_env(key) for key in _ENV_KEYS}
env_matches_config = True
for key in _ENV_KEYS:
config_path = _CONFIG.get(_INFO_FIELDS[key])
if config_path is None or env_paths[key] != _resolve_path(
str(config_path)
):
env_matches_config = False
break
if env_matches_config:
return dict(_CONFIG)
return _environment_info()