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
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""The chart view — Plotly in a QWebEngineView, with a matplotlib fallback.
|
|
2
|
+
|
|
3
|
+
**Every QtWebEngine import in PlotaViz is inside this module.** That isolation is deliberate:
|
|
4
|
+
WebEngine drags in Chromium, which is most of the bundle size and nearly all of the packaging
|
|
5
|
+
pain (AppImage in particular). Keeping it behind one seam means it can be lazy-loaded, swapped,
|
|
6
|
+
or dropped entirely without touching the rest of the app.
|
|
7
|
+
|
|
8
|
+
When WebEngine is unavailable — a slim build, a headless box, a distro that ships PySide6 without
|
|
9
|
+
it — :class:`ChartView` silently falls back to a matplotlib canvas embedded in Qt. The user loses
|
|
10
|
+
hover and zoom, not the chart, and the status line says which renderer is active.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import contextlib
|
|
16
|
+
import tempfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import pandas as pd
|
|
21
|
+
from PySide6.QtCore import Qt, QUrl
|
|
22
|
+
from PySide6.QtWidgets import QLabel, QSizePolicy, QVBoxLayout, QWidget
|
|
23
|
+
|
|
24
|
+
from ..core.errors import PlotError
|
|
25
|
+
from ..core.plotter import build_matplotlib, build_plotly, prepare
|
|
26
|
+
from ..core.spec import ChartSpec
|
|
27
|
+
|
|
28
|
+
#: Set once by :func:`webengine_available` so the probe import happens at most once.
|
|
29
|
+
_WEBENGINE_STATE: bool | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def webengine_available() -> bool:
|
|
33
|
+
"""Whether QtWebEngine can be imported in this environment.
|
|
34
|
+
|
|
35
|
+
The import is attempted once and the answer cached. A failure here is completely normal —
|
|
36
|
+
several Linux distributions package ``pyside6`` without ``pyside6-webengine``.
|
|
37
|
+
"""
|
|
38
|
+
global _WEBENGINE_STATE
|
|
39
|
+
if _WEBENGINE_STATE is None:
|
|
40
|
+
try:
|
|
41
|
+
from PySide6.QtWebEngineWidgets import QWebEngineView # noqa: F401
|
|
42
|
+
|
|
43
|
+
_WEBENGINE_STATE = True
|
|
44
|
+
except Exception:
|
|
45
|
+
_WEBENGINE_STATE = False
|
|
46
|
+
return _WEBENGINE_STATE
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ChartView(QWidget):
|
|
50
|
+
"""Displays a chart, interactively when it can and statically when it cannot.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
parent: Parent widget.
|
|
54
|
+
prefer_static: Force the matplotlib canvas even when WebEngine is present. Useful for
|
|
55
|
+
very large charts and for users who dislike the browser view.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, parent: QWidget | None = None, *, prefer_static: bool = False) -> None:
|
|
59
|
+
super().__init__(parent)
|
|
60
|
+
self._layout = QVBoxLayout(self)
|
|
61
|
+
self._layout.setContentsMargins(0, 0, 0, 0)
|
|
62
|
+
|
|
63
|
+
self._interactive = webengine_available() and not prefer_static
|
|
64
|
+
self._temp_files: list[Path] = []
|
|
65
|
+
self._placeholder: QLabel | None = None
|
|
66
|
+
self._canvas: Any = None
|
|
67
|
+
self._web: Any = None
|
|
68
|
+
self.last_notes: list[str] = []
|
|
69
|
+
|
|
70
|
+
self._show_placeholder(
|
|
71
|
+
"Open a dataset to get started.\n\nDrag a CSV, Excel, JSON, or Parquet file here."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# ------------------------------------------------------------------ properties
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def renderer(self) -> str:
|
|
78
|
+
"""``"interactive"`` (Plotly/WebEngine) or ``"static"`` (matplotlib)."""
|
|
79
|
+
return "interactive" if self._interactive else "static"
|
|
80
|
+
|
|
81
|
+
def renderer_note(self) -> str:
|
|
82
|
+
"""One line for the status bar explaining what the user is looking at."""
|
|
83
|
+
if self._interactive:
|
|
84
|
+
return "Interactive chart — zoom, pan, hover, and toggle series in the legend."
|
|
85
|
+
if webengine_available():
|
|
86
|
+
return "Static chart (interactive view disabled in settings)."
|
|
87
|
+
return "Static chart — QtWebEngine is not installed, so interactive charts are unavailable."
|
|
88
|
+
|
|
89
|
+
def set_prefer_static(self, prefer_static: bool) -> None:
|
|
90
|
+
"""Switch renderers. The caller re-renders afterwards."""
|
|
91
|
+
self._interactive = webengine_available() and not prefer_static
|
|
92
|
+
|
|
93
|
+
# ------------------------------------------------------------------ rendering
|
|
94
|
+
|
|
95
|
+
def render_spec(self, df: pd.DataFrame, spec: ChartSpec) -> list[str]:
|
|
96
|
+
"""Draw a chart.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
df: The cleaned, filtered data.
|
|
100
|
+
spec: What to draw.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
User-facing notes from preparation (sampling, category capping) so the caller can
|
|
104
|
+
surface them. They are also stored on :attr:`last_notes`.
|
|
105
|
+
|
|
106
|
+
Raises:
|
|
107
|
+
PlotError: If the chart cannot be built. The caller shows this in a dialog.
|
|
108
|
+
"""
|
|
109
|
+
prepared = prepare(df, spec)
|
|
110
|
+
self.last_notes = list(prepared.notes)
|
|
111
|
+
|
|
112
|
+
if self._interactive:
|
|
113
|
+
try:
|
|
114
|
+
self._render_interactive(df, spec, prepared)
|
|
115
|
+
return self.last_notes
|
|
116
|
+
except PlotError:
|
|
117
|
+
raise
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
self._interactive = False
|
|
120
|
+
self.last_notes.append(
|
|
121
|
+
f"The interactive view failed ({exc}); showing a static chart instead."
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
self._render_static(df, spec, prepared)
|
|
125
|
+
return self.last_notes
|
|
126
|
+
|
|
127
|
+
def _render_interactive(self, df: pd.DataFrame, spec: ChartSpec, prepared: Any) -> None:
|
|
128
|
+
"""Render through Plotly into a QWebEngineView."""
|
|
129
|
+
from PySide6.QtWebEngineWidgets import QWebEngineView
|
|
130
|
+
|
|
131
|
+
figure = build_plotly(df, spec, prepared=prepared)
|
|
132
|
+
|
|
133
|
+
if not isinstance(self._web, QWebEngineView):
|
|
134
|
+
self._clear()
|
|
135
|
+
self._web = QWebEngineView(self)
|
|
136
|
+
self._web.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
|
137
|
+
self._layout.addWidget(self._web)
|
|
138
|
+
|
|
139
|
+
# Plotly's bundled JS is several megabytes and `setHtml` truncates above ~2 MB, so the
|
|
140
|
+
# figure goes to a temp file and is loaded as a URL instead.
|
|
141
|
+
with tempfile.NamedTemporaryFile(
|
|
142
|
+
mode="w", suffix=".html", prefix="plotaviz_", delete=False, encoding="utf-8"
|
|
143
|
+
) as handle:
|
|
144
|
+
handle.write(
|
|
145
|
+
figure.to_html(include_plotlyjs=True, full_html=True, config={"responsive": True})
|
|
146
|
+
)
|
|
147
|
+
path = Path(handle.name)
|
|
148
|
+
self._temp_files.append(path)
|
|
149
|
+
self._web.load(QUrl.fromLocalFile(str(path)))
|
|
150
|
+
self._prune_temp_files()
|
|
151
|
+
|
|
152
|
+
def _render_static(self, df: pd.DataFrame, spec: ChartSpec, prepared: Any) -> None:
|
|
153
|
+
"""Render through matplotlib into an embedded Qt canvas."""
|
|
154
|
+
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg
|
|
155
|
+
from matplotlib.backends.backend_qtagg import (
|
|
156
|
+
NavigationToolbar2QT as NavigationToolbar,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
figure = build_matplotlib(df, spec, prepared=prepared, figsize=(9, 5.5), dpi=100)
|
|
160
|
+
|
|
161
|
+
self._clear()
|
|
162
|
+
self._canvas = FigureCanvasQTAgg(figure)
|
|
163
|
+
self._canvas.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
|
164
|
+
toolbar = NavigationToolbar(self._canvas, self)
|
|
165
|
+
self._layout.addWidget(toolbar)
|
|
166
|
+
self._layout.addWidget(self._canvas)
|
|
167
|
+
self._canvas.draw_idle()
|
|
168
|
+
|
|
169
|
+
# ------------------------------------------------------------------ housekeeping
|
|
170
|
+
|
|
171
|
+
def _show_placeholder(self, text: str) -> None:
|
|
172
|
+
"""Replace the chart with centred guidance text."""
|
|
173
|
+
self._clear()
|
|
174
|
+
self._placeholder = QLabel(text, self)
|
|
175
|
+
self._placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
176
|
+
self._placeholder.setWordWrap(True)
|
|
177
|
+
self._placeholder.setStyleSheet("color: palette(mid); font-size: 14px; padding: 40px;")
|
|
178
|
+
self._layout.addWidget(self._placeholder)
|
|
179
|
+
|
|
180
|
+
def show_message(self, text: str) -> None:
|
|
181
|
+
"""Show a message in place of a chart (no data, or an error the user must act on)."""
|
|
182
|
+
self._show_placeholder(text)
|
|
183
|
+
|
|
184
|
+
def _clear(self) -> None:
|
|
185
|
+
"""Tear down whatever widget is currently showing."""
|
|
186
|
+
for attr in ("_placeholder", "_canvas", "_web"):
|
|
187
|
+
widget = getattr(self, attr, None)
|
|
188
|
+
if widget is not None:
|
|
189
|
+
self._layout.removeWidget(widget)
|
|
190
|
+
widget.setParent(None)
|
|
191
|
+
widget.deleteLater()
|
|
192
|
+
setattr(self, attr, None)
|
|
193
|
+
|
|
194
|
+
def _prune_temp_files(self, keep: int = 2) -> None:
|
|
195
|
+
"""Delete old temp HTML files, keeping the most recent few.
|
|
196
|
+
|
|
197
|
+
The current page cannot be deleted out from under WebEngine on Windows-style locking
|
|
198
|
+
semantics, so a small tail is retained rather than deleting eagerly.
|
|
199
|
+
"""
|
|
200
|
+
while len(self._temp_files) > keep:
|
|
201
|
+
stale = self._temp_files.pop(0)
|
|
202
|
+
with contextlib.suppress(OSError):
|
|
203
|
+
stale.unlink(missing_ok=True)
|
|
204
|
+
|
|
205
|
+
def cleanup(self) -> None:
|
|
206
|
+
"""Remove every temp file. Called when the window closes."""
|
|
207
|
+
for path in self._temp_files:
|
|
208
|
+
with contextlib.suppress(OSError):
|
|
209
|
+
path.unlink(missing_ok=True)
|
|
210
|
+
self._temp_files.clear()
|