mgplot.finalise_plot

Functions to finalise and save plots to the file system.

  1"""Functions to finalise and save plots to the file system."""
  2
  3import re
  4import unicodedata
  5from collections.abc import Callable, Sequence
  6from pathlib import Path
  7from typing import Any, Final, NotRequired, Unpack
  8
  9import matplotlib.pyplot as plt
 10from matplotlib.axes import Axes
 11from matplotlib.figure import Figure, SubFigure
 12from pandas import Period, PeriodIndex
 13
 14from mgplot.axis_utils import get_period_axes, refresh_period_labels, register_period_axes
 15from mgplot.keyword_checking import BaseKwargs, report_kwargs, validate_kwargs
 16from mgplot.settings import get_setting
 17
 18# --- constants
 19ME: Final[str] = "finalise_plot"
 20MAX_FILENAME_LENGTH: Final[int] = 150
 21DEFAULT_MARGIN: Final[float] = 0.02
 22TIGHT_LAYOUT_PAD: Final[float] = 1.1
 23FOOTNOTE_FONTSIZE: Final[int] = 8
 24FOOTNOTE_FONTSTYLE: Final[str] = "italic"
 25FOOTNOTE_COLOR: Final[str] = "#999999"
 26ZERO_LINE_WIDTH: Final[float] = 0.66
 27ZERO_LINE_COLOR: Final[str] = "#555555"
 28ZERO_AXIS_ADJUSTMENT: Final[float] = 0.02
 29DEFAULT_FILE_TITLE_NAME: Final[str] = "plot"
 30
 31
 32class FinaliseKwargs(BaseKwargs):
 33    """Keyword arguments for the finalise_plot function."""
 34
 35    # --- value options
 36    suptitle: NotRequired[str | None]
 37    title: NotRequired[str | None]
 38    xlabel: NotRequired[str | None]
 39    ylabel: NotRequired[str | None]
 40    xlim: NotRequired[tuple[float, float] | None]
 41    ylim: NotRequired[tuple[float, float] | None]
 42    xticks: NotRequired[list[float] | None]
 43    yticks: NotRequired[list[float] | None]
 44    xscale: NotRequired[str | None]
 45    yscale: NotRequired[str | None]
 46    # --- splat options
 47    legend: NotRequired[bool | dict[str, Any] | None]
 48    axhspan: NotRequired[dict[str, Any] | Sequence[dict[str, Any]] | None]
 49    axvspan: NotRequired[dict[str, Any] | Sequence[dict[str, Any]] | None]
 50    axhline: NotRequired[dict[str, Any] | Sequence[dict[str, Any]] | None]
 51    axvline: NotRequired[dict[str, Any] | Sequence[dict[str, Any]] | None]
 52    # --- options for annotations
 53    lfooter: NotRequired[str]
 54    rfooter: NotRequired[str]
 55    lheader: NotRequired[str]
 56    rheader: NotRequired[str]
 57    # --- file/save options
 58    pre_tag: NotRequired[str]
 59    tag: NotRequired[str]
 60    filename: NotRequired[str]
 61    chart_dir: NotRequired[str]
 62    file_type: NotRequired[str]
 63    dpi: NotRequired[int]
 64    figsize: NotRequired[tuple[float, float]]
 65    show: NotRequired[bool]
 66    # --- other options
 67    preserve_lims: NotRequired[bool]
 68    remove_legend: NotRequired[bool]
 69    zero_y: NotRequired[bool]
 70    y0: NotRequired[bool]
 71    x0: NotRequired[bool]
 72    axisbelow: NotRequired[bool]
 73    dont_save: NotRequired[bool]
 74    dont_close: NotRequired[bool]
 75    axes_only: NotRequired[bool]
 76
 77
 78VALUE_KWARGS = (
 79    "title",
 80    "xlabel",
 81    "ylabel",
 82    "xlim",
 83    "ylim",
 84    "xticks",
 85    "yticks",
 86    "xscale",
 87    "yscale",
 88)
 89SPLAT_KWARGS = (
 90    "axhspan",
 91    "axvspan",
 92    "axhline",
 93    "axvline",
 94    "legend",  # needs to be last in this tuple
 95)
 96HEADER_FOOTER_KWARGS = (
 97    "lfooter",
 98    "rfooter",
 99    "lheader",
100    "rheader",
101)
102
103
104def sanitize_filename(filename: str, max_length: int = MAX_FILENAME_LENGTH) -> str:
105    """Convert a string to a safe filename.
106
107    Args:
108        filename: The string to convert to a filename
109        max_length: Maximum length for the filename
110
111    Returns:
112        A safe filename string
113
114    """
115    if not filename:
116        return "untitled"
117
118    # Normalize unicode characters (e.g., é -> e)
119    filename = unicodedata.normalize("NFKD", filename)
120
121    # Remove non-ASCII characters
122    filename = filename.encode("ascii", "ignore").decode("ascii")
123
124    # Convert to lowercase
125    filename = filename.lower()
126
127    # Replace spaces and other separators with hyphens
128    filename = re.sub(r"[\s\-_]+", "-", filename)
129
130    # Remove unsafe characters, keeping only alphanumeric and hyphens
131    filename = re.sub(r"[^a-z0-9\-]", "", filename)
132
133    # Remove leading/trailing hyphens and collapse multiple hyphens
134    filename = re.sub(r"^-+|-+$", "", filename)
135    filename = re.sub(r"-+", "-", filename)
136
137    # Truncate to max length
138    if len(filename) > max_length:
139        filename = filename[:max_length].rstrip("-")
140
141    # Ensure we have a valid filename
142    return filename or "untitled"
143
144
145def make_legend(axes: Axes, *, legend: None | bool | dict[str, Any]) -> None:
146    """Create a legend for the plot."""
147    if legend is None or legend is False:
148        return
149
150    if legend is True:  # use the global default settings
151        legend = get_setting("legend")
152
153    if isinstance(legend, dict):
154        axes.legend(**legend)
155        return
156
157    print(f"Warning: expected dict argument for legend, but got {type(legend)}.")
158
159
160def apply_value_kwargs(axes: Axes, value_kwargs_: Sequence[str], **kwargs: Unpack[FinaliseKwargs]) -> None:
161    """Set matplotlib elements by name using Axes.set().
162
163    Tricky: some plotting functions may set the xlabel or ylabel.
164    So ... we will set these if a setting is explicitly provided. If no
165    setting is provided, we will set to None if they are not already set.
166    If they have already been set, we will not change them.
167
168    """
169    # --- preliminary
170    function: dict[str, Callable[[], str]] = {
171        "xlabel": axes.get_xlabel,
172        "ylabel": axes.get_ylabel,
173        "title": axes.get_title,
174    }
175
176    def fail() -> str:
177        return ""
178
179    # --- loop over potential value settings
180    for setting in value_kwargs_:
181        value = kwargs.get(setting)
182        if setting in kwargs:
183            # deliberately set, so we will action
184            axes.set(**{setting: value})
185            continue
186        required_to_set = ("title", "xlabel", "ylabel")
187        if setting not in required_to_set:
188            # not set - and not required - so we can skip
189            continue
190
191        # we will set these 'required_to_set' ones
192        # provided they are not already set
193        already_set = function.get(setting, fail)()
194        if already_set and value is None:
195            continue
196
197        # if we get here, we will set the value (implicitly to None)
198        axes.set(**{setting: value})
199
200
201_SplatValue = bool | dict[str, Any] | Sequence[dict[str, Any]] | None
202
203# Keys in each splat-method's kwargs that are x-axis coordinates — when the
204# plot uses a PeriodIndex the axis is mapped to Period ordinals, so a Period
205# passed here must be converted to its ordinal for matplotlib.
206_PERIOD_COORD_KEYS: Final[dict[str, tuple[str, ...]]] = {
207    "axvline": ("x",),
208    "axvspan": ("xmin", "xmax"),
209}
210
211
212def _convert_period_coords(axes: Axes, method_name: str, item: dict[str, Any]) -> dict[str, Any]:
213    """Return a copy of item with any Period x-coordinates replaced by ordinals.
214
215    If the axes was period-mapped by mgplot, the Period's freq must match the
216    axes' stashed freq — otherwise the ordinals live in different spaces.
217    On an axes with no stash we trust the programmer and just take .ordinal.
218    """
219    keys = _PERIOD_COORD_KEYS.get(method_name)
220    if not keys:
221        return item
222    stash = get_period_axes(axes)
223    stashed_freq = stash[0] if stash is not None else None
224    converted = dict(item)
225    for key in keys:
226        val = converted.get(key)
227        if isinstance(val, Period):
228            if stashed_freq is not None and val.freqstr != stashed_freq:
229                raise ValueError(
230                    f"{method_name} Period freq {val.freqstr!r} does not match axes freq {stashed_freq!r}",
231                )
232            if stashed_freq is not None:
233                # Widen the stash so later label refresh covers this coordinate,
234                # even if it falls outside the plotted data's ordinal range.
235                register_period_axes(axes, PeriodIndex([val]))
236            converted[key] = val.ordinal
237    return converted
238
239
240def _apply_splat(axes: Axes, method_name: str, value: _SplatValue) -> None:
241    """Apply a single splat kwarg, which may be a dict or sequence of dicts."""
242    if value is None or value is False:
243        return
244
245    if value is True:  # use the global default settings
246        value = get_setting(method_name)
247
248    # normalise to a list of dicts
249    if isinstance(value, dict):
250        value = [value]
251
252    if isinstance(value, Sequence):
253        method = getattr(axes, method_name)
254        for item in value:
255            if isinstance(item, dict):
256                method(**_convert_period_coords(axes, method_name, item))
257            else:
258                print(f"Warning: expected dict in {method_name} sequence, but got {type(item)}.")
259    else:
260        print(f"Warning: expected dict or sequence of dicts for {method_name}, but got {type(value)}.")
261
262
263def apply_splat_kwargs(axes: Axes, settings: tuple, **kwargs: Unpack[FinaliseKwargs]) -> None:
264    """Set matplotlib elements dynamically using setting_name and splat."""
265    for method_name in settings:
266        if method_name not in kwargs:
267            continue
268
269        if method_name == "legend":
270            legend_value = kwargs.get(method_name)
271            if isinstance(legend_value, (bool, dict, type(None))):
272                make_legend(axes, legend=legend_value)
273            else:
274                print(f"Warning: expected bool, dict, or None for legend, but got {type(legend_value)}.")
275            continue
276
277        _apply_splat(axes, method_name, kwargs.get(method_name))
278
279
280def apply_annotations(axes: Axes, **kwargs: Unpack[FinaliseKwargs]) -> None:
281    """Set figure size and apply chart annotations.
282
283    No-op when axes_only=True: the work here is all figure-level (resize,
284    corner text) and would stomp on other panels in a multi-axes figure.
285    """
286    if kwargs.get("axes_only"):
287        return
288    fig = axes.figure
289    fig_size = kwargs.get("figsize", get_setting("figsize"))
290    if not isinstance(fig, SubFigure):
291        fig.set_size_inches(*fig_size)
292
293    annotations = {
294        "rfooter": (0.99, 0.001, "right", "bottom"),
295        "lfooter": (0.01, 0.001, "left", "bottom"),
296        "rheader": (0.99, 0.999, "right", "top"),
297        "lheader": (0.01, 0.999, "left", "top"),
298    }
299
300    for annotation in HEADER_FOOTER_KWARGS:
301        if annotation in kwargs:
302            x_pos, y_pos, h_align, v_align = annotations[annotation]
303            fig.text(
304                x_pos,
305                y_pos,
306                str(kwargs.get(annotation, "")),
307                ha=h_align,
308                va=v_align,
309                fontsize=FOOTNOTE_FONTSIZE,
310                fontstyle=FOOTNOTE_FONTSTYLE,
311                color=FOOTNOTE_COLOR,
312            )
313
314
315def apply_late_kwargs(axes: Axes, **kwargs: Unpack[FinaliseKwargs]) -> None:
316    """Apply settings found in kwargs, after plotting the data."""
317    apply_splat_kwargs(axes, SPLAT_KWARGS, **kwargs)
318
319
320def apply_kwargs(axes: Axes, **kwargs: Unpack[FinaliseKwargs]) -> None:
321    """Apply settings found in kwargs."""
322
323    def check_kwargs(name: str) -> bool:
324        return name in kwargs and bool(kwargs.get(name))
325
326    apply_value_kwargs(axes, VALUE_KWARGS, **kwargs)
327    apply_annotations(axes, **kwargs)
328
329    if check_kwargs("zero_y"):
330        bottom, top = axes.get_ylim()
331        adj = (top - bottom) * ZERO_AXIS_ADJUSTMENT
332        if bottom > -adj:
333            axes.set_ylim(bottom=-adj)
334        if top < adj:
335            axes.set_ylim(top=adj)
336
337    if check_kwargs("y0"):
338        low, high = axes.get_ylim()
339        if low < 0 < high:
340            axes.axhline(y=0, lw=ZERO_LINE_WIDTH, c=ZERO_LINE_COLOR)
341
342    if check_kwargs("x0"):
343        low, high = axes.get_xlim()
344        if low < 0 < high:
345            axes.axvline(x=0, lw=ZERO_LINE_WIDTH, c=ZERO_LINE_COLOR)
346
347    if check_kwargs("axisbelow"):
348        axes.set_axisbelow(True)
349
350
351def save_to_file(fig: Figure, **kwargs: Unpack[FinaliseKwargs]) -> None:
352    """Save the figure to file."""
353    saving = not kwargs.get("dont_save", False)  # save by default
354    if not saving:
355        return
356
357    try:
358        chart_dir = Path(kwargs.get("chart_dir", get_setting("chart_dir")))
359
360        # Ensure directory exists
361        chart_dir.mkdir(parents=True, exist_ok=True)
362
363        suptitle = kwargs.get("suptitle", "")
364        title = kwargs.get("title", "")
365        pre_tag = kwargs.get("pre_tag", "")
366        tag = kwargs.get("tag", "")
367        name_override = kwargs.get("filename", "")
368        name_title = name_override or suptitle or title
369        file_title = sanitize_filename(name_title or DEFAULT_FILE_TITLE_NAME)
370        file_type = kwargs.get("file_type", get_setting("file_type")).lower()
371        dpi = kwargs.get("dpi", get_setting("dpi"))
372
373        # Construct filename components safely
374        filename_parts = []
375        if pre_tag:
376            filename_parts.append(sanitize_filename(pre_tag))
377        filename_parts.append(file_title)
378        if tag:
379            filename_parts.append(sanitize_filename(tag))
380
381        # Join filename parts and add extension
382        filename = "-".join(filter(None, filename_parts))
383        filepath = chart_dir / f"{filename}.{file_type}"
384
385        fig.savefig(filepath, dpi=dpi)
386
387    except (
388        OSError,
389        PermissionError,
390        FileNotFoundError,
391        ValueError,
392        RuntimeError,
393        TypeError,
394        UnicodeError,
395    ) as e:
396        print(f"Error: Could not save plot to file: {e}")
397
398
399# - public functions for finalise_plot()
400
401
402def finalise_plot(axes: Axes, **kwargs: Unpack[FinaliseKwargs]) -> None:
403    """Finalise and save plots to the file system.
404
405    The filename for the saved plot is constructed from the global
406    chart_dir, the plot's title, any specified tag text, and the
407    file_type for the plot.
408
409    Args:
410        axes: Axes - matplotlib axes object - required
411        kwargs: FinaliseKwargs
412
413    """
414    # --- check the kwargs
415    report_kwargs(caller=ME, **kwargs)
416    validate_kwargs(schema=FinaliseKwargs, caller=ME, **kwargs)
417
418    # --- sanity checks
419    if len(axes.get_children()) < 1:
420        print(f"Warning: {ME}() called with an empty axes, which was ignored.")
421        return
422
423    # --- remember axis-limits should we need to restore thems
424    xlim, ylim = axes.get_xlim(), axes.get_ylim()
425
426    # margins
427    axes.margins(DEFAULT_MARGIN)
428    axes.autoscale(tight=False)  # This is problematic ...
429
430    apply_kwargs(axes, **kwargs)
431
432    # tight layout and save the figure
433    fig = axes.figure
434    axes_only = kwargs.get("axes_only", False)
435    if not axes_only and (suptitle := kwargs.get("suptitle")):
436        fig.suptitle(suptitle)
437    if kwargs.get("preserve_lims"):
438        # restore the original limits of the axes
439        axes.set_xlim(xlim)
440        axes.set_ylim(ylim)
441    if not axes_only and not isinstance(fig, SubFigure):
442        fig.tight_layout(pad=TIGHT_LAYOUT_PAD)
443    apply_late_kwargs(axes, **kwargs)
444    # axvspan/axvline in late_kwargs may have widened xlim beyond what
445    # set_labels() last saw; regenerate ticks from the updated view.
446    refresh_period_labels(axes)
447    legend = axes.get_legend()
448    if legend and kwargs.get("remove_legend", False):
449        legend.remove()
450    if not axes_only and not isinstance(fig, SubFigure):
451        save_to_file(fig, **kwargs)
452
453    # show the plot in Jupyter Lab
454    if not axes_only and kwargs.get("show"):
455        plt.show()
456
457    # And close
458    if not axes_only and not kwargs.get("dont_close", False):
459        plt.close()
ME: Final[str] = 'finalise_plot'
MAX_FILENAME_LENGTH: Final[int] = 150
DEFAULT_MARGIN: Final[float] = 0.02
TIGHT_LAYOUT_PAD: Final[float] = 1.1
FOOTNOTE_FONTSIZE: Final[int] = 8
FOOTNOTE_FONTSTYLE: Final[str] = 'italic'
FOOTNOTE_COLOR: Final[str] = '#999999'
ZERO_LINE_WIDTH: Final[float] = 0.66
ZERO_LINE_COLOR: Final[str] = '#555555'
ZERO_AXIS_ADJUSTMENT: Final[float] = 0.02
DEFAULT_FILE_TITLE_NAME: Final[str] = 'plot'
class FinaliseKwargs(mgplot.keyword_checking.BaseKwargs):
33class FinaliseKwargs(BaseKwargs):
34    """Keyword arguments for the finalise_plot function."""
35
36    # --- value options
37    suptitle: NotRequired[str | None]
38    title: NotRequired[str | None]
39    xlabel: NotRequired[str | None]
40    ylabel: NotRequired[str | None]
41    xlim: NotRequired[tuple[float, float] | None]
42    ylim: NotRequired[tuple[float, float] | None]
43    xticks: NotRequired[list[float] | None]
44    yticks: NotRequired[list[float] | None]
45    xscale: NotRequired[str | None]
46    yscale: NotRequired[str | None]
47    # --- splat options
48    legend: NotRequired[bool | dict[str, Any] | None]
49    axhspan: NotRequired[dict[str, Any] | Sequence[dict[str, Any]] | None]
50    axvspan: NotRequired[dict[str, Any] | Sequence[dict[str, Any]] | None]
51    axhline: NotRequired[dict[str, Any] | Sequence[dict[str, Any]] | None]
52    axvline: NotRequired[dict[str, Any] | Sequence[dict[str, Any]] | None]
53    # --- options for annotations
54    lfooter: NotRequired[str]
55    rfooter: NotRequired[str]
56    lheader: NotRequired[str]
57    rheader: NotRequired[str]
58    # --- file/save options
59    pre_tag: NotRequired[str]
60    tag: NotRequired[str]
61    filename: NotRequired[str]
62    chart_dir: NotRequired[str]
63    file_type: NotRequired[str]
64    dpi: NotRequired[int]
65    figsize: NotRequired[tuple[float, float]]
66    show: NotRequired[bool]
67    # --- other options
68    preserve_lims: NotRequired[bool]
69    remove_legend: NotRequired[bool]
70    zero_y: NotRequired[bool]
71    y0: NotRequired[bool]
72    x0: NotRequired[bool]
73    axisbelow: NotRequired[bool]
74    dont_save: NotRequired[bool]
75    dont_close: NotRequired[bool]
76    axes_only: NotRequired[bool]

