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