pivot-tui 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.
pivot_tui/AGENTS.md ADDED
@@ -0,0 +1,20 @@
1
+ # Pivot TUI - Development Guidelines
2
+
3
+ Pure RPC client over the engine's JSON-RPC 2.0 Unix socket.
4
+
5
+ ## Boundary Rules
6
+
7
+ - **No direct state access.** Must not read lock files, cache files, or state files. All state goes through engine RPC.
8
+ - **Import boundary:** May import `pivot.types` (pure type definitions). Must not import runtime modules (`engine`, `storage`, `config`).
9
+
10
+ ## Lifecycle
11
+
12
+ One-shot and watch mode are source-configuration differences, not different lifecycles. The TUI should outlive run completion.
13
+
14
+ ## Event Stream
15
+
16
+ Engine emits explanation events in the event stream. The TUI reconstructs history client-side from this stream.
17
+
18
+ ## Testing
19
+
20
+ Use `FakeRpcServer` (`testing/fake_server.py`) for TUI tests — test against client interface contracts, not engine internals.
pivot_tui/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Textual-based TUI for the Pivot pipeline framework."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
pivot_tui/client.py ADDED
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ # pyright: reportMissingImports=false
4
+ # pyright: reportUnknownVariableType=false
5
+ # pyright: reportUnknownParameterType=false
6
+ from typing import TYPE_CHECKING, Literal, Protocol, TypedDict
7
+
8
+ if TYPE_CHECKING:
9
+ from pathlib import Path
10
+
11
+ from pivot.types import StageExplanation
12
+
13
+
14
+ class EngineStatus(TypedDict):
15
+ state: Literal["idle", "active"]
16
+ running: list[str]
17
+ pending: list[str]
18
+
19
+
20
+ class CommitResult(TypedDict):
21
+ committed: list[str]
22
+ failed: list[str]
23
+
24
+
25
+ class StageInfoResult(TypedDict):
26
+ name: str
27
+ deps: list[str]
28
+ outs: list[str]
29
+
30
+
31
+ class VersionedEvent(TypedDict):
32
+ version: int
33
+ event: dict[str, object]
34
+
35
+
36
+ class EventBatch(TypedDict):
37
+ version: int
38
+ events: list[VersionedEvent]
39
+
40
+
41
+ class PivotRpc(Protocol):
42
+ """RPC methods for engine interaction (no transport concern)."""
43
+
44
+ async def run(self, stages: list[str] | None = None, *, force: bool = False) -> bool: ...
45
+
46
+ async def cancel(self) -> bool: ...
47
+
48
+ async def commit(self) -> CommitResult: ...
49
+
50
+ async def status(self) -> EngineStatus: ...
51
+
52
+ async def stages(self) -> list[str]: ...
53
+
54
+ async def stage_info(self, stage: str) -> StageInfoResult: ...
55
+
56
+ async def explain(self, stage: str) -> StageExplanation: ...
57
+
58
+ async def events_since(self, version: int) -> EventBatch: ...
59
+
60
+ async def set_on_error(self, mode: Literal["fail", "keep_going"]) -> bool: ...
61
+
62
+ async def diff_output(
63
+ self,
64
+ path: str,
65
+ old_hash: str | None,
66
+ new_hash: str | None,
67
+ *,
68
+ max_rows: int = 50,
69
+ ) -> dict[str, object]: ...
70
+
71
+
72
+ class PivotClient(PivotRpc, Protocol):
73
+ """Full client with transport + RPC."""
74
+
75
+ async def connect(self, socket_path: Path) -> None: ...
76
+
77
+ async def disconnect(self) -> None: ...
pivot_tui/diff.py ADDED
@@ -0,0 +1,290 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, ClassVar, override
4
+
5
+ import rich.markup
6
+ import textual.app
7
+ import textual.binding
8
+ import textual.containers
9
+ import textual.widgets
10
+
11
+ from pivot.types import ChangeType, DataDiffResult, RowChange, SchemaChange
12
+
13
+ # Rich markup symbols for change types
14
+ STATUS_SYMBOLS_RICH: dict[ChangeType, str] = {
15
+ ChangeType.ADDED: "[green]+[/]",
16
+ ChangeType.REMOVED: "[red]-[/]",
17
+ ChangeType.MODIFIED: "[yellow]~[/]",
18
+ }
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Sequence
22
+
23
+
24
+ class DiffSummaryPanel(textual.widgets.Static):
25
+ """Shows diff summary: row counts, column changes, reorder-only status."""
26
+
27
+ _result: DataDiffResult
28
+
29
+ def __init__(self, result: DataDiffResult) -> None:
30
+ super().__init__()
31
+ self._result = result
32
+
33
+ @override
34
+ def compose(self) -> textual.app.ComposeResult: # pragma: no cover
35
+ summary_lines = list[str]()
36
+
37
+ # File path and status
38
+ escaped_path = rich.markup.escape(self._result["path"])
39
+ if self._result["reorder_only"]:
40
+ summary_lines.append(f"[bold blue]{escaped_path}[/] - REORDER ONLY")
41
+ summary_lines.append("[dim]Same content, different row order[/]")
42
+ else:
43
+ summary_lines.append(f"[bold]{escaped_path}[/]")
44
+
45
+ # Row counts
46
+ old_rows = self._result["old_rows"]
47
+ new_rows = self._result["new_rows"]
48
+ if old_rows is not None and new_rows is not None:
49
+ diff = new_rows - old_rows
50
+ if diff > 0:
51
+ diff_str = f"[green]+{diff}[/]"
52
+ elif diff < 0:
53
+ diff_str = f"[red]{diff}[/]"
54
+ else:
55
+ diff_str = "0"
56
+ summary_lines.append(f"Rows: {old_rows:,} -> {new_rows:,} ({diff_str})")
57
+ elif old_rows is not None:
58
+ summary_lines.append(f"Rows: {old_rows:,} [red](removed)[/]")
59
+ elif new_rows is not None:
60
+ summary_lines.append(f"Rows: {new_rows:,} [green](added)[/]")
61
+
62
+ # Column counts
63
+ old_cols = self._result["old_cols"]
64
+ new_cols = self._result["new_cols"]
65
+ if old_cols is not None and new_cols is not None:
66
+ added = len([c for c in new_cols if c not in old_cols])
67
+ removed = len([c for c in old_cols if c not in new_cols])
68
+ parts = list[str]()
69
+ if added:
70
+ parts.append(f"[green]+{added} added[/]")
71
+ if removed:
72
+ parts.append(f"[red]-{removed} removed[/]")
73
+ col_info = f"{len(old_cols)} -> {len(new_cols)}"
74
+ if parts:
75
+ col_info += f" ({', '.join(parts)})"
76
+ summary_lines.append(f"Columns: {col_info}")
77
+
78
+ # Row change summary
79
+ row_changes = self._result["row_changes"]
80
+ if row_changes:
81
+ added = sum(1 for r in row_changes if r["change_type"] == ChangeType.ADDED)
82
+ removed = sum(1 for r in row_changes if r["change_type"] == ChangeType.REMOVED)
83
+ modified = sum(1 for r in row_changes if r["change_type"] == ChangeType.MODIFIED)
84
+ parts = list[str]()
85
+ if added:
86
+ parts.append(f"[green]+{added}[/]")
87
+ if removed:
88
+ parts.append(f"[red]-{removed}[/]")
89
+ if modified:
90
+ parts.append(f"[yellow]~{modified}[/]")
91
+ if self._result["truncated"]:
92
+ parts.append("[dim](truncated)[/]")
93
+ summary_lines.append(f"Changes: {', '.join(parts)}")
94
+
95
+ yield textual.widgets.Static("\n".join(summary_lines))
96
+
97
+
98
+ class SchemaChangesTable(textual.widgets.DataTable[str]):
99
+ """Table showing schema (column) changes."""
100
+
101
+ _changes: list[SchemaChange]
102
+
103
+ def __init__(self, changes: list[SchemaChange]) -> None:
104
+ super().__init__()
105
+ self._changes = changes
106
+
107
+ @override
108
+ def on_mount(self) -> None: # pragma: no cover
109
+ self.add_columns("Status", "Column", "Old Type", "New Type")
110
+ for change in self._changes:
111
+ status = STATUS_SYMBOLS_RICH.get(change["change_type"], "?")
112
+ self.add_row(
113
+ status,
114
+ change["column"],
115
+ change["old_dtype"] or "-",
116
+ change["new_dtype"] or "-",
117
+ )
118
+
119
+
120
+ class RowChangesTable(textual.widgets.DataTable[str]):
121
+ """Table showing row-level changes."""
122
+
123
+ _changes: list[RowChange]
124
+ _columns: list[str]
125
+
126
+ def __init__(self, changes: list[RowChange], columns: list[str]) -> None:
127
+ super().__init__()
128
+ self._changes = changes
129
+ self._columns = columns
130
+
131
+ @override
132
+ def on_mount(self) -> None: # pragma: no cover
133
+ # Add columns: Status, Key, then all data columns
134
+ self.add_columns("", "Key", *self._columns)
135
+
136
+ for change in self._changes:
137
+ status = STATUS_SYMBOLS_RICH.get(change["change_type"], "?")
138
+ key = str(change["key"])
139
+ row_data = [status, key]
140
+
141
+ # Build column values with change highlighting
142
+ for col in self._columns:
143
+ old_val = change["old_values"].get(col) if change["old_values"] else None
144
+ new_val = change["new_values"].get(col) if change["new_values"] else None
145
+
146
+ match change["change_type"]:
147
+ case ChangeType.ADDED:
148
+ row_data.append(f"[green]{new_val}[/]")
149
+ case ChangeType.REMOVED:
150
+ row_data.append(f"[red]{old_val}[/]")
151
+ case _ if old_val != new_val:
152
+ row_data.append(f"[yellow]{old_val} -> {new_val}[/]")
153
+ case _:
154
+ row_data.append(str(new_val) if new_val is not None else "")
155
+
156
+ self.add_row(*row_data)
157
+
158
+
159
+ class FileDiffScreen(textual.widgets.Static):
160
+ """Screen showing diff for a single file."""
161
+
162
+ _result: DataDiffResult
163
+
164
+ def __init__(self, result: DataDiffResult) -> None:
165
+ super().__init__()
166
+ self._result = result
167
+
168
+ @override
169
+ def compose(self) -> textual.app.ComposeResult: # pragma: no cover
170
+ yield DiffSummaryPanel(self._result)
171
+
172
+ # Schema changes section
173
+ if self._result["schema_changes"]:
174
+ yield textual.widgets.Static("[bold]Schema Changes[/]")
175
+ yield SchemaChangesTable(self._result["schema_changes"])
176
+
177
+ # Row changes section
178
+ if self._result["row_changes"] and not self._result["summary_only"]:
179
+ yield textual.widgets.Static("[bold]Row Changes[/]")
180
+ columns = self._result["new_cols"] or self._result["old_cols"] or []
181
+ yield RowChangesTable(self._result["row_changes"], columns)
182
+ elif self._result["summary_only"]:
183
+ yield textual.widgets.Static(
184
+ "[dim]Row-level diff not available (file too large or --summary used)[/]"
185
+ )
186
+
187
+
188
+ class DataDiffApp(textual.app.App[None]):
189
+ """Interactive data diff viewer.
190
+
191
+ Accepts pre-computed DataDiffResult list — all diff computation
192
+ happens before the app is created (typically via PivotClient.diff_output).
193
+ """
194
+
195
+ CSS: ClassVar[str] = """
196
+ DiffSummaryPanel {
197
+ padding: 1;
198
+ border: solid green;
199
+ margin-bottom: 1;
200
+ }
201
+
202
+ SchemaChangesTable {
203
+ height: auto;
204
+ max-height: 10;
205
+ margin-bottom: 1;
206
+ }
207
+
208
+ RowChangesTable {
209
+ height: 1fr;
210
+ }
211
+
212
+ .added { color: green; }
213
+ .removed { color: red; }
214
+ .modified { color: yellow; }
215
+ .reorder-only { color: blue; }
216
+ """
217
+
218
+ BINDINGS: ClassVar[list[textual.binding.BindingType]] = [
219
+ textual.binding.Binding("q", "quit", "Quit"),
220
+ textual.binding.Binding("j", "scroll_down", "Down"),
221
+ textual.binding.Binding("k", "scroll_up", "Up"),
222
+ textual.binding.Binding("n", "next_file", "Next file"),
223
+ textual.binding.Binding("p", "prev_file", "Prev file"),
224
+ ]
225
+
226
+ _results: list[DataDiffResult]
227
+ _current_idx: int
228
+
229
+ def __init__(self, results: Sequence[DataDiffResult]) -> None:
230
+ super().__init__()
231
+ self._results = list(results)
232
+ self._current_idx = 0
233
+
234
+ @override
235
+ def compose(self) -> textual.app.ComposeResult: # pragma: no cover
236
+ yield textual.widgets.Header()
237
+ yield textual.containers.VerticalScroll(id="main-content")
238
+ yield textual.widgets.Footer()
239
+
240
+ async def on_mount(self) -> None: # pragma: no cover
241
+ self._show_current_file()
242
+
243
+ def _show_current_file(self) -> None: # pragma: no cover
244
+ """Display the current file's diff."""
245
+ if not self._results:
246
+ return
247
+
248
+ content = self.query_one("#main-content", textual.containers.VerticalScroll)
249
+ content.remove_children()
250
+
251
+ result = self._results[self._current_idx]
252
+ content.mount(FileDiffScreen(result))
253
+
254
+ # Update title
255
+ total = len(self._results)
256
+ current = self._current_idx + 1
257
+ self.title = f"pivot data diff ({current}/{total}): {result['path']}" # pyright: ignore[reportUnannotatedClassAttribute]
258
+
259
+ def action_next_file(self) -> None: # pragma: no cover
260
+ """Move to next file."""
261
+ if self._current_idx < len(self._results) - 1:
262
+ self._current_idx += 1
263
+ self._show_current_file()
264
+
265
+ def action_prev_file(self) -> None: # pragma: no cover
266
+ """Move to previous file."""
267
+ if self._current_idx > 0:
268
+ self._current_idx -= 1
269
+ self._show_current_file()
270
+
271
+ def action_scroll_down(self) -> None: # pragma: no cover
272
+ """Scroll down."""
273
+ content = self.query_one("#main-content", textual.containers.VerticalScroll)
274
+ content.scroll_down()
275
+
276
+ def action_scroll_up(self) -> None: # pragma: no cover
277
+ """Scroll up."""
278
+ content = self.query_one("#main-content", textual.containers.VerticalScroll)
279
+ content.scroll_up()
280
+
281
+
282
+ def run_diff_app(results: Sequence[DataDiffResult]) -> None: # pragma: no cover
283
+ """Entry point for TUI.
284
+
285
+ Args:
286
+ results: Pre-computed diff results. The caller (e.g. CLI `pivot diff`
287
+ command) is responsible for computing diffs before passing them.
288
+ """
289
+ app = DataDiffApp(results)
290
+ app.run()