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 +20 -0
- pivot_tui/__init__.py +5 -0
- pivot_tui/client.py +77 -0
- pivot_tui/diff.py +290 -0
- pivot_tui/diff_panels.py +812 -0
- pivot_tui/event_poller.py +302 -0
- pivot_tui/rpc_client_impl.py +297 -0
- pivot_tui/run.py +1328 -0
- pivot_tui/screens/__init__.py +17 -0
- pivot_tui/screens/confirm_dialog.py +51 -0
- pivot_tui/screens/help.py +55 -0
- pivot_tui/screens/history_list.py +144 -0
- pivot_tui/stats.py +121 -0
- pivot_tui/styles/__init__.py +0 -0
- pivot_tui/styles/modal.tcss +75 -0
- pivot_tui/styles/pivot.tcss +196 -0
- pivot_tui/testing/__init__.py +5 -0
- pivot_tui/testing/fake_server.py +371 -0
- pivot_tui/types.py +70 -0
- pivot_tui/widgets/__init__.py +32 -0
- pivot_tui/widgets/debug.py +56 -0
- pivot_tui/widgets/footer.py +74 -0
- pivot_tui/widgets/logs.py +308 -0
- pivot_tui/widgets/panels.py +288 -0
- pivot_tui/widgets/stage_list.py +634 -0
- pivot_tui/widgets/status.py +165 -0
- pivot_tui-0.1.0.dist-info/METADATA +50 -0
- pivot_tui-0.1.0.dist-info/RECORD +30 -0
- pivot_tui-0.1.0.dist-info/WHEEL +4 -0
- pivot_tui-0.1.0.dist-info/licenses/LICENSE +21 -0
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
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()
|