Keyword arguments for the finalise_plot function.

suptitle: NotRequired[str | None]
title: NotRequired[str | None]
xlabel: NotRequired[str | None]
ylabel: NotRequired[str | None]
xlim: NotRequired[tuple[float, float] | None]
ylim: NotRequired[tuple[float, float] | None]
xticks: NotRequired[list[float] | None]
yticks: NotRequired[list[float] | None]
xscale: NotRequired[str | None]
yscale: NotRequired[str | None]
legend: NotRequired[bool | dict[str, Any] | None]
axhspan: NotRequired[dict[str, Any] | Sequence[dict[str, Any]] | None]
axvspan: NotRequired[dict[str, Any] | Sequence[dict[str, Any]] | None]
axhline: NotRequired[dict[str, Any] | Sequence[dict[str, Any]] | None]
axvline: NotRequired[dict[str, Any] | Sequence[dict[str, Any]] | None]
lfooter: NotRequired[str]
rfooter: NotRequired[str]
lheader: NotRequired[str]
rheader: NotRequired[str]
pre_tag: NotRequired[str]
tag: NotRequired[str]
filename: NotRequired[str]
chart_dir: NotRequired[str]
file_type: NotRequired[str]
dpi: NotRequired[int]
figsize: NotRequired[tuple[float, float]]
show: NotRequired[bool]
preserve_lims: NotRequired[bool]
remove_legend: NotRequired[bool]
zero_y: NotRequired[bool]
y0: NotRequired[bool]
x0: NotRequired[bool]
axisbelow: NotRequired[bool]
dont_save: NotRequired[bool]
dont_close: NotRequired[bool]
axes_only: NotRequired[bool]
VALUE_KWARGS = ('title', 'xlabel', 'ylabel', 'xlim', 'ylim', 'xticks', 'yticks', 'xscale', 'yscale')
SPLAT_KWARGS = ('axhspan', 'axvspan', 'axhline', 'axvline', 'legend')
def sanitize_filename(filename: str, max_length: int = 150) -> str:
105def sanitize_filename(filename: str, max_length: int = MAX_FILENAME_LENGTH) -> str:
106    """Convert a string to a safe filename.
107
108    Args:
109        filename: The string to convert to a filename
110        max_length: Maximum length for the filename
111
112    Returns:
113        A safe filename string
114
115    """
116    if not filename:
117        return "untitled"
118
119    # Normalize unicode characters (e.g., é -> e)
120    filename = unicodedata.normalize("NFKD", filename)
121
122    # Remove non-ASCII characters
123    filename = filename.encode("ascii", "ignore").decode("ascii")
124
125    # Convert to lowercase
126    filename = filename.lower()
127
128    # Replace spaces and other separators with hyphens
129    filename = re.sub(r"[\s\-_]+", "-", filename)
130
131    # Remove unsafe characters, keeping only alphanumeric and hyphens
132    filename = re.sub(r"[^a-z0-9\-]", "", filename)
133
134    # Remove leading/trailing hyphens and collapse multiple hyphens
135    filename = re.sub(r"^-+|-+$", "", filename)
136    filename = re.sub(r"-+", "-", filename)
137
138    # Truncate to max length
139    if len(filename) > max_length:
140        filename = filename[:max_length].rstrip("-")
141
142    # Ensure we have a valid filename
143    return filename or "untitled"

