"""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})"