jishu-hub 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.
- jishu_hub-0.1.0/PKG-INFO +98 -0
- jishu_hub-0.1.0/README.md +87 -0
- jishu_hub-0.1.0/pyproject.toml +22 -0
- jishu_hub-0.1.0/setup.cfg +4 -0
- jishu_hub-0.1.0/src/jishu_hub/__init__.py +3 -0
- jishu_hub-0.1.0/src/jishu_hub/__main__.py +4 -0
- jishu_hub-0.1.0/src/jishu_hub/cli.py +335 -0
- jishu_hub-0.1.0/src/jishu_hub/device_crypto.py +70 -0
- jishu_hub-0.1.0/src/jishu_hub/label_validate.py +89 -0
- jishu_hub-0.1.0/src/jishu_hub/paths_safe.py +56 -0
- jishu_hub-0.1.0/src/jishu_hub/redact.py +19 -0
- jishu_hub-0.1.0/src/jishu_hub/rpc.py +152 -0
- jishu_hub-0.1.0/src/jishu_hub/store.py +101 -0
- jishu_hub-0.1.0/src/jishu_hub/transfer_io.py +223 -0
- jishu_hub-0.1.0/src/jishu_hub.egg-info/PKG-INFO +98 -0
- jishu_hub-0.1.0/src/jishu_hub.egg-info/SOURCES.txt +18 -0
- jishu_hub-0.1.0/src/jishu_hub.egg-info/dependency_links.txt +1 -0
- jishu_hub-0.1.0/src/jishu_hub.egg-info/entry_points.txt +2 -0
- jishu_hub-0.1.0/src/jishu_hub.egg-info/requires.txt +4 -0
- jishu_hub-0.1.0/src/jishu_hub.egg-info/top_level.txt +1 -0
jishu_hub-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jishu-hub
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: jishu Hub CLI for secure dataset and label transfer
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
Requires-Dist: typer>=0.12
|
|
9
|
+
Requires-Dist: rich>=13.7
|
|
10
|
+
Requires-Dist: cryptography>=42
|
|
11
|
+
|
|
12
|
+
# jishu-hub
|
|
13
|
+
|
|
14
|
+
安全传输 CLI:在**已授权设备**上上传/下载数据集、标注数据,以及拉取评测报告。
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## 在其他机器上的使用流程
|
|
19
|
+
|
|
20
|
+
每台新机器都要由根超管批准一次。密钥在本机生成,**不能**把旧机器的 `~/.jishu` 整包拷过去当「换机」。
|
|
21
|
+
|
|
22
|
+
### 0. 目标机取机器码 → 根超管录入
|
|
23
|
+
|
|
24
|
+
无一次性注册码。设备准入由超管按「用户 + 机器码」批准。
|
|
25
|
+
|
|
26
|
+
1. 在**目标机器**安装 CLI 后执行:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
jishu device-code
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
将 64 位机器码发给管理员(工单 / 消息)。
|
|
33
|
+
|
|
34
|
+
2. 根超管在 **后台管理 → 用户列表** 点「录入机器」:粘贴机器码、选填备注 → 批准。
|
|
35
|
+
3. 管理员告知 **Hub 网关地址**(侧信道,勿写入公开文档)。
|
|
36
|
+
|
|
37
|
+
设备与批准吊销在 **后台管理 → 设备/Hub**。
|
|
38
|
+
|
|
39
|
+
### 1. 目标机器:环境
|
|
40
|
+
|
|
41
|
+
- Python ≥ 3.10
|
|
42
|
+
- 能访问 Hub 网关(HTTPS;生产环境通常要求客户端证书 / mTLS,由网关终止)
|
|
43
|
+
- 安装 CLI(**尚未上架公网 PyPI**,`pip install jishu-hub` 会失败):
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
# 从本仓库开发安装(当前推荐)
|
|
47
|
+
cd /path/to/data_management
|
|
48
|
+
pip install -e ./cli
|
|
49
|
+
|
|
50
|
+
# 或安装已构建的 wheel(内网制品库 / 构建机产出)
|
|
51
|
+
# pip install /path/to/jishu_hub-0.1.0-py3-none-any.whl
|
|
52
|
+
# pip install jishu-hub --index-url https://<私有索引>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
可选:固定网关环境变量:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
export JISHU_API_URL="https://<hub-host>" # Linux/macOS
|
|
59
|
+
# Windows PowerShell: $env:JISHU_API_URL="https://<hub-host>"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### 2. 登录(首次自动激活本机)
|
|
63
|
+
|
|
64
|
+
管理员已录入本机机器码后:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
jishu login --gateway https://<hub-host> --username <平台用户名>
|
|
68
|
+
# 会提示密码;首次会自动 claim 设备证书,之后只需 login
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
成功后本机会生成:
|
|
72
|
+
|
|
73
|
+
| 位置 | 内容 |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `~/.jishu/`(或 `JISHU_HOME`) | 网关、device_id、证书指纹等 |
|
|
76
|
+
| 本机密钥材料 | 私钥 + 设备证书(私钥不出机) |
|
|
77
|
+
|
|
78
|
+
验证:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
jishu whoami
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### 3. 常用命令
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
jishu dataset upload <本地目录> --dataset-id <id> --version <ver>
|
|
88
|
+
jishu dataset download --dataset-id <id> --version <ver> --out <目录>
|
|
89
|
+
jishu label upload <标注目录> --project-id <id>
|
|
90
|
+
jishu eval reports list
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
上传/下载权限由平台账号 ACL 决定,与网页一致。
|
|
94
|
+
|
|
95
|
+
### 4. 换机 / 吊销
|
|
96
|
+
|
|
97
|
+
- 换机:新机 `device-code` → 超管重新录入 → 新机 `login`
|
|
98
|
+
- 吊销:超管在 **设备/Hub** 吊销批准或设备后,该机无法再 login / 传输
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# jishu-hub
|
|
2
|
+
|
|
3
|
+
安全传输 CLI:在**已授权设备**上上传/下载数据集、标注数据,以及拉取评测报告。
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 在其他机器上的使用流程
|
|
8
|
+
|
|
9
|
+
每台新机器都要由根超管批准一次。密钥在本机生成,**不能**把旧机器的 `~/.jishu` 整包拷过去当「换机」。
|
|
10
|
+
|
|
11
|
+
### 0. 目标机取机器码 → 根超管录入
|
|
12
|
+
|
|
13
|
+
无一次性注册码。设备准入由超管按「用户 + 机器码」批准。
|
|
14
|
+
|
|
15
|
+
1. 在**目标机器**安装 CLI 后执行:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
jishu device-code
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
将 64 位机器码发给管理员(工单 / 消息)。
|
|
22
|
+
|
|
23
|
+
2. 根超管在 **后台管理 → 用户列表** 点「录入机器」:粘贴机器码、选填备注 → 批准。
|
|
24
|
+
3. 管理员告知 **Hub 网关地址**(侧信道,勿写入公开文档)。
|
|
25
|
+
|
|
26
|
+
设备与批准吊销在 **后台管理 → 设备/Hub**。
|
|
27
|
+
|
|
28
|
+
### 1. 目标机器:环境
|
|
29
|
+
|
|
30
|
+
- Python ≥ 3.10
|
|
31
|
+
- 能访问 Hub 网关(HTTPS;生产环境通常要求客户端证书 / mTLS,由网关终止)
|
|
32
|
+
- 安装 CLI(**尚未上架公网 PyPI**,`pip install jishu-hub` 会失败):
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# 从本仓库开发安装(当前推荐)
|
|
36
|
+
cd /path/to/data_management
|
|
37
|
+
pip install -e ./cli
|
|
38
|
+
|
|
39
|
+
# 或安装已构建的 wheel(内网制品库 / 构建机产出)
|
|
40
|
+
# pip install /path/to/jishu_hub-0.1.0-py3-none-any.whl
|
|
41
|
+
# pip install jishu-hub --index-url https://<私有索引>
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
可选:固定网关环境变量:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
export JISHU_API_URL="https://<hub-host>" # Linux/macOS
|
|
48
|
+
# Windows PowerShell: $env:JISHU_API_URL="https://<hub-host>"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### 2. 登录(首次自动激活本机)
|
|
52
|
+
|
|
53
|
+
管理员已录入本机机器码后:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
jishu login --gateway https://<hub-host> --username <平台用户名>
|
|
57
|
+
# 会提示密码;首次会自动 claim 设备证书,之后只需 login
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
成功后本机会生成:
|
|
61
|
+
|
|
62
|
+
| 位置 | 内容 |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `~/.jishu/`(或 `JISHU_HOME`) | 网关、device_id、证书指纹等 |
|
|
65
|
+
| 本机密钥材料 | 私钥 + 设备证书(私钥不出机) |
|
|
66
|
+
|
|
67
|
+
验证:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
jishu whoami
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### 3. 常用命令
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
jishu dataset upload <本地目录> --dataset-id <id> --version <ver>
|
|
77
|
+
jishu dataset download --dataset-id <id> --version <ver> --out <目录>
|
|
78
|
+
jishu label upload <标注目录> --project-id <id>
|
|
79
|
+
jishu eval reports list
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
上传/下载权限由平台账号 ACL 决定,与网页一致。
|
|
83
|
+
|
|
84
|
+
### 4. 换机 / 吊销
|
|
85
|
+
|
|
86
|
+
- 换机:新机 `device-code` → 超管重新录入 → 新机 `login`
|
|
87
|
+
- 吊销:超管在 **设备/Hub** 吊销批准或设备后,该机无法再 login / 传输
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "jishu-hub"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "jishu Hub CLI for secure dataset and label transfer"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"httpx>=0.27",
|
|
13
|
+
"typer>=0.12",
|
|
14
|
+
"rich>=13.7",
|
|
15
|
+
"cryptography>=42",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
jishu = "jishu_hub.cli:main"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools.packages.find]
|
|
22
|
+
where = ["src"]
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""jishu CLI 入口。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import zipfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich import print as rprint
|
|
10
|
+
|
|
11
|
+
from jishu_hub import __version__, store
|
|
12
|
+
from jishu_hub.device_crypto import build_csr, device_fp_hash, save_device_cert
|
|
13
|
+
from jishu_hub.label_validate import validate_label_dir
|
|
14
|
+
from jishu_hub.rpc import HubClient, HubClientError
|
|
15
|
+
from jishu_hub.transfer_io import download_dataset, upload_directory
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(add_completion=False, no_args_is_help=True, help="jishu Hub CLI")
|
|
18
|
+
dataset_app = typer.Typer(help="数据集上传/下载")
|
|
19
|
+
label_app = typer.Typer(help="智能标注")
|
|
20
|
+
eval_app = typer.Typer(help="评测报告")
|
|
21
|
+
app.add_typer(dataset_app, name="dataset")
|
|
22
|
+
app.add_typer(label_app, name="label")
|
|
23
|
+
reports_app = typer.Typer(help="报告")
|
|
24
|
+
eval_app.add_typer(reports_app, name="reports")
|
|
25
|
+
app.add_typer(eval_app, name="eval")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _client(gateway: str | None = None) -> HubClient:
|
|
29
|
+
return HubClient(gateway=gateway)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.command("device-code")
|
|
33
|
+
def device_code_cmd() -> None:
|
|
34
|
+
"""打印本机机器码,提交给根超管在后台录入。"""
|
|
35
|
+
fp = device_fp_hash()
|
|
36
|
+
rprint(fp)
|
|
37
|
+
rprint("[dim]将上述机器码发给管理员;录入后在本机执行 jishu login --gateway <url>[/dim]")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _ensure_device_claimed(username: str, gateway: str | None) -> None:
|
|
41
|
+
"""若本地尚无设备证书,则按超管批准自动 claim。"""
|
|
42
|
+
device = store.load_json("device")
|
|
43
|
+
cert = store.load_secret("device_cert")
|
|
44
|
+
if device.get("device_id") and cert:
|
|
45
|
+
return
|
|
46
|
+
gw = (gateway or store.gateway() or "").rstrip("/")
|
|
47
|
+
if not gw:
|
|
48
|
+
raise SystemExit("首次登录需指定网关:jishu login --gateway https://<hub-host>")
|
|
49
|
+
store.save_json("gateway", {"gateway_url": gw})
|
|
50
|
+
csr = build_csr()
|
|
51
|
+
fp = device_fp_hash()
|
|
52
|
+
client = HubClient(gateway=gw)
|
|
53
|
+
try:
|
|
54
|
+
result = client.claim(username, csr, fp)
|
|
55
|
+
except HubClientError as e:
|
|
56
|
+
rprint(f"[red]device claim failed:[/red] {e.code}")
|
|
57
|
+
if e.code == "machine_not_approved":
|
|
58
|
+
rprint(
|
|
59
|
+
"[yellow]本机尚未被管理员批准。请执行 jishu device-code 并将机器码发给根超管录入。[/yellow]"
|
|
60
|
+
)
|
|
61
|
+
raise typer.Exit(1)
|
|
62
|
+
save_device_cert(result.get("cert_pem") or "")
|
|
63
|
+
store.save_json(
|
|
64
|
+
"device",
|
|
65
|
+
{
|
|
66
|
+
"device_id": result.get("device_id"),
|
|
67
|
+
"cert_fingerprint": result.get("cert_fingerprint"),
|
|
68
|
+
"device_fp_hash": fp,
|
|
69
|
+
"scopes": result.get("scopes") or [],
|
|
70
|
+
"cli_min_version": result.get("cli_min_version"),
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
pins = result.get("tls_pins") or []
|
|
74
|
+
store.save_json(
|
|
75
|
+
"gateway",
|
|
76
|
+
{"gateway_url": result.get("gateway_url") or gw, "tls_pins": pins},
|
|
77
|
+
)
|
|
78
|
+
rprint(f"[green]device claimed[/green] device={result.get('device_id')}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@app.command("login")
|
|
82
|
+
def login_cmd(
|
|
83
|
+
username: str = typer.Option(..., prompt=True),
|
|
84
|
+
password: str = typer.Option(..., prompt=True, hide_input=True),
|
|
85
|
+
gateway: Optional[str] = typer.Option(
|
|
86
|
+
None,
|
|
87
|
+
"--gateway",
|
|
88
|
+
"-g",
|
|
89
|
+
envvar="JISHU_API_URL",
|
|
90
|
+
help="Hub 网关 URL(首次登录或本机未配置时必填)",
|
|
91
|
+
),
|
|
92
|
+
) -> None:
|
|
93
|
+
"""登录;若本机尚未激活,会先按管理员录入的机器码自动 claim。"""
|
|
94
|
+
try:
|
|
95
|
+
_ensure_device_claimed(username, gateway)
|
|
96
|
+
client = _client(gateway)
|
|
97
|
+
out = client.login(username, password)
|
|
98
|
+
except HubClientError as e:
|
|
99
|
+
rprint(f"[red]login failed:[/red] {e.code}")
|
|
100
|
+
raise typer.Exit(1)
|
|
101
|
+
rprint(f"[green]logged in[/green] scopes={out.get('scope')}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@app.command("logout")
|
|
105
|
+
def logout_cmd() -> None:
|
|
106
|
+
try:
|
|
107
|
+
client = _client()
|
|
108
|
+
client.rpc("auth.logout", {})
|
|
109
|
+
except Exception:
|
|
110
|
+
pass
|
|
111
|
+
store.delete_secret("access_token")
|
|
112
|
+
store.delete_secret("refresh_token")
|
|
113
|
+
rprint("logged out")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@app.command("whoami")
|
|
117
|
+
def whoami_cmd() -> None:
|
|
118
|
+
try:
|
|
119
|
+
me = _client().rpc("auth.me", {})
|
|
120
|
+
except HubClientError as e:
|
|
121
|
+
rprint(f"[red]{e.code}[/red]")
|
|
122
|
+
raise typer.Exit(1)
|
|
123
|
+
rprint(me)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataset_app.command("upload")
|
|
127
|
+
def dataset_upload(
|
|
128
|
+
local_dir: Path = typer.Argument(..., exists=True, file_okay=False),
|
|
129
|
+
dataset_id: str = typer.Option(..., "--id"),
|
|
130
|
+
version: Optional[str] = typer.Option(
|
|
131
|
+
None, "--version", help="版本标签(分支指针);不存在则创建"
|
|
132
|
+
),
|
|
133
|
+
ref: Optional[str] = typer.Option(None, "--ref", help="与 --version 同义"),
|
|
134
|
+
) -> None:
|
|
135
|
+
ver = (version or ref or "").strip()
|
|
136
|
+
if not ver:
|
|
137
|
+
rprint("[red]need --version or --ref[/red]")
|
|
138
|
+
raise typer.Exit(2)
|
|
139
|
+
try:
|
|
140
|
+
client = _client()
|
|
141
|
+
upload_directory(
|
|
142
|
+
client,
|
|
143
|
+
local_dir,
|
|
144
|
+
dataset_id=dataset_id,
|
|
145
|
+
version=ver,
|
|
146
|
+
)
|
|
147
|
+
except HubClientError as e:
|
|
148
|
+
rprint(f"[red]upload failed:[/red] {e.code} {e.message}")
|
|
149
|
+
raise typer.Exit(1)
|
|
150
|
+
rprint("[green]upload done[/green]")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataset_app.command("download")
|
|
154
|
+
def dataset_download_cmd(
|
|
155
|
+
dataset_id: str = typer.Option(..., "--id"),
|
|
156
|
+
version: str = typer.Option(..., "--version"),
|
|
157
|
+
out: Path = typer.Option(Path("./download"), "--out"),
|
|
158
|
+
paths: Optional[str] = typer.Option(None, "--paths", help="逗号分隔相对路径"),
|
|
159
|
+
incremental: bool = typer.Option(True, "--incremental/--full"),
|
|
160
|
+
) -> None:
|
|
161
|
+
path_list = [p.strip() for p in (paths or "").split(",") if p.strip()]
|
|
162
|
+
try:
|
|
163
|
+
download_dataset(
|
|
164
|
+
_client(),
|
|
165
|
+
dataset_id=dataset_id,
|
|
166
|
+
version=version,
|
|
167
|
+
out_dir=out,
|
|
168
|
+
paths=path_list or None,
|
|
169
|
+
incremental=incremental,
|
|
170
|
+
)
|
|
171
|
+
except HubClientError as e:
|
|
172
|
+
rprint(f"[red]download failed:[/red] {e.code}")
|
|
173
|
+
raise typer.Exit(1)
|
|
174
|
+
except ValueError as e:
|
|
175
|
+
rprint(f"[red]path error:[/red] {e}")
|
|
176
|
+
raise typer.Exit(1)
|
|
177
|
+
rprint(f"[green]downloaded to[/green] {out}")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataset_app.command("pull")
|
|
181
|
+
def dataset_pull(
|
|
182
|
+
dataset_id: str = typer.Option(..., "--id"),
|
|
183
|
+
version: str = typer.Option(..., "--version"),
|
|
184
|
+
out: Path = typer.Option(Path("./download"), "--out"),
|
|
185
|
+
) -> None:
|
|
186
|
+
"""download 别名。"""
|
|
187
|
+
dataset_download_cmd(dataset_id=dataset_id, version=version, out=out, paths=None, incremental=True)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@label_app.command("validate")
|
|
191
|
+
def label_validate_cmd(
|
|
192
|
+
local_dir: Path = typer.Argument(..., exists=True, file_okay=False),
|
|
193
|
+
project: str = typer.Option("", "--project", help="标注项目 id,可选拉 upload-spec"),
|
|
194
|
+
) -> None:
|
|
195
|
+
media_type = "image"
|
|
196
|
+
if project:
|
|
197
|
+
try:
|
|
198
|
+
spec = _client().rpc("label.upload_spec", {"label_id": project})
|
|
199
|
+
media_type = str(spec.get("media_type") or "image")
|
|
200
|
+
except HubClientError as e:
|
|
201
|
+
rprint(f"[yellow]upload_spec skipped:[/yellow] {e.code}")
|
|
202
|
+
errors = validate_label_dir(local_dir, media_type=media_type)
|
|
203
|
+
if errors:
|
|
204
|
+
for e in errors:
|
|
205
|
+
rprint(f"[red]- {e}[/red]")
|
|
206
|
+
raise typer.Exit(1)
|
|
207
|
+
rprint("[green]validate ok[/green]")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@label_app.command("upload")
|
|
211
|
+
def label_upload_cmd(
|
|
212
|
+
local_dir: Path = typer.Argument(..., exists=True, file_okay=False),
|
|
213
|
+
project: str = typer.Option(..., "--project"),
|
|
214
|
+
) -> None:
|
|
215
|
+
errors = validate_label_dir(local_dir)
|
|
216
|
+
if errors:
|
|
217
|
+
for e in errors:
|
|
218
|
+
rprint(f"[red]- {e}[/red]")
|
|
219
|
+
raise typer.Exit(1)
|
|
220
|
+
|
|
221
|
+
# pack zip like UI: assets + annotations if present, else wrap flat
|
|
222
|
+
import tempfile
|
|
223
|
+
|
|
224
|
+
client = _client()
|
|
225
|
+
with tempfile.TemporaryDirectory() as td:
|
|
226
|
+
zip_path = Path(td) / "label.zip"
|
|
227
|
+
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
|
228
|
+
for p in local_dir.rglob("*"):
|
|
229
|
+
if p.is_file() and not p.is_symlink():
|
|
230
|
+
arc = p.relative_to(local_dir).as_posix()
|
|
231
|
+
if ".." in arc.split("/"):
|
|
232
|
+
raise typer.Exit(1)
|
|
233
|
+
zf.write(p, arcname=arc)
|
|
234
|
+
size = zip_path.stat().st_size
|
|
235
|
+
payload = {
|
|
236
|
+
"label_id": project,
|
|
237
|
+
"filename": "label.zip",
|
|
238
|
+
"file_size": size,
|
|
239
|
+
}
|
|
240
|
+
try:
|
|
241
|
+
init = client.rpc(
|
|
242
|
+
"label.archive.init",
|
|
243
|
+
payload,
|
|
244
|
+
with_challenge=True,
|
|
245
|
+
resource_id=project,
|
|
246
|
+
)
|
|
247
|
+
import httpx
|
|
248
|
+
|
|
249
|
+
parts = init.get("parts") or []
|
|
250
|
+
part_size = int(init.get("part_size") or size)
|
|
251
|
+
done = []
|
|
252
|
+
with zip_path.open("rb") as f, httpx.Client(timeout=300.0) as http:
|
|
253
|
+
for pinfo in parts:
|
|
254
|
+
pn = int(pinfo["part_number"])
|
|
255
|
+
start = (pn - 1) * part_size
|
|
256
|
+
end = min(start + part_size, size)
|
|
257
|
+
f.seek(start)
|
|
258
|
+
chunk = f.read(end - start)
|
|
259
|
+
r = http.put(pinfo["url"], content=chunk)
|
|
260
|
+
r.raise_for_status()
|
|
261
|
+
etag = (r.headers.get("ETag") or "").strip('"')
|
|
262
|
+
done.append({"part_number": pn, "etag": etag})
|
|
263
|
+
client.rpc(
|
|
264
|
+
"label.archive.complete",
|
|
265
|
+
{
|
|
266
|
+
"transfer_session_id": init["transfer_session_id"],
|
|
267
|
+
"parts": done,
|
|
268
|
+
},
|
|
269
|
+
)
|
|
270
|
+
batch = init.get("batch_id")
|
|
271
|
+
if batch:
|
|
272
|
+
st = client.rpc(
|
|
273
|
+
"label.archive.status",
|
|
274
|
+
{"label_id": project, "batch_id": batch},
|
|
275
|
+
)
|
|
276
|
+
rprint(st)
|
|
277
|
+
except HubClientError as e:
|
|
278
|
+
rprint(f"[red]label upload failed:[/red] {e.code}")
|
|
279
|
+
raise typer.Exit(1)
|
|
280
|
+
rprint("[green]label upload submitted[/green]")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@reports_app.command("list")
|
|
284
|
+
def eval_reports_list(
|
|
285
|
+
scope: str = typer.Option("mine", "--scope"),
|
|
286
|
+
team_id: Optional[int] = typer.Option(None, "--team-id"),
|
|
287
|
+
) -> None:
|
|
288
|
+
payload: dict = {"scope": scope}
|
|
289
|
+
if team_id is not None:
|
|
290
|
+
payload["team_id"] = team_id
|
|
291
|
+
try:
|
|
292
|
+
out = _client().rpc("eval.reports.list", payload)
|
|
293
|
+
except HubClientError as e:
|
|
294
|
+
rprint(f"[red]{e.code}[/red]")
|
|
295
|
+
raise typer.Exit(1)
|
|
296
|
+
rprint(out)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@reports_app.command("pull")
|
|
300
|
+
def eval_reports_pull(
|
|
301
|
+
report_name: str = typer.Argument(...),
|
|
302
|
+
out: Path = typer.Option(Path("./report.json"), "--out"),
|
|
303
|
+
scope: str = typer.Option("mine", "--scope"),
|
|
304
|
+
team_id: Optional[int] = typer.Option(None, "--team-id"),
|
|
305
|
+
) -> None:
|
|
306
|
+
payload: dict = {"report_name": report_name, "scope": scope}
|
|
307
|
+
if team_id is not None:
|
|
308
|
+
payload["team_id"] = team_id
|
|
309
|
+
try:
|
|
310
|
+
out_data = _client().rpc("eval.reports.pull_meta", payload)
|
|
311
|
+
except HubClientError as e:
|
|
312
|
+
rprint(f"[red]{e.code}[/red]")
|
|
313
|
+
raise typer.Exit(1)
|
|
314
|
+
import json
|
|
315
|
+
|
|
316
|
+
out.write_text(json.dumps(out_data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
317
|
+
rprint(f"[green]wrote[/green] {out}")
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@app.callback()
|
|
321
|
+
def main_callback(
|
|
322
|
+
version: bool = typer.Option(False, "--version", help="Show version"),
|
|
323
|
+
) -> None:
|
|
324
|
+
if version:
|
|
325
|
+
rprint(__version__)
|
|
326
|
+
raise typer.Exit()
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# Typer wants `app` callable for console_scripts — expose as main
|
|
330
|
+
def main() -> None:
|
|
331
|
+
app()
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
if __name__ == "__main__":
|
|
335
|
+
main()
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""设备密钥:本地生成,私钥不离开本机。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import platform
|
|
8
|
+
import uuid
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from cryptography import x509
|
|
12
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
13
|
+
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
|
14
|
+
from cryptography.x509.oid import NameOID
|
|
15
|
+
|
|
16
|
+
from jishu_hub import store
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def device_fp_hash() -> str:
|
|
20
|
+
raw = "|".join(
|
|
21
|
+
[
|
|
22
|
+
platform.node(),
|
|
23
|
+
platform.system(),
|
|
24
|
+
platform.machine(),
|
|
25
|
+
str(uuid.getnode()),
|
|
26
|
+
str(Path.home()),
|
|
27
|
+
]
|
|
28
|
+
)
|
|
29
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def ensure_key_pair() -> rsa.RSAPrivateKey:
|
|
33
|
+
pem = store.load_secret("device_key")
|
|
34
|
+
if pem:
|
|
35
|
+
return serialization.load_pem_private_key(pem.encode("utf-8"), password=None) # type: ignore[return-value]
|
|
36
|
+
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
37
|
+
store.save_secret(
|
|
38
|
+
"device_key",
|
|
39
|
+
key.private_bytes(
|
|
40
|
+
encoding=serialization.Encoding.PEM,
|
|
41
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
42
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
43
|
+
).decode("utf-8"),
|
|
44
|
+
)
|
|
45
|
+
return key
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_csr(device_cn: str = "jishu-device") -> str:
|
|
49
|
+
key = ensure_key_pair()
|
|
50
|
+
csr = (
|
|
51
|
+
x509.CertificateSigningRequestBuilder()
|
|
52
|
+
.subject_name(
|
|
53
|
+
x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, device_cn[:64])])
|
|
54
|
+
)
|
|
55
|
+
.sign(key, hashes.SHA256())
|
|
56
|
+
)
|
|
57
|
+
return csr.public_bytes(serialization.Encoding.PEM).decode("utf-8")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def sign_challenge_message(message_obj: dict) -> str:
|
|
61
|
+
key = ensure_key_pair()
|
|
62
|
+
msg = json.dumps(message_obj, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode(
|
|
63
|
+
"utf-8"
|
|
64
|
+
)
|
|
65
|
+
sig = key.sign(msg, padding.PKCS1v15(), hashes.SHA256())
|
|
66
|
+
return base64.b64encode(sig).decode("ascii")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def save_device_cert(cert_pem: str) -> None:
|
|
70
|
+
store.save_secret("device_cert", cert_pem)
|