Convert a string to a safe filename.

Args: filename: The string to convert to a filename max_length: Maximum length for the filename

Returns: A safe filename string

def make_legend( axes: matplotlib.axes._axes.Axes, *, legend: None | bool | dict[str, typing.Any]) -> None:
146def make_legend(axes: Axes, *, legend: None | bool | dict[str, Any]) -> None:
147    """Create a legend for the plot."""
148    if legend is None or legend is False:
149        return
150
151    if legend is True:  # use the global default settings
152        legend = get_setting("legend")
153
154    if isinstance(legend, dict):
155        axes.legend(**legend)
156        return
157
158    print(f"Warning: expected dict argument for legend, but got {type(legend)}.")

Create a legend for the plot.

def apply_value_kwargs( axes: matplotlib.axes._axes.Axes, value_kwargs_: Sequence[str], **kwargs: Unpack[FinaliseKwargs]) -> None:
161def apply_value_kwargs(axes: Axes, value_kwargs_: Sequence[str], **kwargs: Unpack[FinaliseKwargs]) -> None:
162    """Set matplotlib elements by name using Axes.set().
163
164    Tricky: some plotting functions may set the xlabel or ylabel.
165    So ... we will set these if a setting is explicitly provided. If no
166    setting is provided, we will set to None if they are not already set.
167    If they have already been set, we will not change them.
168
169    """
170    # --- preliminary
171    function: dict[str, Callable[[], str]] = {
172        "xlabel": axes.get_xlabel,
173        "ylabel": axes.get_ylabel,
174        "title": axes.get_title,
175    }
176
177    def fail() -> str:
178        return ""
179
180    # --- loop over potential value settings
181    for setting in value_kwargs_:
182        value = kwargs.get(setting)
183        if setting in kwargs:
184            # deliberately set, so we will action
185            axes.set(**{setting: value})
186            continue
187        required_to_set = ("title", "xlabel", "ylabel")
188        if setting not in required_to_set:
189            # not set - and not required - so we can skip
190            continue
191
192        # we will set these 'required_to_set' ones
193        # provided they are not already set
194        already_set = function.get(setting, fail)()
195        if already_set and value is None:
196            continue
197
198        # if we get here, we will set the value (implicitly to None)
199        axes.set(**{setting: value})

