Source code for scplotkit._config

"""Configuration handling for scplotkit.

The config controls generic styling (fonts, dpi, colormaps) and optional
per-column color palettes. It ships with sensible, domain-agnostic defaults —
any biology-specific palette (cell types, genotypes, conditions, ...) is
supplied by the user for their own dataset, never hard-coded in the package.
"""

from __future__ import annotations

import copy
from pathlib import Path
from typing import Any

import yaml

_DEFAULTS: dict[str, Any] = {
    "general": {
        "dpi": 100,
        "dpi_save": 300,
        "legend_fontsize": 12,
        "legend_fontweight": "bold",
        "title_fontsize": 14,
        "title_fontweight": "bold",
        "font_family": "DejaVu Sans",
        "fallback_color": "lightgrey",
    },
    "embeddings": {
        "layer": None,
        "cmap": "viridis",
        "outline_color": ["black", "white"],
        "outline_width": [0.2, 0.025],
    },
    "continuous": {
        "layer": None,
        "cmap": "viridis",
    },
    "rank_genes": {
        "values_to_plot": "logfoldchanges",
        "vmin": -5,
        "vmax": 5,
        "var_group_rotation": 90,
        "min_logfoldchange": 2,
        "cmap": "bwr",
    },
    # Optional per-column palettes, e.g. {"cell_type": {"T cell": "#1f78b4"}}.
    # Left empty by default; categories without an explicit color fall back
    # to an auto-generated, colorblind-friendly categorical palette.
    "palettes": {},
}


def _deep_merge(base: dict, override: dict) -> dict:
    merged = copy.deepcopy(base)
    for key, value in override.items():
        if isinstance(value, dict) and isinstance(merged.get(key), dict):
            merged[key] = _deep_merge(merged[key], value)
        else:
            merged[key] = value
    return merged


[docs] class PlotConfig: """Holds styling and palette configuration for scplotkit plots. Parameters ---------- data: Nested dict of configuration values. Merged on top of the built-in defaults, so you only need to specify what you want to override. """ def __init__(self, data: dict[str, Any] | None = None): self._data = _deep_merge(_DEFAULTS, data or {})
[docs] @classmethod def default(cls) -> PlotConfig: """Return a config with only the built-in, domain-agnostic defaults.""" return cls()
[docs] @classmethod def from_yaml(cls, path: str | Path) -> PlotConfig: """Load a config from a YAML file, merged on top of the defaults.""" with open(path) as f: data = yaml.safe_load(f) or {} return cls(data)
[docs] @classmethod def load(cls, config: PlotConfig | dict | str | Path | None) -> PlotConfig: """Coerce a config-like value (config, dict, path, or None) into a PlotConfig.""" if config is None: return cls.default() if isinstance(config, PlotConfig): return config if isinstance(config, dict): return cls(config) return cls.from_yaml(config)
def __getitem__(self, key: str) -> Any: return self._data[key]
[docs] def get(self, key: str, default: Any = None) -> Any: return self._data.get(key, default)
@property def general(self) -> dict: return self._data["general"] @property def palettes(self) -> dict: return self._data["palettes"]
[docs] def palette_for(self, column: str) -> dict[str, str] | None: """Return the user-supplied color mapping for a column, if any.""" return self.palettes.get(column)
[docs] def to_dict(self) -> dict: return copy.deepcopy(self._data)
def __repr__(self) -> str: # pragma: no cover - cosmetic return f"PlotConfig({self._data!r})"