flyteplugins-union 0.4.0__py3-none-win_amd64.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.
- flyteplugins/union/__init__.py +4 -0
- flyteplugins/union/cli/__init__.py +48 -0
- flyteplugins/union/cli/_tui/__init__.py +67 -0
- flyteplugins/union/cli/_tui/_volume_explore.py +484 -0
- flyteplugins/union/cli/_volume_index.py +574 -0
- flyteplugins/union/cli/api_key.py +139 -0
- flyteplugins/union/cli/assignment.py +121 -0
- flyteplugins/union/cli/cluster.py +246 -0
- flyteplugins/union/cli/cluster_pool.py +283 -0
- flyteplugins/union/cli/member.py +26 -0
- flyteplugins/union/cli/policy.py +178 -0
- flyteplugins/union/cli/queue.py +366 -0
- flyteplugins/union/cli/role.py +174 -0
- flyteplugins/union/cli/user.py +95 -0
- flyteplugins/union/cli/volume.py +342 -0
- flyteplugins/union/errors.py +93 -0
- flyteplugins/union/internal/__init__.py +0 -0
- flyteplugins/union/internal/authorizer/authorizer_connect.py +1878 -0
- flyteplugins/union/internal/authorizer/authorizer_pb2.py +84 -0
- flyteplugins/union/internal/authorizer/authorizer_pb2.pyi +6 -0
- flyteplugins/union/internal/authorizer/definition_pb2.py +41 -0
- flyteplugins/union/internal/authorizer/definition_pb2.pyi +57 -0
- flyteplugins/union/internal/authorizer/payload_pb2.py +203 -0
- flyteplugins/union/internal/authorizer/payload_pb2.pyi +353 -0
- flyteplugins/union/internal/cluster/cluster_connect.py +578 -0
- flyteplugins/union/internal/cluster/cluster_pb2.py +44 -0
- flyteplugins/union/internal/cluster/cluster_pb2.pyi +6 -0
- flyteplugins/union/internal/cluster/definition_pb2.py +100 -0
- flyteplugins/union/internal/cluster/definition_pb2.pyi +326 -0
- flyteplugins/union/internal/cluster/payload_pb2.py +62 -0
- flyteplugins/union/internal/cluster/payload_pb2.pyi +122 -0
- flyteplugins/union/internal/clusterpool/clusterpool_connect.py +578 -0
- flyteplugins/union/internal/clusterpool/clusterpool_pb2.py +44 -0
- flyteplugins/union/internal/clusterpool/clusterpool_pb2.pyi +6 -0
- flyteplugins/union/internal/clusterpool/payload_pb2.py +70 -0
- flyteplugins/union/internal/clusterpool/payload_pb2.pyi +122 -0
- flyteplugins/union/internal/common/authorization_pb2.py +66 -0
- flyteplugins/union/internal/common/authorization_pb2.pyi +114 -0
- flyteplugins/union/internal/common/cluster_pb2.py +45 -0
- flyteplugins/union/internal/common/cluster_pb2.pyi +55 -0
- flyteplugins/union/internal/common/deployment_pb2.py +26 -0
- flyteplugins/union/internal/common/deployment_pb2.pyi +14 -0
- flyteplugins/union/internal/common/identifier_pb2.py +125 -0
- flyteplugins/union/internal/common/identifier_pb2.pyi +154 -0
- flyteplugins/union/internal/common/identity_pb2.py +48 -0
- flyteplugins/union/internal/common/identity_pb2.pyi +82 -0
- flyteplugins/union/internal/common/list_pb2.py +36 -0
- flyteplugins/union/internal/common/list_pb2.pyi +71 -0
- flyteplugins/union/internal/common/policy_pb2.py +37 -0
- flyteplugins/union/internal/common/policy_pb2.pyi +27 -0
- flyteplugins/union/internal/common/role_pb2.py +37 -0
- flyteplugins/union/internal/common/role_pb2.pyi +57 -0
- flyteplugins/union/internal/identity/app_definition_pb2.py +30 -0
- flyteplugins/union/internal/identity/app_definition_pb2.pyi +54 -0
- flyteplugins/union/internal/identity/app_payload_pb2.py +56 -0
- flyteplugins/union/internal/identity/app_payload_pb2.pyi +132 -0
- flyteplugins/union/internal/identity/app_service_connect.py +383 -0
- flyteplugins/union/internal/identity/app_service_pb2.py +38 -0
- flyteplugins/union/internal/identity/app_service_pb2.pyi +6 -0
- flyteplugins/union/internal/identity/enums_pb2.py +34 -0
- flyteplugins/union/internal/identity/enums_pb2.pyi +54 -0
- flyteplugins/union/internal/identity/member_payload_pb2.py +29 -0
- flyteplugins/union/internal/identity/member_payload_pb2.pyi +21 -0
- flyteplugins/union/internal/identity/member_service_connect.py +123 -0
- flyteplugins/union/internal/identity/member_service_pb2.py +30 -0
- flyteplugins/union/internal/identity/member_service_pb2.pyi +6 -0
- flyteplugins/union/internal/identity/policy_payload_pb2.py +66 -0
- flyteplugins/union/internal/identity/policy_payload_pb2.pyi +82 -0
- flyteplugins/union/internal/identity/policy_service_connect.py +448 -0
- flyteplugins/union/internal/identity/policy_service_pb2.py +40 -0
- flyteplugins/union/internal/identity/policy_service_pb2.pyi +6 -0
- flyteplugins/union/internal/identity/role_payload_pb2.py +76 -0
- flyteplugins/union/internal/identity/role_payload_pb2.pyi +96 -0
- flyteplugins/union/internal/identity/role_service_connect.py +513 -0
- flyteplugins/union/internal/identity/role_service_pb2.py +42 -0
- flyteplugins/union/internal/identity/role_service_pb2.pyi +6 -0
- flyteplugins/union/internal/identity/user_payload_pb2.py +62 -0
- flyteplugins/union/internal/identity/user_payload_pb2.pyi +94 -0
- flyteplugins/union/internal/identity/user_service_connect.py +448 -0
- flyteplugins/union/internal/identity/user_service_pb2.py +40 -0
- flyteplugins/union/internal/identity/user_service_pb2.pyi +6 -0
- flyteplugins/union/internal/queue/queue_connect.py +456 -0
- flyteplugins/union/internal/queue/queue_pb2.py +136 -0
- flyteplugins/union/internal/queue/queue_pb2.pyi +178 -0
- flyteplugins/union/internal/validate/__init__.py +0 -0
- flyteplugins/union/internal/validate/validate/__init__.py +0 -0
- flyteplugins/union/internal/validate/validate/validate_pb2.py +86 -0
- flyteplugins/union/io/__init__.py +30 -0
- flyteplugins/union/io/_base_volume.py +1362 -0
- flyteplugins/union/io/_internal/__init__.py +8 -0
- flyteplugins/union/io/_internal/_juicefs/__init__.py +8 -0
- flyteplugins/union/io/_internal/_juicefs/_backend.py +413 -0
- flyteplugins/union/io/_internal/_juicefs/_common.py +65 -0
- flyteplugins/union/io/_internal/_juicefs/_metadata.py +118 -0
- flyteplugins/union/io/_internal/_juicefs/_redis.py +221 -0
- flyteplugins/union/io/_internal/_juicefs/_sqlite.py +102 -0
- flyteplugins/union/io/_internal/_juicefs/bin/.gitkeep +0 -0
- flyteplugins/union/io/_internal/_juicefs/bin/juicefs.exe +0 -0
- flyteplugins/union/io/_internal/backend.py +53 -0
- flyteplugins/union/io/_ro_volume.py +82 -0
- flyteplugins/union/io/_rw_volume.py +110 -0
- flyteplugins/union/io/_volume_transformer.py +191 -0
- flyteplugins/union/remote/__init__.py +48 -0
- flyteplugins/union/remote/_api_key.py +318 -0
- flyteplugins/union/remote/_assignment.py +191 -0
- flyteplugins/union/remote/_cluster.py +301 -0
- flyteplugins/union/remote/_cluster_pool.py +271 -0
- flyteplugins/union/remote/_member.py +73 -0
- flyteplugins/union/remote/_policy.py +196 -0
- flyteplugins/union/remote/_queue.py +421 -0
- flyteplugins/union/remote/_role.py +148 -0
- flyteplugins/union/remote/_user.py +158 -0
- flyteplugins/union/remote/_volume_explore.py +397 -0
- flyteplugins/union/utils/__init__.py +5 -0
- flyteplugins/union/utils/auth.py +41 -0
- flyteplugins/union/utils/image.py +84 -0
- flyteplugins_union-0.4.0.dist-info/METADATA +125 -0
- flyteplugins_union-0.4.0.dist-info/RECORD +122 -0
- flyteplugins_union-0.4.0.dist-info/WHEEL +5 -0
- flyteplugins_union-0.4.0.dist-info/entry_points.txt +33 -0
- flyteplugins_union-0.4.0.dist-info/licenses/LICENSE +6 -0
- flyteplugins_union-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import tempfile
|
|
2
|
+
|
|
3
|
+
import rich_click as click
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def edit_with_retry(yaml_text: str, apply_fn, *, console, noun: str = "resource"):
|
|
7
|
+
"""Open an editor and retry or save to file on failure.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
yaml_text: Initial YAML content to edit.
|
|
11
|
+
apply_fn: Callable that takes the edited YAML string and applies it.
|
|
12
|
+
Should raise on failure.
|
|
13
|
+
console: Rich console for output.
|
|
14
|
+
noun: Name of the resource for messages (e.g. "role", "policy").
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
The result of apply_fn on success, or None if cancelled.
|
|
18
|
+
"""
|
|
19
|
+
edited = click.edit(yaml_text, extension=".yaml")
|
|
20
|
+
if edited is None:
|
|
21
|
+
console.print(f"[yellow]Edit cancelled, {noun} not modified.[/yellow]")
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
while True:
|
|
25
|
+
try:
|
|
26
|
+
return apply_fn(edited)
|
|
27
|
+
except Exception as e:
|
|
28
|
+
console.print(f"[red]Error:[/red] {e}\n")
|
|
29
|
+
action = click.prompt(
|
|
30
|
+
"Would you like to (r)etry editing, (s)ave to file, or (a)bort?",
|
|
31
|
+
type=click.Choice(["r", "s", "a"], case_sensitive=False),
|
|
32
|
+
default="r",
|
|
33
|
+
)
|
|
34
|
+
if action == "r":
|
|
35
|
+
edited = click.edit(edited, extension=".yaml")
|
|
36
|
+
if edited is None:
|
|
37
|
+
console.print(f"[yellow]Edit cancelled, {noun} not modified.[/yellow]")
|
|
38
|
+
return None
|
|
39
|
+
elif action == "s":
|
|
40
|
+
tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", prefix=f"{noun}-", delete=False)
|
|
41
|
+
tmp.write(edited)
|
|
42
|
+
tmp.close()
|
|
43
|
+
console.print(f"[cyan]Saved to {tmp.name}[/cyan]")
|
|
44
|
+
console.print(f"You can retry with: [cyan]--file {tmp.name}[/cyan]")
|
|
45
|
+
return None
|
|
46
|
+
else:
|
|
47
|
+
console.print(f"[yellow]Aborted, {noun} not modified.[/yellow]")
|
|
48
|
+
return None
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Lazy entrypoint for the volume explore TUI.
|
|
2
|
+
|
|
3
|
+
Importing ``textual`` is gated behind the ``[tui]`` extra so headless
|
|
4
|
+
installs don't pay the cost. The launcher below catches the
|
|
5
|
+
:class:`ImportError` and re-raises with a clear install hint, matching the
|
|
6
|
+
ergonomic of :mod:`flyte.cli._tui` upstream.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from flyteplugins.union.cli._volume_index import IndexReader
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def launch_volume_explore(
|
|
18
|
+
*,
|
|
19
|
+
reader: "IndexReader",
|
|
20
|
+
title: str,
|
|
21
|
+
subtitle: str = "",
|
|
22
|
+
lineage: "list[dict] | None" = None,
|
|
23
|
+
provenance: "dict | None" = None,
|
|
24
|
+
can_navigate_parent: bool = False,
|
|
25
|
+
) -> "str | None":
|
|
26
|
+
"""Run the volume explore TUI against an already-open index.
|
|
27
|
+
|
|
28
|
+
``lineage`` is the version chain rendered in the right-pane Lineage
|
|
29
|
+
box (PRD: walk ``parent`` pointers). Each entry is a dict with at
|
|
30
|
+
least ``label`` (e.g. ``current`` / ``parent``); optional keys
|
|
31
|
+
``name``, ``index_path``, ``total_inodes``, ``used_bytes``, ``error``.
|
|
32
|
+
|
|
33
|
+
``provenance`` carries ``produced_by`` / ``parent_produced_by`` action
|
|
34
|
+
refs for the Provenance box; ``can_navigate_parent`` enables the "open
|
|
35
|
+
parent" keybinding.
|
|
36
|
+
|
|
37
|
+
Returns the navigation request the user made before exiting — ``"parent"``
|
|
38
|
+
to re-open explore against the parent version, or ``None`` to stop.
|
|
39
|
+
|
|
40
|
+
Async: the :class:`IndexReader` is aiosqlite-backed, so we ``await`` the
|
|
41
|
+
volume summary up front (``compose`` can't await) and drive the Textual app
|
|
42
|
+
via ``run_async`` inside the caller's event loop.
|
|
43
|
+
|
|
44
|
+
Raises a helpful :class:`ImportError` when ``textual`` is not installed.
|
|
45
|
+
"""
|
|
46
|
+
try:
|
|
47
|
+
from flyteplugins.union.cli._tui._volume_explore import VolumeExploreApp, summary_header_text
|
|
48
|
+
except ImportError as exc:
|
|
49
|
+
raise ImportError(
|
|
50
|
+
"The volume explore TUI requires the 'textual' package. "
|
|
51
|
+
"Install it with: pip install 'flyteplugins-union[tui]'"
|
|
52
|
+
) from exc
|
|
53
|
+
|
|
54
|
+
# Precompute the header summary now (async read) so compose() stays sync.
|
|
55
|
+
summary = await reader.summary()
|
|
56
|
+
app = VolumeExploreApp(
|
|
57
|
+
reader=reader,
|
|
58
|
+
title=title,
|
|
59
|
+
subtitle=subtitle,
|
|
60
|
+
root_inode=summary.root_inode,
|
|
61
|
+
header_text=summary_header_text(summary, subtitle),
|
|
62
|
+
lineage=lineage or [],
|
|
63
|
+
provenance=provenance,
|
|
64
|
+
can_navigate_parent=can_navigate_parent,
|
|
65
|
+
)
|
|
66
|
+
await app.run_async()
|
|
67
|
+
return app.navigate
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
"""Textual TUI for browsing a Volume metadata index.
|
|
2
|
+
|
|
3
|
+
Mirrors the shape of :mod:`flyte.cli._tui._explore` in flyte-sdk:
|
|
4
|
+
|
|
5
|
+
* Same color palette (Flyte purple).
|
|
6
|
+
* Header + ``Horizontal`` body + Footer skeleton.
|
|
7
|
+
* Left pane: a ``Tree`` widget (vim ``j``/``k`` bindings + arrow keys).
|
|
8
|
+
* Right pane: ``TabbedContent`` with a ``DetailPanel`` of bordered
|
|
9
|
+
``_DetailBox`` cards (Volume Header / Selected Node / Lineage).
|
|
10
|
+
* Top-level key bindings: ``q`` quit, ``r`` refresh.
|
|
11
|
+
|
|
12
|
+
The Tree is lazy: directory nodes are added with a placeholder child so
|
|
13
|
+
they render as expandable; the children are fetched from the
|
|
14
|
+
:class:`IndexReader` on first expansion. This keeps the load proportional
|
|
15
|
+
to what the user actually looks at rather than walking a million-inode
|
|
16
|
+
volume on launch.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import datetime
|
|
22
|
+
import stat as _stat
|
|
23
|
+
from typing import Any, ClassVar
|
|
24
|
+
|
|
25
|
+
from textual.app import App, ComposeResult
|
|
26
|
+
from textual.binding import Binding, BindingType
|
|
27
|
+
from textual.containers import Horizontal, VerticalScroll
|
|
28
|
+
from textual.widgets import Footer, Header, Static, TabbedContent, TabPane, Tree
|
|
29
|
+
from textual.widgets.tree import TreeNode
|
|
30
|
+
|
|
31
|
+
from flyteplugins.union.cli._volume_index import IndexEntry, IndexReader, VolumeSummary
|
|
32
|
+
|
|
33
|
+
# Match flyte-sdk's _explore palette exactly so the experience feels
|
|
34
|
+
# native to anyone who's run `flyte start tui`.
|
|
35
|
+
_FLYTE_PURPLE = "#7652a2"
|
|
36
|
+
_FLYTE_PURPLE_LIGHT = "#f7f5fd"
|
|
37
|
+
_FLYTE_PURPLE_DARK = "#171020"
|
|
38
|
+
|
|
39
|
+
_KIND_ICON = {
|
|
40
|
+
"dir": "📁",
|
|
41
|
+
"file": "📄",
|
|
42
|
+
"symlink": "🔗",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _fmt_size(n: int) -> str:
|
|
47
|
+
units = ["B", "KB", "MB", "GB", "TB", "PB"]
|
|
48
|
+
f = float(n)
|
|
49
|
+
for u in units:
|
|
50
|
+
if f < 1024 or u == units[-1]:
|
|
51
|
+
return f"{f:,.1f} {u}" if u != "B" else f"{int(f):,} {u}"
|
|
52
|
+
f /= 1024
|
|
53
|
+
return f"{n} B"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _fmt_mtime(mtime_ns: int) -> str:
|
|
57
|
+
if not mtime_ns:
|
|
58
|
+
return ""
|
|
59
|
+
try:
|
|
60
|
+
dt = datetime.datetime.fromtimestamp(mtime_ns / 1e9)
|
|
61
|
+
except (OSError, OverflowError, ValueError):
|
|
62
|
+
return ""
|
|
63
|
+
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _fmt_mode(mode: int, kind: str) -> str:
|
|
67
|
+
if not mode:
|
|
68
|
+
return f"<{kind}>"
|
|
69
|
+
try:
|
|
70
|
+
return _stat.filemode(mode)
|
|
71
|
+
except (TypeError, ValueError):
|
|
72
|
+
return oct(mode)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _label(entry: IndexEntry) -> str:
|
|
76
|
+
icon = _KIND_ICON.get(entry.kind, "•")
|
|
77
|
+
if entry.kind == "dir":
|
|
78
|
+
return f"{icon} {entry.name}/"
|
|
79
|
+
if entry.kind == "symlink" and entry.symlink_target:
|
|
80
|
+
return f"{icon} {entry.name} → {entry.symlink_target}"
|
|
81
|
+
return f"{icon} {entry.name} ({_fmt_size(entry.size)})"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _DetailBox(Static):
|
|
85
|
+
"""Bordered card; markup off so raw paths/JSON render literally."""
|
|
86
|
+
|
|
87
|
+
DEFAULT_CSS = """
|
|
88
|
+
_DetailBox {
|
|
89
|
+
border: solid $accent;
|
|
90
|
+
padding: 0 1;
|
|
91
|
+
margin-bottom: 1;
|
|
92
|
+
height: auto;
|
|
93
|
+
}
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
97
|
+
kwargs.setdefault("markup", False)
|
|
98
|
+
super().__init__(*args, **kwargs)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class VolumeFileTree(Tree[int]):
|
|
102
|
+
"""Lazy directory tree backed by the :class:`IndexReader`.
|
|
103
|
+
|
|
104
|
+
Each node's ``data`` carries the inode number. Directories are added
|
|
105
|
+
with a stub child so the disclosure arrow shows up; the stub is
|
|
106
|
+
replaced with real entries on first expand.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
110
|
+
Binding("down,j", "cursor_down", "Cursor Down", show=False),
|
|
111
|
+
Binding("up,k", "cursor_up", "Cursor Up", show=False),
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
def __init__(self, reader: IndexReader, root_inode: int, **kwargs: Any) -> None:
|
|
115
|
+
super().__init__("/", data=root_inode, **kwargs)
|
|
116
|
+
self._reader = reader
|
|
117
|
+
self._populated: set[int] = set()
|
|
118
|
+
|
|
119
|
+
def on_mount(self) -> None:
|
|
120
|
+
self.root.allow_expand = True
|
|
121
|
+
# Reader I/O is async (aiosqlite); populate + expand the root on a
|
|
122
|
+
# worker so the event loop stays responsive while it loads.
|
|
123
|
+
self.run_worker(self._populate_and_expand(self.root), exclusive=False)
|
|
124
|
+
|
|
125
|
+
def reload(self) -> None:
|
|
126
|
+
"""Reset the tree to its root and re-populate from scratch (the ``r``
|
|
127
|
+
refresh). Must clear ``_populated`` — otherwise the post-reset root is
|
|
128
|
+
still flagged as populated and :meth:`_populate` would no-op, leaving an
|
|
129
|
+
empty tree.
|
|
130
|
+
"""
|
|
131
|
+
self.reset(self.root.label, data=self.root.data)
|
|
132
|
+
self._populated.clear()
|
|
133
|
+
self.root.allow_expand = True
|
|
134
|
+
self.run_worker(self._populate_and_expand(self.root), exclusive=False)
|
|
135
|
+
|
|
136
|
+
async def _populate_and_expand(self, node: TreeNode[int]) -> None:
|
|
137
|
+
await self._populate(node)
|
|
138
|
+
node.expand()
|
|
139
|
+
|
|
140
|
+
async def _populate(self, node: TreeNode[int]) -> None:
|
|
141
|
+
inode = node.data
|
|
142
|
+
if inode is None or inode in self._populated:
|
|
143
|
+
return
|
|
144
|
+
self._populated.add(inode)
|
|
145
|
+
node.remove_children()
|
|
146
|
+
for entry in await self._reader.children(inode):
|
|
147
|
+
child = node.add(_label(entry), data=entry.inode, allow_expand=(entry.kind == "dir"))
|
|
148
|
+
if entry.kind == "dir":
|
|
149
|
+
# Placeholder so the disclosure arrow shows up; replaced
|
|
150
|
+
# on first expand.
|
|
151
|
+
child.add_leaf("…")
|
|
152
|
+
self._populated.discard(entry.inode)
|
|
153
|
+
|
|
154
|
+
def on_tree_node_expanded(self, event: Tree.NodeExpanded) -> None:
|
|
155
|
+
node = event.node
|
|
156
|
+
if node.data is not None and node.data not in self._populated:
|
|
157
|
+
self.run_worker(self._populate(node), exclusive=False)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class VolumeDetailPanel(VerticalScroll):
|
|
161
|
+
"""Right pane — volume summary + selected node details.
|
|
162
|
+
|
|
163
|
+
Volume Header is populated once on mount; Selected Node is rewritten
|
|
164
|
+
on each tree-cursor change; Provenance and Lineage are static text the
|
|
165
|
+
launcher fills in.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
def __init__(
|
|
169
|
+
self,
|
|
170
|
+
reader: IndexReader,
|
|
171
|
+
header_text: str,
|
|
172
|
+
provenance_text: str = "",
|
|
173
|
+
lineage_text: str = "",
|
|
174
|
+
**kwargs: Any,
|
|
175
|
+
) -> None:
|
|
176
|
+
super().__init__(**kwargs)
|
|
177
|
+
self._reader = reader
|
|
178
|
+
self._header_text = header_text
|
|
179
|
+
self._provenance_text = provenance_text
|
|
180
|
+
self._lineage_text = lineage_text
|
|
181
|
+
|
|
182
|
+
def compose(self) -> ComposeResult:
|
|
183
|
+
yield _DetailBox(id="box-volume")
|
|
184
|
+
yield _DetailBox(id="box-selected")
|
|
185
|
+
yield _DetailBox(id="box-provenance")
|
|
186
|
+
yield _DetailBox(id="box-lineage")
|
|
187
|
+
|
|
188
|
+
def on_mount(self) -> None:
|
|
189
|
+
vbox = self.query_one("#box-volume", _DetailBox)
|
|
190
|
+
vbox.border_title = "Volume"
|
|
191
|
+
vbox.update(self._header_text)
|
|
192
|
+
|
|
193
|
+
sbox = self.query_one("#box-selected", _DetailBox)
|
|
194
|
+
sbox.border_title = "Selected"
|
|
195
|
+
sbox.update("Pick a file or directory in the tree.")
|
|
196
|
+
|
|
197
|
+
pbox = self.query_one("#box-provenance", _DetailBox)
|
|
198
|
+
pbox.border_title = "Provenance"
|
|
199
|
+
pbox.update(self._provenance_text or "(no provenance recorded)")
|
|
200
|
+
|
|
201
|
+
lbox = self.query_one("#box-lineage", _DetailBox)
|
|
202
|
+
lbox.border_title = "Lineage"
|
|
203
|
+
lbox.update(self._lineage_text or "(no lineage — load a Volume value to walk parent indexes)")
|
|
204
|
+
|
|
205
|
+
def show_selected(self, entry: IndexEntry, chunk_count: int) -> None:
|
|
206
|
+
sbox = self.query_one("#box-selected", _DetailBox)
|
|
207
|
+
sbox.border_title = "Selected"
|
|
208
|
+
lines: list[str] = []
|
|
209
|
+
lines.append(f"name: {entry.name or '/'}")
|
|
210
|
+
lines.append(f"inode: {entry.inode}")
|
|
211
|
+
lines.append(f"kind: {entry.kind}")
|
|
212
|
+
if entry.kind == "file":
|
|
213
|
+
lines.append(f"size: {_fmt_size(entry.size)} ({entry.size:,} bytes)")
|
|
214
|
+
if chunk_count >= 0:
|
|
215
|
+
lines.append(f"chunks: {chunk_count}")
|
|
216
|
+
else:
|
|
217
|
+
lines.append("chunks: — (not available for this backend)")
|
|
218
|
+
if entry.kind == "symlink" and entry.symlink_target:
|
|
219
|
+
lines.append(f"target: {entry.symlink_target}")
|
|
220
|
+
lines.append(f"mode: {_fmt_mode(entry.mode, entry.kind)} ({oct(entry.mode)})")
|
|
221
|
+
lines.append(f"owner: {entry.uid}:{entry.gid}")
|
|
222
|
+
lines.append(f"nlink: {entry.nlink}")
|
|
223
|
+
mtime = _fmt_mtime(entry.mtime_ns)
|
|
224
|
+
if mtime:
|
|
225
|
+
lines.append(f"mtime: {mtime}")
|
|
226
|
+
sbox.update("\n".join(lines))
|
|
227
|
+
|
|
228
|
+
def set_lineage(self, text: str) -> None:
|
|
229
|
+
lbox = self.query_one("#box-lineage", _DetailBox)
|
|
230
|
+
lbox.update(text)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _lineage_text(lineage: "list[dict]") -> str:
|
|
234
|
+
"""Render the lineage chain, newest first.
|
|
235
|
+
|
|
236
|
+
The chain is walked to its root via each version's metadata object, so
|
|
237
|
+
every entry is a real published version — including intra-action
|
|
238
|
+
intermediates. Each shows its producing action, message, and stats.
|
|
239
|
+
"""
|
|
240
|
+
if not lineage:
|
|
241
|
+
return ""
|
|
242
|
+
lines: list[str] = []
|
|
243
|
+
for entry in lineage:
|
|
244
|
+
label = entry.get("label", "?")
|
|
245
|
+
name = entry.get("name", "")
|
|
246
|
+
line = f"[{label}]"
|
|
247
|
+
if name:
|
|
248
|
+
line += f" {name}"
|
|
249
|
+
if entry.get("message"):
|
|
250
|
+
# Commit message inline with the label (PRD UC2: 'epoch N' /
|
|
251
|
+
# 'post-augmentation' / etc.) — git-log shape.
|
|
252
|
+
line += f" — {entry['message']}"
|
|
253
|
+
lines.append(line)
|
|
254
|
+
if "error" in entry:
|
|
255
|
+
lines.append(f" error: {entry['error']}")
|
|
256
|
+
continue
|
|
257
|
+
produced_by = entry.get("produced_by")
|
|
258
|
+
if produced_by:
|
|
259
|
+
who = produced_by.get("action") or "?"
|
|
260
|
+
if produced_by.get("run"):
|
|
261
|
+
who = f"{produced_by['run']} / {who}"
|
|
262
|
+
lines.append(f" by: {who}")
|
|
263
|
+
if entry.get("total_inodes") is not None:
|
|
264
|
+
lines.append(f" inodes: {entry['total_inodes']:,}")
|
|
265
|
+
if entry.get("used_bytes") is not None:
|
|
266
|
+
lines.append(f" size: {entry['used_bytes']:,} bytes")
|
|
267
|
+
path = entry.get("index_path")
|
|
268
|
+
if path:
|
|
269
|
+
lines.append(f" index: {path}")
|
|
270
|
+
return "\n".join(lines)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _fmt_action_ref(ref: "dict | None") -> list[str]:
|
|
274
|
+
"""Render an action-ref dict (run / action / output / scope) as lines."""
|
|
275
|
+
if not ref:
|
|
276
|
+
return [" (unknown)"]
|
|
277
|
+
out: list[str] = []
|
|
278
|
+
if ref.get("run"):
|
|
279
|
+
out.append(f" run: {ref['run']}")
|
|
280
|
+
if ref.get("action"):
|
|
281
|
+
out.append(f" action: {ref['action']}")
|
|
282
|
+
if ref.get("op"):
|
|
283
|
+
out.append(f" output: {ref['op']}")
|
|
284
|
+
if ref.get("project") or ref.get("domain"):
|
|
285
|
+
out.append(f" scope: {ref.get('project') or '?'}/{ref.get('domain') or '?'}")
|
|
286
|
+
return out or [" (unknown)"]
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _provenance_text(provenance: "dict | None", can_navigate_parent: bool) -> str:
|
|
290
|
+
"""Render the producing action for this version and its parent.
|
|
291
|
+
|
|
292
|
+
``provenance`` carries ``produced_by`` / ``parent_produced_by`` action-ref
|
|
293
|
+
dicts (or None). When the parent can be opened, append the keybinding hint.
|
|
294
|
+
"""
|
|
295
|
+
if not provenance:
|
|
296
|
+
return ""
|
|
297
|
+
lines: list[str] = ["produced by:"]
|
|
298
|
+
lines += _fmt_action_ref(provenance.get("produced_by"))
|
|
299
|
+
parent = provenance.get("parent_produced_by")
|
|
300
|
+
if parent or can_navigate_parent:
|
|
301
|
+
lines.append("")
|
|
302
|
+
hint = " · press 'p' to open" if can_navigate_parent else ""
|
|
303
|
+
lines.append(f"parent produced by:{hint}")
|
|
304
|
+
lines += _fmt_action_ref(parent)
|
|
305
|
+
return "\n".join(lines)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def summary_header_text(s: VolumeSummary, subtitle: str) -> str:
|
|
309
|
+
"""Render the Volume Header box text from a precomputed summary.
|
|
310
|
+
|
|
311
|
+
Takes a :class:`VolumeSummary` (read by the launcher before the app starts)
|
|
312
|
+
rather than the reader, so the app's ``compose`` stays synchronous while the
|
|
313
|
+
underlying reads are async (aiosqlite).
|
|
314
|
+
"""
|
|
315
|
+
lines: list[str] = []
|
|
316
|
+
if subtitle:
|
|
317
|
+
lines.append(subtitle)
|
|
318
|
+
lines.append(f"inodes: {s.total_inodes:,}")
|
|
319
|
+
lines.append(f"size: {_fmt_size(s.used_bytes)} ({s.used_bytes:,} bytes)")
|
|
320
|
+
interesting = ("nextInode", "nextChunk", "nextSession", "nextinode", "nextchunk", "nextsession")
|
|
321
|
+
for key in interesting:
|
|
322
|
+
if key in s.counters:
|
|
323
|
+
lines.append(f"{key}: {s.counters[key]:,}")
|
|
324
|
+
return "\n".join(lines)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class VolumeExploreApp(App[None]):
|
|
328
|
+
"""Standalone Textual app for browsing a Volume metadata index."""
|
|
329
|
+
|
|
330
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
331
|
+
Binding("q", "quit_app", "Quit"),
|
|
332
|
+
Binding("r", "refresh_tree", "Refresh"),
|
|
333
|
+
Binding("p", "open_parent", "Open parent"),
|
|
334
|
+
]
|
|
335
|
+
|
|
336
|
+
CSS = f"""
|
|
337
|
+
Screen {{
|
|
338
|
+
background: {_FLYTE_PURPLE_DARK};
|
|
339
|
+
}}
|
|
340
|
+
Header {{
|
|
341
|
+
background: {_FLYTE_PURPLE};
|
|
342
|
+
color: {_FLYTE_PURPLE_LIGHT};
|
|
343
|
+
}}
|
|
344
|
+
Footer {{
|
|
345
|
+
background: {_FLYTE_PURPLE};
|
|
346
|
+
color: {_FLYTE_PURPLE_LIGHT};
|
|
347
|
+
}}
|
|
348
|
+
Horizontal {{
|
|
349
|
+
height: 1fr;
|
|
350
|
+
}}
|
|
351
|
+
VolumeFileTree {{
|
|
352
|
+
width: 1fr;
|
|
353
|
+
min-width: 32;
|
|
354
|
+
border: solid {_FLYTE_PURPLE};
|
|
355
|
+
border-title-color: {_FLYTE_PURPLE_LIGHT};
|
|
356
|
+
background: {_FLYTE_PURPLE_DARK};
|
|
357
|
+
color: {_FLYTE_PURPLE_LIGHT};
|
|
358
|
+
}}
|
|
359
|
+
#right-tabs {{
|
|
360
|
+
width: 2fr;
|
|
361
|
+
}}
|
|
362
|
+
VolumeDetailPanel {{
|
|
363
|
+
background: {_FLYTE_PURPLE_DARK};
|
|
364
|
+
}}
|
|
365
|
+
TabPane {{
|
|
366
|
+
padding: 0;
|
|
367
|
+
}}
|
|
368
|
+
Tabs {{
|
|
369
|
+
background: {_FLYTE_PURPLE_DARK};
|
|
370
|
+
color: {_FLYTE_PURPLE_LIGHT};
|
|
371
|
+
}}
|
|
372
|
+
Tab {{
|
|
373
|
+
background: {_FLYTE_PURPLE_DARK};
|
|
374
|
+
color: {_FLYTE_PURPLE_LIGHT};
|
|
375
|
+
}}
|
|
376
|
+
Tab.-active {{
|
|
377
|
+
background: {_FLYTE_PURPLE};
|
|
378
|
+
color: {_FLYTE_PURPLE_LIGHT};
|
|
379
|
+
}}
|
|
380
|
+
_DetailBox {{
|
|
381
|
+
border: solid {_FLYTE_PURPLE};
|
|
382
|
+
border-title-color: {_FLYTE_PURPLE_LIGHT};
|
|
383
|
+
padding: 0 1;
|
|
384
|
+
margin-bottom: 1;
|
|
385
|
+
height: auto;
|
|
386
|
+
color: {_FLYTE_PURPLE_LIGHT};
|
|
387
|
+
}}
|
|
388
|
+
"""
|
|
389
|
+
|
|
390
|
+
def __init__(
|
|
391
|
+
self,
|
|
392
|
+
*,
|
|
393
|
+
reader: IndexReader,
|
|
394
|
+
title: str,
|
|
395
|
+
subtitle: str = "",
|
|
396
|
+
root_inode: int,
|
|
397
|
+
header_text: str,
|
|
398
|
+
lineage: "list[dict] | None" = None,
|
|
399
|
+
provenance: "dict | None" = None,
|
|
400
|
+
can_navigate_parent: bool = False,
|
|
401
|
+
) -> None:
|
|
402
|
+
super().__init__()
|
|
403
|
+
self._reader = reader
|
|
404
|
+
self._title = title
|
|
405
|
+
self._subtitle = subtitle
|
|
406
|
+
# Precomputed by the launcher (async reader.summary()) so compose()
|
|
407
|
+
# — which can't await — stays synchronous.
|
|
408
|
+
self._root_inode = root_inode
|
|
409
|
+
self._header_text = header_text
|
|
410
|
+
self._lineage = lineage or []
|
|
411
|
+
self._provenance = provenance
|
|
412
|
+
self._can_navigate_parent = can_navigate_parent
|
|
413
|
+
# Set to "parent" by ``action_open_parent`` before exit so the
|
|
414
|
+
# launcher knows to re-open explore against this volume's parent.
|
|
415
|
+
self.navigate: "str | None" = None
|
|
416
|
+
|
|
417
|
+
def compose(self) -> ComposeResult:
|
|
418
|
+
yield Header()
|
|
419
|
+
with Horizontal():
|
|
420
|
+
tree = VolumeFileTree(self._reader, root_inode=self._root_inode, id="file-tree")
|
|
421
|
+
tree.border_title = self._title
|
|
422
|
+
tree.show_root = True
|
|
423
|
+
yield tree
|
|
424
|
+
with TabbedContent(initial="tab-details", id="right-tabs"):
|
|
425
|
+
with TabPane("Details", id="tab-details"):
|
|
426
|
+
yield VolumeDetailPanel(
|
|
427
|
+
self._reader,
|
|
428
|
+
header_text=self._header_text,
|
|
429
|
+
provenance_text=_provenance_text(self._provenance, self._can_navigate_parent),
|
|
430
|
+
lineage_text=_lineage_text(self._lineage),
|
|
431
|
+
id="detail-panel",
|
|
432
|
+
)
|
|
433
|
+
yield Footer()
|
|
434
|
+
|
|
435
|
+
def on_mount(self) -> None:
|
|
436
|
+
self.title = self._title
|
|
437
|
+
self.query_one("#file-tree", VolumeFileTree).focus()
|
|
438
|
+
|
|
439
|
+
def on_tree_node_highlighted(self, event: Tree.NodeHighlighted) -> None:
|
|
440
|
+
self.run_worker(self._render_selected(event.node), exclusive=True)
|
|
441
|
+
|
|
442
|
+
def on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
|
|
443
|
+
self.run_worker(self._render_selected(event.node), exclusive=True)
|
|
444
|
+
|
|
445
|
+
async def _render_selected(self, node: TreeNode[int]) -> None:
|
|
446
|
+
inode = node.data
|
|
447
|
+
if inode is None:
|
|
448
|
+
return
|
|
449
|
+
entry = await self._reader.get(inode)
|
|
450
|
+
if entry is None:
|
|
451
|
+
return
|
|
452
|
+
chunks = await self._reader.chunk_count(inode) if entry.kind == "file" else 0
|
|
453
|
+
panel = self.query_one("#detail-panel", VolumeDetailPanel)
|
|
454
|
+
panel.show_selected(entry, chunks)
|
|
455
|
+
|
|
456
|
+
def check_action(self, action: str, parameters: "tuple[object, ...]") -> "bool | None":
|
|
457
|
+
"""Disable the ``p`` (open-parent) binding when there's no navigable
|
|
458
|
+
parent, so it shows greyed in the footer and the keypress is a true
|
|
459
|
+
no-op — instead of exiting, re-downloading, and landing back on the
|
|
460
|
+
same volume.
|
|
461
|
+
"""
|
|
462
|
+
if action == "open_parent" and not self._can_navigate_parent:
|
|
463
|
+
return False
|
|
464
|
+
return True
|
|
465
|
+
|
|
466
|
+
def action_quit_app(self) -> None:
|
|
467
|
+
self.exit()
|
|
468
|
+
|
|
469
|
+
def action_open_parent(self) -> None:
|
|
470
|
+
"""Exit and signal the launcher to re-open explore on the parent
|
|
471
|
+
version. Guarded by :meth:`check_action`, so this only fires when a
|
|
472
|
+
navigable parent exists.
|
|
473
|
+
"""
|
|
474
|
+
if not self._can_navigate_parent:
|
|
475
|
+
self.notify("No parent version to open.", severity="warning")
|
|
476
|
+
return
|
|
477
|
+
self.navigate = "parent"
|
|
478
|
+
self.exit()
|
|
479
|
+
|
|
480
|
+
def action_refresh_tree(self) -> None:
|
|
481
|
+
# Re-populate the tree against the same reader. The IndexReader is
|
|
482
|
+
# snapshot-based (already-downloaded index), so this is purely
|
|
483
|
+
# a state reset — useful after an accidental collapse-all.
|
|
484
|
+
self.query_one("#file-tree", VolumeFileTree).reload()
|