Set matplotlib elements by name using Axes.set().

Tricky: some plotting functions may set the xlabel or ylabel. So ... we will set these if a setting is explicitly provided. If no setting is provided, we will set to None if they are not already set. If they have already been set, we will not change them.

def apply_splat_kwargs( axes: matplotlib.axes._axes.Axes, settings: tuple, **kwargs: Unpack[FinaliseKwargs]) -> None:
264def apply_splat_kwargs(axes: Axes, settings: tuple, **kwargs: Unpack[FinaliseKwargs]) -> None:
265    """Set matplotlib elements dynamically using setting_name and splat."""
266    for method_name in settings:
267        if method_name not in kwargs:
268            continue
269
270        if method_name == "legend":
271            legend_value = kwargs.get(method_name)
272            if isinstance(legend_value, (bool, dict, type(None))):
273                make_legend(axes, legend=legend_value)
274            else:
275                print(f"Warning: expected bool, dict, or None for legend, but got {type(legend_value)}.")
276            continue
277
278        _apply_splat(axes, method_name, kwargs.get(method_name))

Set matplotlib elements dynamically using setting_name and splat.

def apply_annotations( axes: matplotlib.axes._axes.Axes, **kwargs: Unpack[FinaliseKwargs]) -> None:
281def apply_annotations(axes: Axes, **kwargs: Unpack[FinaliseKwargs]) -> None:
282    """Set figure size and apply chart annotations.
283
284    No-op when axes_only=True: the work here is all figure-level (resize,
285    corner text) and would stomp on other panels in a multi-axes figure.
286    """
287    if kwargs.get("axes_only"):
288        return
289    fig = axes.figure
290    fig_size = kwargs.get("figsize", get_setting("figsize"))
291    if not isinstance(fig, SubFigure):
292        fig.set_size_inches(*fig_size)
293
294    annotations = {
295        "rfooter": (0.99, 0.001, "right", "bottom"),
296        "lfooter": (0.01, 0.001, "left", "bottom"),
297        "rheader": (0.99, 0.999, "right", "top"),
298        "lheader": (0.01, 0.999, "left", "top"),
299    }
300
301    for annotation in HEADER_FOOTER_KWARGS:
302        if annotation in kwargs:
303            x_pos, y_pos, h_align, v_align = annotations[annotation]
304            fig.text(
305                x_pos,
306                y_pos,
307                str(kwargs.get(annotation, "")),
308                ha=h_align,
309                va=v_align,
310                fontsize=FOOTNOTE_FONTSIZE,
311                fontstyle=FOOTNOTE_FONTSTYLE,
312                color=FOOTNOTE_COLOR,
313            )

