runwhere 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.
runw/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """RunWhere.ai CLI — GPU job management tool."""
2
+
3
+ __version__ = "0.1.0"
runw/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m runw`."""
2
+
3
+ from runw.cli.main import main
4
+
5
+ main()
runw/cli/__init__.py ADDED
File without changes
runw/cli/auth.py ADDED
@@ -0,0 +1,64 @@
1
+ """Auth commands: login, whoami."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ import click
8
+
9
+ from runw.client.base import BaseClient
10
+ from runw.config.store import ConfigStore
11
+ from runw.output import console
12
+
13
+
14
+ @click.command()
15
+ @click.option("--endpoint", required=True, help="API 服务地址")
16
+ @click.pass_context
17
+ def login(ctx: click.Context, endpoint: str) -> None:
18
+ """登录 RunWhere.ai"""
19
+ json_mode: bool = ctx.obj["json_mode"]
20
+
21
+ # 交互式:隐藏输入提示粘贴 API Token。
22
+ # 非交互(管道/CI,如 `echo $TOKEN | runw login`):从 stdin 读,
23
+ # 避免 hide_input 走 getpass 读 TTY,在无终端时永久挂起。
24
+ if sys.stdin.isatty():
25
+ token = click.prompt("Token", hide_input=True)
26
+ else:
27
+ token = sys.stdin.read().strip()
28
+ if not token:
29
+ console.fatal(
30
+ "未提供 API Token(stdin 为空)",
31
+ suggestion="交互式直接运行 runw login,或 `echo <token> | runw login --endpoint <url>`",
32
+ )
33
+
34
+ store = ConfigStore()
35
+ store.save(endpoint=endpoint, token=token)
36
+
37
+ console.success(f"登录成功(endpoint: {endpoint})", json_mode=json_mode)
38
+
39
+
40
+ @click.command()
41
+ @click.pass_context
42
+ def whoami(ctx: click.Context) -> None:
43
+ """查看当前登录用户"""
44
+ json_mode: bool = ctx.obj["json_mode"]
45
+
46
+ store = ConfigStore()
47
+ if not store.token:
48
+ console.fatal_server("未登录", suggestion="请先执行 runw login --endpoint <URL>")
49
+
50
+ client = BaseClient(store)
51
+ data = client.get("/api/v1/auth/me")
52
+
53
+ # /auth/me 返回 UserInfo:username 可能为空,身份回退到 phone;
54
+ # org 字段实际叫 current_org_id(旧代码取 "org" 永远空)。
55
+ user = data.get("username") or data.get("phone") or f"user#{data.get('id', '')}"
56
+ # PRD §1:whoami 输出「当前用户 + workspace 地址」。workspace = 登录 endpoint。
57
+ console.output(
58
+ {
59
+ "user": user,
60
+ "org_id": data.get("current_org_id", ""),
61
+ "workspace": store.endpoint or "",
62
+ },
63
+ json_mode=json_mode,
64
+ )
runw/cli/explain.py ADDED
@@ -0,0 +1,159 @@
1
+ """--explain 实现:列出平台自动填充的默认值与来源(spec 027 FR-011 / SC-005)。
2
+
3
+ 设计:CLI 端预演平台填充规则(与 backend job_orchestrator.fill_defaults 对齐),
4
+ 给用户在提交前完整透明的可解释视图。
5
+
6
+ 输出条目格式:
7
+ - name: 字段路径(如 environment.command)
8
+ - value: 填充后的值
9
+ - source: "user_yaml" / "platform_default" / "auto_pricing" / "computed"
10
+ - reason: 为什么填这个值(人类可读)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from typing import Any
17
+
18
+
19
+ @dataclass
20
+ class FillEntry:
21
+ name: str
22
+ value: Any
23
+ source: str # user_yaml / platform_default / auto_pricing / computed
24
+ reason: str
25
+
26
+
27
+ # 平台默认填充规则——与 backend manifest_builder.build_platform_env_vars 对齐
28
+ # 当用户 YAML 未指定时填充
29
+ DEFAULT_RULES: list[dict[str, Any]] = [
30
+ {
31
+ "name": "kind",
32
+ "default": "Training",
33
+ "reason": "未指定 kind 时默认 Training(spec PRD-runw §6)",
34
+ },
35
+ {
36
+ "name": "resources.gpuNum",
37
+ "default": 1,
38
+ "reason": "未指定 gpuNum 时默认 1 卡",
39
+ },
40
+ {
41
+ "name": "environment.command",
42
+ "default": "python main.py",
43
+ "reason": "未指定 command 时默认 `python main.py`(用户应在 YAML 显式指定)",
44
+ },
45
+ ]
46
+
47
+
48
+ # 平台自动注入的 env(spec PRD-workload §6 + spec 031 Wandb)
49
+ PLATFORM_INJECTED_ENVS: list[dict[str, str]] = [
50
+ {
51
+ "name": "RUNW_JOB_ID",
52
+ "reason": "由 backend 生成,注入到 Pod 用于关联日志/指标",
53
+ },
54
+ {
55
+ "name": "RUNW_JOB_NAME",
56
+ "reason": "用户 YAML 中 job.name",
57
+ },
58
+ {
59
+ "name": "RUNW_ORG_ID",
60
+ "reason": "当前 token 的 org",
61
+ },
62
+ {
63
+ "name": "RUNW_USER_ID",
64
+ "reason": "当前用户",
65
+ },
66
+ {
67
+ "name": "RUNW_NPROC_PER_NODE",
68
+ "reason": "Training 任务自动设为 gpuNum(torch.distributed 用)",
69
+ },
70
+ {
71
+ "name": "NCCL_TIMEOUT=1800",
72
+ "reason": "防止 NCCL 通信超时崩溃(30 分钟)",
73
+ },
74
+ {
75
+ "name": "TORCH_NCCL_ASYNC_ERROR_HANDLING=1",
76
+ "reason": "PyTorch NCCL 异常异步处理",
77
+ },
78
+ {
79
+ "name": "NCCL_DEBUG=WARN",
80
+ "reason": "NCCL 调试输出限制为 WARN 级别",
81
+ },
82
+ {
83
+ "name": "WANDB_BASE_URL / WANDB_API_KEY / WANDB_ENTITY / WANDB_PROJECT",
84
+ "reason": "spec 031 — Org 注册时自动建 Wandb 账号,submit 时注入;YAML 设 environment.wandb_external=true 可禁用",
85
+ },
86
+ ]
87
+
88
+
89
+ def compute_fill_entries(spec_dict: dict[str, Any]) -> list[FillEntry]:
90
+ """对照 DEFAULT_RULES 计算用户 YAML 哪些字段是平台填充的。"""
91
+ entries: list[FillEntry] = []
92
+
93
+ for rule in DEFAULT_RULES:
94
+ name = rule["name"]
95
+ path = name.split(".")
96
+ cur: Any = spec_dict
97
+ present = True
98
+ for p in path:
99
+ if not isinstance(cur, dict) or p not in cur or cur[p] is None:
100
+ present = False
101
+ break
102
+ cur = cur[p]
103
+
104
+ if present:
105
+ entries.append(
106
+ FillEntry(
107
+ name=name, value=cur, source="user_yaml",
108
+ reason="用户 YAML 显式指定",
109
+ )
110
+ )
111
+ else:
112
+ entries.append(
113
+ FillEntry(
114
+ name=name, value=rule["default"],
115
+ source="platform_default", reason=rule["reason"],
116
+ )
117
+ )
118
+ return entries
119
+
120
+
121
+ def render_explain(
122
+ spec_dict: dict[str, Any],
123
+ *,
124
+ json_mode: bool = False,
125
+ ) -> dict[str, Any] | None:
126
+ """渲染 --explain 输出。json_mode=True 返回 dict 供 console.output;
127
+ 否则直接打印到 stderr 并返回 None。
128
+ """
129
+ entries = compute_fill_entries(spec_dict)
130
+
131
+ payload: dict[str, Any] = {
132
+ "explain": {
133
+ "fields": [
134
+ {
135
+ "name": e.name,
136
+ "value": e.value,
137
+ "source": e.source,
138
+ "reason": e.reason,
139
+ }
140
+ for e in entries
141
+ ],
142
+ "platform_envs": PLATFORM_INJECTED_ENVS,
143
+ }
144
+ }
145
+
146
+ if json_mode:
147
+ return payload
148
+
149
+ from runw.output import console
150
+
151
+ console.info("─── 平台默认值填充过程(--explain)───")
152
+ for e in entries:
153
+ marker = "·" if e.source == "user_yaml" else "+"
154
+ console.info(f" {marker} {e.name} = {e.value} [{e.source}] — {e.reason}")
155
+ console.info("─── 自动注入的环境变量 ───")
156
+ for env in PLATFORM_INJECTED_ENVS:
157
+ console.info(f" + {env['name']} — {env['reason']}")
158
+ console.info("───────────────────────────────")
159
+ return None
runw/cli/jobs.py ADDED
@@ -0,0 +1,279 @@
1
+ """作业管理命令(PRD 对齐:动词在前)。
2
+
3
+ PRD §7/§8/§11 命令形:
4
+ runw get jobs [--kind] [--status] 列出作业
5
+ runw get usage [--from] [--to] 用量与费用
6
+ runw describe job <name> 作业详情
7
+ runw stop job <name> 停止作业(保留 checkpoint,释放 GPU)
8
+ runw delete job <name> [--force] 删除作业记录
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import click
14
+
15
+ from runw.client.api_client import ApiClient
16
+ from runw.config.store import ConfigStore
17
+ from runw.output import console
18
+
19
+
20
+ def _format_duration(seconds: int | float) -> str:
21
+ if not seconds:
22
+ return "-"
23
+ s = int(seconds)
24
+ if s < 60:
25
+ return f"{s}s"
26
+ if s < 3600:
27
+ return f"{s // 60}m {s % 60}s"
28
+ h = s // 3600
29
+ m = (s % 3600) // 60
30
+ return f"{h}h {m}m"
31
+
32
+
33
+ def _fnum(value: object, prec: int = 1) -> str:
34
+ """数值格式化(容错)。后端 JSON 把 Decimal 序列化成**字符串**(如 "0.0000"),
35
+ 直接 `:.1f` 会 ValueError 崩 → `runw get jobs`/`get usage` 整条命令挂。先 float 兜底。"""
36
+ try:
37
+ return f"{float(value):,.{prec}f}" # type: ignore[arg-type]
38
+ except (TypeError, ValueError):
39
+ return str(value)
40
+
41
+
42
+ # 配额资源类型 → 展示名(与后端 quota_service.PLAN_QUOTAS 的 key 对齐)
43
+ _QUOTA_LABELS = {
44
+ "inference": "推理服务",
45
+ "notebook": "Notebook",
46
+ "training": "训练作业",
47
+ }
48
+
49
+ _CLOUD_BILL_NOTE_FALLBACK = "GPU、存储等云资源费用由您的云账户直接结算,请前往云厂商费用中心查看"
50
+
51
+
52
+ # ══════════════════ get 组(get jobs / get usage)══════════════════
53
+
54
+
55
+ @click.group()
56
+ @click.pass_context
57
+ def get(ctx: click.Context) -> None:
58
+ """查询资源(jobs / usage)"""
59
+ pass
60
+
61
+
62
+ @get.command("jobs")
63
+ @click.option("--status", default=None, help="按状态筛选(running/stopped/failed)")
64
+ @click.option("--kind", default=None, help="按类型筛选(Training/Inference/Notebook)")
65
+ @click.pass_context
66
+ def get_jobs(ctx: click.Context, status: str | None, kind: str | None) -> None:
67
+ """列出所有作业"""
68
+ json_mode: bool = ctx.obj["json_mode"]
69
+ store = ConfigStore()
70
+ if not store.token:
71
+ console.fatal_server("未登录", suggestion="请先执行 runw login")
72
+ return
73
+
74
+ client = ApiClient(store)
75
+ data = client.get_jobs(status=status, kind=kind)
76
+ job_list = data.get("data", []) if isinstance(data, dict) else data
77
+
78
+ if json_mode:
79
+ console.output(data if isinstance(data, dict) else {"data": job_list}, json_mode=True)
80
+ return
81
+
82
+ if not job_list:
83
+ console.info("暂无作业")
84
+ return
85
+
86
+ # 边界(2026-07-10,PRD §8):不展示云账单估算列——平台不替云厂商报钱
87
+ columns = ["名称", "类型", "状态", "GPU", "运行时长", "URL"]
88
+ rows = []
89
+ for j in job_list:
90
+ gpu_info = f"{j.get('gpu_count', 1)}×{j.get('gpu_type', '?')}"
91
+ elapsed = _format_duration(j.get("elapsed_seconds", 0))
92
+ rows.append([
93
+ j.get("name", j.get("gpuctl_job_id", "")),
94
+ j.get("job_type", j.get("kind", "")),
95
+ j.get("status", ""),
96
+ gpu_info,
97
+ elapsed,
98
+ j.get("url") or "-", # 推理/Notebook 访问地址;训练无
99
+ ])
100
+ console.table(columns, rows)
101
+
102
+
103
+ @get.command("usage")
104
+ @click.option("--from", "from_date", default=None, help="起始日期 YYYY-MM-DD")
105
+ @click.option("--to", "to_date", default=None, help="结束日期 YYYY-MM-DD")
106
+ @click.pass_context
107
+ def get_usage(ctx: click.Context, from_date: str | None, to_date: str | None) -> None:
108
+ """查看用量与平台账单(配额占用 + GPU 时长 + 订阅/超额)"""
109
+ json_mode: bool = ctx.obj["json_mode"]
110
+ store = ConfigStore()
111
+ if not store.token:
112
+ console.fatal_server("未登录", suggestion="请先执行 runw login")
113
+ return
114
+
115
+ client = ApiClient(store)
116
+ data = client.get_usage(from_date=from_date, to_date=to_date)
117
+
118
+ if json_mode:
119
+ console.output(data, json_mode=True)
120
+ return
121
+
122
+ # 边界(2026-07-10,PRD §8):只报事实(配额/时长/平台账单),
123
+ # 云侧金额不估算不展示——指路云厂商费用中心。
124
+ period = data.get("period", {})
125
+ summary = data.get("summary", {})
126
+
127
+ console.success(f"{period.get('from_date', '')} ~ {period.get('to_date', '')} 用量\n")
128
+
129
+ # 配额使用(并发数量制:在用 X / 上限 Y)
130
+ quota = data.get("quota", [])
131
+ if quota:
132
+ plan = data.get("plan", "free")
133
+ console.info(f"配额使用({plan} 套餐)")
134
+ for q in quota:
135
+ label = _QUOTA_LABELS.get(q.get("resource_type", ""), q.get("resource_type", ""))
136
+ current = q.get("current", 0)
137
+ limit = q.get("limit")
138
+ usage = f"{current}(不限)" if limit is None else f"{current} / {limit}"
139
+ console.info(f" {label}: {usage}")
140
+ console.info("")
141
+
142
+ # 资源用量(分型号 GPU 时长——超额计费的事实依据)
143
+ total_hours = summary.get("total_gpu_hours", 0)
144
+ if total_hours:
145
+ console.info(f"资源用量:GPU·小时 {_fnum(total_hours, 1)} hr")
146
+ for g in data.get("gpu_usage", []):
147
+ console.info(f" {g.get('gpu_type', '')}: {_fnum(g.get('gpu_hours', 0), 1)} h")
148
+ console.info("")
149
+ else:
150
+ console.info("本期暂无 GPU 用量\n")
151
+
152
+ # 平台账单(RunWhere 实际出账:订阅 + 超额)——本命令的核心
153
+ platform_bill = data.get("platform_bill", {})
154
+ if platform_bill:
155
+ console.info("平台账单(RunWhere 出账)")
156
+ console.info(f" 订阅已摊销: ¥{_fnum(platform_bill.get('subscription', 0), 2)}")
157
+ console.info(f" 超额预估: ¥{_fnum(platform_bill.get('overage', 0), 2)}")
158
+ console.info(f" 本期平台账单: ¥{_fnum(platform_bill.get('total', 0), 2)}\n")
159
+
160
+ console.info(data.get("cloud_bill_note") or _CLOUD_BILL_NOTE_FALLBACK)
161
+
162
+
163
+ # ══════════════════ describe 组(describe job)══════════════════
164
+
165
+
166
+ @click.group()
167
+ @click.pass_context
168
+ def describe(ctx: click.Context) -> None:
169
+ """查看资源详情(job)"""
170
+ pass
171
+
172
+
173
+ @describe.command("job")
174
+ @click.argument("job_name")
175
+ @click.pass_context
176
+ def describe_job(ctx: click.Context, job_name: str) -> None:
177
+ """查看作业详情"""
178
+ json_mode: bool = ctx.obj["json_mode"]
179
+ store = ConfigStore()
180
+ if not store.token:
181
+ console.fatal_server("未登录")
182
+ return
183
+
184
+ client = ApiClient(store)
185
+ data = client.describe_job(job_name)
186
+
187
+ if json_mode:
188
+ console.output(data, json_mode=True)
189
+ return
190
+
191
+ console.success(f"作业: {data.get('name', job_name)}")
192
+ console.info(f" ID: {data.get('job_id', data.get('gpuctl_job_id', ''))}")
193
+ console.info(f" 类型: {data.get('job_type', data.get('kind', ''))}")
194
+ console.info(f" 状态: {data.get('status', '')}")
195
+ if data.get("url"): # 推理/Notebook 访问地址
196
+ console.info(f" URL: {data['url']}")
197
+
198
+ metrics = data.get("gpu_metrics")
199
+ if metrics:
200
+ console.info("\n GPU 指标(运行期滚动统计):")
201
+ console.info(
202
+ f" 利用率: 平均 {metrics.get('gpu_util_avg', 0):.1f}% / 峰值 "
203
+ f"{metrics.get('gpu_util_max', 0):.1f}%"
204
+ )
205
+ console.info(
206
+ f" 显存峰值: {metrics.get('gpu_mem_used_max_gb', 0):.1f} GB"
207
+ )
208
+ console.info(f" 温度峰值: {metrics.get('gpu_temp_max', 0):.0f} °C")
209
+ console.info(f" 采样数: {metrics.get('samples_count', 0)}")
210
+
211
+ if data.get("platform_defaults"):
212
+ console.info("\n 平台自动行为:")
213
+ for key, val in data["platform_defaults"].items():
214
+ if isinstance(val, dict):
215
+ console.info(f" {key}: {val.get('value', '')}({val.get('reason', '')})")
216
+ else:
217
+ console.info(f" {key}: {val}")
218
+
219
+
220
+ # ══════════════════ stop 组(stop job)══════════════════
221
+
222
+
223
+ @click.group()
224
+ @click.pass_context
225
+ def stop(ctx: click.Context) -> None:
226
+ """停止资源(job)"""
227
+ pass
228
+
229
+
230
+ @stop.command("job")
231
+ @click.argument("job_name")
232
+ @click.pass_context
233
+ def stop_job(ctx: click.Context, job_name: str) -> None:
234
+ """停止运行中的作业(保留 checkpoint,释放 GPU)"""
235
+ json_mode: bool = ctx.obj["json_mode"]
236
+ store = ConfigStore()
237
+ if not store.token:
238
+ console.fatal_server("未登录")
239
+ return
240
+
241
+ client = ApiClient(store)
242
+ data = client.stop_job(job_name)
243
+
244
+ if json_mode:
245
+ console.output(data, json_mode=True)
246
+ else:
247
+ # 边界(2026-07-10,PRD §8):不展示云侧金额(final_cost 是内部审计口径)
248
+ console.success(f"作业 {job_name} 已停止,GPU 已释放,计费停止")
249
+
250
+
251
+ # ══════════════════ delete 组(delete job)══════════════════
252
+
253
+
254
+ @click.group()
255
+ @click.pass_context
256
+ def delete(ctx: click.Context) -> None:
257
+ """删除资源(job)"""
258
+ pass
259
+
260
+
261
+ @delete.command("job")
262
+ @click.argument("job_name")
263
+ @click.option("--force", is_flag=True, help="强制删除运行中的作业")
264
+ @click.pass_context
265
+ def delete_job(ctx: click.Context, job_name: str, force: bool) -> None:
266
+ """删除作业记录(不删 checkpoint / workspace 数据)"""
267
+ json_mode: bool = ctx.obj["json_mode"]
268
+ store = ConfigStore()
269
+ if not store.token:
270
+ console.fatal_server("未登录")
271
+ return
272
+
273
+ client = ApiClient(store)
274
+ data = client.delete_job(job_name, force=force)
275
+
276
+ if json_mode:
277
+ console.output(data, json_mode=True)
278
+ else:
279
+ console.success(f"作业 {job_name} 已删除")
runw/cli/logs.py ADDED
@@ -0,0 +1,103 @@
1
+ """Logs command — view job logs (historical or real-time via WebSocket)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ import click
8
+
9
+ from runw.client.api_client import ApiClient
10
+ from runw.client.ws_client import WSClient
11
+ from runw.config.store import ConfigStore
12
+ from runw.output import console
13
+
14
+
15
+ @click.command()
16
+ @click.argument("job_name")
17
+ @click.option("-f", "--follow", is_flag=True, help="实时跟踪日志")
18
+ @click.option("-n", "--tail", default=200, show_default=True, help="取尾部 N 行")
19
+ @click.pass_context
20
+ def logs(ctx: click.Context, job_name: str, follow: bool, tail: int) -> None:
21
+ """查看任务日志"""
22
+ json_mode: bool = ctx.obj["json_mode"]
23
+
24
+ store = ConfigStore()
25
+ if not store.token:
26
+ console.fatal_server("未登录", suggestion="请先执行 runw login --endpoint <URL>")
27
+ return
28
+
29
+ if follow:
30
+ _follow_logs(store, job_name, json_mode)
31
+ else:
32
+ _get_historical_logs(store, job_name, json_mode, tail)
33
+
34
+
35
+ def _follow_logs(store: ConfigStore, job_name: str, json_mode: bool) -> None:
36
+ """Connect to WebSocket and stream logs in real-time."""
37
+ endpoint = store.endpoint
38
+ token = store.token
39
+ if not endpoint or not token:
40
+ console.fatal_user_input("未配置 endpoint")
41
+ return
42
+
43
+ ws = WSClient(endpoint, job_name, token)
44
+ console.info(f"[runw] 连接实时日志流: {job_name}")
45
+
46
+ try:
47
+ asyncio.run(_ws_log_loop(ws, json_mode))
48
+ except KeyboardInterrupt:
49
+ console.info("[runw] 已断开")
50
+
51
+
52
+ async def _ws_log_loop(ws: WSClient, json_mode: bool) -> None:
53
+ async for event in ws.listen():
54
+ event_type = event.get("type", "")
55
+ payload = event.get("payload", {})
56
+
57
+ if event_type == "log":
58
+ line = payload.get("line", "")
59
+ if json_mode:
60
+ console.output(payload, json_mode=True)
61
+ else:
62
+ click.echo(line)
63
+ elif event_type == "status_changed":
64
+ msg = payload.get("message", payload.get("status", ""))
65
+ if not json_mode:
66
+ console.info(f"[runw] {msg}")
67
+ elif event_type == "url_ready":
68
+ # 推理/Notebook 就绪 → 显示访问地址(手册 §9.6:notebook URL 从 runw logs 看)
69
+ if json_mode:
70
+ console.output(payload, json_mode=True)
71
+ else:
72
+ console.success(f"[runw] 服务地址 → {payload.get('url', '')}")
73
+ elif event_type in ("completed", "failed"):
74
+ if not json_mode:
75
+ if event_type == "completed":
76
+ console.success("[runw] 任务完成")
77
+ else:
78
+ console.error(f"[runw] 任务失败: {payload.get('error', '')}")
79
+ return
80
+ elif event_type == "_reconnecting":
81
+ if not json_mode:
82
+ console.warn("[runw] 连接断开,正在重连...")
83
+
84
+
85
+ def _get_historical_logs(
86
+ store: ConfigStore, job_name: str, json_mode: bool, tail: int
87
+ ) -> None:
88
+ """Get historical logs from API (P1-4: 返回日志行,不再只给 URL)。"""
89
+ client = ApiClient(store)
90
+ try:
91
+ data = client.get_logs(job_name, tail=tail)
92
+ except SystemExit:
93
+ return # API error already displayed by BaseClient
94
+
95
+ lines = data.get("lines", [])
96
+ if json_mode:
97
+ console.output(data, json_mode=True)
98
+ return
99
+ if lines:
100
+ for line in lines:
101
+ click.echo(line)
102
+ else:
103
+ console.info("暂无日志(任务尚未产生输出,或日志已过期)")
runw/cli/main.py ADDED
@@ -0,0 +1,45 @@
1
+ """Root CLI group with global options."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from runw import __version__
8
+
9
+
10
+ @click.group()
11
+ @click.option("--json", "json_mode", is_flag=True, envvar="RUNW_JSON", help="输出结构化 JSON")
12
+ @click.option("--explain", is_flag=True, help="显示平台默认值填充过程")
13
+ @click.version_option(version=__version__, prog_name="runw")
14
+ @click.pass_context
15
+ def main(ctx: click.Context, json_mode: bool, explain: bool) -> None:
16
+ """RunWhere.ai CLI — GPU 任务管理工具"""
17
+ ctx.ensure_object(dict)
18
+ ctx.obj["json_mode"] = json_mode
19
+ ctx.obj["explain"] = explain
20
+
21
+
22
+ # Register sub-commands
23
+ # PRD 对齐(手册):统一模型——submit 创建(按 kind 分发训练/推理/notebook),
24
+ # get/describe/logs/stop job/resume/delete job 管理(按 name 寻址、行为按 kind)。
25
+ # 已删 deploy/notebook 命令组(创建收口到 submit;推理/notebook 的 stop/resume/list
26
+ # 走通用 job 命令,resume 按 kind 兼管训练 checkpoint 与 notebook 挂起恢复)。
27
+ from runw.cli.auth import login, whoami # noqa: E402
28
+ from runw.cli.sync import sync # noqa: E402
29
+ from runw.cli.submit import submit # noqa: E402
30
+ from runw.cli.logs import logs # noqa: E402
31
+ from runw.cli.price import price # noqa: E402
32
+ from runw.cli.jobs import delete, describe, get, stop # noqa: E402
33
+ from runw.cli.resume import resume # noqa: E402
34
+
35
+ main.add_command(login)
36
+ main.add_command(whoami)
37
+ main.add_command(sync)
38
+ main.add_command(submit)
39
+ main.add_command(logs)
40
+ main.add_command(price)
41
+ main.add_command(get) # runw get jobs / runw get usage
42
+ main.add_command(describe) # runw describe job <name>
43
+ main.add_command(stop) # runw stop job <name>(notebook=挂起)
44
+ main.add_command(delete) # runw delete job <name>
45
+ main.add_command(resume) # runw resume <name>(训练 checkpoint / notebook 挂起恢复)