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,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