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

Set matplotlib elements dynamically using setting_name and splat.

def apply_annotations( axes: matplotlib.axes._axes.Axes, **kwargs: Unpack[FinaliseKwargs]) -> None:
228def apply_annotations(axes: Axes, **kwargs: Unpack[FinaliseKwargs]) -> None:
229    """Set figure size and apply chart annotations."""
230    fig = axes.figure
231    fig_size = kwargs.get("figsize", get_setting("figsize"))
232    if not isinstance(fig, SubFigure):
233        fig.set_size_inches(*fig_size)
234
235    annotations = {
236        "rfooter": (0.99, 0.001, "right", "bottom"),
237        "lfooter": (0.01, 0.001, "left", "bottom"),
238        "rheader": (0.99, 0.999, "right", "top"),
239        "lheader": (0.01, 0.999, "left", "top"),
240    }
241
242    for annotation in HEADER_FOOTER_KWARGS:
243        if annotation in kwargs:
244            x_pos, y_pos, h_align, v_align = annotations[annotation]
245            fig.text(
246                x_pos,
247                y_pos,
248                str(kwargs.get(annotation, "")),
249                ha=h_align,
250                va=v_align,
251                fontsize=FOOTNOTE_FONTSIZE,
252                fontstyle=FOOTNOTE_FONTSTYLE,
253                color=FOOTNOTE_COLOR,
254            )

Set figure size and apply chart annotations.

def apply_late_kwargs( axes: matplotlib.axes._axes.Axes, **kwargs: Unpack[FinaliseKwargs]) -> None:
257def apply_late_kwargs(axes: Axes, **kwargs: Unpack[FinaliseKwargs]) -> None:
258    """Apply settings found in kwargs, after plotting the data."""
259    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:
262def apply_kwargs(axes: Axes, **kwargs: Unpack[FinaliseKwargs]) -> None:
263    """Apply settings found in kwargs."""
264
265    def check_kwargs(name: str) -> bool:
266        return name in kwargs and bool(kwargs.get(name))
267
268    apply_value_kwargs(axes, VALUE_KWARGS, **kwargs)
269    apply_annotations(axes, **kwargs)
270
271    if check_kwargs("zero_y"):
272        bottom, top = axes.get_ylim()
273        adj = (top - bottom) * ZERO_AXIS_ADJUSTMENT
274        if bottom > -adj:
275            axes.set_ylim(bottom=-adj)
276        if top < adj:
277            axes.set_ylim(top=adj)
278
279    if check_kwargs("y0"):
280        low, high = axes.get_ylim()
281        if low < 0 < high:
282            axes.axhline(y=0, lw=ZERO_LINE_WIDTH, c=ZERO_LINE_COLOR)
283
284    if check_kwargs("x0"):
285        low, high = axes.get_xlim()
286        if low < 0 < high:
287            axes.axvline(x=0, lw=ZERO_LINE_WIDTH, c=ZERO_LINE_COLOR)
288
289    if check_kwargs("axisbelow"):
290        axes.set_axisbelow(True)

Apply settings found in kwargs.

def save_to_file( fig: matplotlib.figure.Figure, **kwargs: Unpack[FinaliseKwargs]) -> None:
293def save_to_file(fig: Figure, **kwargs: Unpack[FinaliseKwargs]) -> None:
294    """Save the figure to file."""
295    saving = not kwargs.get("dont_save", False)  # save by default
296    if not saving:
297        return
298
299    try:
300        chart_dir = Path(kwargs.get("chart_dir", get_setting("chart_dir")))
301
302        # Ensure directory exists
303        chart_dir.mkdir(parents=True, exist_ok=True)
304
305        suptitle = kwargs.get("suptitle", "")
306        title = kwargs.get("title", "")
307        pre_tag = kwargs.get("pre_tag", "")
308        tag = kwargs.get("tag", "")
309        name_title = suptitle if suptitle else title
310        file_title = sanitize_filename(name_title if name_title else DEFAULT_FILE_TITLE_NAME)
311        file_type = kwargs.get("file_type", get_setting("file_type")).lower()
312        dpi = kwargs.get("dpi", get_setting("dpi"))
313
314        # Construct filename components safely
315        filename_parts = []
316        if pre_tag:
317            filename_parts.append(sanitize_filename(pre_tag))
318        filename_parts.append(file_title)
319        if tag:
320            filename_parts.append(sanitize_filename(tag))
321
322        # Join filename parts and add extension
323        filename = "-".join(filter(None, filename_parts))
324        filepath = chart_dir / f"{filename}.{file_type}"
325
326        fig.savefig(filepath, dpi=dpi)
327
328    except (
329        OSError,
330        PermissionError,
331        FileNotFoundError,
332        ValueError,
333        RuntimeError,
334        TypeError,
335        UnicodeError,
336    ) as e:
337        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:
343def finalise_plot(axes: Axes, **kwargs: Unpack[FinaliseKwargs]) -> None:
344    """Finalise and save plots to the file system.
345
346    The filename for the saved plot is constructed from the global
347    chart_dir, the plot's title, any specified tag text, and the
348    file_type for the plot.
349
350    Args:
351        axes: Axes - matplotlib axes object - required
352        kwargs: FinaliseKwargs
353
354    """
355    # --- check the kwargs
356    report_kwargs(caller=ME, **kwargs)
357    validate_kwargs(schema=FinaliseKwargs, caller=ME, **kwargs)
358
359    # --- sanity checks
360    if len(axes.get_children()) < 1:
361        print(f"Warning: {ME}() called with an empty axes, which was ignored.")
362        return
363
364    # --- remember axis-limits should we need to restore thems
365    xlim, ylim = axes.get_xlim(), axes.get_ylim()
366
367    # margins
368    axes.margins(DEFAULT_MARGIN)
369    axes.autoscale(tight=False)  # This is problematic ...
370
371    apply_kwargs(axes, **kwargs)
372
373    # tight layout and save the figure
374    fig = axes.figure
375    if suptitle := kwargs.get("suptitle"):
376        fig.suptitle(suptitle)
377    if kwargs.get("preserve_lims"):
378        # restore the original limits of the axes
379        axes.set_xlim(xlim)
380        axes.set_ylim(ylim)
381    if not isinstance(fig, SubFigure):
382        fig.tight_layout(pad=TIGHT_LAYOUT_PAD)
383    apply_late_kwargs(axes, **kwargs)
384    legend = axes.get_legend()
385    if legend and kwargs.get("remove_legend", False):
386        legend.remove()
387    if not isinstance(fig, SubFigure):
388        save_to_file(fig, **kwargs)
389
390    # show the plot in Jupyter Lab
391    if kwargs.get("show"):
392        plt.show()
393
394    # And close
395    if not kwargs.get("dont_close", False):
396        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