jstdata 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,277 @@
1
+ """Host-level session modal: list, remove, and inspect session resources."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from typing import Any
8
+
9
+ from textual import on, work
10
+ from textual.app import ComposeResult
11
+ from textual.binding import Binding
12
+ from textual.containers import Horizontal, Vertical
13
+ from textual.screen import ModalScreen
14
+ from textual.widgets import Label, ListItem, ListView, Static
15
+
16
+ from ..client import JSTDataClient
17
+ from ..session import Session
18
+ from .base import label_for
19
+
20
+ _PLACEHOLDER = "Highlight an item and press i to inspect."
21
+
22
+
23
+ class SessionResourceRow(ListItem):
24
+ """One staged resource in the session list (single compact line)."""
25
+
26
+ def __init__(self, resource_id: str, resource_type: str, display_name: str) -> None:
27
+ super().__init__()
28
+ self.resource_id = resource_id
29
+ self.resource_type = resource_type
30
+ self.display_name = display_name
31
+
32
+ def compose(self) -> ComposeResult:
33
+ yield Label(
34
+ f"{self.resource_type.upper():<8} {self.display_name} [{self.resource_id}]",
35
+ classes="sess-row-label",
36
+ )
37
+
38
+
39
+ class SessionManagerModal(ModalScreen[None]):
40
+ """Shared session browser: remove items, inspect API payloads."""
41
+
42
+ DEFAULT_CSS = """
43
+ SessionManagerModal {
44
+ align: center middle;
45
+ background: rgba(0, 0, 0, 0.7);
46
+ }
47
+
48
+ #session-mgr-container {
49
+ width: 90%;
50
+ height: 80%;
51
+ max-width: 140;
52
+ max-height: 40;
53
+ border: thick #4ade80;
54
+ background: #111111;
55
+ padding: 1 1;
56
+ }
57
+
58
+ #session-mgr-title {
59
+ text-style: bold;
60
+ color: #4ade80;
61
+ text-align: center;
62
+ height: 1;
63
+ margin-bottom: 1;
64
+ }
65
+
66
+ #session-mgr-body {
67
+ height: 1fr;
68
+ width: 100%;
69
+ }
70
+
71
+ #session-list-pane {
72
+ width: 1fr;
73
+ height: 100%;
74
+ border-right: solid #333;
75
+ padding-right: 1;
76
+ }
77
+
78
+ #session-inspect-pane {
79
+ width: 1fr;
80
+ height: 100%;
81
+ padding-left: 1;
82
+ }
83
+
84
+ .session-pane-header {
85
+ color: #4ade80;
86
+ text-style: bold;
87
+ height: 1;
88
+ margin-bottom: 1;
89
+ }
90
+
91
+ #session-resource-list {
92
+ height: 1fr;
93
+ width: 100%;
94
+ background: #0a0a0a;
95
+ border: solid #222;
96
+ }
97
+
98
+ SessionResourceRow {
99
+ height: 1;
100
+ min-height: 1;
101
+ max-height: 1;
102
+ width: 100%;
103
+ padding: 0 1;
104
+ overflow: hidden;
105
+ }
106
+
107
+ SessionResourceRow:hover,
108
+ SessionResourceRow.-highlighted {
109
+ background: #1a3a1a;
110
+ }
111
+
112
+ .sess-row-label {
113
+ width: 100%;
114
+ height: 1;
115
+ color: #e0e0e0;
116
+ overflow: hidden;
117
+ }
118
+
119
+ ListView > ListItem {
120
+ height: 1;
121
+ min-height: 1;
122
+ max-height: 1;
123
+ }
124
+
125
+ #session-inspect-payload {
126
+ height: 1fr;
127
+ width: 100%;
128
+ background: #0a0a0a;
129
+ border: solid #222;
130
+ color: #e0e0e0;
131
+ padding: 1;
132
+ overflow-y: auto;
133
+ overflow-x: auto;
134
+ }
135
+
136
+ #session-mgr-hint {
137
+ color: #888;
138
+ height: 1;
139
+ margin-top: 1;
140
+ dock: bottom;
141
+ }
142
+ """
143
+
144
+ BINDINGS = [
145
+ Binding("escape", "dismiss", "Close"),
146
+ Binding("i", "inspect", "Inspect"),
147
+ Binding("backspace", "remove", "Remove"),
148
+ Binding("delete", "remove", "Remove", show=False),
149
+ Binding("j", "cursor_down", "Down", show=False),
150
+ Binding("k", "cursor_up", "Up", show=False),
151
+ Binding("down", "cursor_down", "Down", show=False),
152
+ Binding("up", "cursor_up", "Up", show=False),
153
+ ]
154
+
155
+ def __init__(
156
+ self,
157
+ client: JSTDataClient,
158
+ session: Session,
159
+ labels: dict[str, str],
160
+ ) -> None:
161
+ super().__init__()
162
+ self.client = client
163
+ self.session = session
164
+ self.labels = labels
165
+ self._inspected_id: str | None = None
166
+
167
+ def compose(self) -> ComposeResult:
168
+ with Vertical(id="session-mgr-container"):
169
+ yield Label("SESSION // STAGED RESOURCES", id="session-mgr-title")
170
+ with Horizontal(id="session-mgr-body"):
171
+ with Vertical(id="session-list-pane"):
172
+ yield Label("RESOURCES", classes="session-pane-header")
173
+ yield ListView(id="session-resource-list")
174
+ with Vertical(id="session-inspect-pane"):
175
+ yield Label("INSPECT", classes="session-pane-header")
176
+ yield Static(_PLACEHOLDER, id="session-inspect-payload", markup=False)
177
+ yield Label(
178
+ "[bold]i[/bold] inspect // [bold]backspace[/bold] remove // "
179
+ "[bold]j[/bold]/[bold]k[/bold] or [bold]↑[/bold]/[bold]↓[/bold] // [bold]esc[/bold] close",
180
+ id="session-mgr-hint",
181
+ markup=True,
182
+ )
183
+
184
+ def on_mount(self) -> None:
185
+ self._rebuild_list()
186
+ self.query_one("#session-resource-list", ListView).focus()
187
+
188
+ def _iter_session_entries(self) -> list[tuple[str, str]]:
189
+ """(resource_type, id) in stable metric → entity → series order."""
190
+ entries: list[tuple[str, str]] = []
191
+ for mid in self.session.metric:
192
+ entries.append(("metric", mid))
193
+ for eid in self.session.entity:
194
+ entries.append(("entity", eid))
195
+ for sid in self.session.series:
196
+ entries.append(("series", sid))
197
+ return entries
198
+
199
+ def _rebuild_list(self) -> None:
200
+ list_view = self.query_one("#session-resource-list", ListView)
201
+ list_view.clear()
202
+ entries = self._iter_session_entries()
203
+ if not entries:
204
+ list_view.append(
205
+ ListItem(Label("Session is empty", classes="sess-row-label"), disabled=True)
206
+ )
207
+ self._show_placeholder()
208
+ return
209
+ for rtype, rid in entries:
210
+ list_view.append(
211
+ SessionResourceRow(rid, rtype, label_for(self.labels, rid))
212
+ )
213
+
214
+ def _show_placeholder(self) -> None:
215
+ self._inspected_id = None
216
+ self.query_one("#session-inspect-payload", Static).update(_PLACEHOLDER)
217
+
218
+ def _highlighted_row(self) -> SessionResourceRow | None:
219
+ list_view = self.query_one("#session-resource-list", ListView)
220
+ child = list_view.highlighted_child
221
+ if isinstance(child, SessionResourceRow):
222
+ return child
223
+ return None
224
+
225
+ @on(ListView.Highlighted, "#session-resource-list")
226
+ def on_highlight_changed(self, event: ListView.Highlighted) -> None:
227
+ row = event.item if isinstance(event.item, SessionResourceRow) else None
228
+ if row is None or row.resource_id != self._inspected_id:
229
+ self._show_placeholder()
230
+
231
+ def action_inspect(self) -> None:
232
+ row = self._highlighted_row()
233
+ if row is None:
234
+ self.notify("Select a resource to inspect", severity="warning")
235
+ return
236
+ self.query_one("#session-inspect-payload", Static).update("Fetching...")
237
+ self._fetch_payload(row.resource_type, row.resource_id)
238
+
239
+ @work(exclusive=True)
240
+ async def _fetch_payload(self, resource_type: str, resource_id: str) -> None:
241
+ try:
242
+ data = await asyncio.to_thread(
243
+ self.client.make_request, f"{resource_type}/{resource_id}"
244
+ )
245
+ text = json.dumps(data, indent=2, default=str)
246
+ self._inspected_id = resource_id
247
+ self.query_one("#session-inspect-payload", Static).update(text)
248
+ except Exception as e:
249
+ self._inspected_id = None
250
+ self.query_one("#session-inspect-payload", Static).update(
251
+ f"Inspect failed: {e}"
252
+ )
253
+
254
+ def action_remove(self) -> None:
255
+ row = self._highlighted_row()
256
+ if row is None:
257
+ return
258
+ self._remove_resource(row)
259
+
260
+ def _remove_resource(self, row: SessionResourceRow) -> None:
261
+ rid = row.resource_id
262
+ self.session.remove_id(rid)
263
+ self.labels.pop(rid, None)
264
+ if self._inspected_id == rid:
265
+ self._show_placeholder()
266
+ self._rebuild_list()
267
+ self.notify(f"Removed {rid}")
268
+ self.query_one("#session-resource-list", ListView).focus()
269
+
270
+ def action_cursor_down(self) -> None:
271
+ self.query_one("#session-resource-list", ListView).action_cursor_down()
272
+
273
+ def action_cursor_up(self) -> None:
274
+ self.query_one("#session-resource-list", ListView).action_cursor_up()
275
+
276
+ def action_dismiss(self) -> None:
277
+ self.dismiss()
@@ -0,0 +1,308 @@
1
+ """Persisted workflows: named pipelines saved as YAML under ``~/.jstdata/workflows``.
2
+
3
+ Steps are the catalog units (``jst steps`` / ``jst run``). Workflows are
4
+ user-saved compositions of those steps.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Any, Optional
13
+
14
+ import yaml
15
+
16
+ from importlib.resources import files
17
+
18
+ from ..client import APP_DIR
19
+ from .base import PipelineError, ResolvedStep, resolve_pipeline
20
+
21
+ WORKFLOWS_DIR = APP_DIR / "workflows"
22
+ TUTORIAL_WORKFLOW_ID = "tutorial"
23
+ BUNDLED_WORKFLOW_IDS = frozenset({TUTORIAL_WORKFLOW_ID})
24
+ _SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
25
+
26
+
27
+ class WorkflowStoreError(ValueError):
28
+ """Invalid workflow id, missing file, or corrupt YAML."""
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class SavedStep:
33
+ """One step in a saved workflow (id + seed args)."""
34
+
35
+ id: str
36
+ args: dict[str, Any] = field(default_factory=dict)
37
+
38
+ def to_dict(self) -> dict[str, Any]:
39
+ data: dict[str, Any] = {"id": self.id}
40
+ if self.args:
41
+ data["args"] = dict(self.args)
42
+ return data
43
+
44
+ @classmethod
45
+ def from_dict(cls, data: dict[str, Any]) -> SavedStep:
46
+ if not isinstance(data, dict) or "id" not in data:
47
+ raise WorkflowStoreError("Each step must be a mapping with an 'id'.")
48
+ args = data.get("args") or {}
49
+ if not isinstance(args, dict):
50
+ raise WorkflowStoreError(f"Step {data['id']!r} args must be a mapping.")
51
+ return cls(id=str(data["id"]), args=dict(args))
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class SavedWorkflow:
56
+ """User-saved pipeline stored on disk."""
57
+
58
+ id: str
59
+ description: str = ""
60
+ steps: tuple[SavedStep, ...] = ()
61
+
62
+ def to_dict(self) -> dict[str, Any]:
63
+ return {
64
+ "id": self.id,
65
+ "description": self.description or "",
66
+ "steps": [s.to_dict() for s in self.steps],
67
+ }
68
+
69
+ @classmethod
70
+ def from_dict(cls, data: dict[str, Any]) -> SavedWorkflow:
71
+ if not isinstance(data, dict):
72
+ raise WorkflowStoreError("Workflow file must be a YAML mapping.")
73
+ wid = data.get("id")
74
+ if not wid or not isinstance(wid, str):
75
+ raise WorkflowStoreError("Workflow is missing a string 'id'.")
76
+ validate_workflow_id(wid)
77
+ raw_steps = data.get("steps")
78
+ if not isinstance(raw_steps, list) or not raw_steps:
79
+ raise WorkflowStoreError(f"Workflow {wid!r} must list at least one step.")
80
+ steps = tuple(SavedStep.from_dict(s) for s in raw_steps)
81
+ description = data.get("description") or ""
82
+ if not isinstance(description, str):
83
+ raise WorkflowStoreError("Workflow 'description' must be a string.")
84
+ return cls(id=wid, description=description, steps=steps)
85
+
86
+ @classmethod
87
+ def from_resolved(
88
+ cls,
89
+ workflow_id: str,
90
+ description: str,
91
+ resolved: list[ResolvedStep],
92
+ ) -> SavedWorkflow:
93
+ validate_workflow_id(workflow_id)
94
+ if not resolved:
95
+ raise WorkflowStoreError("Pipeline is empty.")
96
+ steps = tuple(
97
+ SavedStep(id=r.spec.id, args=dict(r.kwargs)) for r in resolved
98
+ )
99
+ return cls(id=workflow_id, description=description or "", steps=steps)
100
+
101
+
102
+ def validate_workflow_id(workflow_id: str) -> str:
103
+ """Require a lowercase slug: ``gdp-rank``, not ``GDP Rank``."""
104
+ if not _SLUG_RE.match(workflow_id):
105
+ raise WorkflowStoreError(
106
+ f"Invalid workflow id {workflow_id!r}. "
107
+ "Use a slug like 'gdp-rank' (lowercase letters, digits, hyphens)."
108
+ )
109
+ return workflow_id
110
+
111
+
112
+ def is_bundled_workflow(workflow_id: str) -> bool:
113
+ return workflow_id in BUNDLED_WORKFLOW_IDS
114
+
115
+
116
+ def load_bundled_workflow(workflow_id: str) -> SavedWorkflow:
117
+ if not is_bundled_workflow(workflow_id):
118
+ raise WorkflowStoreError(f"No built-in workflow named {workflow_id!r}.")
119
+ try:
120
+ text = files("jstdata.workflows.bundled").joinpath(
121
+ f"{workflow_id}.yaml"
122
+ ).read_text(encoding="utf-8")
123
+ except FileNotFoundError as exc:
124
+ raise WorkflowStoreError(
125
+ f"Built-in workflow {workflow_id!r} is missing from the package."
126
+ ) from exc
127
+ try:
128
+ data = yaml.safe_load(text)
129
+ except yaml.YAMLError as exc:
130
+ raise WorkflowStoreError(f"Could not parse built-in {workflow_id!r}: {exc}") from exc
131
+ workflow = SavedWorkflow.from_dict(data or {})
132
+ if workflow.id != workflow_id:
133
+ raise WorkflowStoreError(
134
+ f"Built-in workflow file has id {workflow.id!r}, expected {workflow_id!r}."
135
+ )
136
+ return workflow
137
+
138
+
139
+ def list_bundled_workflows() -> list[SavedWorkflow]:
140
+ return [load_bundled_workflow(wid) for wid in sorted(BUNDLED_WORKFLOW_IDS)]
141
+
142
+
143
+ def workflow_path(workflow_id: str, *, root: Optional[Path] = None) -> Path:
144
+ validate_workflow_id(workflow_id)
145
+ base = root if root is not None else WORKFLOWS_DIR
146
+ return base / f"{workflow_id}.yaml"
147
+
148
+
149
+ def ensure_workflows_dir(*, root: Optional[Path] = None) -> Path:
150
+ base = root if root is not None else WORKFLOWS_DIR
151
+ base.mkdir(parents=True, exist_ok=True)
152
+ return base
153
+
154
+
155
+ def workflow_exists(workflow_id: str, *, root: Optional[Path] = None) -> bool:
156
+ return workflow_path(workflow_id, root=root).is_file()
157
+
158
+
159
+ def save_workflow(
160
+ workflow: SavedWorkflow,
161
+ *,
162
+ root: Optional[Path] = None,
163
+ ) -> Path:
164
+ """Write workflow YAML; overwrites if the file already exists."""
165
+ ensure_workflows_dir(root=root)
166
+ path = workflow_path(workflow.id, root=root)
167
+ with path.open("w", encoding="utf-8") as f:
168
+ yaml.safe_dump(
169
+ workflow.to_dict(),
170
+ f,
171
+ default_flow_style=False,
172
+ sort_keys=False,
173
+ allow_unicode=True,
174
+ )
175
+ return path
176
+
177
+
178
+ def load_workflow(
179
+ workflow_id: str,
180
+ *,
181
+ root: Optional[Path] = None,
182
+ ) -> SavedWorkflow:
183
+ validate_workflow_id(workflow_id)
184
+ if root is not None:
185
+ path = root / f"{workflow_id}.yaml"
186
+ if not path.is_file():
187
+ raise WorkflowStoreError(f"No workflow named {workflow_id!r}.")
188
+ try:
189
+ with path.open(encoding="utf-8") as f:
190
+ data = yaml.safe_load(f)
191
+ except yaml.YAMLError as exc:
192
+ raise WorkflowStoreError(f"Could not parse {path}: {exc}") from exc
193
+ workflow = SavedWorkflow.from_dict(data or {})
194
+ if workflow.id != workflow_id:
195
+ raise WorkflowStoreError(
196
+ f"Workflow file {path.name} has id {workflow.id!r}, "
197
+ f"expected {workflow_id!r}."
198
+ )
199
+ return workflow
200
+
201
+ path = workflow_path(workflow_id)
202
+ if path.is_file():
203
+ try:
204
+ with path.open(encoding="utf-8") as f:
205
+ data = yaml.safe_load(f)
206
+ except yaml.YAMLError as exc:
207
+ raise WorkflowStoreError(f"Could not parse {path}: {exc}") from exc
208
+ workflow = SavedWorkflow.from_dict(data or {})
209
+ if workflow.id != workflow_id:
210
+ raise WorkflowStoreError(
211
+ f"Workflow file {path.name} has id {workflow.id!r}, "
212
+ f"expected {workflow_id!r}."
213
+ )
214
+ return workflow
215
+ if is_bundled_workflow(workflow_id):
216
+ return load_bundled_workflow(workflow_id)
217
+ raise WorkflowStoreError(f"No workflow named {workflow_id!r}.")
218
+
219
+
220
+ def delete_workflow(workflow_id: str, *, root: Optional[Path] = None) -> None:
221
+ if root is None and is_bundled_workflow(workflow_id):
222
+ raise WorkflowStoreError(
223
+ f"Cannot remove built-in workflow {workflow_id!r}."
224
+ )
225
+ path = workflow_path(workflow_id, root=root)
226
+ if not path.is_file():
227
+ raise WorkflowStoreError(f"No workflow named {workflow_id!r}.")
228
+ path.unlink()
229
+
230
+
231
+ def list_saved_workflows(*, root: Optional[Path] = None) -> list[SavedWorkflow]:
232
+ if root is not None:
233
+ base = root
234
+ if not base.is_dir():
235
+ return []
236
+ out: list[SavedWorkflow] = []
237
+ for path in sorted(base.glob("*.yaml")):
238
+ try:
239
+ with path.open(encoding="utf-8") as f:
240
+ data = yaml.safe_load(f)
241
+ out.append(SavedWorkflow.from_dict(data or {}))
242
+ except (WorkflowStoreError, yaml.YAMLError, OSError):
243
+ continue
244
+ return out
245
+
246
+ seen: set[str] = set()
247
+ out: list[SavedWorkflow] = []
248
+ for wf in list_bundled_workflows():
249
+ out.append(wf)
250
+ seen.add(wf.id)
251
+ base = WORKFLOWS_DIR
252
+ if base.is_dir():
253
+ for path in sorted(base.glob("*.yaml")):
254
+ try:
255
+ with path.open(encoding="utf-8") as f:
256
+ data = yaml.safe_load(f)
257
+ wf = SavedWorkflow.from_dict(data or {})
258
+ except (WorkflowStoreError, yaml.YAMLError, OSError):
259
+ continue
260
+ if wf.id in seen:
261
+ continue
262
+ out.append(wf)
263
+ seen.add(wf.id)
264
+ return out
265
+
266
+
267
+ def workflow_to_tokens(workflow: SavedWorkflow) -> list[str]:
268
+ """Rebuild ``jst run``-style argv from structured YAML (no raw argv stored)."""
269
+ tokens: list[str] = []
270
+ for i, step in enumerate(workflow.steps):
271
+ if i:
272
+ tokens.append(":")
273
+ tokens.append(step.id)
274
+ for name, value in step.args.items():
275
+ flag = "--" + str(name).replace("_", "-")
276
+ if isinstance(value, bool):
277
+ if value:
278
+ tokens.append(flag)
279
+ continue
280
+ values = value if isinstance(value, (list, tuple)) else [value]
281
+ for item in values:
282
+ tokens.append(flag)
283
+ tokens.append(str(item))
284
+ return tokens
285
+
286
+
287
+ def format_pipeline(workflow: SavedWorkflow) -> str:
288
+ """One-line pipeline summary for ``jst workflow ls``."""
289
+ return " ".join(workflow_to_tokens(workflow))
290
+
291
+
292
+ def resolve_saved_workflow(workflow: SavedWorkflow) -> list[ResolvedStep]:
293
+ """Validate a saved workflow against the live step catalog (no UI)."""
294
+ try:
295
+ return resolve_pipeline(workflow_to_tokens(workflow))
296
+ except KeyError as exc:
297
+ raise PipelineError(str(exc)) from exc
298
+
299
+
300
+ def create_workflow_from_tokens(
301
+ workflow_id: str,
302
+ description: str,
303
+ tokens: list[str],
304
+ ) -> SavedWorkflow:
305
+ """Parse/validate pipeline tokens, then build a SavedWorkflow."""
306
+ validate_workflow_id(workflow_id)
307
+ resolved = resolve_pipeline(tokens)
308
+ return SavedWorkflow.from_resolved(workflow_id, description, resolved)