runwhere 0.1.0__tar.gz
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.
- runwhere-0.1.0/PKG-INFO +66 -0
- runwhere-0.1.0/README.md +33 -0
- runwhere-0.1.0/pyproject.toml +55 -0
- runwhere-0.1.0/src/runw/__init__.py +3 -0
- runwhere-0.1.0/src/runw/__main__.py +5 -0
- runwhere-0.1.0/src/runw/cli/__init__.py +0 -0
- runwhere-0.1.0/src/runw/cli/auth.py +64 -0
- runwhere-0.1.0/src/runw/cli/explain.py +159 -0
- runwhere-0.1.0/src/runw/cli/jobs.py +279 -0
- runwhere-0.1.0/src/runw/cli/logs.py +103 -0
- runwhere-0.1.0/src/runw/cli/main.py +45 -0
- runwhere-0.1.0/src/runw/cli/price.py +170 -0
- runwhere-0.1.0/src/runw/cli/resume.py +33 -0
- runwhere-0.1.0/src/runw/cli/submit.py +366 -0
- runwhere-0.1.0/src/runw/cli/sync.py +360 -0
- runwhere-0.1.0/src/runw/client/__init__.py +0 -0
- runwhere-0.1.0/src/runw/client/api_client.py +182 -0
- runwhere-0.1.0/src/runw/client/base.py +122 -0
- runwhere-0.1.0/src/runw/client/pricing_client.py +167 -0
- runwhere-0.1.0/src/runw/client/ws_client.py +41 -0
- runwhere-0.1.0/src/runw/config/__init__.py +0 -0
- runwhere-0.1.0/src/runw/config/constants.py +22 -0
- runwhere-0.1.0/src/runw/config/store.py +42 -0
- runwhere-0.1.0/src/runw/models/__init__.py +0 -0
- runwhere-0.1.0/src/runw/models/auth.py +10 -0
- runwhere-0.1.0/src/runw/models/sync.py +31 -0
- runwhere-0.1.0/src/runw/models/yaml_schema.py +82 -0
- runwhere-0.1.0/src/runw/output/__init__.py +0 -0
- runwhere-0.1.0/src/runw/output/console.py +94 -0
- runwhere-0.1.0/src/runw/sync/__init__.py +0 -0
- runwhere-0.1.0/src/runw/sync/compat.py +100 -0
- runwhere-0.1.0/src/runw/sync/conda_export.py +357 -0
- runwhere-0.1.0/src/runw/sync/env_detector.py +102 -0
- runwhere-0.1.0/src/runw/sync/ignore.py +39 -0
- runwhere-0.1.0/src/runw/sync/model_detector.py +65 -0
- runwhere-0.1.0/src/runw/sync/scanner.py +97 -0
- runwhere-0.1.0/src/runw/sync/uploader.py +111 -0
runwhere-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: runwhere
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: RunWhere.ai CLI — GPU job management tool
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: gpu,ai,cloud,cli
|
|
7
|
+
Author: RunWhere.ai
|
|
8
|
+
Author-email: dev@runwhere.ai
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: click (>=8.0)
|
|
20
|
+
Requires-Dist: httpx (>=0.27)
|
|
21
|
+
Requires-Dist: pathspec (>=0.12)
|
|
22
|
+
Requires-Dist: pydantic (>=2.0)
|
|
23
|
+
Requires-Dist: pytest (>=8.0) ; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest-mock ; extra == "dev"
|
|
25
|
+
Requires-Dist: pyyaml (>=6.0)
|
|
26
|
+
Requires-Dist: rich (>=13.0)
|
|
27
|
+
Requires-Dist: tomli-w (>=1.0)
|
|
28
|
+
Requires-Dist: websockets (>=12.0)
|
|
29
|
+
Project-URL: Homepage, https://runwhere.ai
|
|
30
|
+
Project-URL: Repository, https://github.com/runwhere/runw
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# runw — RunWhere.ai CLI
|
|
34
|
+
|
|
35
|
+
GPU 任务管理工具。通过 HTTP API 管理云端 GPU 资源和 AI 计算任务。
|
|
36
|
+
|
|
37
|
+
## 安装
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install -e ".[dev]"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## 使用
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
runw login --endpoint https://api.runwhere.ai
|
|
47
|
+
runw sync -f train.yaml
|
|
48
|
+
runw submit -f train.yaml
|
|
49
|
+
runw logs <job-name> -f
|
|
50
|
+
runw price summary
|
|
51
|
+
runw jobs get
|
|
52
|
+
runw usage
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## 开发
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pytest tests/ -v
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## 构建
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
bash scripts/build.sh
|
|
65
|
+
```
|
|
66
|
+
|
runwhere-0.1.0/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# runw — RunWhere.ai CLI
|
|
2
|
+
|
|
3
|
+
GPU 任务管理工具。通过 HTTP API 管理云端 GPU 资源和 AI 计算任务。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install -e ".[dev]"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## 使用
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
runw login --endpoint https://api.runwhere.ai
|
|
15
|
+
runw sync -f train.yaml
|
|
16
|
+
runw submit -f train.yaml
|
|
17
|
+
runw logs <job-name> -f
|
|
18
|
+
runw price summary
|
|
19
|
+
runw jobs get
|
|
20
|
+
runw usage
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## 开发
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pytest tests/ -v
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## 构建
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
bash scripts/build.sh
|
|
33
|
+
```
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["poetry-core>=1.0.0"]
|
|
3
|
+
build-backend = "poetry.core.masonry.api"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "runwhere"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "RunWhere.ai CLI — GPU job management tool"
|
|
9
|
+
authors = [
|
|
10
|
+
{name = "RunWhere.ai", email = "dev@runwhere.ai"}
|
|
11
|
+
]
|
|
12
|
+
license = {text = "MIT"}
|
|
13
|
+
readme = {file = "README.md", content-type = "text/markdown"}
|
|
14
|
+
keywords = ["gpu", "ai", "cloud", "cli"]
|
|
15
|
+
requires-python = ">=3.11"
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Science/Research",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
21
|
+
"License :: OSI Approved :: MIT License",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"click>=8.0",
|
|
28
|
+
"httpx>=0.27",
|
|
29
|
+
"rich>=13.0",
|
|
30
|
+
"pydantic>=2.0",
|
|
31
|
+
"tomli-w>=1.0",
|
|
32
|
+
"websockets>=12.0",
|
|
33
|
+
"pyyaml>=6.0",
|
|
34
|
+
"pathspec>=0.12",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
dev = [
|
|
39
|
+
"pytest>=8.0",
|
|
40
|
+
"pytest-mock",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[project.urls]
|
|
44
|
+
Homepage = "https://runwhere.ai"
|
|
45
|
+
Repository = "https://github.com/runwhere/runw"
|
|
46
|
+
|
|
47
|
+
[project.scripts]
|
|
48
|
+
runw = "runw.cli.main:main"
|
|
49
|
+
|
|
50
|
+
[tool.poetry]
|
|
51
|
+
packages = [{include = "runw", from = "src"}]
|
|
52
|
+
|
|
53
|
+
[tool.pytest.ini_options]
|
|
54
|
+
testpaths = ["tests"]
|
|
55
|
+
addopts = "-v"
|
|
File without changes
|
|
@@ -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
|
+
)
|
|
@@ -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
|
|
@@ -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} 已删除")
|