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 copy
import difflib
import json
from collections.abc import Iterable, Iterator
from pathlib import Path
from typing import Any, Literal

import matplotlib.pyplot as plt
from matplotlib import RcParams

from .._rc import rc_lock

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


# Deep copies of normalized values written by the last successful preset stack.
# Contexts merge temporary preset writes and restore the immediate outer copy.
_dm_preset_values: dict[str, object] = {}


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() -> dict[str, object]:
    """Copy detectable caller edits before a preset reset.

    Prior-owned values equal to their saved preset value are residue; differing
    values are caller edits even when equal to Matplotlib's default. For other
    keys retain nondefault values. Native same-value writes are unobservable.
    """
    defaults = plt.rcParamsDefault  # type: ignore[attr-defined]
    overrides: dict[str, object] = {}
    for key in list(plt.rcParams):
        if key not in defaults:
            continue
        current = plt.rcParams[key]
        reference = _dm_preset_values.get(key, defaults[key])
        if _rcparam_differs(current, reference):
            overrides[key] = copy.deepcopy(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 ------ TypeError If the style name is not a string. ValueError If the specified style name cannot be found. """ if not isinstance(name, str): raise TypeError(f"style name must be str, got {type(name).__name__}") path: Path = ( Path(__file__).parent.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.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 # The locale whose semantic colour tokens (``dc.pos``/``dc.neg``) a style # activates. Decided once per style application by the two parsers below; # nothing else inspects style or preset names for a locale. _Locale = Literal["kr", "default"] def _is_korean_style(name: str) -> bool: """Return whether a style or preset name marks Korean (``lang-kr`` / ``*-kr``).""" return "lang-kr" in name or name.endswith("-kr") def _locale_of_styles(style_names: Iterable[str]) -> _Locale: """Return the locale of a style stack: Korean if any style name marks Korean.""" return "kr" if any(_is_korean_style(n) for n in style_names) else "default" def _locale_of_preset(preset_name: str, style_names: Iterable[str]) -> _Locale: """Return the locale :meth:`Style.context` applies for ``preset_name``. Korean when the preset name ends in ``-kr`` or one of its styles contains ``lang-kr``. For every shipped preset this equals :func:`_locale_of_styles` of its styles (the rule ``use`` applies); the two differ only for a caller-added preset that breaks the naming convention, and that difference is kept as it was. """ korean = preset_name.endswith("-kr") or any( "lang-kr" in s for s in style_names ) return "kr" if korean else "default"
[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. Raises ------ TypeError If the style name is not a string. ValueError If the named style file does not exist. """ # 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.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. Detectable caller edits survive on incoming-silent keys; unchanged prior preset values reset. Preset-produced mutable values are copied before caller overrides. 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 rc_lock: user_overrides = _snapshot_user_rcparams() plt.rcParams.update(plt.rcParamsDefault) # type: ignore[attr-defined] plt.style.use( [style_path(style_name) for style_name in style_names] ) # Capture normalized preset values before caller overrides. preset_values = { key: copy.deepcopy(plt.rcParams[key]) for key in incoming_keys } _restore_untouched_user_rcparams(user_overrides, incoming_keys) _dm_preset_values.clear() _dm_preset_values.update(preset_values) # 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.registry import apply_semantic apply_semantic(_locale_of_styles(style_names))
[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. Resolve the complete request and validate overrides in isolated rcParams before font loading or changes to live style, semantics or bookkeeping. Successful requests retain the usual preset and caller-override order. Detectable native edits survive when the incoming preset is silent; incoming declarations win and current kwargs apply last. A native same-value rewrite is indistinguishable from unchanged preset residue; pass that override with or after the later switch. Explicit kwargs are known caller intent, including when equal to a preset value. 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 ------ TypeError If a preset name or list member is not a string. KeyError If the preset name is unknown or an override key is invalid. ValueError If an override value fails Matplotlib's rcParam validation. Rejected requests leave the active style and semantic tokens unchanged. 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 """ # Reject name types before string suggestions or any live mutation. names = preset_name if isinstance(preset_name, list) else [preset_name] for name in names: if not isinstance(name, str): raise TypeError( f"preset name must be str, got {type(name).__name__}" ) 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]) else: # Single preset if preset_name not in self.presets: raise KeyError(self._unknown_preset_message(preset_name)) style_list = self.presets[preset_name] # Resolve aliases before validation so the last canonical-key value wins. # Keep normalized values: validating a supported one-shot iterable twice # would exhaust it before the live update. overrides = {_resolve_rcparam_key(k): v for k, v in kwargs.items()} validated_overrides = RcParams(overrides) self.stack(style_list) if kwargs: with rc_lock: plt.rcParams.update(validated_overrides) # Explicit kwargs are known caller intent, even at equal values. for key in validated_overrides: _dm_preset_values.pop(key, None)
[docs] @contextlib.contextmanager def context( self, preset_name: str, **kwargs: float | str ) -> Iterator[None]: """ Temporarily apply a style, restoring rcParams, semantics and ownership. Body use() calls, exceptions and nested scopes restore the immediate outer tracking at exit. Preset-only normalized values are recorded before kwargs; inherited keys remain tracked. Non-string names raise TypeError before mutation; unknown strings retain KeyError suggestions. 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 not isinstance(preset_name, str): raise TypeError( f"preset name must be str, got {type(preset_name).__name__}" ) if preset_name not in self.presets: raise KeyError(self._unknown_preset_message(preset_name)) names = self.presets[preset_name] style_list = [style_path(name) for name in names] incoming_keys = _style_declared_keys(names) overrides = RcParams( {_resolve_rcparam_key(k): v for k, v in kwargs.items()} ) saved_ownership = copy.deepcopy(_dm_preset_values) import matplotlib.colors as mcolors from .._colors.registry import SEMANTIC_TOKEN_NAMES, apply_semantic mapping = mcolors.get_named_colors_mapping() saved = {t: mapping.get(t) for t in SEMANTIC_TOKEN_NAMES} locale = _locale_of_preset(preset_name, names) try: with plt.style.context(style_list): # Retain inherited silent-key ownership; snapshot before kwargs. with rc_lock: _dm_preset_values.update( { key: copy.deepcopy(plt.rcParams[key]) for key in incoming_keys } ) plt.rcParams.update(overrides) for key in overrides: _dm_preset_values.pop(key, None) apply_semantic(locale) yield finally: with rc_lock: _dm_preset_values.clear() _dm_preset_values.update(saved_ownership) 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()
[docs] def install_twinx_patch() -> None: """Make ``Axes.twinx`` show the twin's right spine at the preset line width. dartwork-mpl presets hide the right spine, so a twin axis would otherwise have no visible axis line for its own scale. Called once by ``import dartwork_mpl``. Idempotent: the wrapper is tagged ``__dm_patched__``, so a repeated call (or ``importlib.reload``) never wraps the patched method again and recurses. """ import matplotlib as mpl import matplotlib.axes if getattr(matplotlib.axes.Axes.twinx, "__dm_patched__", False): return original_twinx = matplotlib.axes.Axes.twinx def _patched_twinx( self: matplotlib.axes.Axes, *args: Any, **kwargs: Any ) -> matplotlib.axes.Axes: ax2 = original_twinx(self, *args, **kwargs) ax2.spines["right"].set_visible(True) ax2.spines["right"].set_linewidth( mpl.rcParams.get("axes.linewidth", 0.3) ) return ax2 _patched_twinx.__dm_patched__ = True # type: ignore[attr-defined] matplotlib.axes.Axes.twinx = _patched_twinx # type: ignore[method-assign,assignment]