CodonAdaptPy 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,220 @@
1
+ """
2
+ CodonAdaptPy Sequence and Metadata Parsers
3
+
4
+ This module provides batch-friendly readers for FASTA, GenBank, delimited
5
+ tables, ZIP archives, open text streams, and directly supplied nucleotide
6
+ strings. FASTA parsing is implemented in the standard library; GenBank parsing
7
+ uses Biopython when requested.
8
+
9
+ Features:
10
+ - Duplicate identifier detection across every input source
11
+ - Transparent DNA/RNA sequence preservation for later validation
12
+ - Metadata attachment from CSV and TSV files
13
+ - Safe ZIP member inspection without extracting archive contents
14
+
15
+ Classes:
16
+ - SequenceParser: Unified high-level parser for supported input formats.
17
+
18
+ :Created: July 20, 2026
19
+ :Updated: July 20, 2026
20
+ :Author: Naveen Duhan
21
+ :Version: 1.0.0
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import csv
27
+ import io
28
+ import shlex
29
+ import zipfile
30
+ from collections.abc import Iterable, Iterator
31
+ from pathlib import Path
32
+ from typing import TextIO, cast
33
+
34
+ from .exceptions import OptionalDependencyError, SequenceValidationError
35
+ from .models import SequenceRecord
36
+
37
+
38
+ class SequenceParser:
39
+ """Read supported sequence inputs into consistent sequence records."""
40
+
41
+ def parse(self, source: str | Path | TextIO, *, fmt: str | None = None) -> list[SequenceRecord]:
42
+ """Parse a path, text stream, or direct nucleotide sequence.
43
+
44
+ Format is inferred from file extensions when omitted. Plain strings
45
+ that do not identify existing paths are treated as pasted sequences
46
+ unless they begin with ``>`` and therefore represent FASTA text.
47
+ """
48
+
49
+ if hasattr(source, "read"):
50
+ return list(self.parse_fasta(cast(TextIO, source)))
51
+ if isinstance(source, str):
52
+ text = source.strip()
53
+ if text.startswith(">"):
54
+ return list(self.parse_fasta(io.StringIO(text)))
55
+ compact = "".join(text.split()).upper()
56
+ if compact and not (set(compact) - set("ACGTURYSWKMBDHVN")):
57
+ return [SequenceRecord(identifier="sequence_1", sequence=text)]
58
+ path = Path(source)
59
+ if path.exists():
60
+ inferred = (fmt or path.suffix.lstrip(".")).lower()
61
+ if inferred in {"fa", "fna", "fas", "fasta"}:
62
+ with path.open(encoding="utf-8") as handle:
63
+ return list(self.parse_fasta(handle))
64
+ if inferred in {"gb", "gbk", "genbank"}:
65
+ return list(self.parse_genbank(path))
66
+ if inferred in {"csv", "tsv", "txt"}:
67
+ return list(self.parse_table(path))
68
+ if inferred == "zip":
69
+ return list(self.parse_zip(path))
70
+ raise ValueError(f"Unsupported sequence input format: {path.suffix or fmt}")
71
+ return [SequenceRecord(identifier="sequence_1", sequence=str(source))]
72
+
73
+ def parse_fasta(self, handle: Iterable[str]) -> Iterator[SequenceRecord]:
74
+ """Yield records from FASTA-formatted text with duplicate checks."""
75
+
76
+ identifier: str | None = None
77
+ description = ""
78
+ metadata: dict[str, str] = {}
79
+ sequence: list[str] = []
80
+ seen: set[str] = set()
81
+ for line_number, raw_line in enumerate(handle, start=1):
82
+ line = raw_line.strip()
83
+ if not line:
84
+ continue
85
+ if line.startswith(">"):
86
+ if identifier is not None:
87
+ yield SequenceRecord(identifier, "".join(sequence), description, metadata)
88
+ header = line[1:].strip()
89
+ if not header:
90
+ raise SequenceValidationError(f"Empty FASTA header on line {line_number}.")
91
+ fields = header.split(maxsplit=1)
92
+ identifier = fields[0]
93
+ description = fields[1] if len(fields) == 2 else ""
94
+ if identifier in seen:
95
+ raise SequenceValidationError(f"Duplicate FASTA identifier: {identifier}")
96
+ seen.add(identifier)
97
+ sequence = []
98
+ metadata = {
99
+ key: value
100
+ for token in shlex.split(description)
101
+ if "=" in token
102
+ for key, value in [token.split("=", 1)]
103
+ }
104
+ elif identifier is None:
105
+ raise SequenceValidationError(f"FASTA sequence data precede the first header on line {line_number}.")
106
+ else:
107
+ sequence.append(line)
108
+ if identifier is not None:
109
+ yield SequenceRecord(identifier, "".join(sequence), description, metadata)
110
+
111
+ def parse_table(self, path: str | Path) -> Iterator[SequenceRecord]:
112
+ """Yield records from CSV/TSV containing identifier and sequence columns."""
113
+
114
+ path = Path(path)
115
+ delimiter = "," if path.suffix.lower() == ".csv" else "\t"
116
+ with path.open(encoding="utf-8", newline="") as handle:
117
+ reader = csv.DictReader(handle, delimiter=delimiter)
118
+ normalized = {name.lower(): name for name in (reader.fieldnames or [])}
119
+ id_column = normalized.get("identifier") or normalized.get("id") or normalized.get("name")
120
+ sequence_column = normalized.get("sequence") or normalized.get("seq")
121
+ if not id_column or not sequence_column:
122
+ raise SequenceValidationError("Delimited input requires identifier/id/name and sequence/seq columns.")
123
+ seen: set[str] = set()
124
+ for row in reader:
125
+ identifier = (row.get(id_column) or "").strip()
126
+ if not identifier:
127
+ raise SequenceValidationError("Delimited input contains an empty identifier.")
128
+ if identifier in seen:
129
+ raise SequenceValidationError(f"Duplicate table identifier: {identifier}")
130
+ seen.add(identifier)
131
+ metadata = {key: value for key, value in row.items() if key not in {id_column, sequence_column}}
132
+ yield SequenceRecord(identifier, row.get(sequence_column) or "", metadata=metadata)
133
+
134
+ def parse_genbank(self, path: str | Path) -> Iterator[SequenceRecord]:
135
+ """Yield CDS features, or whole records without CDS, from GenBank input."""
136
+
137
+ try:
138
+ from Bio import SeqIO
139
+ except ImportError as exc:
140
+ raise OptionalDependencyError("GenBank parsing requires CodonAdaptPy[io].") from exc
141
+ for source in SeqIO.parse(str(path), "genbank"):
142
+ cds_features = [feature for feature in source.features if feature.type == "CDS"]
143
+ if not cds_features:
144
+ yield SequenceRecord(source.id, str(source.seq), source.description, dict(source.annotations))
145
+ continue
146
+ for index, feature in enumerate(cds_features, start=1):
147
+ qualifiers = feature.qualifiers
148
+ product = (qualifiers.get("product") or [""])[0]
149
+ protein_id = (qualifiers.get("protein_id") or [""])[0]
150
+ identifier = (
151
+ qualifiers.get("locus_tag") or qualifiers.get("protein_id") or [f"{source.id}_CDS{index}"]
152
+ )[0]
153
+ gene = (qualifiers.get("gene") or qualifiers.get("locus_tag") or [identifier])[0]
154
+ yield SequenceRecord(
155
+ identifier,
156
+ str(feature.extract(source.seq)),
157
+ product,
158
+ {
159
+ "source_record": source.id,
160
+ "isolate": source.id,
161
+ "gene": gene,
162
+ "product": product,
163
+ "protein_id": protein_id,
164
+ "feature_index": index,
165
+ "location": str(feature.location),
166
+ },
167
+ )
168
+
169
+ def attach_metadata(
170
+ self,
171
+ records: list[SequenceRecord],
172
+ path: str | Path,
173
+ *,
174
+ identifier_column: str = "identifier",
175
+ ) -> list[SequenceRecord]:
176
+ """Attach CSV/TSV metadata rows to records by unique identifier."""
177
+
178
+ metadata_path = Path(path)
179
+ delimiter = "," if metadata_path.suffix.lower() == ".csv" else "\t"
180
+ with metadata_path.open(encoding="utf-8", newline="") as handle:
181
+ rows = list(csv.DictReader(handle, delimiter=delimiter))
182
+ if not rows or identifier_column not in rows[0]:
183
+ raise SequenceValidationError(f"Metadata requires a {identifier_column!r} column.")
184
+ metadata_by_id: dict[str, dict[str, str | None]] = {}
185
+ for row in rows:
186
+ identifier = (row.get(identifier_column) or "").strip()
187
+ if not identifier:
188
+ raise SequenceValidationError("Metadata contains an empty identifier.")
189
+ if identifier in metadata_by_id:
190
+ raise SequenceValidationError(f"Duplicate metadata identifier: {identifier}")
191
+ metadata_by_id[identifier] = row
192
+ record_ids = {record.identifier for record in records}
193
+ missing = sorted(record_ids - metadata_by_id.keys())
194
+ if missing:
195
+ raise SequenceValidationError(f"Metadata are missing for: {', '.join(missing)}")
196
+ for record in records:
197
+ record.metadata.update(
198
+ {key: value for key, value in metadata_by_id[record.identifier].items() if key != identifier_column}
199
+ )
200
+ return records
201
+
202
+ def parse_zip(self, path: str | Path) -> Iterator[SequenceRecord]:
203
+ """Yield FASTA and delimited records directly from a ZIP archive."""
204
+
205
+ seen: set[str] = set()
206
+ with zipfile.ZipFile(path) as archive:
207
+ for member in sorted(archive.namelist()):
208
+ suffix = Path(member).suffix.lower()
209
+ if member.endswith("/") or suffix not in {".fa", ".fna", ".fas", ".fasta"}:
210
+ continue
211
+ with archive.open(member) as binary:
212
+ text = io.TextIOWrapper(binary, encoding="utf-8")
213
+ for record in self.parse_fasta(text):
214
+ if record.identifier in seen:
215
+ raise SequenceValidationError(
216
+ f"Duplicate identifier across ZIP members: {record.identifier}"
217
+ )
218
+ seen.add(record.identifier)
219
+ record.metadata["archive_member"] = member
220
+ yield record
@@ -0,0 +1,334 @@
1
+ """
2
+ CodonAdaptPy ETE3 Phylogenetic Visualization
3
+
4
+ This module converts ETE3 tree objects into publication-ready Matplotlib
5
+ figures. ETE3 supplies topology parsing, rooting, traversal, pruning, and node
6
+ metadata; Matplotlib supplies stable headless rendering to PNG and vector PDF
7
+ without requiring ETE3's optional Qt graphical stack.
8
+
9
+ The plotting methods preserve branch lengths, show optional support values,
10
+ use colorblind-safe group encodings, and distinguish deterministic time-tree
11
+ and lineage-through-time outputs from Bayesian chronograms or skyline plots.
12
+
13
+ Classes:
14
+ - ETE3TreePlotter: Rectangular ML trees, time-scaled trees, root-to-tip
15
+ regressions, and lineage-through-time trajectory figures.
16
+
17
+ :Created: July 20, 2026
18
+ :Updated: July 20, 2026
19
+ :Author: Naveen Duhan
20
+ :Version: 1.0.0
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from collections.abc import Mapping, Sequence
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+
30
+ class ETE3TreePlotter:
31
+ """Render ETE3 phylogenies with consistent publication styling."""
32
+
33
+ COLORS = ("#0072B2", "#E69F00", "#009E73", "#CC79A7", "#7A7A7A", "#D55E00")
34
+ MARKERS = ("o", "s", "^", "D", "P", "X")
35
+
36
+ @staticmethod
37
+ def _matplotlib() -> tuple[Any, Any]:
38
+ """Import and configure Matplotlib only when a plot is requested."""
39
+
40
+ try:
41
+ import matplotlib as mpl
42
+ import matplotlib.pyplot as plt
43
+ except ImportError as exc:
44
+ raise RuntimeError("Phylogenetic plotting requires matplotlib; install the 'plot' extra.") from exc
45
+ mpl.rcParams.update(
46
+ {
47
+ "font.family": "sans-serif",
48
+ "font.sans-serif": ["Arial", "Helvetica", "DejaVu Sans"],
49
+ "font.size": 8,
50
+ "axes.labelsize": 9,
51
+ "axes.titlesize": 10,
52
+ "xtick.labelsize": 7,
53
+ "ytick.labelsize": 7,
54
+ "pdf.fonttype": 42,
55
+ "ps.fonttype": 42,
56
+ }
57
+ )
58
+ return plt, mpl
59
+
60
+ @staticmethod
61
+ def _import_ete3() -> Any:
62
+ """Import ETE3 Tree with Python >= 3.13 cgi compatibility."""
63
+ import sys
64
+ import types
65
+
66
+ if "cgi" not in sys.modules:
67
+ dummy_cgi = types.ModuleType("cgi")
68
+ dummy_cgi.escape = (
69
+ lambda text, quote=True: text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
70
+ )
71
+ sys.modules["cgi"] = dummy_cgi
72
+ from ete3 import Tree
73
+
74
+ return Tree
75
+
76
+ @classmethod
77
+ def _tree(cls, tree: str | Path | Any) -> Any:
78
+ """Load or copy a tree through ETE3."""
79
+
80
+ try:
81
+ Tree = cls._import_ete3()
82
+ except (ImportError, ModuleNotFoundError) as exc:
83
+ raise RuntimeError("Tree plotting requires ete3; install the 'phylo' extra.") from exc
84
+ tree_object: Any = tree
85
+ if hasattr(tree_object, "traverse"):
86
+ return tree_object.copy(method="deepcopy")
87
+ try:
88
+ return Tree(str(tree), format=0)
89
+ except Exception:
90
+ return Tree(str(tree), format=1)
91
+
92
+ @staticmethod
93
+ def _coordinates(tree: Any, *, time_scaled: bool = False) -> tuple[dict[Any, float], dict[Any, float]]:
94
+ """Calculate rectangular-tree x/y coordinates for all ETE3 nodes."""
95
+
96
+ leaves = list(tree.iter_leaves())
97
+ y = {leaf: float(len(leaves) - index - 1) for index, leaf in enumerate(leaves)}
98
+ for node in tree.traverse("postorder"):
99
+ if not node.is_leaf():
100
+ y[node] = sum(y[child] for child in node.children) / len(node.children)
101
+ if time_scaled:
102
+ if any(not hasattr(node, "calendar_date") for node in tree.traverse()):
103
+ raise ValueError("Every tree node must contain calendar_date for time-tree plotting.")
104
+ x = {node: float(node.calendar_date) for node in tree.traverse()}
105
+ else:
106
+ x = {node: float(tree.get_distance(node)) for node in tree.traverse()}
107
+ return x, y
108
+
109
+ @staticmethod
110
+ def _clean_axis(axis: Any) -> None:
111
+ """Apply an open, outward-spine publication style."""
112
+
113
+ axis.spines["top"].set_visible(False)
114
+ axis.spines["right"].set_visible(False)
115
+ axis.spines["left"].set_position(("outward", 5))
116
+ axis.spines["bottom"].set_position(("outward", 5))
117
+
118
+ def tree(
119
+ self,
120
+ tree: str | Path | Any,
121
+ *,
122
+ groups: Mapping[str, str] | None = None,
123
+ title: str = "Maximum-likelihood phylogeny",
124
+ show_support: bool = True,
125
+ minimum_support: float = 70.0,
126
+ time_scaled: bool = False,
127
+ label_tips: bool = True,
128
+ align_tip_labels: bool = True,
129
+ tip_font_size: float = 7.0,
130
+ support_font_size: float = 6.5,
131
+ ) -> Any:
132
+ """Plot a branch-length or calendar-time rectangular phylogeny.
133
+
134
+ Parameters:
135
+ tree: Newick path/string or ETE3 tree.
136
+ groups: Optional mapping from leaf name to genotype/group.
137
+ show_support: Label internal support at or above ``minimum_support``.
138
+ time_scaled: Use each node's ``calendar_date`` feature for x.
139
+ label_tips: Show leaf names; disable for dense overview trees.
140
+ align_tip_labels: Place names in one right-hand column connected
141
+ to their terminal nodes by dotted leader lines.
142
+ tip_font_size: Tip-label size in points. The default remains
143
+ legible when a dense tree is reproduced at journal width.
144
+ support_font_size: Internal branch-support label size in points.
145
+ """
146
+
147
+ loaded = self._tree(tree)
148
+ if len(loaded) < 2:
149
+ raise ValueError("A plotted phylogeny must contain at least two leaves.")
150
+ plt, _ = self._matplotlib()
151
+ if tip_font_size <= 0 or support_font_size <= 0:
152
+ raise ValueError("Tree label font sizes must be positive.")
153
+ height = max(4.5, min(14.0, len(loaded) * (0.16 if label_tips else 0.08)))
154
+ figure, axis = plt.subplots(figsize=(9.2, height), constrained_layout=True)
155
+ x, y = self._coordinates(loaded, time_scaled=time_scaled)
156
+ x_span = max(x.values()) - min(x.values())
157
+ label_x = max(x.values()) + max(x_span * 0.025, 1e-6)
158
+ known_groups = sorted({groups.get(leaf.name, "Unknown") for leaf in loaded.iter_leaves()}) if groups else []
159
+ palette = {group: self.COLORS[index % len(self.COLORS)] for index, group in enumerate(known_groups)}
160
+ for node in loaded.traverse("preorder"):
161
+ if node.is_leaf():
162
+ continue
163
+ child_y = [y[child] for child in node.children]
164
+ axis.plot([x[node], x[node]], [min(child_y), max(child_y)], color="#4D4D4D", linewidth=0.65)
165
+ for child in node.children:
166
+ axis.plot([x[node], x[child]], [y[child], y[child]], color="#4D4D4D", linewidth=0.65)
167
+ support = float(getattr(node, "support", 0.0))
168
+ support_percent = support * 100 if 0 < support <= 1 else support
169
+ visible_branch = not node.is_root() and abs(x[node] - x[node.up]) >= max(x_span * 0.008, 1e-12)
170
+ if show_support and support_percent >= minimum_support and visible_branch:
171
+ axis.text(
172
+ x[node],
173
+ y[node] + 0.16,
174
+ f"{support_percent:.0f}",
175
+ ha="center",
176
+ va="bottom",
177
+ fontsize=support_font_size,
178
+ )
179
+ for leaf in loaded.iter_leaves():
180
+ group = groups.get(leaf.name, "Unknown") if groups else "Samples"
181
+ color = palette.get(group, self.COLORS[0])
182
+ axis.scatter(x[leaf], y[leaf], s=12, color=color, edgecolor="white", linewidth=0.25, zorder=3)
183
+ if label_tips:
184
+ text_x = label_x if align_tip_labels else x[leaf]
185
+ if align_tip_labels:
186
+ axis.plot(
187
+ [x[leaf], label_x],
188
+ [y[leaf], y[leaf]],
189
+ color="#A6A6A6",
190
+ linestyle=(0, (1.2, 2.0)),
191
+ linewidth=0.45,
192
+ zorder=1,
193
+ )
194
+ axis.annotate(
195
+ leaf.name,
196
+ (text_x, y[leaf]),
197
+ xytext=(3, 0),
198
+ textcoords="offset points",
199
+ va="center",
200
+ fontsize=tip_font_size,
201
+ color=color,
202
+ )
203
+ if groups:
204
+ handles = [
205
+ axis.scatter([], [], s=18, color=palette[group], label=group, edgecolor="white", linewidth=0.3)
206
+ for group in known_groups
207
+ ]
208
+ axis.legend(
209
+ handles=handles,
210
+ title="Group",
211
+ frameon=False,
212
+ loc="lower center",
213
+ bbox_to_anchor=(0.5, 1.005),
214
+ ncol=len(handles),
215
+ columnspacing=1.4,
216
+ handletextpad=0.45,
217
+ borderaxespad=0.0,
218
+ fontsize=8,
219
+ title_fontsize=8,
220
+ )
221
+ axis.set_yticks([])
222
+ axis.set_xlabel("Calendar year" if time_scaled else "Substitutions per site")
223
+ axis.set_title(title, fontweight="bold", pad=50 if groups else 10)
224
+ self._clean_axis(axis)
225
+ axis.spines["left"].set_visible(False)
226
+ return figure
227
+
228
+ def temporal_signal(
229
+ self,
230
+ analysis: Any,
231
+ *,
232
+ groups: Mapping[str, str] | None = None,
233
+ title: str = "Root-to-tip temporal signal",
234
+ ) -> Any:
235
+ """Plot root-to-tip regression with sample identities and group encoding."""
236
+
237
+ plt, _ = self._matplotlib()
238
+ figure, axis = plt.subplots(figsize=(5.2, 3.8), constrained_layout=True)
239
+ names = sorted(analysis.sample_dates, key=analysis.sample_dates.get)
240
+ known_groups = sorted({groups.get(name, "Unknown") for name in names}) if groups else ["Samples"]
241
+ palette = {group: self.COLORS[index % len(self.COLORS)] for index, group in enumerate(known_groups)}
242
+ for index, group in enumerate(known_groups):
243
+ selected = [name for name in names if (groups.get(name, "Unknown") if groups else "Samples") == group]
244
+ axis.scatter(
245
+ [analysis.sample_dates[name] for name in selected],
246
+ [analysis.root_to_tip[name] for name in selected],
247
+ color=palette[group],
248
+ marker=self.MARKERS[index % len(self.MARKERS)],
249
+ s=26,
250
+ alpha=0.78,
251
+ edgecolor="white",
252
+ linewidth=0.35,
253
+ label=f"{group} (n={len(selected)})",
254
+ )
255
+ limits = [min(analysis.sample_dates.values()), max(analysis.sample_dates.values())]
256
+ axis.plot(
257
+ limits,
258
+ [analysis.intercept + analysis.rate * value for value in limits],
259
+ color="#222222",
260
+ linestyle="--",
261
+ linewidth=1.0,
262
+ )
263
+ axis.text(
264
+ 0.02,
265
+ 0.98,
266
+ f"rate = {analysis.rate:.3g} substitutions/site/year\n"
267
+ f"R² = {analysis.r_squared:.3f}; p = {analysis.p_value:.3g}\n"
268
+ f"fitted root = {analysis.root_date:.1f}",
269
+ transform=axis.transAxes,
270
+ va="top",
271
+ fontsize=7,
272
+ )
273
+ axis.set(xlabel="Sampling year", ylabel="Root-to-tip divergence", title=title)
274
+ axis.legend(frameon=False, loc="lower right", fontsize=7)
275
+ self._clean_axis(axis)
276
+ return figure
277
+
278
+ def lineage_through_time(
279
+ self,
280
+ trajectories: Mapping[str, Mapping[str, Sequence[float]]],
281
+ *,
282
+ title: str = "Lineage-through-time trajectories",
283
+ normalize: bool = True,
284
+ ) -> Any:
285
+ """Plot deterministic dated-tree lineage trajectories.
286
+
287
+ This figure is an exploratory demographic proxy. It does not estimate
288
+ effective population size and its lines are not Bayesian posterior
289
+ medians or highest-posterior-density intervals.
290
+ """
291
+
292
+ if not trajectories:
293
+ raise ValueError("At least one trajectory is required.")
294
+ validated: list[tuple[str, list[float], list[float]]] = []
295
+ for label, values in trajectories.items():
296
+ times = list(values["time"])
297
+ lineages = list(values["lineages"])
298
+ if len(times) != len(lineages) or len(times) < 2:
299
+ raise ValueError(f"Trajectory {label!r} has invalid time/lineage arrays.")
300
+ validated.append((label, times, lineages))
301
+ plt, _ = self._matplotlib()
302
+ figure, axis = plt.subplots(figsize=(7.2, 4.0), constrained_layout=True)
303
+ for index, (label, times, lineages) in enumerate(validated):
304
+ denominator = max(lineages) if normalize else 1.0
305
+ plotted = [value / denominator for value in lineages]
306
+ axis.plot(
307
+ times,
308
+ plotted,
309
+ color=self.COLORS[index % len(self.COLORS)],
310
+ linestyle=("-", "--", "-.", ":")[index % 4],
311
+ linewidth=1.4,
312
+ label=label,
313
+ )
314
+ axis.set_xlabel("Calendar year")
315
+ axis.set_ylabel("Relative lineages" if normalize else "Lineages")
316
+ axis.set_title(title, fontweight="bold")
317
+ axis.legend(
318
+ frameon=False,
319
+ loc="upper left",
320
+ bbox_to_anchor=(1.01, 1.0),
321
+ ncol=1,
322
+ fontsize=7,
323
+ )
324
+ self._clean_axis(axis)
325
+ return figure
326
+
327
+ @staticmethod
328
+ def save(figure: Any, destination: str | Path, *, dpi: int = 600) -> Path:
329
+ """Save a figure with a transparent vector or high-resolution raster backend."""
330
+
331
+ output = Path(destination)
332
+ output.parent.mkdir(parents=True, exist_ok=True)
333
+ figure.savefig(output, dpi=dpi, bbox_inches="tight", facecolor="white")
334
+ return output