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.
- pivot_tui-0.1.0/LICENSE +21 -0
- pivot_tui-0.1.0/PKG-INFO +50 -0
- pivot_tui-0.1.0/README.md +26 -0
- pivot_tui-0.1.0/pyproject.toml +48 -0
- pivot_tui-0.1.0/src/pivot_tui/AGENTS.md +20 -0
- pivot_tui-0.1.0/src/pivot_tui/__init__.py +5 -0
- pivot_tui-0.1.0/src/pivot_tui/client.py +77 -0
- pivot_tui-0.1.0/src/pivot_tui/diff.py +290 -0
- pivot_tui-0.1.0/src/pivot_tui/diff_panels.py +812 -0
- pivot_tui-0.1.0/src/pivot_tui/event_poller.py +302 -0
- pivot_tui-0.1.0/src/pivot_tui/rpc_client_impl.py +297 -0
- pivot_tui-0.1.0/src/pivot_tui/run.py +1328 -0
- pivot_tui-0.1.0/src/pivot_tui/screens/__init__.py +17 -0
- pivot_tui-0.1.0/src/pivot_tui/screens/confirm_dialog.py +51 -0
- pivot_tui-0.1.0/src/pivot_tui/screens/help.py +55 -0
- pivot_tui-0.1.0/src/pivot_tui/screens/history_list.py +144 -0
- pivot_tui-0.1.0/src/pivot_tui/stats.py +121 -0
- pivot_tui-0.1.0/src/pivot_tui/styles/__init__.py +0 -0
- pivot_tui-0.1.0/src/pivot_tui/styles/modal.tcss +75 -0
- pivot_tui-0.1.0/src/pivot_tui/styles/pivot.tcss +196 -0
- pivot_tui-0.1.0/src/pivot_tui/testing/__init__.py +5 -0
- pivot_tui-0.1.0/src/pivot_tui/testing/fake_server.py +371 -0
- pivot_tui-0.1.0/src/pivot_tui/types.py +70 -0
- pivot_tui-0.1.0/src/pivot_tui/widgets/__init__.py +32 -0
- pivot_tui-0.1.0/src/pivot_tui/widgets/debug.py +56 -0
- pivot_tui-0.1.0/src/pivot_tui/widgets/footer.py +74 -0
- pivot_tui-0.1.0/src/pivot_tui/widgets/logs.py +308 -0
- pivot_tui-0.1.0/src/pivot_tui/widgets/panels.py +288 -0
- pivot_tui-0.1.0/src/pivot_tui/widgets/stage_list.py +634 -0
- pivot_tui-0.1.0/src/pivot_tui/widgets/status.py +165 -0
pivot_tui-0.1.0/LICENSE
ADDED
|
@@ -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.
|
pivot_tui-0.1.0/PKG-INFO
ADDED
|
@@ -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,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()
|