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

Keyword arguments for the finalise_plot function.

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:
 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"

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

Create a legend for the plot.

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

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:
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                )

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)

Apply settings found in kwargs.

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