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,184 @@
|
|
|
1
|
+
"""A virtualized table model for the data preview.
|
|
2
|
+
|
|
3
|
+
``QTableView`` only asks for the cells it is about to paint, so backing it with a model that
|
|
4
|
+
reads straight from the dataframe means a million-row file costs the same to display as a
|
|
5
|
+
hundred-row one. The rule from the performance guardrails — *never render 1M rows* — is satisfied
|
|
6
|
+
by never materializing them: no list of rows is ever built.
|
|
7
|
+
|
|
8
|
+
A page cap is still applied on top, because scrolling through a million rows is not a feature
|
|
9
|
+
anyone wants; the view shows the first :data:`MAX_PREVIEW_ROWS` and says so.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import pandas as pd
|
|
17
|
+
from PySide6.QtCore import QAbstractTableModel, QModelIndex, QObject, Qt
|
|
18
|
+
|
|
19
|
+
from ..core.profiler import DatasetProfile
|
|
20
|
+
|
|
21
|
+
#: Rows exposed to the preview table. Beyond this the user is looking at data, not reading it.
|
|
22
|
+
MAX_PREVIEW_ROWS = 5_000
|
|
23
|
+
|
|
24
|
+
#: Role-specific tints, so a glance at the header tells you how each column was classified.
|
|
25
|
+
_ROLE_COLOURS = {
|
|
26
|
+
"numeric": "#2E6DA4",
|
|
27
|
+
"categorical": "#7B4EA8",
|
|
28
|
+
"datetime": "#1F7A5C",
|
|
29
|
+
"boolean": "#A8681F",
|
|
30
|
+
"text": "#6B6B6B",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DataFrameModel(QAbstractTableModel):
|
|
35
|
+
"""Exposes a pandas DataFrame to a ``QTableView`` without copying it.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
df: The frame to display.
|
|
39
|
+
profile: Optional profile, used to annotate headers with each column's role.
|
|
40
|
+
parent: Parent object.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
df: pd.DataFrame | None = None,
|
|
46
|
+
profile: DatasetProfile | None = None,
|
|
47
|
+
parent: QObject | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
super().__init__(parent)
|
|
50
|
+
self._df = df if df is not None else pd.DataFrame()
|
|
51
|
+
self._profile = profile
|
|
52
|
+
self._rows = min(len(self._df), MAX_PREVIEW_ROWS)
|
|
53
|
+
|
|
54
|
+
# ------------------------------------------------------------------ data access
|
|
55
|
+
|
|
56
|
+
def set_frame(self, df: pd.DataFrame, profile: DatasetProfile | None = None) -> None:
|
|
57
|
+
"""Swap in a new frame and repaint."""
|
|
58
|
+
self.beginResetModel()
|
|
59
|
+
self._df = df
|
|
60
|
+
self._profile = profile
|
|
61
|
+
self._rows = min(len(df), MAX_PREVIEW_ROWS)
|
|
62
|
+
self.endResetModel()
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def frame(self) -> pd.DataFrame:
|
|
66
|
+
"""The frame being displayed."""
|
|
67
|
+
return self._df
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def truncated(self) -> bool:
|
|
71
|
+
"""Whether the view is showing fewer rows than the frame holds."""
|
|
72
|
+
return len(self._df) > self._rows
|
|
73
|
+
|
|
74
|
+
def status_text(self) -> str:
|
|
75
|
+
"""A line describing what the preview is showing."""
|
|
76
|
+
rows, cols = len(self._df), len(self._df.columns)
|
|
77
|
+
if self.truncated:
|
|
78
|
+
return f"Previewing the first {self._rows:,} of {rows:,} rows × {cols} columns"
|
|
79
|
+
return f"{rows:,} rows × {cols} columns"
|
|
80
|
+
|
|
81
|
+
# ------------------------------------------------------------------ Qt model API
|
|
82
|
+
|
|
83
|
+
def rowCount(self, parent: QModelIndex | None = None) -> int:
|
|
84
|
+
"""Rows the view should ask about."""
|
|
85
|
+
if parent is not None and parent.isValid():
|
|
86
|
+
return 0
|
|
87
|
+
return self._rows
|
|
88
|
+
|
|
89
|
+
def columnCount(self, parent: QModelIndex | None = None) -> int:
|
|
90
|
+
"""Column count."""
|
|
91
|
+
if parent is not None and parent.isValid():
|
|
92
|
+
return 0
|
|
93
|
+
return len(self._df.columns)
|
|
94
|
+
|
|
95
|
+
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any:
|
|
96
|
+
"""Return one cell's display text or alignment."""
|
|
97
|
+
if not index.isValid():
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
row, col = index.row(), index.column()
|
|
101
|
+
if row >= self._rows or col >= len(self._df.columns):
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
if role == Qt.ItemDataRole.DisplayRole:
|
|
105
|
+
value = self._df.iat[row, col]
|
|
106
|
+
if pd.isna(value):
|
|
107
|
+
return "—"
|
|
108
|
+
if isinstance(value, float):
|
|
109
|
+
return f"{value:,.4g}"
|
|
110
|
+
if isinstance(value, pd.Timestamp):
|
|
111
|
+
return value.strftime(
|
|
112
|
+
"%Y-%m-%d %H:%M" if value.time() != value.min.time() else "%Y-%m-%d"
|
|
113
|
+
)
|
|
114
|
+
return str(value)
|
|
115
|
+
|
|
116
|
+
if role == Qt.ItemDataRole.TextAlignmentRole:
|
|
117
|
+
column = self._df.columns[col]
|
|
118
|
+
if pd.api.types.is_numeric_dtype(self._df[column]):
|
|
119
|
+
return int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
|
120
|
+
return int(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
|
121
|
+
|
|
122
|
+
if role == Qt.ItemDataRole.ForegroundRole and pd.isna(self._df.iat[row, col]):
|
|
123
|
+
from PySide6.QtGui import QColor
|
|
124
|
+
|
|
125
|
+
return QColor("#999999")
|
|
126
|
+
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
def headerData(
|
|
130
|
+
self,
|
|
131
|
+
section: int,
|
|
132
|
+
orientation: Qt.Orientation,
|
|
133
|
+
role: int = Qt.ItemDataRole.DisplayRole,
|
|
134
|
+
) -> Any:
|
|
135
|
+
"""Column names with their inferred role, and row numbers down the side."""
|
|
136
|
+
if orientation == Qt.Orientation.Vertical:
|
|
137
|
+
if role == Qt.ItemDataRole.DisplayRole:
|
|
138
|
+
return str(section + 1)
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
if section >= len(self._df.columns):
|
|
142
|
+
return None
|
|
143
|
+
name = str(self._df.columns[section])
|
|
144
|
+
|
|
145
|
+
if role == Qt.ItemDataRole.DisplayRole:
|
|
146
|
+
return name
|
|
147
|
+
|
|
148
|
+
if role == Qt.ItemDataRole.ToolTipRole:
|
|
149
|
+
return self._column_tooltip(name)
|
|
150
|
+
|
|
151
|
+
if role == Qt.ItemDataRole.ForegroundRole and self._profile:
|
|
152
|
+
prof = self._profile.columns.get(name)
|
|
153
|
+
if prof:
|
|
154
|
+
from PySide6.QtGui import QColor
|
|
155
|
+
|
|
156
|
+
return QColor(_ROLE_COLOURS.get(prof.role, "#333333"))
|
|
157
|
+
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
def _column_tooltip(self, name: str) -> str:
|
|
161
|
+
"""Per-column summary shown when hovering a header."""
|
|
162
|
+
if not self._profile:
|
|
163
|
+
return name
|
|
164
|
+
prof = self._profile.columns.get(name)
|
|
165
|
+
if prof is None:
|
|
166
|
+
return name
|
|
167
|
+
|
|
168
|
+
lines = [f"{name} — {prof.role}"]
|
|
169
|
+
if prof.is_identifier:
|
|
170
|
+
lines.append("Looks like an identifier, so it is excluded from chart suggestions.")
|
|
171
|
+
if prof.is_derived:
|
|
172
|
+
lines.append("Added by PlotaViz.")
|
|
173
|
+
lines.append(f"{prof.n_unique:,} distinct values")
|
|
174
|
+
if prof.pct_missing:
|
|
175
|
+
lines.append(f"{prof.pct_missing:.1f}% missing")
|
|
176
|
+
if prof.role == "numeric" and prof.stats:
|
|
177
|
+
lines.append(
|
|
178
|
+
f"min {prof.stats.get('min', 0):,.4g} · "
|
|
179
|
+
f"median {prof.stats.get('median', 0):,.4g} · "
|
|
180
|
+
f"max {prof.stats.get('max', 0):,.4g}"
|
|
181
|
+
)
|
|
182
|
+
if prof.note:
|
|
183
|
+
lines.append(prof.note)
|
|
184
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""The column type override panel.
|
|
2
|
+
|
|
3
|
+
Automatic type inference gets identifiers and dates wrong constantly — a zip code becomes a
|
|
4
|
+
number, an order ID becomes a measure, a date column stays text — and every wrong type produces a
|
|
5
|
+
wrong chart. Letting the user correct the inference *before* analysis proceeds is the single
|
|
6
|
+
biggest frustration-preventer in the app, which is why this panel is a first-class part of the
|
|
7
|
+
flow rather than a settings page.
|
|
8
|
+
|
|
9
|
+
Corrections are cheap because the preprocessing pipeline is replayable: changing a type re-runs a
|
|
10
|
+
short list of pure steps rather than reloading anything.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from PySide6.QtCore import Qt, Signal
|
|
16
|
+
from PySide6.QtWidgets import (
|
|
17
|
+
QComboBox,
|
|
18
|
+
QHBoxLayout,
|
|
19
|
+
QHeaderView,
|
|
20
|
+
QLabel,
|
|
21
|
+
QPushButton,
|
|
22
|
+
QTableWidget,
|
|
23
|
+
QTableWidgetItem,
|
|
24
|
+
QVBoxLayout,
|
|
25
|
+
QWidget,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from ..core.profiler import ROLES, DatasetProfile
|
|
29
|
+
|
|
30
|
+
#: Roles offered in the dropdown, with the labels users actually recognise.
|
|
31
|
+
_ROLE_LABELS = {
|
|
32
|
+
"numeric": "Number",
|
|
33
|
+
"categorical": "Category",
|
|
34
|
+
"datetime": "Date / time",
|
|
35
|
+
"boolean": "True / false",
|
|
36
|
+
"text": "Free text",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TypeOverridePanel(QWidget):
|
|
41
|
+
"""Shows every column's inferred type and lets the user correct it.
|
|
42
|
+
|
|
43
|
+
Signals:
|
|
44
|
+
types_changed: Emitted with ``{column: role}`` when the user applies corrections.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
types_changed = Signal(dict)
|
|
48
|
+
|
|
49
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
50
|
+
super().__init__(parent)
|
|
51
|
+
self._profile: DatasetProfile | None = None
|
|
52
|
+
self._combos: dict[str, QComboBox] = {}
|
|
53
|
+
self._inferred: dict[str, str] = {}
|
|
54
|
+
|
|
55
|
+
layout = QVBoxLayout(self)
|
|
56
|
+
layout.setContentsMargins(8, 8, 8, 8)
|
|
57
|
+
|
|
58
|
+
intro = QLabel(
|
|
59
|
+
"PlotaViz guessed these types. Correct any it got wrong — IDs read as numbers and "
|
|
60
|
+
"dates read as text are the usual culprits."
|
|
61
|
+
)
|
|
62
|
+
intro.setWordWrap(True)
|
|
63
|
+
intro.setStyleSheet("color: palette(mid);")
|
|
64
|
+
layout.addWidget(intro)
|
|
65
|
+
|
|
66
|
+
self.table = QTableWidget(0, 4, self)
|
|
67
|
+
self.table.setHorizontalHeaderLabels(["Column", "Detected as", "Treat as", "Notes"])
|
|
68
|
+
self.table.verticalHeader().setVisible(False)
|
|
69
|
+
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
|
70
|
+
self.table.setSelectionMode(QTableWidget.SelectionMode.NoSelection)
|
|
71
|
+
header = self.table.horizontalHeader()
|
|
72
|
+
header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
|
73
|
+
header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
|
|
74
|
+
header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
|
|
75
|
+
header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch)
|
|
76
|
+
layout.addWidget(self.table, stretch=1)
|
|
77
|
+
|
|
78
|
+
buttons = QHBoxLayout()
|
|
79
|
+
self.reset_button = QPushButton("Reset to detected", self)
|
|
80
|
+
self.reset_button.clicked.connect(self.reset)
|
|
81
|
+
self.apply_button = QPushButton("Apply types", self)
|
|
82
|
+
self.apply_button.setDefault(True)
|
|
83
|
+
self.apply_button.clicked.connect(self._emit_changes)
|
|
84
|
+
buttons.addWidget(self.reset_button)
|
|
85
|
+
buttons.addStretch(1)
|
|
86
|
+
buttons.addWidget(self.apply_button)
|
|
87
|
+
layout.addLayout(buttons)
|
|
88
|
+
|
|
89
|
+
# ------------------------------------------------------------------ population
|
|
90
|
+
|
|
91
|
+
def set_profile(self, profile: DatasetProfile, overrides: dict[str, str] | None = None) -> None:
|
|
92
|
+
"""Rebuild the panel for a dataset.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
profile: The current profile, whose inferred roles seed the dropdowns.
|
|
96
|
+
overrides: Corrections already in force, so reopening the panel shows them.
|
|
97
|
+
"""
|
|
98
|
+
self._profile = profile
|
|
99
|
+
overrides = overrides or {}
|
|
100
|
+
self._combos.clear()
|
|
101
|
+
self._inferred.clear()
|
|
102
|
+
|
|
103
|
+
columns = list(profile.columns.values())
|
|
104
|
+
self.table.setRowCount(len(columns))
|
|
105
|
+
|
|
106
|
+
for row, prof in enumerate(columns):
|
|
107
|
+
self._inferred[prof.name] = prof.role
|
|
108
|
+
|
|
109
|
+
name_item = QTableWidgetItem(prof.name)
|
|
110
|
+
name_item.setToolTip(
|
|
111
|
+
f"{prof.n_unique:,} distinct values, {prof.pct_missing:.1f}% missing"
|
|
112
|
+
)
|
|
113
|
+
self.table.setItem(row, 0, name_item)
|
|
114
|
+
|
|
115
|
+
detected = QTableWidgetItem(_ROLE_LABELS.get(prof.role, prof.role))
|
|
116
|
+
detected.setForeground(Qt.GlobalColor.gray)
|
|
117
|
+
self.table.setItem(row, 1, detected)
|
|
118
|
+
|
|
119
|
+
combo = QComboBox(self)
|
|
120
|
+
for role in ROLES:
|
|
121
|
+
combo.addItem(_ROLE_LABELS.get(role, role), role)
|
|
122
|
+
current = overrides.get(prof.name, prof.role)
|
|
123
|
+
index = combo.findData(current)
|
|
124
|
+
combo.setCurrentIndex(max(0, index))
|
|
125
|
+
combo.setEnabled(not prof.is_derived)
|
|
126
|
+
self._combos[prof.name] = combo
|
|
127
|
+
self.table.setCellWidget(row, 2, combo)
|
|
128
|
+
|
|
129
|
+
notes = []
|
|
130
|
+
if prof.note:
|
|
131
|
+
notes.append(prof.note)
|
|
132
|
+
if prof.is_identifier:
|
|
133
|
+
notes.append("Excluded from chart suggestions.")
|
|
134
|
+
if prof.is_derived:
|
|
135
|
+
notes.append("Added by PlotaViz.")
|
|
136
|
+
note_item = QTableWidgetItem(" ".join(notes))
|
|
137
|
+
note_item.setToolTip(" ".join(notes))
|
|
138
|
+
self.table.setItem(row, 3, note_item)
|
|
139
|
+
|
|
140
|
+
# ------------------------------------------------------------------ interaction
|
|
141
|
+
|
|
142
|
+
def overrides(self) -> dict[str, str]:
|
|
143
|
+
"""The corrections the user has made — only entries that differ from inference."""
|
|
144
|
+
return {
|
|
145
|
+
name: combo.currentData()
|
|
146
|
+
for name, combo in self._combos.items()
|
|
147
|
+
if combo.currentData() != self._inferred.get(name)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
def reset(self) -> None:
|
|
151
|
+
"""Put every dropdown back to what inference chose, and apply."""
|
|
152
|
+
for name, combo in self._combos.items():
|
|
153
|
+
index = combo.findData(self._inferred.get(name))
|
|
154
|
+
combo.setCurrentIndex(max(0, index))
|
|
155
|
+
self._emit_changes()
|
|
156
|
+
|
|
157
|
+
def _emit_changes(self) -> None:
|
|
158
|
+
"""Emit the current corrections."""
|
|
159
|
+
self.types_changed.emit(self.overrides())
|
plotaviz/ui/workers.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Background work — keeping long operations off the UI thread.
|
|
2
|
+
|
|
3
|
+
Loading a 400 MB CSV, replaying a pipeline, and waiting on an LLM all take long enough to freeze
|
|
4
|
+
a window, and a frozen window looks like a crash. Every one of those runs through :class:`Worker`
|
|
5
|
+
on a ``QThread``, reports progress, and can be cancelled.
|
|
6
|
+
|
|
7
|
+
The cancellation model is cooperative and honest: :meth:`Worker.cancel` sets a flag the task can
|
|
8
|
+
check, and if the task is stuck inside pandas the flag is only honoured when it returns. The UI
|
|
9
|
+
therefore says "Cancelling…" rather than pretending the work stopped instantly.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import traceback
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from PySide6.QtCore import QObject, QThread, Signal, Slot
|
|
19
|
+
|
|
20
|
+
from ..core.errors import PlotaVizError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Worker(QObject):
|
|
24
|
+
"""Runs one callable on a background thread.
|
|
25
|
+
|
|
26
|
+
The callable may accept a ``progress`` keyword — a ``Callable[[int, str], None]`` it can call
|
|
27
|
+
to report percentage and a status message — and a ``should_cancel`` keyword returning whether
|
|
28
|
+
the user has asked to stop.
|
|
29
|
+
|
|
30
|
+
Signals:
|
|
31
|
+
finished: Emitted with the return value on success.
|
|
32
|
+
failed: Emitted with a user-facing message and the traceback on failure.
|
|
33
|
+
progress: Emitted with ``(percent, message)``.
|
|
34
|
+
done: Emitted after either outcome, for teardown.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
finished = Signal(object)
|
|
38
|
+
failed = Signal(str, str)
|
|
39
|
+
progress = Signal(int, str)
|
|
40
|
+
done = Signal()
|
|
41
|
+
|
|
42
|
+
def __init__(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> None:
|
|
43
|
+
super().__init__()
|
|
44
|
+
self._fn = fn
|
|
45
|
+
self._args = args
|
|
46
|
+
self._kwargs = kwargs
|
|
47
|
+
self._cancelled = False
|
|
48
|
+
|
|
49
|
+
def cancel(self) -> None:
|
|
50
|
+
"""Ask the task to stop at its next checkpoint."""
|
|
51
|
+
self._cancelled = True
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def cancelled(self) -> bool:
|
|
55
|
+
"""Whether cancellation has been requested."""
|
|
56
|
+
return self._cancelled
|
|
57
|
+
|
|
58
|
+
def run(self) -> None:
|
|
59
|
+
"""Execute the callable. Connected to the thread's ``started`` signal."""
|
|
60
|
+
try:
|
|
61
|
+
import inspect
|
|
62
|
+
|
|
63
|
+
params = inspect.signature(self._fn).parameters
|
|
64
|
+
kwargs = dict(self._kwargs)
|
|
65
|
+
if "progress" in params:
|
|
66
|
+
kwargs["progress"] = self._emit_progress
|
|
67
|
+
if "should_cancel" in params:
|
|
68
|
+
kwargs["should_cancel"] = lambda: self._cancelled
|
|
69
|
+
|
|
70
|
+
result = self._fn(*self._args, **kwargs)
|
|
71
|
+
except PlotaVizError as exc:
|
|
72
|
+
# Already written for a human — pass the message straight through.
|
|
73
|
+
self.failed.emit(str(exc), traceback.format_exc())
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
self.failed.emit(
|
|
76
|
+
f"Something went wrong: {exc}",
|
|
77
|
+
traceback.format_exc(),
|
|
78
|
+
)
|
|
79
|
+
else:
|
|
80
|
+
if not self._cancelled:
|
|
81
|
+
self.finished.emit(result)
|
|
82
|
+
finally:
|
|
83
|
+
self.done.emit()
|
|
84
|
+
|
|
85
|
+
def _emit_progress(self, percent: int, message: str = "") -> None:
|
|
86
|
+
"""Progress callback handed to the task."""
|
|
87
|
+
self.progress.emit(int(percent), message)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class TaskRunner(QObject):
|
|
91
|
+
"""Owns a worker and its thread, and marshals results back to the UI thread.
|
|
92
|
+
|
|
93
|
+
Two problems this solves, both of which crash an app rather than merely annoying it:
|
|
94
|
+
|
|
95
|
+
* Qt garbage-collects a running ``QThread`` if nothing holds a reference, which shows up as
|
|
96
|
+
an intermittent segfault under load. This object holds that reference.
|
|
97
|
+
* A signal connected to a *plain callable* — a lambda, a nested function — is delivered
|
|
98
|
+
synchronously **in the emitting thread**. Callbacks written against the UI would then touch
|
|
99
|
+
widgets from the worker thread, which Qt does not allow. So worker signals are connected to
|
|
100
|
+
slots on this object (which lives in the UI thread, so Qt queues the delivery), and those
|
|
101
|
+
slots re-emit on the UI thread where the caller's callbacks are safe to run.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
parent: Parent object, usually the main window.
|
|
105
|
+
|
|
106
|
+
Signals:
|
|
107
|
+
finished: Emitted on the UI thread with the task's return value.
|
|
108
|
+
failed: Emitted on the UI thread with ``(message, traceback)``.
|
|
109
|
+
progress: Emitted on the UI thread with ``(percent, message)``.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
finished = Signal(object)
|
|
113
|
+
failed = Signal(str, str)
|
|
114
|
+
progress = Signal(int, str)
|
|
115
|
+
|
|
116
|
+
def __init__(self, parent: QObject | None = None) -> None:
|
|
117
|
+
super().__init__(parent)
|
|
118
|
+
self._thread: QThread | None = None
|
|
119
|
+
self._worker: Worker | None = None
|
|
120
|
+
self._on_finished: Callable[[Any], None] | None = None
|
|
121
|
+
self._on_failed: Callable[[str, str], None] | None = None
|
|
122
|
+
self._on_progress: Callable[[int, str], None] | None = None
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def busy(self) -> bool:
|
|
126
|
+
"""Whether a task is currently running."""
|
|
127
|
+
return self._thread is not None and self._thread.isRunning()
|
|
128
|
+
|
|
129
|
+
def start(
|
|
130
|
+
self,
|
|
131
|
+
fn: Callable[..., Any],
|
|
132
|
+
*args: Any,
|
|
133
|
+
on_finished: Callable[[Any], None] | None = None,
|
|
134
|
+
on_failed: Callable[[str, str], None] | None = None,
|
|
135
|
+
on_progress: Callable[[int, str], None] | None = None,
|
|
136
|
+
**kwargs: Any,
|
|
137
|
+
) -> Worker:
|
|
138
|
+
"""Run ``fn`` on a background thread.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
fn: The callable to run.
|
|
142
|
+
*args: Positional arguments for it.
|
|
143
|
+
on_finished: Called with the result on the UI thread.
|
|
144
|
+
on_failed: Called with ``(message, traceback)`` on the UI thread.
|
|
145
|
+
on_progress: Called with ``(percent, message)`` on the UI thread.
|
|
146
|
+
**kwargs: Keyword arguments for ``fn``.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
The :class:`Worker`, so the caller can cancel it.
|
|
150
|
+
|
|
151
|
+
Raises:
|
|
152
|
+
RuntimeError: If a task is already running. Callers disable their triggers instead of
|
|
153
|
+
relying on this.
|
|
154
|
+
"""
|
|
155
|
+
if self.busy:
|
|
156
|
+
raise RuntimeError("A background task is already running.")
|
|
157
|
+
|
|
158
|
+
thread = QThread()
|
|
159
|
+
worker = Worker(fn, *args, **kwargs)
|
|
160
|
+
worker.moveToThread(thread)
|
|
161
|
+
|
|
162
|
+
thread.started.connect(worker.run)
|
|
163
|
+
|
|
164
|
+
# Route through this object's slots so delivery is queued onto the UI thread; only then
|
|
165
|
+
# are the caller's plain callables safe to invoke.
|
|
166
|
+
self._on_finished = on_finished
|
|
167
|
+
self._on_failed = on_failed
|
|
168
|
+
self._on_progress = on_progress
|
|
169
|
+
worker.finished.connect(self._relay_finished)
|
|
170
|
+
worker.failed.connect(self._relay_failed)
|
|
171
|
+
worker.progress.connect(self._relay_progress)
|
|
172
|
+
|
|
173
|
+
worker.done.connect(thread.quit)
|
|
174
|
+
worker.done.connect(worker.deleteLater)
|
|
175
|
+
thread.finished.connect(thread.deleteLater)
|
|
176
|
+
thread.finished.connect(self._clear)
|
|
177
|
+
|
|
178
|
+
self._thread, self._worker = thread, worker
|
|
179
|
+
thread.start()
|
|
180
|
+
return worker
|
|
181
|
+
|
|
182
|
+
# ------------------------------------------------------------------ UI-thread relays
|
|
183
|
+
|
|
184
|
+
@Slot(object)
|
|
185
|
+
def _relay_finished(self, result: Any) -> None:
|
|
186
|
+
"""Runs on the UI thread. Re-emit, then call the caller's success handler."""
|
|
187
|
+
self.finished.emit(result)
|
|
188
|
+
if self._on_finished is not None:
|
|
189
|
+
self._on_finished(result)
|
|
190
|
+
|
|
191
|
+
@Slot(str, str)
|
|
192
|
+
def _relay_failed(self, message: str, trace: str) -> None:
|
|
193
|
+
"""Runs on the UI thread. Re-emit, then call the caller's failure handler."""
|
|
194
|
+
self.failed.emit(message, trace)
|
|
195
|
+
if self._on_failed is not None:
|
|
196
|
+
self._on_failed(message, trace)
|
|
197
|
+
|
|
198
|
+
@Slot(int, str)
|
|
199
|
+
def _relay_progress(self, percent: int, message: str) -> None:
|
|
200
|
+
"""Runs on the UI thread. Re-emit, then call the caller's progress handler."""
|
|
201
|
+
self.progress.emit(percent, message)
|
|
202
|
+
if self._on_progress is not None:
|
|
203
|
+
self._on_progress(percent, message)
|
|
204
|
+
|
|
205
|
+
def cancel(self) -> None:
|
|
206
|
+
"""Ask the running task to stop."""
|
|
207
|
+
if self._worker is not None:
|
|
208
|
+
self._worker.cancel()
|
|
209
|
+
|
|
210
|
+
def wait(self, timeout_ms: int = 5000) -> None:
|
|
211
|
+
"""Block until the current task finishes. Used on window close."""
|
|
212
|
+
if self._thread is not None and self._thread.isRunning():
|
|
213
|
+
self._thread.quit()
|
|
214
|
+
self._thread.wait(timeout_ms)
|
|
215
|
+
|
|
216
|
+
def _clear(self) -> None:
|
|
217
|
+
"""Drop references and per-task callbacks once the thread is done."""
|
|
218
|
+
self._thread = None
|
|
219
|
+
self._worker = None
|
|
220
|
+
self._on_finished = None
|
|
221
|
+
self._on_failed = None
|
|
222
|
+
self._on_progress = None
|