plotaviz 0.1.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,482 @@
1
+ """Generate a standalone Python script that reproduces a chart.
2
+
3
+ This is a headline feature, so the bar is specific: the emitted script must run on a machine that
4
+ has never heard of PlotaViz. No ``import plotaviz`` anywhere, no helper module, no hidden state —
5
+ just pandas plus one plotting library, the exact preprocessing steps that were applied, and the
6
+ chart. Paste it into a notebook or a report pipeline and it works.
7
+
8
+ The preprocessing steps write their own source (``Step.to_code``), so generated code cannot drift
9
+ away from what the app actually did to the data.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from datetime import UTC, datetime
15
+ from pathlib import Path
16
+
17
+ from .errors import ExportError
18
+ from .loader import read_code_for
19
+ from .plotter import effective_spec, group_keys
20
+ from .preprocess import Pipeline
21
+ from .spec import MATRIX_CHARTS, ChartSpec
22
+
23
+ #: Flavours the generator can emit.
24
+ FLAVOURS: tuple[str, ...] = ("matplotlib", "plotly")
25
+
26
+ _HEADER = '''"""{title}
27
+
28
+ Generated by PlotaViz {version} on {timestamp}.
29
+
30
+ This script is standalone — it does not import plotaviz. Run it with:
31
+
32
+ python {filename}
33
+
34
+ Requirements: {requirements}
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import argparse
40
+ '''
41
+
42
+
43
+ def generate(
44
+ spec: ChartSpec,
45
+ pipeline: Pipeline,
46
+ source_path: str | Path,
47
+ *,
48
+ flavour: str = "matplotlib",
49
+ version: str = "0.1.0",
50
+ output_image: str | None = None,
51
+ ) -> str:
52
+ """Emit a runnable script reproducing ``spec``.
53
+
54
+ Args:
55
+ spec: The chart to reproduce.
56
+ pipeline: The preprocessing steps that were applied, in order.
57
+ source_path: Path to the dataset the script should read.
58
+ flavour: ``"matplotlib"`` for static output or ``"plotly"`` for an interactive figure.
59
+ version: PlotaViz version, recorded in the header.
60
+ output_image: Default ``--save`` path baked into the script.
61
+
62
+ Returns:
63
+ The complete script as a string.
64
+
65
+ Raises:
66
+ ExportError: If ``flavour`` is not one of :data:`FLAVOURS`.
67
+ """
68
+ if flavour not in FLAVOURS:
69
+ raise ExportError(
70
+ f"Unknown code flavour {flavour!r}.", hint=f"Choose one of: {', '.join(FLAVOURS)}."
71
+ )
72
+ spec.validate()
73
+
74
+ source_path = Path(source_path)
75
+ filename = f"plot_{spec.chart}.py"
76
+ requirements = "pandas, matplotlib" if flavour == "matplotlib" else "pandas, plotly"
77
+
78
+ lines: list[str] = [
79
+ _HEADER.format(
80
+ title=spec.display_title(),
81
+ version=version,
82
+ timestamp=datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC"),
83
+ filename=filename,
84
+ requirements=requirements,
85
+ )
86
+ ]
87
+
88
+ lines.append("import pandas as pd")
89
+ if flavour == "matplotlib":
90
+ lines.append("import matplotlib.pyplot as plt")
91
+ if spec.chart in {"scatter", "kde"} or spec.chart in MATRIX_CHARTS:
92
+ lines.append("import numpy as np")
93
+ else:
94
+ lines.append("import plotly.express as px")
95
+ if spec.chart == "kde":
96
+ lines.append("import numpy as np")
97
+ lines.append("import plotly.graph_objects as go")
98
+
99
+ lines += [
100
+ "",
101
+ "",
102
+ "def load():",
103
+ ' """Read the source dataset."""',
104
+ f" return {read_code_for(source_path)}",
105
+ "",
106
+ "",
107
+ "def preprocess(df):",
108
+ ' """Replay the exact cleaning steps PlotaViz applied."""',
109
+ ]
110
+
111
+ step_code = pipeline.to_code()
112
+ if step_code:
113
+ lines += [f" {line}" if line else "" for line in step_code]
114
+ else:
115
+ lines.append(" # No cleaning steps were applied.")
116
+ lines += [" return df", "", ""]
117
+
118
+ lines += _chart_function(spec, flavour)
119
+
120
+ lines += [
121
+ "",
122
+ "",
123
+ 'if __name__ == "__main__":',
124
+ " parser = argparse.ArgumentParser(description=__doc__)",
125
+ f" parser.add_argument('--save', default={output_image!r}, "
126
+ "help='write the figure to this path instead of (only) showing it')",
127
+ " parser.add_argument('--no-show', action='store_true', help='do not open a window')",
128
+ " args = parser.parse_args()",
129
+ "",
130
+ " df = preprocess(load())",
131
+ " fig = build_chart(df)",
132
+ "",
133
+ ]
134
+ if flavour == "matplotlib":
135
+ lines += [
136
+ " if args.save:",
137
+ " fig.savefig(args.save, dpi=300, bbox_inches='tight')",
138
+ " print(f'Saved {args.save}')",
139
+ " if not args.no_show:",
140
+ " plt.show()",
141
+ ]
142
+ else:
143
+ lines += [
144
+ " if args.save:",
145
+ " if args.save.endswith('.html'):",
146
+ " fig.write_html(args.save)",
147
+ " else:",
148
+ " fig.write_image(args.save, scale=3) # needs kaleido",
149
+ " print(f'Saved {args.save}')",
150
+ " if not args.no_show:",
151
+ " fig.show()",
152
+ ]
153
+
154
+ return "\n".join(lines) + "\n"
155
+
156
+
157
+ def _chart_function(spec: ChartSpec, flavour: str) -> list[str]:
158
+ """Emit the ``build_chart`` function body for one spec and flavour.
159
+
160
+ The chart body is written against the *effective* spec — the same one the live renderer uses
161
+ — so a counted bar chart plots the ``count`` column the aggregation just created rather than
162
+ a column that never existed.
163
+ """
164
+ eff = effective_spec(spec)
165
+ lines = [
166
+ "def build_chart(df):",
167
+ f' """Build the {spec.chart} chart."""',
168
+ ]
169
+ lines += [f" {line}" for line in _prepare_lines(spec, eff)]
170
+ lines.append("")
171
+ body = _plotly_body(eff) if flavour == "plotly" else _matplotlib_body(eff)
172
+ lines += [f" {line}" if line else "" for line in body]
173
+ return lines
174
+
175
+
176
+ def _prepare_lines(spec: ChartSpec, eff: ChartSpec) -> list[str]:
177
+ """Emit the aggregation/sorting the plotter would have done, so the script matches the app."""
178
+ lines: list[str] = []
179
+ if spec.chart in MATRIX_CHARTS:
180
+ lines.append("plot_df = df.select_dtypes('number')")
181
+ lines.append(
182
+ "plot_df = plot_df[[c for c in plot_df.columns if not str(c).endswith('__outlier')]]"
183
+ )
184
+ return lines
185
+
186
+ used = list(spec.columns_used)
187
+ lines.append(f"plot_df = df[{used!r}].dropna()")
188
+
189
+ if spec.agg:
190
+ keys = group_keys(spec.x, spec.color)
191
+ measure = eff.y
192
+ source_measure = spec.y
193
+ if spec.chart == "heatmap":
194
+ keys = group_keys(spec.x, spec.y)
195
+ measure = eff.color
196
+ source_measure = spec.color
197
+
198
+ if spec.agg == "count" or not source_measure:
199
+ lines.append(
200
+ f"plot_df = plot_df.groupby({keys!r}, observed=True).size()"
201
+ f".reset_index(name={measure!r})"
202
+ )
203
+ else:
204
+ lines.append(
205
+ f"plot_df = plot_df.groupby({keys!r}, observed=True)[{source_measure!r}]"
206
+ f".agg({spec.agg!r}).reset_index()"
207
+ )
208
+
209
+ if spec.chart in {"line", "area"} and eff.x:
210
+ lines.append(f"plot_df = plot_df.sort_values({eff.x!r})")
211
+ elif spec.chart in {"bar", "treemap", "pie"} and eff.y:
212
+ lines.append(f"plot_df = plot_df.sort_values({eff.y!r}, ascending=False)")
213
+
214
+ top_n = spec.options.get("top_n")
215
+ if top_n and spec.chart in {"bar", "treemap", "pie"}:
216
+ lines.append(f"plot_df = plot_df.head({int(top_n)})")
217
+
218
+ return lines
219
+
220
+
221
+ def _plotly_body(spec: ChartSpec) -> list[str]:
222
+ """The Plotly call for one spec."""
223
+ title = spec.display_title()
224
+ x, y, color = spec.x, spec.y, spec.color
225
+ chart = spec.chart
226
+ lines: list[str] = []
227
+
228
+ if chart == "histogram":
229
+ lines.append(f"fig = px.histogram(plot_df, x={x!r}, color={color!r}, title={title!r})")
230
+ elif chart == "kde":
231
+ lines += [
232
+ f"values = pd.to_numeric(plot_df[{x!r}], errors='coerce').dropna().to_numpy()",
233
+ "bw = 0.9 * values.std(ddof=1) * len(values) ** (-1 / 5)",
234
+ "grid = np.linspace(values.min() - 3 * bw, values.max() + 3 * bw, 200)",
235
+ "z = (grid[:, None] - values[None, :]) / bw",
236
+ "density = np.exp(-0.5 * z ** 2).sum(axis=1) / (len(values) * bw * np.sqrt(2 * np.pi))",
237
+ "fig = go.Figure(go.Scatter(x=grid, y=density, fill='tozeroy', mode='lines'))",
238
+ f"fig.update_layout(title={title!r}, xaxis_title={x!r}, yaxis_title='density')",
239
+ ]
240
+ elif chart in {"bar", "grouped_bar", "stacked_bar"}:
241
+ barmode = "stack" if chart == "stacked_bar" or spec.options.get("stacked") else "group"
242
+ lines.append(
243
+ f"fig = px.bar(plot_df, x={x!r}, y={y!r}, color={color!r}, "
244
+ f"barmode={barmode!r}, title={title!r})"
245
+ )
246
+ elif chart == "pie":
247
+ lines.append(f"fig = px.pie(plot_df, names={x!r}, values={y!r}, title={title!r})")
248
+ elif chart == "treemap":
249
+ path = [color, x] if color else [x]
250
+ lines.append(f"fig = px.treemap(plot_df, path={path!r}, values={y!r}, title={title!r})")
251
+ elif chart == "scatter":
252
+ lines.append(
253
+ f"fig = px.scatter(plot_df, x={x!r}, y={y!r}, color={color!r}, "
254
+ f"opacity=0.75, title={title!r})"
255
+ )
256
+ elif chart == "line":
257
+ lines.append(f"fig = px.line(plot_df, x={x!r}, y={y!r}, color={color!r}, title={title!r})")
258
+ elif chart == "area":
259
+ lines.append(f"fig = px.area(plot_df, x={x!r}, y={y!r}, color={color!r}, title={title!r})")
260
+ elif chart == "box":
261
+ lines.append(
262
+ f"fig = px.box(plot_df, x={x!r}, y={y!r}, color={color!r}, "
263
+ f"points='outliers', title={title!r})"
264
+ )
265
+ elif chart == "violin":
266
+ lines.append(
267
+ f"fig = px.violin(plot_df, x={x!r}, y={y!r}, color={color!r}, "
268
+ f"box=True, title={title!r})"
269
+ )
270
+ elif chart == "heatmap":
271
+ value = color or y
272
+ lines += [
273
+ f"matrix = plot_df.pivot_table(index={y!r}, columns={x!r}, values={value!r})",
274
+ f"fig = px.imshow(matrix, aspect='auto', title={title!r})",
275
+ ]
276
+ elif chart == "correlation_heatmap":
277
+ lines += [
278
+ "corr = plot_df.corr(numeric_only=True)",
279
+ "fig = px.imshow(corr, zmin=-1, zmax=1, color_continuous_scale='RdBu_r', "
280
+ f"text_auto='.2f', aspect='auto', title={title!r})",
281
+ ]
282
+ elif chart == "pair_plot":
283
+ lines += [
284
+ "fig = px.scatter_matrix(plot_df, "
285
+ f"dimensions=list(plot_df.columns)[:6], title={title!r})",
286
+ "fig.update_traces(diagonal_visible=False, showupperhalf=False)",
287
+ ]
288
+
289
+ if spec.options.get("log_y"):
290
+ lines.append("fig.update_yaxes(type='log')")
291
+ if spec.options.get("log_x"):
292
+ lines.append("fig.update_xaxes(type='log')")
293
+
294
+ lines.append("fig.update_layout(template='plotly_white')")
295
+ lines.append("return fig")
296
+ return lines
297
+
298
+
299
+ def _matplotlib_body(spec: ChartSpec) -> list[str]:
300
+ """The matplotlib calls for one spec."""
301
+ title = spec.display_title()
302
+ x, y, color = spec.x, spec.y, spec.color
303
+ chart = spec.chart
304
+ lines: list[str] = []
305
+
306
+ if chart == "pair_plot":
307
+ return [
308
+ "cols = list(plot_df.columns)[:5]",
309
+ "fig, axes = plt.subplots(len(cols), len(cols), figsize=(10, 8), squeeze=False)",
310
+ "for i, row_col in enumerate(cols):",
311
+ " for j, col_col in enumerate(cols):",
312
+ " ax = axes[i][j]",
313
+ " if i == j:",
314
+ " ax.hist(plot_df[row_col].dropna(), bins=20, color='#4C78A8')",
315
+ " else:",
316
+ " ax.scatter(plot_df[col_col], plot_df[row_col], s=6, alpha=0.5)",
317
+ " if i == len(cols) - 1:",
318
+ " ax.set_xlabel(col_col, fontsize=8)",
319
+ " if j == 0:",
320
+ " ax.set_ylabel(row_col, fontsize=8)",
321
+ f"fig.suptitle({title!r})",
322
+ "fig.tight_layout()",
323
+ "return fig",
324
+ ]
325
+
326
+ lines.append("fig, ax = plt.subplots(figsize=(10, 6))")
327
+
328
+ if chart == "histogram":
329
+ lines += [
330
+ f"ax.hist(pd.to_numeric(plot_df[{x!r}], errors='coerce').dropna(), "
331
+ "bins='auto', color='#4C78A8', edgecolor='white')",
332
+ "ax.set_ylabel('count')",
333
+ ]
334
+ elif chart == "kde":
335
+ lines += [
336
+ f"values = pd.to_numeric(plot_df[{x!r}], errors='coerce').dropna().to_numpy()",
337
+ "bw = 0.9 * values.std(ddof=1) * len(values) ** (-1 / 5)",
338
+ "grid = np.linspace(values.min() - 3 * bw, values.max() + 3 * bw, 200)",
339
+ "z = (grid[:, None] - values[None, :]) / bw",
340
+ "density = np.exp(-0.5 * z ** 2).sum(axis=1) / (len(values) * bw * np.sqrt(2 * np.pi))",
341
+ "ax.plot(grid, density, color='#4C78A8')",
342
+ "ax.fill_between(grid, density, alpha=0.3, color='#4C78A8')",
343
+ "ax.set_ylabel('density')",
344
+ ]
345
+ elif chart in {"bar", "grouped_bar", "stacked_bar"}:
346
+ stacked = chart == "stacked_bar" or bool(spec.options.get("stacked"))
347
+ if color:
348
+ lines += [
349
+ f"pivot = plot_df.pivot_table(index={x!r}, columns={color!r}, values={y!r})",
350
+ f"pivot.plot(kind='bar', stacked={stacked!r}, ax=ax, width=0.8)",
351
+ f"ax.legend(title={color!r}, fontsize=8)",
352
+ ]
353
+ elif y:
354
+ lines.append(f"ax.bar(plot_df[{x!r}].astype(str), plot_df[{y!r}], color='#4C78A8')")
355
+ else:
356
+ lines += [
357
+ f"counts = plot_df[{x!r}].value_counts()",
358
+ "ax.bar(counts.index.astype(str), counts.to_numpy(), color='#4C78A8')",
359
+ "ax.set_ylabel('count')",
360
+ ]
361
+ lines.append("ax.tick_params(axis='x', rotation=45)")
362
+ elif chart == "pie":
363
+ values = f"plot_df[{y!r}]" if y else f"plot_df[{x!r}].value_counts()"
364
+ lines.append(f"ax.pie({values}, labels=plot_df[{x!r}].astype(str), autopct='%1.1f%%')")
365
+ elif chart == "treemap":
366
+ lines += [
367
+ "# A simple slice-and-dice treemap; swap in the `squarify` package for nicer layout.",
368
+ "from matplotlib.patches import Rectangle",
369
+ f"pairs = list(zip(plot_df[{x!r}].astype(str), plot_df[{y!r}]))[:40]",
370
+ "total = sum(size for _, size in pairs) or 1",
371
+ "left, horizontal = 0.0, True",
372
+ "cmap = plt.get_cmap('tab20')",
373
+ "for i, (label, size) in enumerate(pairs):",
374
+ " width = size / total",
375
+ " ax.add_patch(Rectangle((left, 0), width, 1, facecolor=cmap(i % 20), "
376
+ "edgecolor='white'))",
377
+ " if width > 0.06:",
378
+ " ax.text(left + width / 2, 0.5, label, ha='center', va='center', fontsize=8)",
379
+ " left += width",
380
+ "ax.set_xlim(0, 1); ax.set_ylim(0, 1); ax.axis('off')",
381
+ ]
382
+ elif chart == "scatter":
383
+ if color:
384
+ lines += [
385
+ f"for name, group in plot_df.groupby({color!r}, observed=True):",
386
+ f" ax.scatter(group[{x!r}], group[{y!r}], s=14, alpha=0.7, label=str(name))",
387
+ f"ax.legend(title={color!r}, fontsize=8)",
388
+ ]
389
+ else:
390
+ lines.append(
391
+ f"ax.scatter(plot_df[{x!r}], plot_df[{y!r}], s=14, alpha=0.7, color='#4C78A8')"
392
+ )
393
+ if spec.options.get("trendline"):
394
+ lines += [
395
+ f"slope, intercept = np.polyfit(plot_df[{x!r}], plot_df[{y!r}], 1)",
396
+ f"line = np.linspace(plot_df[{x!r}].min(), plot_df[{x!r}].max(), 100)",
397
+ "ax.plot(line, slope * line + intercept, color='#E45756', linewidth=1.5)",
398
+ ]
399
+ elif chart in {"line", "area"}:
400
+ if color:
401
+ lines += [
402
+ f"for name, group in plot_df.groupby({color!r}, observed=True):",
403
+ f" ax.plot(group[{x!r}], group[{y!r}], label=str(name), linewidth=1.6)",
404
+ f"ax.legend(title={color!r}, fontsize=8)",
405
+ ]
406
+ else:
407
+ lines.append(f"ax.plot(plot_df[{x!r}], plot_df[{y!r}], color='#4C78A8', linewidth=1.6)")
408
+ if chart == "area":
409
+ lines.append(
410
+ f"ax.fill_between(plot_df[{x!r}], plot_df[{y!r}], alpha=0.3, color='#4C78A8')"
411
+ )
412
+ lines.append("fig.autofmt_xdate()")
413
+ elif chart in {"box", "violin"}:
414
+ lines += [
415
+ f"groups = [(str(k), g[{y!r}].dropna().to_numpy()) "
416
+ f"for k, g in plot_df.groupby({x!r}, observed=True)]"
417
+ if x
418
+ else f"groups = [({y!r}, plot_df[{y!r}].dropna().to_numpy())]",
419
+ "labels = [name for name, _ in groups]",
420
+ "values = [vals for _, vals in groups]",
421
+ ]
422
+ if chart == "violin":
423
+ lines += [
424
+ "ax.violinplot(values, showmedians=True)",
425
+ "ax.set_xticks(range(1, len(labels) + 1), labels)",
426
+ ]
427
+ else:
428
+ lines.append("ax.boxplot(values, tick_labels=labels)")
429
+ lines.append("ax.tick_params(axis='x', rotation=45)")
430
+ elif chart == "heatmap":
431
+ value = color or y
432
+ lines += [
433
+ f"matrix = plot_df.pivot_table(index={y!r}, columns={x!r}, values={value!r})",
434
+ "image = ax.imshow(matrix.to_numpy(dtype=float), aspect='auto', cmap='viridis')",
435
+ "ax.set_xticks(range(len(matrix.columns)), [str(c) for c in matrix.columns], "
436
+ "rotation=45, ha='right')",
437
+ "ax.set_yticks(range(len(matrix.index)), [str(i) for i in matrix.index])",
438
+ f"fig.colorbar(image, ax=ax, label={str(value)!r})",
439
+ ]
440
+ elif chart == "correlation_heatmap":
441
+ lines += [
442
+ "corr = plot_df.corr(numeric_only=True)",
443
+ "image = ax.imshow(corr.to_numpy(dtype=float), vmin=-1, vmax=1, cmap='RdBu_r')",
444
+ "ax.set_xticks(range(len(corr.columns)), list(corr.columns), rotation=45, ha='right')",
445
+ "ax.set_yticks(range(len(corr.index)), list(corr.index))",
446
+ "for i in range(len(corr.index)):",
447
+ " for j in range(len(corr.columns)):",
448
+ " ax.text(j, i, f'{corr.iat[i, j]:.2f}', ha='center', va='center', fontsize=8)",
449
+ "fig.colorbar(image, ax=ax, label='Pearson r')",
450
+ ]
451
+
452
+ if spec.options.get("log_y"):
453
+ lines.append("ax.set_yscale('log')")
454
+ if spec.options.get("log_x"):
455
+ lines.append("ax.set_xscale('log')")
456
+
457
+ lines.append(f"ax.set_title({title!r})")
458
+ if chart not in {"pie", "treemap", "heatmap", "correlation_heatmap"}:
459
+ if x:
460
+ lines.append(f"ax.set_xlabel({x!r})")
461
+ if y and chart not in {"histogram", "kde"}:
462
+ lines.append(f"ax.set_ylabel({y!r})")
463
+ lines.append("ax.grid(True, alpha=0.25, linestyle='--')")
464
+ lines.append("ax.set_axisbelow(True)")
465
+
466
+ lines += ["fig.tight_layout()", "return fig"]
467
+ return lines
468
+
469
+
470
+ def write(path: str | Path, code: str) -> Path:
471
+ """Write generated code to disk.
472
+
473
+ Raises:
474
+ ExportError: If the file cannot be written.
475
+ """
476
+ target = Path(path).expanduser()
477
+ try:
478
+ target.parent.mkdir(parents=True, exist_ok=True)
479
+ target.write_text(code, encoding="utf-8")
480
+ except OSError as exc:
481
+ raise ExportError(f"Could not write {target}.", hint=str(exc)) from exc
482
+ return target
@@ -0,0 +1,65 @@
1
+ """Exception hierarchy for PlotaViz.
2
+
3
+ Every error the user can plausibly trigger is one of these, and every one carries a message
4
+ written for a human rather than a stack trace. The UI catches :class:`PlotaVizError` at the
5
+ boundary and shows ``str(exc)`` in a dialog; nothing else should reach the user.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ class PlotaVizError(Exception):
12
+ """Base class for all PlotaViz errors intended to be shown to the user.
13
+
14
+ Args:
15
+ message: Human-readable description of what went wrong.
16
+ hint: Optional suggestion for how to fix it, shown as secondary text.
17
+ """
18
+
19
+ def __init__(self, message: str, hint: str | None = None) -> None:
20
+ super().__init__(message)
21
+ self.message = message
22
+ self.hint = hint
23
+
24
+ def __str__(self) -> str:
25
+ return f"{self.message}\n\n{self.hint}" if self.hint else self.message
26
+
27
+
28
+ class LoadError(PlotaVizError):
29
+ """A dataset could not be read — unsupported format, corrupt file, bad encoding."""
30
+
31
+
32
+ class PreprocessError(PlotaVizError):
33
+ """A cleaning step could not be applied to the dataframe."""
34
+
35
+
36
+ class ProfileError(PlotaVizError):
37
+ """The dataset could not be profiled (empty, or no usable columns)."""
38
+
39
+
40
+ class SelectionError(PlotaVizError):
41
+ """No chart could be recommended for the given data shape."""
42
+
43
+
44
+ class SpecError(PlotaVizError):
45
+ """A chart spec is malformed or references columns that do not exist."""
46
+
47
+
48
+ class PlotError(PlotaVizError):
49
+ """A figure could not be built from a chart spec."""
50
+
51
+
52
+ class ExportError(PlotaVizError):
53
+ """Writing an image, script, or session file failed."""
54
+
55
+
56
+ class SessionError(PlotaVizError):
57
+ """A ``.pviz`` session file could not be read or is from an incompatible version."""
58
+
59
+
60
+ class LLMError(PlotaVizError):
61
+ """An LLM provider failed. Always recoverable — the caller falls back to the local engine."""
62
+
63
+
64
+ class ProviderNotConfigured(LLMError):
65
+ """No provider selected, or the selected provider has no API key in the keyring."""
@@ -0,0 +1,145 @@
1
+ """Static image export — PNG, SVG, PDF.
2
+
3
+ Export always goes through matplotlib rather than the Plotly figure on screen. Plotly's static
4
+ export needs the kaleido binary, which is another large dependency and another packaging problem;
5
+ matplotlib is already present, renders headless, and produces better print output. The two
6
+ renderers share :func:`~plotaviz.core.plotter.prepare`, so what gets saved matches what was seen.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import pandas as pd
16
+
17
+ from .errors import ExportError
18
+ from .plotter import build_matplotlib, prepare
19
+ from .spec import ChartSpec
20
+
21
+ #: Formats :func:`export_image` can write.
22
+ IMAGE_FORMATS: tuple[str, ...] = ("png", "svg", "pdf", "jpg", "jpeg", "webp", "tiff")
23
+
24
+ #: Export DPI. 300 is print quality.
25
+ DEFAULT_DPI = 300
26
+
27
+ #: Default figure size in inches.
28
+ DEFAULT_SIZE = (10.0, 6.0)
29
+
30
+
31
+ @dataclass
32
+ class ExportOptions:
33
+ """Settings from the export dialog.
34
+
35
+ Attributes:
36
+ dpi: Dots per inch for raster formats. Ignored by SVG and PDF, which are vector.
37
+ width: Figure width in inches.
38
+ height: Figure height in inches.
39
+ transparent: Whether to leave the background transparent.
40
+ include_notice: Whether to stamp the "showing a sample of N" note onto the image. On by
41
+ default — an exported chart that hides its own sampling is a chart that misleads.
42
+ """
43
+
44
+ dpi: int = DEFAULT_DPI
45
+ width: float = DEFAULT_SIZE[0]
46
+ height: float = DEFAULT_SIZE[1]
47
+ transparent: bool = False
48
+ include_notice: bool = True
49
+
50
+ @property
51
+ def figsize(self) -> tuple[float, float]:
52
+ """Size as the ``(width, height)`` tuple matplotlib wants."""
53
+ return (float(self.width), float(self.height))
54
+
55
+
56
+ def export_image(
57
+ df: pd.DataFrame,
58
+ spec: ChartSpec,
59
+ path: str | Path,
60
+ *,
61
+ options: ExportOptions | None = None,
62
+ ) -> Path:
63
+ """Render a spec and write it to an image file.
64
+
65
+ The format comes from the file extension.
66
+
67
+ Args:
68
+ df: Cleaned, filtered data.
69
+ spec: What to draw.
70
+ path: Destination file.
71
+ options: Size, DPI, and background settings.
72
+
73
+ Returns:
74
+ The path written.
75
+
76
+ Raises:
77
+ ExportError: If the extension is unsupported or the file cannot be written.
78
+ """
79
+ options = options or ExportOptions()
80
+ target = Path(path).expanduser()
81
+ fmt = target.suffix.lower().lstrip(".")
82
+
83
+ if not fmt:
84
+ target = target.with_suffix(".png")
85
+ fmt = "png"
86
+ if fmt not in IMAGE_FORMATS:
87
+ raise ExportError(
88
+ f"PlotaViz cannot export {fmt!r} images.",
89
+ hint=f"Supported formats: {', '.join(IMAGE_FORMATS)}.",
90
+ )
91
+
92
+ prepared = prepare(df, spec)
93
+ if not options.include_notice:
94
+ prepared.sampled = False
95
+
96
+ figure = build_matplotlib(df, spec, prepared=prepared, figsize=options.figsize, dpi=options.dpi)
97
+
98
+ try:
99
+ target.parent.mkdir(parents=True, exist_ok=True)
100
+ figure.savefig(
101
+ target,
102
+ dpi=options.dpi,
103
+ bbox_inches="tight",
104
+ transparent=options.transparent,
105
+ format=fmt if fmt not in {"jpg"} else "jpeg",
106
+ )
107
+ except OSError as exc:
108
+ raise ExportError(f"Could not write {target}.", hint=str(exc)) from exc
109
+ except ValueError as exc:
110
+ raise ExportError(f"Could not render the chart as {fmt}.", hint=str(exc)) from exc
111
+ finally:
112
+ _close(figure)
113
+
114
+ return target
115
+
116
+
117
+ def export_html(df: pd.DataFrame, spec: ChartSpec, path: str | Path) -> Path:
118
+ """Write the interactive Plotly figure as a self-contained HTML file.
119
+
120
+ Useful for sharing a chart someone can actually zoom and hover, without them installing
121
+ anything.
122
+
123
+ Raises:
124
+ ExportError: If Plotly is missing or the file cannot be written.
125
+ """
126
+ from .plotter import build_plotly
127
+
128
+ target = Path(path).expanduser().with_suffix(".html")
129
+ figure = build_plotly(df, spec)
130
+ try:
131
+ target.parent.mkdir(parents=True, exist_ok=True)
132
+ figure.write_html(str(target), include_plotlyjs="cdn", full_html=True)
133
+ except OSError as exc:
134
+ raise ExportError(f"Could not write {target}.", hint=str(exc)) from exc
135
+ return target
136
+
137
+
138
+ def _close(figure: Any) -> None:
139
+ """Close a matplotlib figure so long sessions do not leak them."""
140
+ try:
141
+ import matplotlib.pyplot as plt
142
+
143
+ plt.close(figure)
144
+ except Exception:
145
+ pass