Source code for nstat.plot_style

"""Lightweight Python analogue of the upstream MATLAB plot-style helpers."""

from __future__ import annotations

import os
from pathlib import Path

import matplotlib.axes
import matplotlib.figure
import matplotlib.lines
from matplotlib.collections import PathCollection


def _resolve_style_file() -> Path:
    """Return the user-writable location for the persisted style.

    Order of preference:
      1. ``$NSTAT_STYLE_FILE`` if set (escape hatch for test isolation).
      2. ``$XDG_CONFIG_HOME/nstat/plot_style`` (Linux convention).
      3. ``~/.config/nstat/plot_style`` (default cross-platform).
      4. Package-install directory fallback (legacy) — kept for back-compat
         when the user explicitly chooses to write into the install path.

    The previous default (``Path(__file__).with_name(".plot_style")``)
    raises ``PermissionError`` on pip-installed packages in a system
    site-packages directory.  Routing through the user config dir
    eliminates that failure mode without an extra dependency.
    """
    explicit = os.environ.get("NSTAT_STYLE_FILE")
    if explicit:
        return Path(explicit)
    xdg = os.environ.get("XDG_CONFIG_HOME")
    base = Path(xdg) if xdg else Path.home() / ".config"
    return base / "nstat" / "plot_style"


_STYLE_FILE = _resolve_style_file()
# Legacy location (pre-v0.3.1): the sidecar file alongside this module.
# Reads honor it for migration; writes target the user-config path.
_LEGACY_STYLE_FILE = Path(__file__).resolve().with_name(".plot_style")


def _validate_style(style: str) -> str:
    norm = str(style).strip().lower()
    if norm not in {"legacy", "modern"}:
        raise ValueError('Invalid plot style. Valid styles: "legacy", "modern".')
    return norm


[docs] def get_plot_style(default: str = "modern") -> str: """Return the persisted global plotting style. Falls back to *default* when no persisted file exists or when the file contains a value outside the known {``legacy``, ``modern``} set. Reads the user-config location first, then the legacy in-package location (for back-compat with pre-v0.3.1 installs). """ fallback = _validate_style(default) for candidate in (_STYLE_FILE, _LEGACY_STYLE_FILE): if not candidate.exists(): continue try: return _validate_style(candidate.read_text(encoding="utf-8").strip()) except (OSError, ValueError): continue return fallback
[docs] def set_plot_style(style: str = "modern") -> str: """Persist the plotting style for future sessions. Writes to ``~/.config/nstat/plot_style`` (or ``$XDG_CONFIG_HOME`` / ``$NSTAT_STYLE_FILE``) so a pip-installed package can be configured without write access to its install directory. """ norm = _validate_style(style) _STYLE_FILE.parent.mkdir(parents=True, exist_ok=True) _STYLE_FILE.write_text(norm + "\n", encoding="utf-8") return norm
[docs] def apply_plot_style(target=None, *, style: str = ""): """Apply the current nSTAT plot style to a matplotlib figure or axes.""" chosen = get_plot_style() if not style else _validate_style(style) if chosen == "legacy": return target if target is None: return target if isinstance(target, matplotlib.figure.Figure): figure = target axes_list = list(target.axes) elif isinstance(target, matplotlib.axes.Axes): figure = target.figure axes_list = [target] else: figure = getattr(target, "figure", None) axes = getattr(target, "axes", None) axes_list = [axes] if isinstance(axes, matplotlib.axes.Axes) else [] if figure is None: return target if figure is not None: figure.set_facecolor("white") for ax in axes_list: # Match MATLAB nstat.applyPlotStyle: Helvetica 10pt, ticks out, layer top ax.tick_params(direction="out", top=True, right=True, length=6, width=1) ax.set_axisbelow(False) # layer = 'top' equivalent for spine in ax.spines.values(): spine.set_linewidth(1.0) # Set font on existing labels and title for label in [ax.title, ax.xaxis.label, ax.yaxis.label]: if label.get_fontfamily() == ["sans-serif"] or not label.get_text(): label.set_fontfamily("Helvetica") for label in ax.get_xticklabels() + ax.get_yticklabels(): label.set_fontsize(10) label.set_fontfamily("Helvetica") for line in ax.get_lines(): if isinstance(line, matplotlib.lines.Line2D) and float(line.get_linewidth()) < 1.25: line.set_linewidth(1.25) if line.get_marker() == ".": line.set_markersize(max(float(line.get_markersize()), 9.0)) for coll in ax.collections: if isinstance(coll, PathCollection): sizes = coll.get_sizes() if sizes.size: coll.set_sizes(sizes.clip(min=30.0)) # Fix per-axes legends leg = ax.get_legend() if leg is not None: leg.set_frame_on(False) for text in leg.get_texts(): text.set_fontsize(10) # Fix figure-level legends legend = None if figure is None else figure.legends for item in legend or []: item.set_frame_on(False) item.prop.set_size(10) return target