Set figure size and apply chart annotations.

No-op when axes_only=True: the work here is all figure-level (resize, corner text) and would stomp on other panels in a multi-axes figure.

def apply_late_kwargs( axes: matplotlib.axes._axes.Axes, **kwargs: Unpack[FinaliseKwargs]) -> None:
316def apply_late_kwargs(axes: Axes, **kwargs: Unpack[FinaliseKwargs]) -> None:
317    """Apply settings found in kwargs, after plotting the data."""
318    apply_splat_kwargs(axes, SPLAT_KWARGS, **kwargs)

Apply settings found in kwargs, after plotting the data.

def apply_kwargs( axes: matplotlib.axes._axes.Axes, **kwargs: Unpack[FinaliseKwargs]) -> None:
321def apply_kwargs(axes: Axes, **kwargs: Unpack[FinaliseKwargs]) -> None:
322    """Apply settings found in kwargs."""
323
324    def check_kwargs(name: str) -> bool:
325        return name in kwargs and bool(kwargs.get(name))
326
327    apply_value_kwargs(axes, VALUE_KWARGS, **kwargs)
328    apply_annotations(axes, **kwargs)
329
330    if check_kwargs("zero_y"):
331        bottom, top = axes.get_ylim()
332        adj = (top - bottom) * ZERO_AXIS_ADJUSTMENT
333        if bottom > -adj:
334            axes.set_ylim(bottom=-adj)
335        if top < adj:
336            axes.set_ylim(top=adj)
337
338    if check_kwargs("y0"):
339        low, high = axes.get_ylim()
340        if low < 0 < high:
341            axes.axhline(y=0, lw=ZERO_LINE_WIDTH, c=ZERO_LINE_COLOR)
342
343    if check_kwargs("x0"):
344        low, high = axes.get_xlim()
345        if low < 0 < high:
346            axes.axvline(x=0, lw=ZERO_LINE_WIDTH, c=ZERO_LINE_COLOR)
347
348    if check_kwargs("axisbelow"):
349        axes.set_axisbelow(True)

