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]]
 47    axvspan: NotRequired[dict[str, Any]]
 48    axhline: NotRequired[dict[str, Any]]
 49    axvline: NotRequired[dict[str, Any]]
 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    dont_save: NotRequired[bool]
 70    dont_close: NotRequired[bool]
 71
 72
 73VALUE_KWARGS = (
 74    "title",
 75    "xlabel",
 76    "ylabel",
 77    "xlim",
 78    "ylim",
 79    "xticks",
 80    "yticks",
 81    "xscale",
 82    "yscale",
 83)
 84SPLAT_KWARGS = (
 85    "axhspan",
 86    "axvspan",
 87    "axhline",
 88    "axvline",
 89    "legend",  # needs to be last in this tuple
 90)
 91HEADER_FOOTER_KWARGS = (
 92    "lfooter",
 93    "rfooter",
 94    "lheader",
 95    "rheader",
 96)
 97
 98
 99def sanitize_filename(filename: str, max_length: int = MAX_FILENAME_LENGTH) -> str:
100    """Convert a string to a safe filename.
101
102    Args:
103        filename: The string to convert to a filename
104        max_length: Maximum length for the filename
105
106    Returns:
107        A safe filename string
108
109    """
110    if not filename:
111        return "untitled"
112
113    # Normalize unicode characters (e.g., é -> e)
114    filename = unicodedata.normalize("NFKD", filename)
115
116    # Remove non-ASCII characters
117    filename = filename.encode("ascii", "ignore").decode("ascii")
118
119    # Convert to lowercase
120    filename = filename.lower()
121
122    # Replace spaces and other separators with hyphens
123    filename = re.sub(r"[\s\-_]+", "-", filename)
124
125    # Remove unsafe characters, keeping only alphanumeric and hyphens
126    filename = re.sub(r"[^a-z0-9\-]", "", filename)
127
128    # Remove leading/trailing hyphens and collapse multiple hyphens
129    filename = re.sub(r"^-+|-+$", "", filename)
130    filename = re.sub(r"-+", "-", filename)
131
132    # Truncate to max length
133    if len(filename) > max_length:
134        filename = filename[:max_length].rstrip("-")
135
136    # Ensure we have a valid filename
137    return filename if filename else "untitled"
138
139
140def make_legend(axes: Axes, *, legend: None | bool | dict[str, Any]) -> None:
141    """Create a legend for the plot."""
142    if legend is None or legend is False:
143        return
144
145    if legend is True:  # use the global default settings
146        legend = get_setting("legend")
147
148    if isinstance(legend, dict):
149        axes.legend(**legend)
150        return
151
152    print(f"Warning: expected dict argument for legend, but got {type(legend)}.")
153
154
155def apply_value_kwargs(axes: Axes, value_kwargs_: Sequence[str], **kwargs: Unpack[FinaliseKwargs]) -> None:
156    """Set matplotlib elements by name using Axes.set().
157
158    Tricky: some plotting functions may set the xlabel or ylabel.
159    So ... we will set these if a setting is explicitly provided. If no
160    setting is provided, we will set to None if they are not already set.
161    If they have already been set, we will not change them.
162
163    """
164    # --- preliminary
165    function: dict[str, Callable[[], str]] = {
166        "xlabel": axes.get_xlabel,
167        "ylabel": axes.get_ylabel,
168        "title": axes.get_title,
169    }
170
171    def fail() -> str:
172        return ""
173
174    # --- loop over potential value settings
175    for setting in value_kwargs_:
176        value = kwargs.get(setting)
177        if setting in kwargs:
178            # deliberately set, so we will action
179            axes.set(**{setting: value})
180            continue
181        required_to_set = ("title", "xlabel", "ylabel")
182        if setting not in required_to_set:
183            # not set - and not required - so we can skip
184            continue
185
186        # we will set these 'required_to_set' ones
187        # provided they are not already set
188        already_set = function.get(setting, fail)()
189        if already_set and value is None:
190            continue
191
192        # if we get here, we will set the value (implicitly to None)
193        axes.set(**{setting: value})
194
195
196def apply_splat_kwargs(axes: Axes, settings: tuple, **kwargs: Unpack[FinaliseKwargs]) -> None:
197    """Set matplotlib elements dynamically using setting_name and splat."""
198    for method_name in settings:
199        if method_name in kwargs:
200            if method_name == "legend":
201                # special case for legend
202                legend_value = kwargs.get(method_name)
203                if isinstance(legend_value, (bool, dict, type(None))):
204                    make_legend(axes, legend=legend_value)
205                else:
206                    print(f"Warning: expected bool, dict, or None for legend, but got {type(legend_value)}.")
207                continue
208
209            if method_name not in kwargs:
210                continue
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                )
226
227
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            )
255
256
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)
260
261
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
290def save_to_file(fig: Figure, **kwargs: Unpack[FinaliseKwargs]) -> None:
291    """Save the figure to file."""
292    saving = not kwargs.get("dont_save", False)  # save by default
293    if not saving:
294        return
295
296    try:
297        chart_dir = Path(kwargs.get("chart_dir", get_setting("chart_dir")))
298
299        # Ensure directory exists
300        chart_dir.mkdir(parents=True, exist_ok=True)
301
302        suptitle = kwargs.get("suptitle", "")
303        title = kwargs.get("title", "")
304        pre_tag = kwargs.get("pre_tag", "")
305        tag = kwargs.get("tag", "")
306        name_title = suptitle if suptitle else title
307        file_title = sanitize_filename(name_title if name_title else DEFAULT_FILE_TITLE_NAME)
308        file_type = kwargs.get("file_type", get_setting("file_type")).lower()
309        dpi = kwargs.get("dpi", get_setting("dpi"))
310
311        # Construct filename components safely
312        filename_parts = []
313        if pre_tag:
314            filename_parts.append(sanitize_filename(pre_tag))
315        filename_parts.append(file_title)
316        if tag:
317            filename_parts.append(sanitize_filename(tag))
318
319        # Join filename parts and add extension
320        filename = "-".join(filter(None, filename_parts))
321        filepath = chart_dir / f"{filename}.{file_type}"
322
323        fig.savefig(filepath, dpi=dpi)
324
325    except (
326        OSError,
327        PermissionError,
328        FileNotFoundError,
329        ValueError,
330        RuntimeError,
331        TypeError,
332        UnicodeError,
333    ) as e:
334        print(f"Error: Could not save plot to file: {e}")
335
336
337# - public functions for finalise_plot()
338
339
340def finalise_plot(axes: Axes, **kwargs: Unpack[FinaliseKwargs]) -> None:
341    """Finalise and save plots to the file system.
342
343    The filename for the saved plot is constructed from the global
344    chart_dir, the plot's title, any specified tag text, and the
345    file_type for the plot.
346
347    Args:
348        axes: Axes - matplotlib axes object - required
349        kwargs: FinaliseKwargs
350
351    """
352    # --- check the kwargs
353    report_kwargs(caller=ME, **kwargs)
354    validate_kwargs(schema=FinaliseKwargs, caller=ME, **kwargs)
355
356    # --- sanity checks
357    if len(axes.get_children()) < 1:
358        print(f"Warning: {ME}() called with an empty axes, which was ignored.")
359        return
360
361    # --- remember axis-limits should we need to restore thems
362    xlim, ylim = axes.get_xlim(), axes.get_ylim()
363
364    # margins
365    axes.margins(DEFAULT_MARGIN)
366    axes.autoscale(tight=False)  # This is problematic ...
367
368    apply_kwargs(axes, **kwargs)
369
370    # tight layout and save the figure
371    fig = axes.figure
372    if suptitle := kwargs.get("suptitle"):
373        fig.suptitle(suptitle)
374    if kwargs.get("preserve_lims"):
375        # restore the original limits of the axes
376        axes.set_xlim(xlim)
377        axes.set_ylim(ylim)
378    if not isinstance(fig, SubFigure):
379        fig.tight_layout(pad=TIGHT_LAYOUT_PAD)
380    apply_late_kwargs(axes, **kwargs)
381    legend = axes.get_legend()
382    if legend and kwargs.get("remove_legend", False):
383        legend.remove()
384    if not isinstance(fig, SubFigure):
385        save_to_file(fig, **kwargs)
386
387    # show the plot in Jupyter Lab
388    if kwargs.get("show"):
389        plt.show()
390
391    # And close
392    if not kwargs.get("dont_close", False):
393        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]]
48    axvspan: NotRequired[dict[str, Any]]
49    axhline: NotRequired[dict[str, Any]]
50    axvline: NotRequired[dict[str, Any]]
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    dont_save: NotRequired[bool]
71    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]]
axvspan: NotRequired[dict[str, Any]]
axhline: NotRequired[dict[str, Any]]
axvline: NotRequired[dict[str, Any]]
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]
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:
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"

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:
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)}.")

Create a legend for the plot.

def apply_value_kwargs( axes: matplotlib.axes._axes.Axes, value_kwargs_: Sequence[str], **kwargs: Unpack[FinaliseKwargs]) -> None:
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})

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:
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            if method_name not in kwargs:
211                continue
212            value = kwargs.get(method_name)
213            if value is None or value is False:
214                continue
215
216            if value is True:  # use the global default settings
217                value = get_setting(method_name)
218
219            # splat the kwargs to the method
220            if isinstance(value, dict):
221                method = getattr(axes, method_name)
222                method(**value)
223            else:
224                print(
225                    f"Warning expected dict argument for {method_name} but got {type(value)}.",
226                )

Set matplotlib elements dynamically using setting_name and splat.

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

Set figure size and apply chart annotations.

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

Apply settings found in kwargs.

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