Source code for dartwork_mpl.style

"""Matplotlib style management utilities.

This module provides functions and classes for loading and applying
matplotlib styles from the package's built-in style library.
"""

import contextlib
import difflib
import json
import threading
from collections.abc import Iterator
from pathlib import Path

import matplotlib.pyplot as plt

__all__ = ["Style", "list_styles", "load_style_dict", "style", "style_path"]


# Module-level lock guarding global matplotlib state mutations
# (rcParams, style.use). Without this, concurrent ``dm.style.use(...)``
# calls from multiple threads can interleave rcParams updates and
# corrupt the active style.
_style_lock: threading.Lock = threading.Lock()

# Keys that any dartwork-mpl preset has declared during this process.
# Used to tell *the user's own* rcParams apart from residue left by a
# previously-applied dm preset, so switching presets performs a clean
# swap instead of leaking the prior theme (a "franken-theme").
_dm_managed_keys: set[str] = set()


def _did_you_mean(value: str, candidates: list[str]) -> str | None:
    """Return the single closest match to ``value`` from ``candidates``
    or ``None`` if no entry is close enough.

    Wraps :func:`difflib.get_close_matches` with the cutoff dartwork-mpl
    uses everywhere a "did you mean" hint is shown (0.5 — close enough
    to catch typos like ``"sceintific"`` / ``"reort"`` but far enough
    to avoid suggesting unrelated keys for completely wrong input).
    """
    matches = difflib.get_close_matches(
        value.strip().lower(), [c.lower() for c in candidates], n=1, cutoff=0.5
    )
    if not matches:
        return None
    # Return the original-case spelling so the user sees the canonical
    # form rather than the lowercased lookup key.
    lowered = matches[0]
    for c in candidates:
        if c.lower() == lowered:
            return c
    return lowered


def _rcparam_differs(value: object, default: object) -> bool:
    """Return ``True`` when ``value != default``, treating mutable
    matplotlib rcParam types (Cyclers, lists, Paths, …) as equal when
    their ``repr`` matches.

    Matplotlib stores a handful of rcParams as mutable / wrapped
    objects (``axes.prop_cycle`` is a ``Cycler``; ``image.lut`` is an
    int — fine; ``axes.formatter.use_locale`` is a bool — fine; the
    bullets are the cycler and a couple of unhashable lists). Plain
    ``!=`` on those raises ``TypeError`` rather than returning a bool,
    so we fall through to a ``repr`` comparison for the awkward cases
    only.
    """
    try:
        result: bool = value != default
        return result
    except (TypeError, ValueError):
        return repr(value) != repr(default)


def _snapshot_user_rcparams(exclude: set[str]) -> dict[str, object]:
    """Capture rcParams the *user* set away from matplotlib's default.

    Used by :meth:`Style.stack` to preserve caller configuration across
    the ``rcParams.update(rcParamsDefault)`` reset that style switching
    performs. Keys in ``exclude`` (those any dm preset has managed) are
    skipped: a value differing from the default there is residue from a
    previously-applied preset, not user intent, and preserving it would
    leak the prior theme into the next preset.
    """
    defaults = plt.rcParamsDefault  # type: ignore[attr-defined]
    overrides: dict[str, object] = {}
    for key in list(plt.rcParams):
        if key not in defaults or key in exclude:
            continue
        current = plt.rcParams[key]
        if _rcparam_differs(current, defaults[key]):
            overrides[key] = current
    return overrides


def _restore_untouched_user_rcparams(
    user_overrides: dict[str, object], preset_keys: set[str]
) -> None:
    """Restore user rcParams the freshly-applied preset does not own.

    Run *after* ``plt.style.use(...)``. A key is "owned by the preset"
    iff the preset *declared* it (``preset_keys``), regardless of the
    value it set — so a preset that explicitly sets a key to matplotlib's
    default value (e.g. ``axes.grid: False``) still wins over a
    pre-existing user value. Only keys the preset is genuinely silent
    about have the user value reinstated.
    """
    for key, user_value in user_overrides.items():
        if key in preset_keys or key not in plt.rcParams:
            continue
        plt.rcParams[key] = user_value


