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.
- plotaviz/__init__.py +19 -0
- plotaviz/core/__init__.py +53 -0
- plotaviz/core/analysis.py +230 -0
- plotaviz/core/codegen.py +482 -0
- plotaviz/core/errors.py +65 -0
- plotaviz/core/exporter.py +145 -0
- plotaviz/core/llm/__init__.py +211 -0
- plotaviz/core/llm/anthropic.py +61 -0
- plotaviz/core/llm/base.py +373 -0
- plotaviz/core/llm/gemini.py +51 -0
- plotaviz/core/llm/ollama.py +113 -0
- plotaviz/core/llm/openai.py +58 -0
- plotaviz/core/loader.py +366 -0
- plotaviz/core/plotter.py +790 -0
- plotaviz/core/preprocess.py +836 -0
- plotaviz/core/profiler.py +509 -0
- plotaviz/core/rules.yaml +190 -0
- plotaviz/core/selector.py +495 -0
- plotaviz/core/session.py +174 -0
- plotaviz/core/spec.py +236 -0
- plotaviz/main.py +329 -0
- plotaviz/ui/__init__.py +23 -0
- plotaviz/ui/export_dialog.py +228 -0
- plotaviz/ui/filter_panel.py +273 -0
- plotaviz/ui/interactive_view.py +210 -0
- plotaviz/ui/main_window.py +859 -0
- plotaviz/ui/nl_query_bar.py +219 -0
- plotaviz/ui/settings_dialog.py +313 -0
- plotaviz/ui/table_model.py +184 -0
- plotaviz/ui/type_override.py +159 -0
- plotaviz/ui/workers.py +222 -0
- plotaviz-0.1.0.dist-info/METADATA +410 -0
- plotaviz-0.1.0.dist-info/RECORD +37 -0
- plotaviz-0.1.0.dist-info/WHEEL +5 -0
- plotaviz-0.1.0.dist-info/entry_points.txt +2 -0
- plotaviz-0.1.0.dist-info/licenses/LICENSE +21 -0
- plotaviz-0.1.0.dist-info/top_level.txt +1 -0
plotaviz/core/codegen.py
ADDED
|
@@ -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
|
plotaviz/core/errors.py
ADDED
|
@@ -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
|