stepyard 0.1.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.
- stepyard/__init__.py +8 -0
- stepyard/_version.py +24 -0
- stepyard/api/__init__.py +1 -0
- stepyard/api/service.py +540 -0
- stepyard/cli/__init__.py +10 -0
- stepyard/cli/__main__.py +4 -0
- stepyard/cli/app.py +266 -0
- stepyard/cli/commands/__init__.py +1 -0
- stepyard/cli/commands/doctor.py +99 -0
- stepyard/cli/commands/dx.py +155 -0
- stepyard/cli/commands/inspect.py +296 -0
- stepyard/cli/commands/interactive.py +135 -0
- stepyard/cli/commands/logs.py +298 -0
- stepyard/cli/commands/manage.py +211 -0
- stepyard/cli/commands/plugin.py +212 -0
- stepyard/cli/commands/run.py +281 -0
- stepyard/cli/commands/tools.py +514 -0
- stepyard/cli/completions.py +89 -0
- stepyard/cli/renderers/__init__.py +1 -0
- stepyard/cli/renderers/live_view.py +224 -0
- stepyard/cli/repl.py +325 -0
- stepyard/cli/run/__init__.py +1 -0
- stepyard/cli/run/inputs.py +199 -0
- stepyard/cli/run/panels.py +70 -0
- stepyard/cli/run/session.py +546 -0
- stepyard/cli/theme.py +68 -0
- stepyard/cli/ui.py +304 -0
- stepyard/config.py +50 -0
- stepyard/core/__init__.py +18 -0
- stepyard/core/errors.py +174 -0
- stepyard/core/expressions.py +77 -0
- stepyard/core/flow.py +189 -0
- stepyard/core/models.py +91 -0
- stepyard/core/node_executor.py +110 -0
- stepyard/core/ports.py +97 -0
- stepyard/core/service.py +28 -0
- stepyard/engine/__init__.py +1 -0
- stepyard/engine/evaluator.py +120 -0
- stepyard/engine/executor.py +585 -0
- stepyard/engine/navigation.py +73 -0
- stepyard/engine/recorder.py +140 -0
- stepyard/engine/runner.py +103 -0
- stepyard/engine/strategies.py +162 -0
- stepyard/executor/__init__.py +10 -0
- stepyard/executor/process_manager.py +228 -0
- stepyard/executor/worker.py +116 -0
- stepyard/logging_/__init__.py +1 -0
- stepyard/logging_/log_store.py +199 -0
- stepyard/plugin.py +34 -0
- stepyard/plugins/__init__.py +22 -0
- stepyard/plugins/execution.py +94 -0
- stepyard/plugins/host.py +474 -0
- stepyard/plugins/invoker.py +120 -0
- stepyard/plugins/manager.py +315 -0
- stepyard/py.typed +0 -0
- stepyard/scheduler/__init__.py +9 -0
- stepyard/scheduler/__main__.py +90 -0
- stepyard/scheduler/daemon.py +151 -0
- stepyard/scheduler/triggers.py +45 -0
- stepyard/sdk/__init__.py +27 -0
- stepyard/sdk/_stamps.py +37 -0
- stepyard/sdk/hooks.py +20 -0
- stepyard/sdk/inputs.py +34 -0
- stepyard/sdk/node.py +132 -0
- stepyard/sdk/testing.py +134 -0
- stepyard/sdk/trigger.py +32 -0
- stepyard/storage/__init__.py +17 -0
- stepyard/storage/database.py +121 -0
- stepyard/storage/facade.py +295 -0
- stepyard/storage/models.py +58 -0
- stepyard-0.1.0.dist-info/METADATA +281 -0
- stepyard-0.1.0.dist-info/RECORD +84 -0
- stepyard-0.1.0.dist-info/WHEEL +4 -0
- stepyard-0.1.0.dist-info/entry_points.txt +19 -0
- stepyard-0.1.0.dist-info/licenses/LICENSE +21 -0
- stepyard_builtin/__init__.py +1 -0
- stepyard_builtin/file.py +54 -0
- stepyard_builtin/hooks.py +70 -0
- stepyard_builtin/http.py +97 -0
- stepyard_builtin/llm.py +489 -0
- stepyard_builtin/shell.py +105 -0
- stepyard_builtin/system.py +180 -0
- stepyard_builtin/text.py +37 -0
- stepyard_builtin/triggers.py +50 -0
stepyard/__init__.py
ADDED
stepyard/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
stepyard/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Stepyard public API package."""
|
stepyard/api/service.py
ADDED
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Stepyard Service Facade.
|
|
3
|
+
|
|
4
|
+
Single high-level API used by the CLI and any future integrations.
|
|
5
|
+
The CLI should never import directly from ``core/``, ``engine/``, or
|
|
6
|
+
``scheduler/`` - all operations go through this class.
|
|
7
|
+
|
|
8
|
+
Usage::
|
|
9
|
+
|
|
10
|
+
svc = StepyardService.from_cwd() # auto-detect project root
|
|
11
|
+
svc = StepyardService("/path/to/project") # explicit path
|
|
12
|
+
|
|
13
|
+
logs = svc.get_log_lines(run_id)
|
|
14
|
+
svc.start_scheduler(foreground=False)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import uuid
|
|
23
|
+
from collections.abc import Iterator
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from stepyard.core.flow import Flow, FlowResolver
|
|
29
|
+
from stepyard.logging_.log_store import LogStore
|
|
30
|
+
from stepyard.storage.facade import Storage
|
|
31
|
+
|
|
32
|
+
# ─── Result types ─────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class SchedulerStatus:
|
|
37
|
+
is_running: bool
|
|
38
|
+
pid: int | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class FlowInfo:
|
|
43
|
+
name: str
|
|
44
|
+
file_path: str
|
|
45
|
+
is_active: bool
|
|
46
|
+
has_trigger: bool
|
|
47
|
+
trigger_type: str | None = None
|
|
48
|
+
trigger_schedule: str | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ─── Service ──────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class StepyardService:
|
|
55
|
+
"""High-level facade - the ONLY entry point for CLI commands."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, project_dir: str) -> None:
|
|
58
|
+
self.project_dir = os.path.abspath(project_dir)
|
|
59
|
+
self.storage = Storage(self.project_dir)
|
|
60
|
+
self._stepyard_dir = os.path.join(self.project_dir, ".stepyard")
|
|
61
|
+
|
|
62
|
+
# Transparent Background Initialization
|
|
63
|
+
os.makedirs(self._stepyard_dir, exist_ok=True)
|
|
64
|
+
|
|
65
|
+
self._log_store = LogStore(self._stepyard_dir)
|
|
66
|
+
self._resolver = FlowResolver(self.project_dir)
|
|
67
|
+
|
|
68
|
+
os.makedirs(self._resolver.flows_dir, exist_ok=True)
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_cwd(cls) -> StepyardService:
|
|
72
|
+
"""Auto-detect the project root by walking up from cwd."""
|
|
73
|
+
curr = os.getcwd()
|
|
74
|
+
while True:
|
|
75
|
+
if os.path.isdir(os.path.join(curr, ".stepyard")):
|
|
76
|
+
return cls(curr)
|
|
77
|
+
parent = os.path.dirname(curr)
|
|
78
|
+
if parent == curr:
|
|
79
|
+
break
|
|
80
|
+
curr = parent
|
|
81
|
+
return cls(os.getcwd())
|
|
82
|
+
|
|
83
|
+
# ── Flow execution ────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
def run_flow(
|
|
86
|
+
self,
|
|
87
|
+
flow_name: str,
|
|
88
|
+
vars: dict[str, Any] | None = None,
|
|
89
|
+
trigger_type: str = "manual",
|
|
90
|
+
) -> str:
|
|
91
|
+
"""Queue a flow for execution. Returns the new run_id."""
|
|
92
|
+
flow_file = self.find_flow_file(flow_name)
|
|
93
|
+
if not flow_file:
|
|
94
|
+
from stepyard.core.errors import FlowNotFoundError
|
|
95
|
+
|
|
96
|
+
raise FlowNotFoundError(flow_name)
|
|
97
|
+
|
|
98
|
+
import datetime
|
|
99
|
+
|
|
100
|
+
run_id = f"run-{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}-{uuid.uuid4().hex[:6]}"
|
|
101
|
+
self.storage.create_run(run_id, flow_name, trigger_type=trigger_type)
|
|
102
|
+
return run_id
|
|
103
|
+
|
|
104
|
+
def find_flow_file(self, flow_name: str) -> str | None:
|
|
105
|
+
"""Resolve flow name to YAML file path."""
|
|
106
|
+
return self._resolver.find(flow_name)
|
|
107
|
+
|
|
108
|
+
def list_flows(self) -> list[FlowInfo]:
|
|
109
|
+
"""List all available flows from the flows directory."""
|
|
110
|
+
flows_dir = self._resolver.flows_dir
|
|
111
|
+
if not os.path.isdir(flows_dir):
|
|
112
|
+
return []
|
|
113
|
+
|
|
114
|
+
result: list[FlowInfo] = []
|
|
115
|
+
for fn in sorted(os.listdir(flows_dir)):
|
|
116
|
+
if not fn.endswith((".yaml", ".yml")):
|
|
117
|
+
continue
|
|
118
|
+
filepath = os.path.join(flows_dir, fn)
|
|
119
|
+
name = fn.rsplit(".", 1)[0]
|
|
120
|
+
try:
|
|
121
|
+
flow = Flow.from_file(filepath)
|
|
122
|
+
trigger = flow.model.trigger
|
|
123
|
+
result.append(
|
|
124
|
+
FlowInfo(
|
|
125
|
+
name=flow.model.name,
|
|
126
|
+
file_path=filepath,
|
|
127
|
+
is_active=self.storage.is_flow_active(flow.model.name),
|
|
128
|
+
has_trigger=trigger is not None,
|
|
129
|
+
trigger_type=trigger.uses if trigger else None,
|
|
130
|
+
trigger_schedule=trigger.with_config.get("schedule") if trigger else None,
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
except Exception:
|
|
134
|
+
result.append(
|
|
135
|
+
FlowInfo(
|
|
136
|
+
name=name,
|
|
137
|
+
file_path=filepath,
|
|
138
|
+
is_active=False,
|
|
139
|
+
has_trigger=False,
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
return result
|
|
143
|
+
|
|
144
|
+
# ── Run inspection ────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
def get_run(self, run_id: str) -> dict[str, Any] | None:
|
|
147
|
+
return self.storage.get_run(run_id)
|
|
148
|
+
|
|
149
|
+
def get_step_runs(self, run_id: str) -> list[dict[str, Any]]:
|
|
150
|
+
return self.storage.get_step_runs(run_id)
|
|
151
|
+
|
|
152
|
+
def cancel_run(self, run_id: str) -> bool:
|
|
153
|
+
"""Attempt to cancel a running flow (state-driven cancellation)."""
|
|
154
|
+
self.storage.update_run_status(run_id, "cancelled")
|
|
155
|
+
return True
|
|
156
|
+
|
|
157
|
+
# ── Logs ──────────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
def get_log_lines(self, run_id: str, last_n: int | None = None) -> list[str]:
|
|
160
|
+
return self._log_store.tail(run_id, last_n)
|
|
161
|
+
|
|
162
|
+
def follow_logs(self, run_id: str) -> Iterator[str]:
|
|
163
|
+
return self._log_store.follow(run_id)
|
|
164
|
+
|
|
165
|
+
def get_scheduler_logs(self, last_n: int | None = None) -> list[str]:
|
|
166
|
+
return self._log_store.tail_scheduler(last_n)
|
|
167
|
+
|
|
168
|
+
def follow_scheduler_logs(self) -> Iterator[str]:
|
|
169
|
+
return self._log_store.follow_scheduler()
|
|
170
|
+
|
|
171
|
+
def search_logs(self, query: str, run_id: str | None = None) -> list[dict]:
|
|
172
|
+
return self._log_store.search(query, run_id)
|
|
173
|
+
|
|
174
|
+
# ── Scheduler management ──────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
def _scheduler_pid_path(self) -> str:
|
|
177
|
+
return os.path.join(self._stepyard_dir, "scheduler.pid")
|
|
178
|
+
|
|
179
|
+
def _scheduler_command(self, executable: str | None = None) -> list[str]:
|
|
180
|
+
return [
|
|
181
|
+
executable or sys.executable,
|
|
182
|
+
"-m",
|
|
183
|
+
"stepyard.scheduler",
|
|
184
|
+
"--project-dir",
|
|
185
|
+
self.project_dir,
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
def _launchd_plist_path(self, label: str = "com.stepyard.scheduler") -> Path:
|
|
189
|
+
return Path.home() / "Library" / "LaunchAgents" / f"{label}.plist"
|
|
190
|
+
|
|
191
|
+
def _systemd_service_path(self) -> Path:
|
|
192
|
+
return Path.home() / ".config" / "systemd" / "user" / "stepyard.service"
|
|
193
|
+
|
|
194
|
+
def scheduler_status(self) -> SchedulerStatus:
|
|
195
|
+
pid_file = self._scheduler_pid_path()
|
|
196
|
+
if not os.path.exists(pid_file):
|
|
197
|
+
return SchedulerStatus(is_running=False)
|
|
198
|
+
try:
|
|
199
|
+
with open(pid_file) as fh:
|
|
200
|
+
pid = int(fh.read().strip())
|
|
201
|
+
os.kill(pid, 0) # Check if process exists
|
|
202
|
+
return SchedulerStatus(is_running=True, pid=pid)
|
|
203
|
+
except (ProcessLookupError, ValueError, OSError):
|
|
204
|
+
return SchedulerStatus(is_running=False)
|
|
205
|
+
|
|
206
|
+
def start_scheduler(self, foreground: bool = False) -> None:
|
|
207
|
+
"""Start the scheduler daemon."""
|
|
208
|
+
if foreground:
|
|
209
|
+
import asyncio
|
|
210
|
+
|
|
211
|
+
from stepyard.executor.process_manager import ProcessManager
|
|
212
|
+
from stepyard.executor.worker import ExecutorWorker
|
|
213
|
+
from stepyard.scheduler.daemon import SchedulerDaemon, _configure_logging
|
|
214
|
+
|
|
215
|
+
log_path = str(self._log_store.scheduler_log_path())
|
|
216
|
+
_configure_logging(log_path)
|
|
217
|
+
pm = ProcessManager(logs_dir=os.path.join(self._stepyard_dir, "logs"))
|
|
218
|
+
scheduler = SchedulerDaemon(
|
|
219
|
+
storage=self.storage,
|
|
220
|
+
log_store=self._log_store,
|
|
221
|
+
)
|
|
222
|
+
executor = ExecutorWorker(
|
|
223
|
+
storage=self.storage,
|
|
224
|
+
process_manager=pm,
|
|
225
|
+
log_store=self._log_store,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
async def run_supervisor():
|
|
229
|
+
await asyncio.gather(
|
|
230
|
+
scheduler.run_forever(),
|
|
231
|
+
executor.run_forever(),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
asyncio.run(run_supervisor())
|
|
235
|
+
else:
|
|
236
|
+
# Spawn detached background process
|
|
237
|
+
cmd = self._scheduler_command()
|
|
238
|
+
proc = subprocess.Popen(
|
|
239
|
+
cmd,
|
|
240
|
+
stdout=subprocess.DEVNULL,
|
|
241
|
+
stderr=subprocess.DEVNULL,
|
|
242
|
+
stdin=subprocess.DEVNULL,
|
|
243
|
+
start_new_session=True,
|
|
244
|
+
)
|
|
245
|
+
with open(self._scheduler_pid_path(), "w") as fh:
|
|
246
|
+
fh.write(str(proc.pid))
|
|
247
|
+
|
|
248
|
+
def stop_scheduler(self) -> bool:
|
|
249
|
+
status = self.scheduler_status()
|
|
250
|
+
if not status.is_running or status.pid is None:
|
|
251
|
+
return False
|
|
252
|
+
try:
|
|
253
|
+
import signal
|
|
254
|
+
|
|
255
|
+
os.kill(status.pid, signal.SIGTERM)
|
|
256
|
+
try:
|
|
257
|
+
os.remove(self._scheduler_pid_path())
|
|
258
|
+
except OSError:
|
|
259
|
+
pass
|
|
260
|
+
return True
|
|
261
|
+
except ProcessLookupError:
|
|
262
|
+
return False
|
|
263
|
+
|
|
264
|
+
def install_system_service(self) -> str:
|
|
265
|
+
"""Generate and install a system service file (launchd/systemd).
|
|
266
|
+
|
|
267
|
+
Returns a human-readable description of what was installed.
|
|
268
|
+
"""
|
|
269
|
+
executable = sys.executable
|
|
270
|
+
if sys.platform == "darwin":
|
|
271
|
+
return self._install_launchd(executable)
|
|
272
|
+
return self._install_systemd(executable)
|
|
273
|
+
|
|
274
|
+
def _install_launchd(self, executable: str) -> str:
|
|
275
|
+
import plistlib
|
|
276
|
+
|
|
277
|
+
log_path = str(self._log_store.scheduler_log_path())
|
|
278
|
+
label = "com.stepyard.scheduler"
|
|
279
|
+
plist = {
|
|
280
|
+
"Label": label,
|
|
281
|
+
"ProgramArguments": self._scheduler_command(executable),
|
|
282
|
+
"KeepAlive": True,
|
|
283
|
+
"RunAtLoad": True,
|
|
284
|
+
"StandardOutPath": log_path,
|
|
285
|
+
"StandardErrorPath": log_path,
|
|
286
|
+
"WorkingDirectory": self.project_dir,
|
|
287
|
+
}
|
|
288
|
+
plist_path = self._launchd_plist_path(label)
|
|
289
|
+
plist_path.parent.mkdir(parents=True, exist_ok=True)
|
|
290
|
+
with open(plist_path, "wb") as fh:
|
|
291
|
+
plistlib.dump(plist, fh)
|
|
292
|
+
return f"launchd plist installed at {plist_path}\nRun: launchctl load {plist_path}"
|
|
293
|
+
|
|
294
|
+
def _install_systemd(self, executable: str) -> str:
|
|
295
|
+
try:
|
|
296
|
+
login = os.getlogin()
|
|
297
|
+
except Exception:
|
|
298
|
+
login = "root"
|
|
299
|
+
log_path = str(self._log_store.scheduler_log_path())
|
|
300
|
+
exec_start = " ".join(self._scheduler_command(executable))
|
|
301
|
+
service_content = f"""[Unit]
|
|
302
|
+
Description=Stepyard Scheduler Daemon
|
|
303
|
+
After=network.target
|
|
304
|
+
|
|
305
|
+
[Service]
|
|
306
|
+
ExecStart={exec_start}
|
|
307
|
+
Restart=always
|
|
308
|
+
User={login}
|
|
309
|
+
WorkingDirectory={self.project_dir}
|
|
310
|
+
StandardOutput=append:{log_path}
|
|
311
|
+
StandardError=append:{log_path}
|
|
312
|
+
|
|
313
|
+
[Install]
|
|
314
|
+
WantedBy=default.target
|
|
315
|
+
"""
|
|
316
|
+
service_path = self._systemd_service_path()
|
|
317
|
+
service_path.parent.mkdir(parents=True, exist_ok=True)
|
|
318
|
+
service_path.write_text(service_content)
|
|
319
|
+
return (
|
|
320
|
+
f"systemd unit installed at {service_path}\n"
|
|
321
|
+
f"Run: systemctl --user enable --now stepyard.service"
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
def uninstall_system_service(self) -> str:
|
|
325
|
+
"""Remove the system service file (launchd/systemd).
|
|
326
|
+
|
|
327
|
+
Returns a human-readable description of what was removed.
|
|
328
|
+
"""
|
|
329
|
+
if sys.platform == "darwin":
|
|
330
|
+
return self._uninstall_launchd()
|
|
331
|
+
return self._uninstall_systemd()
|
|
332
|
+
|
|
333
|
+
def _uninstall_launchd(self) -> str:
|
|
334
|
+
label = "com.stepyard.scheduler"
|
|
335
|
+
plist_path = self._launchd_plist_path(label)
|
|
336
|
+
|
|
337
|
+
try:
|
|
338
|
+
import subprocess
|
|
339
|
+
|
|
340
|
+
subprocess.run(["launchctl", "unload", str(plist_path)], capture_output=True)
|
|
341
|
+
except Exception: # noqa: BLE001 - launchctl may be absent on non-macOS
|
|
342
|
+
pass
|
|
343
|
+
|
|
344
|
+
if plist_path.exists():
|
|
345
|
+
plist_path.unlink()
|
|
346
|
+
return f"launchd plist removed at {plist_path}"
|
|
347
|
+
return f"launchd plist not found at {plist_path}"
|
|
348
|
+
|
|
349
|
+
def _uninstall_systemd(self) -> str:
|
|
350
|
+
service_path = self._systemd_service_path()
|
|
351
|
+
|
|
352
|
+
try:
|
|
353
|
+
import subprocess
|
|
354
|
+
|
|
355
|
+
subprocess.run(
|
|
356
|
+
["systemctl", "--user", "disable", "--now", "stepyard.service"], capture_output=True
|
|
357
|
+
)
|
|
358
|
+
except Exception: # noqa: BLE001 - systemctl may be absent on non-Linux
|
|
359
|
+
pass
|
|
360
|
+
|
|
361
|
+
if service_path.exists():
|
|
362
|
+
service_path.unlink()
|
|
363
|
+
return f"systemd unit removed at {service_path}"
|
|
364
|
+
return f"systemd unit not found at {service_path}"
|
|
365
|
+
|
|
366
|
+
# ── DX helpers ────────────────────────────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
def init_project(self, *, force: bool = False) -> dict[str, list[str]]:
|
|
369
|
+
"""Scaffold a new Stepyard project in :attr:`project_dir`.
|
|
370
|
+
|
|
371
|
+
Creates ``flows/``, ``.gitignore``, and an example flow if the
|
|
372
|
+
directory is empty or *force* is ``True``.
|
|
373
|
+
|
|
374
|
+
Returns
|
|
375
|
+
-------
|
|
376
|
+
dict
|
|
377
|
+
``{"created": [...], "skipped": [...]}`` listing which files were
|
|
378
|
+
written and which were already present.
|
|
379
|
+
"""
|
|
380
|
+
created: list[str] = []
|
|
381
|
+
skipped: list[str] = []
|
|
382
|
+
|
|
383
|
+
def _write(path: str, content: str) -> None:
|
|
384
|
+
if os.path.exists(path) and not force:
|
|
385
|
+
skipped.append(path)
|
|
386
|
+
return
|
|
387
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
388
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
389
|
+
fh.write(content)
|
|
390
|
+
created.append(path)
|
|
391
|
+
|
|
392
|
+
flows_dir = os.path.join(self.project_dir, "flows")
|
|
393
|
+
os.makedirs(flows_dir, exist_ok=True)
|
|
394
|
+
|
|
395
|
+
_write(
|
|
396
|
+
os.path.join(flows_dir, "hello.yaml"),
|
|
397
|
+
"""\
|
|
398
|
+
name: hello
|
|
399
|
+
description: "A minimal example flow"
|
|
400
|
+
steps:
|
|
401
|
+
- id: greet
|
|
402
|
+
uses: shell.run
|
|
403
|
+
with:
|
|
404
|
+
command: echo "Hello from Stepyard!"
|
|
405
|
+
""",
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
_write(
|
|
409
|
+
os.path.join(self.project_dir, ".gitignore"),
|
|
410
|
+
"""\
|
|
411
|
+
# Stepyard runtime data
|
|
412
|
+
.stepyard/
|
|
413
|
+
.stepyard_history
|
|
414
|
+
""",
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
# Force Storage initialisation so the .stepyard/ dir is created.
|
|
418
|
+
_ = self.storage
|
|
419
|
+
|
|
420
|
+
return {"created": created, "skipped": skipped}
|
|
421
|
+
|
|
422
|
+
def validate_flow(self, flow_file: str) -> list[dict]:
|
|
423
|
+
"""Validate *flow_file* and return a list of error dicts.
|
|
424
|
+
|
|
425
|
+
Each error dict has keys ``field``, ``message``, and ``hint``.
|
|
426
|
+
|
|
427
|
+
Returns an empty list when the flow is valid.
|
|
428
|
+
"""
|
|
429
|
+
import difflib # noqa: PLC0415
|
|
430
|
+
|
|
431
|
+
from stepyard.core.flow import Flow # noqa: PLC0415
|
|
432
|
+
|
|
433
|
+
errors: list[dict] = []
|
|
434
|
+
|
|
435
|
+
try:
|
|
436
|
+
flow = Flow.from_file(flow_file)
|
|
437
|
+
except Exception as exc:
|
|
438
|
+
# Extract Pydantic validation locations when available.
|
|
439
|
+
if hasattr(exc, "errors"):
|
|
440
|
+
for err in exc.errors():
|
|
441
|
+
loc = ".".join(str(x) for x in err.get("loc", []))
|
|
442
|
+
errors.append(
|
|
443
|
+
{
|
|
444
|
+
"field": loc or "(root)",
|
|
445
|
+
"message": err.get("msg", str(err)),
|
|
446
|
+
"hint": "",
|
|
447
|
+
}
|
|
448
|
+
)
|
|
449
|
+
else:
|
|
450
|
+
errors.append({"field": "(root)", "message": str(exc), "hint": ""})
|
|
451
|
+
return errors
|
|
452
|
+
|
|
453
|
+
# Semantic validation: check that all `uses` values are registered.
|
|
454
|
+
registry = None
|
|
455
|
+
try:
|
|
456
|
+
from stepyard.plugin import discover_capabilities # noqa: PLC0415
|
|
457
|
+
|
|
458
|
+
registry = discover_capabilities(self.project_dir)
|
|
459
|
+
except Exception: # noqa: BLE001 - plugin discovery is best-effort during validation
|
|
460
|
+
pass
|
|
461
|
+
|
|
462
|
+
if registry is not None:
|
|
463
|
+
available = sorted(registry.nodes.keys())
|
|
464
|
+
for step in _iter_steps(flow.model.steps):
|
|
465
|
+
if not step.uses:
|
|
466
|
+
continue
|
|
467
|
+
if step.uses not in registry.nodes:
|
|
468
|
+
close = difflib.get_close_matches(step.uses, available, n=3, cutoff=0.5)
|
|
469
|
+
hint = f"Did you mean: {', '.join(close)}?" if close else ""
|
|
470
|
+
errors.append(
|
|
471
|
+
{
|
|
472
|
+
"field": f"steps[{step.id}].uses",
|
|
473
|
+
"message": f"Unknown node '{step.uses}'",
|
|
474
|
+
"hint": hint,
|
|
475
|
+
}
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
return errors
|
|
479
|
+
|
|
480
|
+
def export_flow_schema(self, output_path: str | None = None) -> str:
|
|
481
|
+
"""Export a JSON Schema for flow YAML files.
|
|
482
|
+
|
|
483
|
+
If *output_path* is omitted the schema is written to
|
|
484
|
+
``.stepyard/flow.schema.json`` and that path is returned.
|
|
485
|
+
"""
|
|
486
|
+
import json # noqa: PLC0415
|
|
487
|
+
|
|
488
|
+
from stepyard.core.flow import FlowModel # noqa: PLC0415
|
|
489
|
+
|
|
490
|
+
schema = FlowModel.model_json_schema()
|
|
491
|
+
|
|
492
|
+
# Enrich the `uses` field with available node names.
|
|
493
|
+
try:
|
|
494
|
+
from stepyard.plugin import discover_capabilities # noqa: PLC0415
|
|
495
|
+
|
|
496
|
+
registry = discover_capabilities(self.project_dir)
|
|
497
|
+
node_names = sorted(registry.nodes.keys())
|
|
498
|
+
if node_names and "properties" in schema:
|
|
499
|
+
# Inject enum into every `uses` property recursively.
|
|
500
|
+
_inject_uses_enum(schema, node_names)
|
|
501
|
+
except Exception: # noqa: BLE001 - schema enum enrichment is optional
|
|
502
|
+
pass
|
|
503
|
+
|
|
504
|
+
if output_path is None:
|
|
505
|
+
output_path = os.path.join(self._stepyard_dir, "flow.schema.json")
|
|
506
|
+
|
|
507
|
+
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
|
508
|
+
with open(output_path, "w", encoding="utf-8") as fh:
|
|
509
|
+
json.dump(schema, fh, indent=2)
|
|
510
|
+
|
|
511
|
+
return output_path
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _iter_steps(steps, parent_id: str | None = None):
|
|
515
|
+
"""Recursively yield all StepModel instances from *steps*."""
|
|
516
|
+
for step in steps:
|
|
517
|
+
yield step
|
|
518
|
+
if getattr(step, "steps", None):
|
|
519
|
+
yield from _iter_steps(step.steps)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _inject_uses_enum(schema: dict, node_names: list[str]) -> None:
|
|
523
|
+
"""Recursively add an ``enum`` hint for ``uses`` fields in the schema."""
|
|
524
|
+
if isinstance(schema, dict):
|
|
525
|
+
if schema.get("title") == "Uses" or "uses" in str(schema.get("description", "")).lower():
|
|
526
|
+
pass
|
|
527
|
+
for key, value in schema.items():
|
|
528
|
+
if key == "uses" and isinstance(value, dict):
|
|
529
|
+
value["enum"] = node_names
|
|
530
|
+
value["description"] = (
|
|
531
|
+
value.get("description", "")
|
|
532
|
+
+ f" Available: {', '.join(node_names[:10])}"
|
|
533
|
+
+ (" …" if len(node_names) > 10 else "")
|
|
534
|
+
)
|
|
535
|
+
elif isinstance(value, dict):
|
|
536
|
+
_inject_uses_enum(value, node_names)
|
|
537
|
+
elif isinstance(value, list):
|
|
538
|
+
for item in value:
|
|
539
|
+
if isinstance(item, dict):
|
|
540
|
+
_inject_uses_enum(item, node_names)
|
stepyard/cli/__init__.py
ADDED
stepyard/cli/__main__.py
ADDED