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.
Files changed (122) hide show
  1. flyteplugins/union/__init__.py +4 -0
  2. flyteplugins/union/cli/__init__.py +48 -0
  3. flyteplugins/union/cli/_tui/__init__.py +67 -0
  4. flyteplugins/union/cli/_tui/_volume_explore.py +484 -0
  5. flyteplugins/union/cli/_volume_index.py +574 -0
  6. flyteplugins/union/cli/api_key.py +139 -0
  7. flyteplugins/union/cli/assignment.py +121 -0
  8. flyteplugins/union/cli/cluster.py +246 -0
  9. flyteplugins/union/cli/cluster_pool.py +283 -0
  10. flyteplugins/union/cli/member.py +26 -0
  11. flyteplugins/union/cli/policy.py +178 -0
  12. flyteplugins/union/cli/queue.py +366 -0
  13. flyteplugins/union/cli/role.py +174 -0
  14. flyteplugins/union/cli/user.py +95 -0
  15. flyteplugins/union/cli/volume.py +342 -0
  16. flyteplugins/union/errors.py +93 -0
  17. flyteplugins/union/internal/__init__.py +0 -0
  18. flyteplugins/union/internal/authorizer/authorizer_connect.py +1878 -0
  19. flyteplugins/union/internal/authorizer/authorizer_pb2.py +84 -0
  20. flyteplugins/union/internal/authorizer/authorizer_pb2.pyi +6 -0
  21. flyteplugins/union/internal/authorizer/definition_pb2.py +41 -0
  22. flyteplugins/union/internal/authorizer/definition_pb2.pyi +57 -0
  23. flyteplugins/union/internal/authorizer/payload_pb2.py +203 -0
  24. flyteplugins/union/internal/authorizer/payload_pb2.pyi +353 -0
  25. flyteplugins/union/internal/cluster/cluster_connect.py +578 -0
  26. flyteplugins/union/internal/cluster/cluster_pb2.py +44 -0
  27. flyteplugins/union/internal/cluster/cluster_pb2.pyi +6 -0
  28. flyteplugins/union/internal/cluster/definition_pb2.py +100 -0
  29. flyteplugins/union/internal/cluster/definition_pb2.pyi +326 -0
  30. flyteplugins/union/internal/cluster/payload_pb2.py +62 -0
  31. flyteplugins/union/internal/cluster/payload_pb2.pyi +122 -0
  32. flyteplugins/union/internal/clusterpool/clusterpool_connect.py +578 -0
  33. flyteplugins/union/internal/clusterpool/clusterpool_pb2.py +44 -0
  34. flyteplugins/union/internal/clusterpool/clusterpool_pb2.pyi +6 -0
  35. flyteplugins/union/internal/clusterpool/payload_pb2.py +70 -0
  36. flyteplugins/union/internal/clusterpool/payload_pb2.pyi +122 -0
  37. flyteplugins/union/internal/common/authorization_pb2.py +66 -0
  38. flyteplugins/union/internal/common/authorization_pb2.pyi +114 -0
  39. flyteplugins/union/internal/common/cluster_pb2.py +45 -0
  40. flyteplugins/union/internal/common/cluster_pb2.pyi +55 -0
  41. flyteplugins/union/internal/common/deployment_pb2.py +26 -0
  42. flyteplugins/union/internal/common/deployment_pb2.pyi +14 -0
  43. flyteplugins/union/internal/common/identifier_pb2.py +125 -0
  44. flyteplugins/union/internal/common/identifier_pb2.pyi +154 -0
  45. flyteplugins/union/internal/common/identity_pb2.py +48 -0
  46. flyteplugins/union/internal/common/identity_pb2.pyi +82 -0
  47. flyteplugins/union/internal/common/list_pb2.py +36 -0
  48. flyteplugins/union/internal/common/list_pb2.pyi +71 -0
  49. flyteplugins/union/internal/common/policy_pb2.py +37 -0
  50. flyteplugins/union/internal/common/policy_pb2.pyi +27 -0
  51. flyteplugins/union/internal/common/role_pb2.py +37 -0
  52. flyteplugins/union/internal/common/role_pb2.pyi +57 -0
  53. flyteplugins/union/internal/identity/app_definition_pb2.py +30 -0
  54. flyteplugins/union/internal/identity/app_definition_pb2.pyi +54 -0
  55. flyteplugins/union/internal/identity/app_payload_pb2.py +56 -0
  56. flyteplugins/union/internal/identity/app_payload_pb2.pyi +132 -0
  57. flyteplugins/union/internal/identity/app_service_connect.py +383 -0
  58. flyteplugins/union/internal/identity/app_service_pb2.py +38 -0
  59. flyteplugins/union/internal/identity/app_service_pb2.pyi +6 -0
  60. flyteplugins/union/internal/identity/enums_pb2.py +34 -0
  61. flyteplugins/union/internal/identity/enums_pb2.pyi +54 -0
  62. flyteplugins/union/internal/identity/member_payload_pb2.py +29 -0
  63. flyteplugins/union/internal/identity/member_payload_pb2.pyi +21 -0
  64. flyteplugins/union/internal/identity/member_service_connect.py +123 -0
  65. flyteplugins/union/internal/identity/member_service_pb2.py +30 -0
  66. flyteplugins/union/internal/identity/member_service_pb2.pyi +6 -0
  67. flyteplugins/union/internal/identity/policy_payload_pb2.py +66 -0
  68. flyteplugins/union/internal/identity/policy_payload_pb2.pyi +82 -0
  69. flyteplugins/union/internal/identity/policy_service_connect.py +448 -0
  70. flyteplugins/union/internal/identity/policy_service_pb2.py +40 -0
  71. flyteplugins/union/internal/identity/policy_service_pb2.pyi +6 -0
  72. flyteplugins/union/internal/identity/role_payload_pb2.py +76 -0
  73. flyteplugins/union/internal/identity/role_payload_pb2.pyi +96 -0
  74. flyteplugins/union/internal/identity/role_service_connect.py +513 -0
  75. flyteplugins/union/internal/identity/role_service_pb2.py +42 -0
  76. flyteplugins/union/internal/identity/role_service_pb2.pyi +6 -0
  77. flyteplugins/union/internal/identity/user_payload_pb2.py +62 -0
  78. flyteplugins/union/internal/identity/user_payload_pb2.pyi +94 -0
  79. flyteplugins/union/internal/identity/user_service_connect.py +448 -0
  80. flyteplugins/union/internal/identity/user_service_pb2.py +40 -0
  81. flyteplugins/union/internal/identity/user_service_pb2.pyi +6 -0
  82. flyteplugins/union/internal/queue/queue_connect.py +456 -0
  83. flyteplugins/union/internal/queue/queue_pb2.py +136 -0
  84. flyteplugins/union/internal/queue/queue_pb2.pyi +178 -0
  85. flyteplugins/union/internal/validate/__init__.py +0 -0
  86. flyteplugins/union/internal/validate/validate/__init__.py +0 -0
  87. flyteplugins/union/internal/validate/validate/validate_pb2.py +86 -0
  88. flyteplugins/union/io/__init__.py +30 -0
  89. flyteplugins/union/io/_base_volume.py +1362 -0
  90. flyteplugins/union/io/_internal/__init__.py +8 -0
  91. flyteplugins/union/io/_internal/_juicefs/__init__.py +8 -0
  92. flyteplugins/union/io/_internal/_juicefs/_backend.py +413 -0
  93. flyteplugins/union/io/_internal/_juicefs/_common.py +65 -0
  94. flyteplugins/union/io/_internal/_juicefs/_metadata.py +118 -0
  95. flyteplugins/union/io/_internal/_juicefs/_redis.py +221 -0
  96. flyteplugins/union/io/_internal/_juicefs/_sqlite.py +102 -0
  97. flyteplugins/union/io/_internal/_juicefs/bin/.gitkeep +0 -0
  98. flyteplugins/union/io/_internal/_juicefs/bin/juicefs.exe +0 -0
  99. flyteplugins/union/io/_internal/backend.py +53 -0
  100. flyteplugins/union/io/_ro_volume.py +82 -0
  101. flyteplugins/union/io/_rw_volume.py +110 -0
  102. flyteplugins/union/io/_volume_transformer.py +191 -0
  103. flyteplugins/union/remote/__init__.py +48 -0
  104. flyteplugins/union/remote/_api_key.py +318 -0
  105. flyteplugins/union/remote/_assignment.py +191 -0
  106. flyteplugins/union/remote/_cluster.py +301 -0
  107. flyteplugins/union/remote/_cluster_pool.py +271 -0
  108. flyteplugins/union/remote/_member.py +73 -0
  109. flyteplugins/union/remote/_policy.py +196 -0
  110. flyteplugins/union/remote/_queue.py +421 -0
  111. flyteplugins/union/remote/_role.py +148 -0
  112. flyteplugins/union/remote/_user.py +158 -0
  113. flyteplugins/union/remote/_volume_explore.py +397 -0
  114. flyteplugins/union/utils/__init__.py +5 -0
  115. flyteplugins/union/utils/auth.py +41 -0
  116. flyteplugins/union/utils/image.py +84 -0
  117. flyteplugins_union-0.4.0.dist-info/METADATA +125 -0
  118. flyteplugins_union-0.4.0.dist-info/RECORD +122 -0
  119. flyteplugins_union-0.4.0.dist-info/WHEEL +5 -0
  120. flyteplugins_union-0.4.0.dist-info/entry_points.txt +33 -0
  121. flyteplugins_union-0.4.0.dist-info/licenses/LICENSE +6 -0
  122. flyteplugins_union-0.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,4 @@
1
+ """Union SDK - Proprietary extensions for Flyte.
2
+
3
+ This package provides Union-specific functionality on top of the open-source Flyte SDK.
4
+ """
@@ -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()