starforge-cli 0.1.6__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.
- starforge_cli/__init__.py +3 -0
- starforge_cli/api_client.py +589 -0
- starforge_cli/auth.py +349 -0
- starforge_cli/catalog.py +124 -0
- starforge_cli/cli.py +74 -0
- starforge_cli/cli_ui.py +469 -0
- starforge_cli/client_device.py +104 -0
- starforge_cli/commands/__init__.py +1 -0
- starforge_cli/commands/admin.py +140 -0
- starforge_cli/commands/bench.py +94 -0
- starforge_cli/commands/common.py +178 -0
- starforge_cli/commands/dataset.py +150 -0
- starforge_cli/commands/exp.py +213 -0
- starforge_cli/commands/init.py +52 -0
- starforge_cli/commands/jobs.py +223 -0
- starforge_cli/commands/login.py +54 -0
- starforge_cli/commands/plugin.py +243 -0
- starforge_cli/commands/recipe.py +163 -0
- starforge_cli/commands/serve.py +79 -0
- starforge_cli/commands/submit.py +467 -0
- starforge_cli/commands/sweep.py +154 -0
- starforge_cli/config_resolve.py +17 -0
- starforge_cli/data_prep.py +60 -0
- starforge_cli/new_experiment.py +195 -0
- starforge_cli/packing.py +179 -0
- starforge_cli/plugins_lock.py +73 -0
- starforge_cli/project.py +130 -0
- starforge_cli/recipe_lock.py +453 -0
- starforge_cli/scaffold/agent-run.py.tmpl +146 -0
- starforge_cli/scaffold/custom-framework/train.sh +56 -0
- starforge_cli/scaffold/experiment-template/.gitkeep +0 -0
- starforge_cli/scaffold/experiment-template/README.md +36 -0
- starforge_cli/scaffold/experiment-template/config.yaml +44 -0
- starforge_cli/scaffold/project/common/README.md +12 -0
- starforge_cli/scaffold/project/common/__init__.py +0 -0
- starforge_cli/scaffold/project/configs/README.md +103 -0
- starforge_cli/scaffold/project/configs/base/README.md +24 -0
- starforge_cli/scaffold/project/configs/base/distillation_math.yaml +284 -0
- starforge_cli/scaffold/project/configs/base/grpo_lora.yaml +30 -0
- starforge_cli/scaffold/project/configs/base/grpo_math_1B.yaml +470 -0
- starforge_cli/scaffold/project/configs/base/grpo_megatron.yaml +43 -0
- starforge_cli/scaffold/project/configs/base/grpo_noncolocated.yaml +18 -0
- starforge_cli/scaffold/project/configs/base/grpo_sliding_puzzle.yaml +81 -0
- starforge_cli/scaffold/project/configs/base/ppo_math_1B.yaml +454 -0
- starforge_cli/scaffold/project/configs/base/rm.yaml +224 -0
- starforge_cli/scaffold/project/configs/base/sft.yaml +294 -0
- starforge_cli/scaffold/project/configs/models/README.md +16 -0
- starforge_cli/scaffold/project/configs/models/qwen3.5-4b.yaml +12 -0
- starforge_cli/scaffold/project/configs/models/qwen3.5-9b.yaml +10 -0
- starforge_cli/scaffold/project/gitignore +11 -0
- starforge_cli/spec_builder.py +372 -0
- starforge_cli-0.1.6.dist-info/METADATA +40 -0
- starforge_cli-0.1.6.dist-info/RECORD +55 -0
- starforge_cli-0.1.6.dist-info/WHEEL +4 -0
- starforge_cli-0.1.6.dist-info/entry_points.txt +2 -0
starforge_cli/cli_ui.py
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
"""CLI 用户可见的错误 / 提示格式化(stderr,简洁可读)。
|
|
2
|
+
|
|
3
|
+
避免把 API / 业务错误当作 typer.BadParameter 抛出——那会显示误导性的
|
|
4
|
+
「Invalid value:」前缀。风格参考现代 CLI:短标题 + 要点 + 可执行提示。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
import urllib.error
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
import typer
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class ParsedMessage:
|
|
22
|
+
title: str
|
|
23
|
+
items: tuple[str, ...] = ()
|
|
24
|
+
body: str = ""
|
|
25
|
+
hint: str = ""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# 常见服务端文案 → 更短的标题与固定提示
|
|
29
|
+
_KNOWN_TITLES: tuple[tuple[str, str], ...] = (
|
|
30
|
+
("提交前 HuggingFace 资源预检未通过", "HuggingFace 资源预检未通过"),
|
|
31
|
+
("HuggingFace 资源预检未通过", "HuggingFace 资源预检未通过"),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_HINT_RULES: tuple[tuple[tuple[str, ...], str], ...] = (
|
|
35
|
+
(("未绑定 HF", "绑定后重试"), "在 Web 控制台绑定 HuggingFace:集成 → HuggingFace"),
|
|
36
|
+
(("gated", "访问条款"), "到 HuggingFace 接受该资源的访问条款后再试"),
|
|
37
|
+
(("请先运行 sf login", "登录"), "运行 sf login 登录"),
|
|
38
|
+
(("登录令牌无效", "登录失败"), "运行 sf login 重新登录"),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# 含这些片段时不附加 CLI 侧「→ 提示」(服务端文案已足够或会误导)
|
|
42
|
+
_HINT_SUPPRESS = ("不是有效的 HuggingFace repo id", "继承了未 override", "org/name")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def http_error_detail(e: urllib.error.HTTPError, *, fallback: str) -> str:
|
|
46
|
+
"""从 HTTP 响应提取可读错误信息(不暴露状态码等实现细节)。"""
|
|
47
|
+
raw = e.read().decode(errors="ignore")
|
|
48
|
+
try:
|
|
49
|
+
payload = json.loads(raw)
|
|
50
|
+
detail = payload.get("detail", payload)
|
|
51
|
+
if isinstance(detail, str) and detail.strip():
|
|
52
|
+
return detail.strip()
|
|
53
|
+
if isinstance(detail, list) and detail:
|
|
54
|
+
first = detail[0]
|
|
55
|
+
if isinstance(first, dict) and first.get("msg"):
|
|
56
|
+
return str(first["msg"])
|
|
57
|
+
return str(first)
|
|
58
|
+
except json.JSONDecodeError:
|
|
59
|
+
pass
|
|
60
|
+
text = raw.strip()
|
|
61
|
+
return text[:240] if text else fallback
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_message(text: str) -> ParsedMessage:
|
|
65
|
+
"""把服务端长文本拆成标题 / 要点 / 提示。"""
|
|
66
|
+
raw = (text or "").strip()
|
|
67
|
+
if not raw:
|
|
68
|
+
return ParsedMessage(title="操作失败")
|
|
69
|
+
|
|
70
|
+
title = raw
|
|
71
|
+
items: list[str] = []
|
|
72
|
+
body = ""
|
|
73
|
+
|
|
74
|
+
if "\n" in raw:
|
|
75
|
+
first, rest = raw.split("\n", 1)
|
|
76
|
+
title = first.rstrip(":").strip()
|
|
77
|
+
for line in rest.splitlines():
|
|
78
|
+
line = line.strip()
|
|
79
|
+
if line.startswith("- "):
|
|
80
|
+
items.append(line[2:].strip())
|
|
81
|
+
elif line:
|
|
82
|
+
body = f"{body}\n{line}".strip() if body else line
|
|
83
|
+
elif raw.startswith("- "):
|
|
84
|
+
items.append(raw[2:].strip())
|
|
85
|
+
title = "操作失败"
|
|
86
|
+
|
|
87
|
+
for prefix, short in _KNOWN_TITLES:
|
|
88
|
+
if title.startswith(prefix):
|
|
89
|
+
title = short
|
|
90
|
+
break
|
|
91
|
+
if title.endswith(":"):
|
|
92
|
+
title = title[:-1]
|
|
93
|
+
|
|
94
|
+
hint = _guess_hint(" ".join(items) or raw)
|
|
95
|
+
if not items and not body:
|
|
96
|
+
body = raw if title != raw else ""
|
|
97
|
+
|
|
98
|
+
return ParsedMessage(title=title, items=tuple(items), body=body, hint=hint)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _guess_hint(text: str) -> str:
|
|
102
|
+
if any(s in text for s in _HINT_SUPPRESS):
|
|
103
|
+
return ""
|
|
104
|
+
for keys, hint in _HINT_RULES:
|
|
105
|
+
if any(k in text for k in keys):
|
|
106
|
+
return hint
|
|
107
|
+
return ""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _shorten_bullet(item: str) -> tuple[str, str]:
|
|
111
|
+
"""把「主句;补充说明」拆成两行,便于扫读。"""
|
|
112
|
+
extra = ""
|
|
113
|
+
core = item.strip()
|
|
114
|
+
if ";" in core:
|
|
115
|
+
core, extra = core.split(";", 1)
|
|
116
|
+
core, extra = core.strip(), extra.strip()
|
|
117
|
+
# 常见 HF 预检:「无权访问 dataset X(私有/未授权,401)」
|
|
118
|
+
m = re.match(r"^(无权访问|无法访问|找不到)\s+(model|dataset)\s+(\S+)", core)
|
|
119
|
+
if m:
|
|
120
|
+
verb, kind, name = m.groups()
|
|
121
|
+
name = name.split("(", 1)[0].split("(", 1)[0]
|
|
122
|
+
kind_label = "模型" if kind == "model" else "数据集"
|
|
123
|
+
rest = core[m.end():].strip()
|
|
124
|
+
tail_parts = [p.strip("()() ") for p in (rest, verb, extra) if p and p.strip("()() ")]
|
|
125
|
+
return f"{kind_label} {name}", " · ".join(tail_parts)
|
|
126
|
+
if extra:
|
|
127
|
+
return core, extra
|
|
128
|
+
return core, ""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def emit_error(
|
|
132
|
+
title: str,
|
|
133
|
+
*,
|
|
134
|
+
items: Optional[list[str]] = None,
|
|
135
|
+
body: str = "",
|
|
136
|
+
hint: str = "",
|
|
137
|
+
) -> None:
|
|
138
|
+
"""向 stderr 输出一块结构化错误(不退出)。"""
|
|
139
|
+
stop_active_progress()
|
|
140
|
+
typer.echo("", err=True)
|
|
141
|
+
typer.secho(f" ✗ {title}", fg=typer.colors.RED, bold=True, err=True)
|
|
142
|
+
if items:
|
|
143
|
+
for item in items:
|
|
144
|
+
head, tail = _shorten_bullet(item)
|
|
145
|
+
typer.secho(f" • {head}", fg=typer.colors.RED, err=True)
|
|
146
|
+
if tail:
|
|
147
|
+
typer.secho(f" {tail}", err=True)
|
|
148
|
+
elif body:
|
|
149
|
+
for line in body.splitlines():
|
|
150
|
+
typer.secho(f" {line}", fg=typer.colors.RED, err=True)
|
|
151
|
+
if hint:
|
|
152
|
+
typer.echo("", err=True)
|
|
153
|
+
typer.secho(f" → {hint}", fg=typer.colors.YELLOW, err=True)
|
|
154
|
+
typer.echo("", err=True)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def emit_warning(title: str, *, body: str = "", hint: str = "") -> None:
|
|
158
|
+
stop_active_progress()
|
|
159
|
+
typer.secho(f" ! {title}", fg=typer.colors.YELLOW, bold=True, err=True)
|
|
160
|
+
if body:
|
|
161
|
+
for line in body.splitlines():
|
|
162
|
+
typer.secho(f" {line}", err=True)
|
|
163
|
+
if hint:
|
|
164
|
+
typer.secho(f" → {hint}", fg=typer.colors.YELLOW, err=True)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def fail(
|
|
168
|
+
message: str,
|
|
169
|
+
*,
|
|
170
|
+
title: str = "",
|
|
171
|
+
items: Optional[list[str]] = None,
|
|
172
|
+
hint: str = "",
|
|
173
|
+
code: int = 1,
|
|
174
|
+
) -> None:
|
|
175
|
+
"""打印错误并退出(替代 typer.BadParameter 用于非参数校验场景)。"""
|
|
176
|
+
if title or items or hint:
|
|
177
|
+
emit_error(title or "操作失败", items=items, body="" if items else message, hint=hint)
|
|
178
|
+
else:
|
|
179
|
+
parsed = parse_message(message)
|
|
180
|
+
emit_error(
|
|
181
|
+
parsed.title,
|
|
182
|
+
items=list(parsed.items) or None,
|
|
183
|
+
body=parsed.body if not parsed.items else "",
|
|
184
|
+
hint=parsed.hint or hint,
|
|
185
|
+
)
|
|
186
|
+
raise typer.Exit(code)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def fail_http(e: urllib.error.HTTPError, *, fallback: str, title: str = "") -> None:
|
|
190
|
+
"""HTTP 4xx/5xx:解析 detail 后友好展示并退出。"""
|
|
191
|
+
detail = http_error_detail(e, fallback=fallback)
|
|
192
|
+
parsed = parse_message(detail)
|
|
193
|
+
emit_error(
|
|
194
|
+
title or parsed.title,
|
|
195
|
+
items=list(parsed.items) or None,
|
|
196
|
+
body=parsed.body if not parsed.items else detail,
|
|
197
|
+
hint=parsed.hint,
|
|
198
|
+
)
|
|
199
|
+
raise typer.Exit(1) from e
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ----------------------------- 提交进度条(打包 → 上传 → 受理)-----------------------------
|
|
203
|
+
# 当前活跃的进度显示。rich Live 每秒重绘十余次,重绘会把光标移回步骤条顶部并擦掉下方内容——
|
|
204
|
+
# 若在 Live 运行期间直接往 stderr 打错误(提交被服务端拒绝就是这条路径),文案会被下一帧覆盖,
|
|
205
|
+
# 用户只剩一个退出码。因此所有 emit_* 都先停掉进度显示,再输出。
|
|
206
|
+
_active_progress: Optional[object] = None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def stop_active_progress() -> None:
|
|
210
|
+
"""停掉当前进度显示(若有),让后续 stderr 输出不会被重绘覆盖。"""
|
|
211
|
+
global _active_progress
|
|
212
|
+
reporter, _active_progress = _active_progress, None
|
|
213
|
+
if reporter is not None:
|
|
214
|
+
reporter.stop() # type: ignore[attr-defined]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def human_bytes(n: float) -> str:
|
|
218
|
+
"""字节数 → 人类可读(1023 B / 5.8 MB)。"""
|
|
219
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
220
|
+
if n < 1024 or unit == "GB":
|
|
221
|
+
return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
|
|
222
|
+
n /= 1024
|
|
223
|
+
return f"{n:.1f} GB"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def format_elapsed(seconds: float) -> str:
|
|
227
|
+
"""阶段耗时 → 紧凑可读(3.2s / 1m 05s)。"""
|
|
228
|
+
seconds = max(0.0, seconds)
|
|
229
|
+
if seconds < 60:
|
|
230
|
+
return f"{seconds:.1f}s"
|
|
231
|
+
minutes, secs = divmod(int(seconds), 60)
|
|
232
|
+
if minutes < 60:
|
|
233
|
+
return f"{minutes}m {secs:02d}s"
|
|
234
|
+
hours, minutes = divmod(minutes, 60)
|
|
235
|
+
return f"{hours}h {minutes:02d}m"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _stage_elapsed(stage: "_StageState") -> float:
|
|
239
|
+
if stage.started is None:
|
|
240
|
+
return 0.0
|
|
241
|
+
end = stage.finished if stage.finished is not None else time.monotonic()
|
|
242
|
+
return max(0.0, end - stage.started)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@dataclass
|
|
246
|
+
class _StageState:
|
|
247
|
+
key: str
|
|
248
|
+
label: str
|
|
249
|
+
status: str = "pending" # pending | active | done
|
|
250
|
+
detail: str = ""
|
|
251
|
+
started: float | None = None
|
|
252
|
+
finished: float | None = None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class _PlainReporter:
|
|
256
|
+
"""无 rich / 非 TTY 时的降级上报:分阶段输出 + 完成耗时。"""
|
|
257
|
+
|
|
258
|
+
def __init__(self) -> None:
|
|
259
|
+
self._phase_started = time.monotonic()
|
|
260
|
+
|
|
261
|
+
def _mark_phase_done(self, label: str) -> None:
|
|
262
|
+
elapsed = format_elapsed(time.monotonic() - self._phase_started)
|
|
263
|
+
typer.secho(f" ✓ {label} {elapsed}", fg=typer.colors.GREEN, dim=True, err=True)
|
|
264
|
+
self._phase_started = time.monotonic()
|
|
265
|
+
|
|
266
|
+
def start_pack(self, total: int) -> None:
|
|
267
|
+
self._phase_started = time.monotonic()
|
|
268
|
+
typer.echo(f" · 打包工作目录({total} 个文件)…", err=True)
|
|
269
|
+
|
|
270
|
+
def pack_tick(self, n: int = 1) -> None: # noqa: D401
|
|
271
|
+
pass
|
|
272
|
+
|
|
273
|
+
def start_upload(self, total_bytes: int) -> None:
|
|
274
|
+
self._mark_phase_done("打包完成")
|
|
275
|
+
typer.echo(f" · 上传到 Lab({human_bytes(total_bytes)})…", err=True)
|
|
276
|
+
|
|
277
|
+
def upload_tick(self, n: int) -> None:
|
|
278
|
+
pass
|
|
279
|
+
|
|
280
|
+
def awaiting_server(self) -> None:
|
|
281
|
+
self._mark_phase_done("上传完成")
|
|
282
|
+
typer.echo(" · 服务端受理(预检 / 配额 / Ray 提交)…", err=True)
|
|
283
|
+
|
|
284
|
+
def finish(self) -> None:
|
|
285
|
+
self._mark_phase_done("已受理")
|
|
286
|
+
|
|
287
|
+
def stop(self) -> None:
|
|
288
|
+
pass
|
|
289
|
+
|
|
290
|
+
def __enter__(self):
|
|
291
|
+
global _active_progress
|
|
292
|
+
_active_progress = self
|
|
293
|
+
return self
|
|
294
|
+
|
|
295
|
+
def __exit__(self, *exc):
|
|
296
|
+
global _active_progress
|
|
297
|
+
if _active_progress is self:
|
|
298
|
+
_active_progress = None
|
|
299
|
+
self.stop()
|
|
300
|
+
return False
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class _PipelineReporter:
|
|
304
|
+
"""Claude Code 风格垂直步骤条:已完成 ✓ + 当前 spinner + 右对齐耗时。"""
|
|
305
|
+
|
|
306
|
+
_ORDER = ("pack", "upload", "server")
|
|
307
|
+
|
|
308
|
+
def __init__(self, console) -> None:
|
|
309
|
+
self._console = console
|
|
310
|
+
self._live = None
|
|
311
|
+
self._pack_total = 0
|
|
312
|
+
self._pack_done = 0
|
|
313
|
+
self._upload_total = 0
|
|
314
|
+
self._upload_done = 0
|
|
315
|
+
self._stages = {
|
|
316
|
+
"pack": _StageState("pack", "打包工作目录"),
|
|
317
|
+
"upload": _StageState("upload", "上传到 Lab"),
|
|
318
|
+
"server": _StageState("server", "服务端受理"),
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
def _ensure_live(self) -> None:
|
|
322
|
+
if self._live is not None:
|
|
323
|
+
return
|
|
324
|
+
from rich.live import Live
|
|
325
|
+
|
|
326
|
+
self._live = Live(
|
|
327
|
+
self._render(),
|
|
328
|
+
console=self._console,
|
|
329
|
+
refresh_per_second=12,
|
|
330
|
+
transient=True,
|
|
331
|
+
)
|
|
332
|
+
self._live.__enter__()
|
|
333
|
+
|
|
334
|
+
def _activate(self, key: str, *, detail: str = "") -> None:
|
|
335
|
+
stage = self._stages[key]
|
|
336
|
+
stage.status = "active"
|
|
337
|
+
stage.started = time.monotonic()
|
|
338
|
+
stage.finished = None
|
|
339
|
+
stage.detail = detail
|
|
340
|
+
self._refresh()
|
|
341
|
+
|
|
342
|
+
def _complete(self, key: str, *, detail: str = "") -> None:
|
|
343
|
+
stage = self._stages[key]
|
|
344
|
+
stage.status = "done"
|
|
345
|
+
if stage.started is None:
|
|
346
|
+
stage.started = time.monotonic()
|
|
347
|
+
stage.finished = time.monotonic()
|
|
348
|
+
if detail:
|
|
349
|
+
stage.detail = detail
|
|
350
|
+
self._refresh()
|
|
351
|
+
|
|
352
|
+
def _refresh(self) -> None:
|
|
353
|
+
if self._live is not None:
|
|
354
|
+
self._live.update(self._render())
|
|
355
|
+
|
|
356
|
+
def _render(self):
|
|
357
|
+
from rich.spinner import Spinner
|
|
358
|
+
from rich.table import Table
|
|
359
|
+
from rich.text import Text
|
|
360
|
+
|
|
361
|
+
table = Table(show_header=False, box=None, padding=(0, 1), expand=False, pad_edge=False)
|
|
362
|
+
table.add_column(width=2, no_wrap=True)
|
|
363
|
+
table.add_column(min_width=14, no_wrap=True)
|
|
364
|
+
table.add_column(min_width=28)
|
|
365
|
+
table.add_column(justify="right", min_width=8, no_wrap=True)
|
|
366
|
+
|
|
367
|
+
table.add_row("", Text("sf submit", style="bold"), "", "")
|
|
368
|
+
|
|
369
|
+
for key in self._ORDER:
|
|
370
|
+
stage = self._stages[key]
|
|
371
|
+
if stage.status == "pending":
|
|
372
|
+
continue
|
|
373
|
+
elapsed = format_elapsed(_stage_elapsed(stage))
|
|
374
|
+
if stage.status == "done":
|
|
375
|
+
table.add_row(
|
|
376
|
+
Text("✓", style="green"),
|
|
377
|
+
Text(stage.label, style="dim"),
|
|
378
|
+
Text(stage.detail, style="dim"),
|
|
379
|
+
Text(elapsed, style="dim"),
|
|
380
|
+
)
|
|
381
|
+
else:
|
|
382
|
+
table.add_row(
|
|
383
|
+
Spinner("dots", style="cyan", speed=0.85),
|
|
384
|
+
Text(stage.label, style="bold cyan"),
|
|
385
|
+
Text(stage.detail),
|
|
386
|
+
Text(elapsed, style="bold cyan"),
|
|
387
|
+
)
|
|
388
|
+
return table
|
|
389
|
+
|
|
390
|
+
def start_pack(self, total: int) -> None:
|
|
391
|
+
self._ensure_live()
|
|
392
|
+
self._pack_total = total
|
|
393
|
+
self._pack_done = 0
|
|
394
|
+
self._activate("pack", detail=f"0/{total} 文件")
|
|
395
|
+
|
|
396
|
+
def pack_tick(self, n: int = 1) -> None:
|
|
397
|
+
if self._stages["pack"].status != "active":
|
|
398
|
+
return
|
|
399
|
+
self._pack_done += n
|
|
400
|
+
self._stages["pack"].detail = f"{self._pack_done}/{self._pack_total} 文件"
|
|
401
|
+
self._refresh()
|
|
402
|
+
|
|
403
|
+
def start_upload(self, total_bytes: int) -> None:
|
|
404
|
+
self._complete("pack", detail=f"{self._pack_total}/{self._pack_total} 文件")
|
|
405
|
+
self._upload_total = total_bytes
|
|
406
|
+
self._upload_done = 0
|
|
407
|
+
self._activate("upload", detail=f"0 B / {human_bytes(total_bytes)}")
|
|
408
|
+
|
|
409
|
+
def upload_tick(self, n: int) -> None:
|
|
410
|
+
if self._stages["upload"].status != "active":
|
|
411
|
+
return
|
|
412
|
+
self._upload_done += n
|
|
413
|
+
elapsed = _stage_elapsed(self._stages["upload"])
|
|
414
|
+
detail = f"{human_bytes(self._upload_done)} / {human_bytes(self._upload_total)}"
|
|
415
|
+
if elapsed >= 0.2 and self._upload_done > 0:
|
|
416
|
+
detail += f" · {human_bytes(self._upload_done / elapsed)}/s"
|
|
417
|
+
self._stages["upload"].detail = detail
|
|
418
|
+
self._refresh()
|
|
419
|
+
|
|
420
|
+
def awaiting_server(self) -> None:
|
|
421
|
+
upload = self._stages["upload"]
|
|
422
|
+
upload_detail = human_bytes(self._upload_total)
|
|
423
|
+
elapsed = _stage_elapsed(upload)
|
|
424
|
+
if elapsed >= 0.2 and self._upload_total > 0:
|
|
425
|
+
upload_detail += f" · {human_bytes(self._upload_total / elapsed)}/s"
|
|
426
|
+
self._complete("upload", detail=upload_detail)
|
|
427
|
+
self._activate("server", detail="预检 · 配额 · 提交")
|
|
428
|
+
|
|
429
|
+
def finish(self) -> None:
|
|
430
|
+
self._complete("server", detail="完成")
|
|
431
|
+
|
|
432
|
+
def stop(self) -> None:
|
|
433
|
+
live, self._live = self._live, None
|
|
434
|
+
if live is not None:
|
|
435
|
+
live.__exit__(None, None, None)
|
|
436
|
+
|
|
437
|
+
def __enter__(self):
|
|
438
|
+
global _active_progress
|
|
439
|
+
_active_progress = self
|
|
440
|
+
return self
|
|
441
|
+
|
|
442
|
+
def __exit__(self, *exc):
|
|
443
|
+
global _active_progress
|
|
444
|
+
if _active_progress is self:
|
|
445
|
+
_active_progress = None
|
|
446
|
+
self.stop()
|
|
447
|
+
return False
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
@contextmanager
|
|
451
|
+
def submit_progress():
|
|
452
|
+
"""提交进度条上下文:`with submit_progress() as reporter: submit_via_server(..., reporter=reporter)`。
|
|
453
|
+
|
|
454
|
+
有 rich 且输出是 TTY 时用垂直步骤条;否则(CI / 管道 / 无 rich)降级为分阶段状态行。
|
|
455
|
+
"""
|
|
456
|
+
reporter = _make_reporter()
|
|
457
|
+
with reporter as r:
|
|
458
|
+
yield r
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _make_reporter():
|
|
462
|
+
if not sys.stderr.isatty():
|
|
463
|
+
return _PlainReporter()
|
|
464
|
+
try:
|
|
465
|
+
from rich.console import Console
|
|
466
|
+
except Exception: # noqa: BLE001
|
|
467
|
+
return _PlainReporter()
|
|
468
|
+
console = Console(stderr=True, highlight=False, soft_wrap=False)
|
|
469
|
+
return _PipelineReporter(console)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""CLI 登录设备信息:终端采集 + 稳定设备 ID(哈希,不上传原始 UUID)。
|
|
2
|
+
|
|
3
|
+
业界 CLI/OAuth 场景常见做法(GitHub CLI、gcloud、AWS SSO 等):
|
|
4
|
+
- 客户端自报 hostname / OS / 用户(可读标签)
|
|
5
|
+
- 读取 OS 级稳定标识(machine-id / Platform UUID / MachineGuid)后 **只上传 SHA256 短哈希**
|
|
6
|
+
- 不采集浏览器式 canvas/WebGL 指纹;不做跨站追踪
|
|
7
|
+
|
|
8
|
+
与服务端 server/core/client_device.py 的字段契约保持一致。
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import platform
|
|
17
|
+
import socket
|
|
18
|
+
import subprocess
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Optional
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def collect_cli_device() -> dict[str, str]:
|
|
24
|
+
"""从当前终端环境采集设备信息(sf login 打开浏览器前调用)。"""
|
|
25
|
+
hostname = (socket.gethostname() or platform.node() or "").strip()[:64]
|
|
26
|
+
shell_path = (os.environ.get("SHELL") or "").strip()
|
|
27
|
+
shell = Path(shell_path).name[:32] if shell_path else ""
|
|
28
|
+
|
|
29
|
+
info: dict[str, str] = {
|
|
30
|
+
"hostname": hostname or "unknown",
|
|
31
|
+
"os": (platform.system() or "")[:32],
|
|
32
|
+
"os_release": (platform.release() or "")[:32],
|
|
33
|
+
"machine": (platform.machine() or "")[:32],
|
|
34
|
+
"platform": (platform.platform(terse=True) or "")[:120],
|
|
35
|
+
"user": (os.environ.get("USER") or os.environ.get("USERNAME") or "")[:64],
|
|
36
|
+
"terminal": (os.environ.get("TERM") or "")[:32],
|
|
37
|
+
"shell": shell,
|
|
38
|
+
"lab_version": _lab_version()[:32],
|
|
39
|
+
"source": "lab-cli",
|
|
40
|
+
}
|
|
41
|
+
device_id = _device_id_hash(_stable_machine_id(), fallback=hostname)
|
|
42
|
+
if device_id:
|
|
43
|
+
info["device_id"] = device_id
|
|
44
|
+
return info
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def encode_device_param(info: dict[str, Any]) -> str:
|
|
48
|
+
raw = json.dumps(info, ensure_ascii=False, separators=(",", ":")).encode()
|
|
49
|
+
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _lab_version() -> str:
|
|
53
|
+
try:
|
|
54
|
+
from importlib.metadata import version
|
|
55
|
+
|
|
56
|
+
return version("starforge")
|
|
57
|
+
except Exception: # noqa: BLE001
|
|
58
|
+
return "unknown"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _stable_machine_id() -> Optional[str]:
|
|
62
|
+
"""读取 OS 原生稳定标识(原始值仅本地使用,不上传)。"""
|
|
63
|
+
system = platform.system()
|
|
64
|
+
try:
|
|
65
|
+
if system == "Darwin":
|
|
66
|
+
proc = subprocess.run(
|
|
67
|
+
["ioreg", "-rd1", "-c", "IOPlatformExpertDevice"],
|
|
68
|
+
capture_output=True,
|
|
69
|
+
text=True,
|
|
70
|
+
timeout=2,
|
|
71
|
+
check=False,
|
|
72
|
+
)
|
|
73
|
+
for line in proc.stdout.splitlines():
|
|
74
|
+
if "IOPlatformUUID" in line:
|
|
75
|
+
parts = line.split('"')
|
|
76
|
+
if len(parts) >= 2:
|
|
77
|
+
val = parts[-2].strip()
|
|
78
|
+
return val or None
|
|
79
|
+
elif system == "Linux":
|
|
80
|
+
for path in (Path("/etc/machine-id"), Path("/var/lib/dbus/machine-id")):
|
|
81
|
+
if path.is_file():
|
|
82
|
+
val = path.read_text(encoding="utf-8", errors="ignore").strip()
|
|
83
|
+
if val:
|
|
84
|
+
return val
|
|
85
|
+
elif system == "Windows":
|
|
86
|
+
import winreg
|
|
87
|
+
|
|
88
|
+
with winreg.OpenKey(
|
|
89
|
+
winreg.HKEY_LOCAL_MACHINE,
|
|
90
|
+
r"SOFTWARE\Microsoft\Cryptography",
|
|
91
|
+
) as key:
|
|
92
|
+
val, _ = winreg.QueryValueEx(key, "MachineGuid")
|
|
93
|
+
if val:
|
|
94
|
+
return str(val).strip()
|
|
95
|
+
except Exception: # noqa: BLE001
|
|
96
|
+
return None
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _device_id_hash(stable_id: Optional[str], *, fallback: str) -> Optional[str]:
|
|
101
|
+
raw = (stable_id or "").strip() or (fallback or "").strip()
|
|
102
|
+
if not raw:
|
|
103
|
+
return None
|
|
104
|
+
return hashlib.sha256(raw.encode()).hexdigest()[:16]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""lab CLI 命令实现,按领域分模块;app 组装见 starforge_cli.cli。"""
|