Apply settings found in kwargs.

def save_to_file( fig: matplotlib.figure.Figure, **kwargs: Unpack[FinaliseKwargs]) -> None:
352def save_to_file(fig: Figure, **kwargs: Unpack[FinaliseKwargs]) -> None:
353    """Save the figure to file."""
354    saving = not kwargs.get("dont_save", False)  # save by default
355    if not saving:
356        return
357
358    try:
359        chart_dir = Path(kwargs.get("chart_dir", get_setting("chart_dir")))
360
361        # Ensure directory exists
362        chart_dir.mkdir(parents=True, exist_ok=True)
363
364        suptitle = kwargs.get("suptitle", "")
365        title = kwargs.get("title", "")
366        pre_tag = kwargs.get("pre_tag", "")
367        tag = kwargs.get("tag", "")
368        name_override = kwargs.get("filename", "")
369        name_title = name_override or suptitle or title
370        file_title = sanitize_filename(name_title or DEFAULT_FILE_TITLE_NAME)
371        file_type = kwargs.get("file_type", get_setting("file_type")).lower()
372        dpi = kwargs.get("dpi", get_setting("dpi"))
373
374        # Construct filename components safely
375        filename_parts = []
376        if pre_tag:
377            filename_parts.append(sanitize_filename(pre_tag))
378        filename_parts.append(file_title)
379        if tag:
380            filename_parts.append(sanitize_filename(tag))
381
382        # Join filename parts and add extension
383        filename = "-".join(filter(None, filename_parts))
384        filepath = chart_dir / f"{filename}.{file_type}"
385
386        fig.savefig(filepath, dpi=dpi)
387
388    except (
389        OSError,
390        PermissionError,
391        FileNotFoundError,
392        ValueError,
393        RuntimeError,
394        TypeError,
395        UnicodeError,
396    ) as e:
397        print(f"Error: Could not save plot to file: {e}")