def _style_declared_keys(style_names: list[str]) -> set[str]:
    """Union of rcParam keys explicitly declared by the given style files."""
    keys: set[str] = set()
    for name in style_names:
        keys.update(load_style_dict(name).keys())
    return keys


def _resolve_rcparam_key(k: str) -> str:
    """Map a kwarg name to its canonical rcParam key.

    Accepts dotted names as-is (``legend.title_fontsize``) and the
    underscore shorthand. A naive ``k.replace("_", ".")`` breaks for
    rcParams whose canonical name itself contains an underscore
    (``legend.title_fontsize`` -> ``legend.title.fontsize``, invalid),
    so after the dotted-name and full-replace attempts we fall back to
    matching against the live rcParam whose dotted form, with dots
    turned to underscores, equals ``k``. Returns ``k`` unchanged when
    nothing matches, letting the downstream update raise matplotlib's
    standard "not a valid rc parameter" error.
    """
    if k in plt.rcParams:
        return k
    dotted = k.replace("_", ".")
    if dotted in plt.rcParams:
        return dotted
    for rc in plt.rcParams:
        if rc.replace(".", "_") == k:
            return rc
    return k


[docs] def style_path(name: str) -> Path: """ Get the path to a style file. Parameters ---------- name : str Name of the style (e.g., 'report', 'scientific'). Returns ------- Path Absolute path to the style file (.mplstyle). Raises ------ ValueError If the specified style name cannot be found. """ path: Path = Path(__file__).parent / f"asset/mplstyle/{name}.mplstyle" if not path.exists(): available = list_styles() hint = _did_you_mean(name, available) raise ValueError( f"Style {name!r} not found. " f"Available styles: {available}." + (f" Did you mean {hint!r}?" if hint else "") ) return path
[docs] def list_styles() -> list[str]: """ Return a list of all available styles. Returns ------- list[str] List of style names. """ path: Path = Path(__file__).parent / "asset/mplstyle" return sorted([p.stem for p in path.glob("*.mplstyle")])
def _strip_mplstyle_value(raw: str) -> str: """Extract an mplstyle value: drop the inline comment, unwrap quotes. ``#`` starts a comment only outside quotes (so a quoted colour like ``"#1e1e1e"`` is preserved), after which a matching pair of surrounding quotes is removed — matching how matplotlib's own ``_rc_params_in_file`` normalizes the value. """ quote: str | None = None chars: list[str] = [] for ch in raw.strip(): if quote is not None: chars.append(ch) if ch == quote: quote = None elif ch in "\"'": quote = ch chars.append(ch) elif ch == "#": break else: chars.append(ch) value = "".join(chars).strip() if len(value) >= 2 and value[0] in "\"'" and value[-1] == value[0]: value = value[1:-1] return value
[docs] def load_style_dict(name: str) -> dict[str, float | str]: """ Read key-value pairs from an mplstyle file. Parameters ---------- name : str Name of the style to load. Returns ------- dict[str, float | str] Dictionary of style parameters. Values are converted to float where possible; otherwise they are kept as strings. """ # Load key, value pair from mplstyle files. path: Path = style_path(name) style_dict: dict[str, float | str] = {} with open(path) as f: for line in f: stripped = line.strip() if not stripped or stripped.startswith("#"): continue # Split on first colon only (values may contain colons). if ":" not in stripped: continue key, raw_value = stripped.split(":", maxsplit=1) key = key.strip() # Strip an inline comment (``#`` outside quotes) and any # surrounding quotes, so quoted values like ``"#1e1e1e"`` # round-trip to what matplotlib's own parser yields # (``#1e1e1e``) rather than the raw ``'"#1e1e1e"'``. value_str = _strip_mplstyle_value(raw_value) if not value_str: continue try: value_float: float = float(value_str) style_dict[key] = value_float except ValueError: style_dict[key] = value_str return style_dict
[docs] class Style: """ Class for managing and applying multiple matplotlib styles. This class provides functionality for loading style presets and stacking multiple styles sequentially. Examples -------- >>> import dartwork_mpl as dm >>> dm.style.use("scientific") # Apply a single preset >>> dm.style.stack(["base", "lang-kr"]) # Stack multiple styles """ def __init__(self) -> None: """Initialize the Style instance and load presets.""" self.presets: dict[str, list[str]] = {} # Load presets self.load_presets()
[docs] @staticmethod def presets_path() -> Path: """ Get the path to the presets configuration file (presets.json). Returns ------- Path Path to the presets.json file containing combined style presets. """ return Path(__file__).parent / "asset/mplstyle/presets.json"
[docs] def load_presets(self) -> None: """ Load style presets from the JSON file. Reads presets.json and stores the configuration in the instance's presets attribute. """ with open(self.presets_path()) as f: self.presets = json.load(f)
def _unknown_preset_message(self, name: str) -> str: """Build the actionable message used by every "preset not found" error site in this class. Lists every preset the caller could have used and, when the input is a near-miss for one of them, appends a single ``did you mean`` hint. Matches the format used by :func:`style_path` and :func:`dartwork_mpl.icon.icon_font_path` so the three "missing style/preset/icon" errors all read the same way. """ available = sorted(self.presets) hint = _did_you_mean(name, available) return f"Preset {name!r} not found. Available presets: {available}." + ( f" Did you mean {hint!r}?" if hint else "" )
[docs] @staticmethod def stack(style_names: list[str]) -> None: """ Stack multiple styles in order. Applies multiple style files sequentially. Later styles override values set by earlier ones for the same keys. Parameters ---------- style_names : list[str] List of style names to apply. Styles are applied in order, with later entries taking precedence. Examples -------- >>> import dartwork_mpl as dm >>> dm.style.stack(["base", "font-scientific", "lang-kr"]) """ from .font import ensure_loaded as ensure_fonts_loaded # Ensure fonts are registered before Matplotlib tries to resolve # them (v5 colormaps register eagerly at import time). ensure_fonts_loaded() # Keys the incoming preset explicitly declares — parsed from the # style files, so "does the preset own this key?" is answered by # declaration, not by comparing values against the default. incoming_keys = _style_declared_keys(style_names) # Serialize global rcParams + style application across threads. with _style_lock: # Snapshot rcParams the *user* set away from matplotlib's # default *before* the reset below, so genuine caller config # (svg.hashsalt for reproducible builds, savefig.dpi, …) # survives the switch. Exclude keys any dm preset has managed # — a differing value there is residue from the previously # applied preset, and preserving it would leak the old theme. user_overrides = _snapshot_user_rcparams(exclude=_dm_managed_keys) plt.rcParams.update(plt.rcParamsDefault) # type: ignore[attr-defined] plt.style.use( [style_path(style_name) for style_name in style_names] ) # Reinstate user overrides for keys the incoming preset does # not declare; the preset wins on every key it declares. _restore_untouched_user_rcparams(user_overrides, incoming_keys) # Remember these keys so the next switch treats their values # as preset residue rather than user intent. _dm_managed_keys.update(incoming_keys) # Locale-aware semantic tokens (dc.pos/neg/ref/hl) applied at the # choke point use() funnels through, so direct stack() callers and # use() both get locale semantics. KR is detected from the STYLE # names (they carry "lang-kr" for Korean presets). Under the lock # for the same reason the rcParams mutation above is. from ._colors._semantic import apply_semantic is_kr = any( "lang-kr" in nm or nm.endswith("-kr") for nm in style_names ) apply_semantic("kr" if is_kr else "default")
[docs] def use(self, preset_name: str | list[str], **kwargs: float | str) -> None: """ Apply a preset style configuration or a list of presets. This is the recommended way to apply styles in this module. Presets are pre-optimized combinations of styles for specific use cases. Parameters ---------- preset_name : str or list of str Name of the preset to apply. Available presets: - "scientific": Academic papers (default English) - "report": Documents, reports, and dashboards - "minimal": Tufte-style with minimal lines and ticks - "presentation": Slide presentations - "poster": Conference posters and large displays - "web": Web pages and documentation - "dark": Dark background theme - "scientific-kr": Academic papers (Korean fonts) - "report-kr": Reports and dashboards (Korean fonts) - "minimal-kr": Minimal style (Korean fonts) - "presentation-kr": Presentations (Korean fonts) - "poster-kr": Conference posters (Korean fonts) - "web-kr": Web pages (Korean fonts) - "dark-kr": Dark theme (Korean fonts) **kwargs : float | str Additional rcParams to override the preset defaults (e.g., font_size=12). Both underscore (font_size) and dot (font.size) notation are supported. Raises ------ KeyError If the requested preset name is not found in the presets dictionary. Examples -------- >>> import dartwork_mpl as dm >>> dm.style.use("scientific") >>> dm.style.use("presentation-kr", font_size=16) >>> dm.style.use(["scientific", "dark"]) # Stack multiple presets """ # Handle both single string and list of strings if isinstance(preset_name, list): # Stack multiple presets in order style_list = [] for name in preset_name: if name not in self.presets: raise KeyError(self._unknown_preset_message(name)) style_list.extend(self.presets[name]) self.stack(style_list) else: # Single preset if preset_name not in self.presets: raise KeyError(self._unknown_preset_message(preset_name)) self.stack(self.presets[preset_name]) if kwargs: overrides = {} for k, v in kwargs.items(): overrides[_resolve_rcparam_key(k)] = v with _style_lock: plt.rcParams.update(overrides)
[docs] @contextlib.contextmanager def context( self, preset_name: str, **kwargs: float | str ) -> Iterator[None]: """ Context manager that temporarily applies a style within a code block. Parameters ---------- preset_name : str Name of the preset to apply. **kwargs : float | str Additional rcParams to override. Examples -------- >>> with dm.style.context("dark"): ... plt.plot([1, 2, 3]) Notes ----- Not thread-safe: the underlying ``plt.rcParams`` mutation is process-global, and holding a lock across the ``yield`` would deadlock any body code that calls ``style.use``. Apply styles from one thread (matplotlib itself is not thread-safe for concurrent rcParams mutation). """ if preset_name not in self.presets: raise KeyError(self._unknown_preset_message(preset_name)) style_list: list[Path | dict[str, float | str]] = [ style_path(style_name) for style_name in self.presets[preset_name] ] if kwargs: overrides: dict[str, float | str] = {} for k, v in kwargs.items(): overrides[_resolve_rcparam_key(k)] = v style_list.append(overrides) import matplotlib.colors as mcolors from ._colors._semantic import SEMANTIC_TOKEN_NAMES, apply_semantic mapping = mcolors.get_named_colors_mapping() saved = {t: mapping.get(t) for t in SEMANTIC_TOKEN_NAMES} is_kr = preset_name.endswith("-kr") or any( "lang-kr" in s for s in self.presets[preset_name] ) try: with plt.style.context(style_list): apply_semantic("kr" if is_kr else "default") yield finally: for token, value in saved.items(): if value is None: mapping.pop(token, None) else: mapping[token] = value
[docs] def presets_dict(self) -> dict[str, list[str]]: """ Return all available presets as a dictionary. Returns ------- dict[str, list[str]] Dictionary mapping preset names (keys) to their constituent style lists (values). """ return dict(self.presets.items())
style: Style = Style()