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.
- jstdata/__init__.py +13 -0
- jstdata/agent_guide.py +530 -0
- jstdata/cli.py +682 -0
- jstdata/client.py +543 -0
- jstdata/models.py +161 -0
- jstdata/session.py +251 -0
- jstdata/utils.py +103 -0
- jstdata/workflows/__init__.py +88 -0
- jstdata/workflows/base.py +356 -0
- jstdata/workflows/bundled/__init__.py +1 -0
- jstdata/workflows/bundled/tutorial.yaml +14 -0
- jstdata/workflows/bundled/tutorial_script.yaml +45 -0
- jstdata/workflows/chart.py +357 -0
- jstdata/workflows/console.py +560 -0
- jstdata/workflows/discover.py +502 -0
- jstdata/workflows/export.py +171 -0
- jstdata/workflows/find.py +386 -0
- jstdata/workflows/host.py +255 -0
- jstdata/workflows/rank.py +658 -0
- jstdata/workflows/session_manager.py +277 -0
- jstdata/workflows/store.py +308 -0
- jstdata/workflows/tutorial.py +163 -0
- jstdata/workflows/tutorial_host.py +178 -0
- jstdata-0.2.0.dist-info/METADATA +227 -0
- jstdata-0.2.0.dist-info/RECORD +28 -0
- jstdata-0.2.0.dist-info/WHEEL +4 -0
- jstdata-0.2.0.dist-info/entry_points.txt +3 -0
- jstdata-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|