Save the figure to file.

def finalise_plot( axes: matplotlib.axes._axes.Axes, **kwargs: Unpack[FinaliseKwargs]) -> None:
403def finalise_plot(axes: Axes, **kwargs: Unpack[FinaliseKwargs]) -> None:
404    """Finalise and save plots to the file system.
405
406    The filename for the saved plot is constructed from the global
407    chart_dir, the plot's title, any specified tag text, and the
408    file_type for the plot.
409
410    Args:
411        axes: Axes - matplotlib axes object - required
412        kwargs: FinaliseKwargs
413
414    """
415    # --- check the kwargs
416    report_kwargs(caller=ME, **kwargs)
417    validate_kwargs(schema=FinaliseKwargs, caller=ME, **kwargs)
418
419    # --- sanity checks
420    if len(axes.get_children()) < 1:
421        print(f"Warning: {ME}() called with an empty axes, which was ignored.")
422        return
423
424    # --- remember axis-limits should we need to restore thems
425    xlim, ylim = axes.get_xlim(), axes.get_ylim()
426
427    # margins
428    axes.margins(DEFAULT_MARGIN)
429    axes.autoscale(tight=False)  # This is problematic ...
430
431    apply_kwargs(axes, **kwargs)
432
433    # tight layout and save the figure
434    fig = axes.figure
435    axes_only = kwargs.get("axes_only", False)
436    if not axes_only and (suptitle := kwargs.get("suptitle")):
437        fig.suptitle(suptitle)
438    if kwargs.get("preserve_lims"):
439        # restore the original limits of the axes
440        axes.set_xlim(xlim)
441        axes.set_ylim(ylim)
442    if not axes_only and not isinstance(fig, SubFigure):
443        fig.tight_layout(pad=TIGHT_LAYOUT_PAD)
444    apply_late_kwargs(axes, **kwargs)
445    # axvspan/axvline in late_kwargs may have widened xlim beyond what
446    # set_labels() last saw; regenerate ticks from the updated view.
447    refresh_period_labels(axes)
448    legend = axes.get_legend()
449    if legend and kwargs.get("remove_legend", False):
450        legend.remove()
451    if not axes_only and not isinstance(fig, SubFigure):
452        save_to_file(fig, **kwargs)
453
454    # show the plot in Jupyter Lab
455    if not axes_only and kwargs.get("show"):
456        plt.show()
457
458    # And close
459    if not axes_only and not kwargs.get("dont_close", False):
460        plt.close()

Finalise and save plots to the file system.

The filename for the saved plot is constructed from the global chart_dir, the plot's title, any specified tag text, and the file_type for the plot.

Args: axes: Axes - matplotlib axes object - required kwargs: FinaliseKwargs