pivot-tui 0.1.0__tar.gz

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.
Files changed (30) hide show
  1. pivot_tui-0.1.0/LICENSE +21 -0
  2. pivot_tui-0.1.0/PKG-INFO +50 -0
  3. pivot_tui-0.1.0/README.md +26 -0
  4. pivot_tui-0.1.0/pyproject.toml +48 -0
  5. pivot_tui-0.1.0/src/pivot_tui/AGENTS.md +20 -0
  6. pivot_tui-0.1.0/src/pivot_tui/__init__.py +5 -0
  7. pivot_tui-0.1.0/src/pivot_tui/client.py +77 -0
  8. pivot_tui-0.1.0/src/pivot_tui/diff.py +290 -0
  9. pivot_tui-0.1.0/src/pivot_tui/diff_panels.py +812 -0
  10. pivot_tui-0.1.0/src/pivot_tui/event_poller.py +302 -0
  11. pivot_tui-0.1.0/src/pivot_tui/rpc_client_impl.py +297 -0
  12. pivot_tui-0.1.0/src/pivot_tui/run.py +1328 -0
  13. pivot_tui-0.1.0/src/pivot_tui/screens/__init__.py +17 -0
  14. pivot_tui-0.1.0/src/pivot_tui/screens/confirm_dialog.py +51 -0
  15. pivot_tui-0.1.0/src/pivot_tui/screens/help.py +55 -0
  16. pivot_tui-0.1.0/src/pivot_tui/screens/history_list.py +144 -0
  17. pivot_tui-0.1.0/src/pivot_tui/stats.py +121 -0
  18. pivot_tui-0.1.0/src/pivot_tui/styles/__init__.py +0 -0
  19. pivot_tui-0.1.0/src/pivot_tui/styles/modal.tcss +75 -0
  20. pivot_tui-0.1.0/src/pivot_tui/styles/pivot.tcss +196 -0
  21. pivot_tui-0.1.0/src/pivot_tui/testing/__init__.py +5 -0
  22. pivot_tui-0.1.0/src/pivot_tui/testing/fake_server.py +371 -0
  23. pivot_tui-0.1.0/src/pivot_tui/types.py +70 -0
  24. pivot_tui-0.1.0/src/pivot_tui/widgets/__init__.py +32 -0
  25. pivot_tui-0.1.0/src/pivot_tui/widgets/debug.py +56 -0
  26. pivot_tui-0.1.0/src/pivot_tui/widgets/footer.py +74 -0
  27. pivot_tui-0.1.0/src/pivot_tui/widgets/logs.py +308 -0
  28. pivot_tui-0.1.0/src/pivot_tui/widgets/panels.py +288 -0
  29. pivot_tui-0.1.0/src/pivot_tui/widgets/stage_list.py +634 -0
  30. pivot_tui-0.1.0/src/pivot_tui/widgets/status.py +165 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sami Jawhar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,50 @@
1
+ Metadata-Version: 2.4
2
+ Name: pivot-tui
3
+ Version: 0.1.0
4
+ Summary: Textual-based TUI for the Pivot pipeline framework
5
+ Author: Sami Jawhar
6
+ Author-email: Sami Jawhar <sami@metr.org>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Operating System :: POSIX
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Requires-Dist: pivot
13
+ Requires-Dist: textual>=7.0
14
+ Requires-Dist: pytest>=7.0 ; extra == 'dev'
15
+ Requires-Dist: pytest-asyncio>=0.24.0 ; extra == 'dev'
16
+ Requires-Dist: pytest-mock>=3.15.1 ; extra == 'dev'
17
+ Requires-Python: >=3.13, <3.14
18
+ Project-URL: Documentation, https://sjawhar.github.io/pivot/
19
+ Project-URL: Homepage, https://github.com/sjawhar/pivot
20
+ Project-URL: Issues, https://github.com/sjawhar/pivot/issues
21
+ Project-URL: Repository, https://github.com/sjawhar/pivot
22
+ Provides-Extra: dev
23
+ Description-Content-Type: text/markdown
24
+
25
+ # pivot-tui
26
+
27
+ Interactive terminal UI for [Pivot](https://pypi.org/project/pivot/), the
28
+ high-performance Python pipeline tool with automatic code change detection.
29
+
30
+ Built on [Textual](https://textual.textualize.io/), pivot-tui gives you a live
31
+ view of your pipeline as it runs: stage status, dependency/output diffs, logs,
32
+ and run history — all updating in real time over Pivot's RPC interface.
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install pivot[tui]
38
+ ```
39
+
40
+ ## Usage
41
+
42
+ ```bash
43
+ pivot repro --tui # Run the pipeline with the interactive TUI
44
+ pivot repro --tui --watch # Watch mode: re-runs stages as files change
45
+ ```
46
+
47
+ ## Learn More
48
+
49
+ - [Documentation](https://sjawhar.github.io/pivot/)
50
+ - [Source code](https://github.com/sjawhar/pivot)
@@ -0,0 +1,26 @@
1
+ # pivot-tui
2
+
3
+ Interactive terminal UI for [Pivot](https://pypi.org/project/pivot/), the
4
+ high-performance Python pipeline tool with automatic code change detection.
5
+
6
+ Built on [Textual](https://textual.textualize.io/), pivot-tui gives you a live
7
+ view of your pipeline as it runs: stage status, dependency/output diffs, logs,
8
+ and run history — all updating in real time over Pivot's RPC interface.
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ pip install pivot[tui]
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ ```bash
19
+ pivot repro --tui # Run the pipeline with the interactive TUI
20
+ pivot repro --tui --watch # Watch mode: re-runs stages as files change
21
+ ```
22
+
23
+ ## Learn More
24
+
25
+ - [Documentation](https://sjawhar.github.io/pivot/)
26
+ - [Source code](https://github.com/sjawhar/pivot)
@@ -0,0 +1,48 @@
1
+ [project]
2
+ name = "pivot-tui"
3
+ version = "0.1.0"
4
+ description = "Textual-based TUI for the Pivot pipeline framework"
5
+ requires-python = ">=3.13,<3.14"
6
+ readme = "README.md"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "Sami Jawhar", email = "sami@metr.org" }]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Operating System :: POSIX",
13
+ "Programming Language :: Python :: 3.13",
14
+ ]
15
+ dependencies = [
16
+ "pivot",
17
+ "textual>=7.0",
18
+ ]
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/sjawhar/pivot"
22
+ Documentation = "https://sjawhar.github.io/pivot/"
23
+ Repository = "https://github.com/sjawhar/pivot"
24
+ Issues = "https://github.com/sjawhar/pivot/issues"
25
+
26
+ [build-system]
27
+ requires = ["uv_build>=0.9.18,<0.10.0"]
28
+ build-backend = "uv_build"
29
+
30
+ [project.optional-dependencies]
31
+ dev = [
32
+ "pytest>=7.0",
33
+ "pytest-asyncio>=0.24.0",
34
+ "pytest-mock>=3.15.1",
35
+ ]
36
+
37
+ [tool.uv.sources]
38
+ pivot = { workspace = true }
39
+
40
+ [tool.pytest.ini_options]
41
+ testpaths = ["tests"]
42
+ addopts = ["-v", "--strict-markers", "--tb=short"]
43
+ markers = [
44
+ "slow: marks tests as slow (deselect with '-m \"not slow\"')",
45
+ "integration: marks tests as integration tests",
46
+ ]
47
+ asyncio_mode = "auto"
48
+ asyncio_default_fixture_loop_scope = "function"
@@ -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.
@@ -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"
@@ -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: ...
@@ -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()