tars-cli 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.
tars_cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
tars_cli/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from tars_cli.app import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
tars_cli/app.py ADDED
@@ -0,0 +1,164 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ import typer
6
+ from rich.console import Console
7
+
8
+ from tars_cli import __version__
9
+
10
+ console = Console()
11
+
12
+ app = typer.Typer(
13
+ name="tars",
14
+ help=(
15
+ "tars CLI - 评估平台命令行工具\n\n"
16
+ "🚀 快速上手:\n"
17
+ " 路线 A(智能生成): tars auth login → tars plugin import → tars dataset generate → tars eval run\n"
18
+ " 路线 B(手动上传): tars auth login → tars plugin import → tars template download → tars dataset upload → tars eval run\n\n"
19
+ "运行 tars quickstart 查看完整示例"
20
+ ),
21
+ )
22
+
23
+ auth_app = typer.Typer(
24
+ name="auth",
25
+ help="认证管理\n\n典型流程: tars auth login --server <url>",
26
+ no_args_is_help=True,
27
+ )
28
+ plugin_app = typer.Typer(
29
+ name="plugin",
30
+ help="Plugin 管理\n\n典型流程: tars plugin import <git_url> → tars plugin export-report <plugin_id>",
31
+ no_args_is_help=True,
32
+ )
33
+ dataset_app = typer.Typer(
34
+ name="dataset",
35
+ help="数据集管理\n\n典型流程: tars dataset generate <plugin_id> → tars dataset publish <version_id>",
36
+ no_args_is_help=True,
37
+ )
38
+ eval_app = typer.Typer(
39
+ name="eval",
40
+ help="评估管理\n\n典型流程: tars eval run <plugin_id> → tars eval status <task_id>",
41
+ no_args_is_help=True,
42
+ )
43
+ template_app = typer.Typer(
44
+ name="template",
45
+ help="模板管理\n\n典型流程: tars template download plan → tars template download testcase-single",
46
+ no_args_is_help=True,
47
+ )
48
+
49
+ app.add_typer(auth_app, name="auth")
50
+ app.add_typer(plugin_app, name="plugin")
51
+ app.add_typer(dataset_app, name="dataset")
52
+ app.add_typer(eval_app, name="eval")
53
+ app.add_typer(template_app, name="template")
54
+
55
+ # Global options stored in context
56
+ _output_format: str = "table"
57
+ _quiet: bool = False
58
+ _verbose: bool = False
59
+ _server: Optional[str] = None
60
+
61
+
62
+ @app.callback(invoke_without_command=True)
63
+ def main(
64
+ ctx: typer.Context,
65
+ output: str = typer.Option("table", "--output", "-o", help="输出格式: table, json, yaml"),
66
+ quiet: bool = typer.Option(False, "--quiet", "-q", help="静默模式,仅输出核心结果"),
67
+ verbose: bool = typer.Option(False, "--verbose", help="详细模式,输出 HTTP 请求日志"),
68
+ server: Optional[str] = typer.Option(None, "--server", help="服务端地址"),
69
+ version: bool = typer.Option(False, "--version", "-V", help="显示版本号"),
70
+ ) -> None:
71
+ global _output_format, _quiet, _verbose, _server
72
+ if version:
73
+ typer.echo(f"tars-cli {__version__}")
74
+ raise typer.Exit()
75
+ _output_format = output
76
+ _quiet = quiet
77
+ _verbose = verbose
78
+ _server = server
79
+ if ctx.invoked_subcommand is None:
80
+ typer.echo(ctx.get_help())
81
+ raise typer.Exit()
82
+
83
+
84
+ def get_global_options() -> tuple[str, bool, bool, Optional[str]]:
85
+ return _output_format, _quiet, _verbose, _server
86
+
87
+
88
+ QUICKSTART_TEXT = """\
89
+ 🚀 tars CLI 快速上手
90
+
91
+ ━━━ 路线 A: 智能生成 ━━━
92
+
93
+ # 1. 登录
94
+ tars auth login --server https://tars.example.com
95
+
96
+ # 2. 导入 Plugin
97
+ tars plugin import https://git.example.com/my-plugin.git
98
+
99
+ # 3. 智能生成数据集(Agent / Skill)
100
+ tars dataset generate <plugin_id> --agent-name <name> --publish
101
+ tars dataset generate <plugin_id> --skill-name <name> --publish
102
+
103
+ # 多轮场景
104
+ tars dataset generate <plugin_id> --agent-name <name> -t multi_turn --publish
105
+
106
+ # 4. 运行评估
107
+ tars eval run <plugin_id> --agent-name <name> --dataset-version <version_id>
108
+ tars eval run <plugin_id> --skill-name <name> --dataset-version <version_id>
109
+
110
+ # 5. 导出报告(可选)
111
+ tars plugin export-report <plugin_id>
112
+
113
+ ━━━ 路线 B: 手动上传 ━━━
114
+
115
+ # 1. 登录
116
+ tars auth login --server https://tars.example.com
117
+
118
+ # 2. 导入 Plugin
119
+ tars plugin import https://git.example.com/my-plugin.git
120
+
121
+ # 3. 下载模板
122
+ tars template download plan
123
+ tars template download testcase-single
124
+ tars template download testcase-multi
125
+
126
+ # 4. 填写模板后上传(Agent / Skill)
127
+ tars dataset upload <plugin_id> --agent-name <name> --plan plan.yaml --cases cases.yaml
128
+ tars dataset upload <plugin_id> --skill-name <name> --plan plan.yaml --cases cases.yaml
129
+
130
+ # 5. 运行评估
131
+ tars eval run <plugin_id> --agent-name <name> --dataset-version <version_id>
132
+
133
+ ━━━ 其他常用命令 ━━━
134
+
135
+ # 查看数据集版本列表
136
+ tars dataset list <plugin_id> --agent-name <name>
137
+ tars dataset list <plugin_id> --skill-name <name> -t multi_turn
138
+
139
+ # 发布草稿数据集
140
+ tars dataset publish <version_id>
141
+
142
+ # 查看评估任务状态
143
+ tars eval status <task_id>
144
+
145
+ ━━━ CI/CD 集成 ━━━
146
+
147
+ export TARS_ACCESS_TOKEN=eyJhbGc...
148
+ export TARS_SERVER_URL=https://tars.example.com
149
+ tars eval run <plugin_id> --agent-name <name> --dataset-version <id> -o json --threshold 0.8
150
+ # 退出码: 0=通过, 1=未通过, 2=错误
151
+ """
152
+
153
+
154
+ @app.command()
155
+ def quickstart() -> None:
156
+ """输出完整的端到端使用示例"""
157
+ typer.echo(QUICKSTART_TEXT)
158
+
159
+
160
+ import tars_cli.commands.auth_cmd # noqa: E402, F401
161
+ import tars_cli.commands.plugin_cmd # noqa: E402, F401
162
+ import tars_cli.commands.dataset_cmd # noqa: E402, F401
163
+ import tars_cli.commands.eval_cmd # noqa: E402, F401
164
+ import tars_cli.commands.template_cmd # noqa: E402, F401
tars_cli/auth.py ADDED
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import stat
6
+ from dataclasses import dataclass
7
+
8
+ from tars_cli.config import get_credentials_path, get_env_token, resolve_server_url
9
+
10
+
11
+ @dataclass
12
+ class Credentials:
13
+ server_url: str
14
+ access_token: str
15
+ refresh_token: str
16
+ expires_at: str = ""
17
+
18
+
19
+ def save_credentials(creds: Credentials) -> None:
20
+ path = get_credentials_path()
21
+ path.parent.mkdir(parents=True, exist_ok=True)
22
+ path.write_text(json.dumps({
23
+ "server_url": creds.server_url,
24
+ "access_token": creds.access_token,
25
+ "refresh_token": creds.refresh_token,
26
+ "expires_at": creds.expires_at,
27
+ }, indent=2))
28
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
29
+
30
+
31
+ def load_credentials(cli_server: str | None = None) -> Credentials | None:
32
+ env_token = get_env_token()
33
+ if env_token:
34
+ return Credentials(
35
+ server_url=resolve_server_url(cli_server),
36
+ access_token=env_token,
37
+ refresh_token="",
38
+ )
39
+
40
+ path = get_credentials_path()
41
+ if not path.exists():
42
+ return None
43
+ try:
44
+ data = json.loads(path.read_text())
45
+ return Credentials(
46
+ server_url=resolve_server_url(cli_server) if cli_server else data.get("server_url", ""),
47
+ access_token=data["access_token"],
48
+ refresh_token=data.get("refresh_token", ""),
49
+ expires_at=data.get("expires_at", ""),
50
+ )
51
+ except (json.JSONDecodeError, KeyError):
52
+ return None
53
+
54
+
55
+ def clear_credentials() -> None:
56
+ path = get_credentials_path()
57
+ if path.exists():
58
+ path.unlink()
tars_cli/client.py ADDED
@@ -0,0 +1,117 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+ import typer
7
+
8
+ from httpx import HTTPTransport
9
+
10
+ from tars_cli.auth import Credentials, load_credentials, save_credentials
11
+
12
+
13
+ class AuthExpiredError(Exception):
14
+ pass
15
+
16
+
17
+ class TarsAPIClient:
18
+ def __init__(self, server_url: str, credentials: Credentials, verbose: bool = False) -> None:
19
+ self._server_url = server_url.rstrip("/")
20
+ self._credentials = credentials
21
+ self._verbose = verbose
22
+ self._client = httpx.Client(base_url=f"{self._server_url}/api/v1", timeout=30.0, transport=HTTPTransport(proxy=None))
23
+
24
+ @classmethod
25
+ def from_context(cls, cli_server: str | None = None, verbose: bool = False) -> TarsAPIClient:
26
+ creds = load_credentials(cli_server)
27
+ if not creds:
28
+ typer.echo("未登录,请先执行 tars auth login", err=True)
29
+ raise typer.Exit(code=2)
30
+ server_url = creds.server_url
31
+ return cls(server_url=server_url, credentials=creds, verbose=verbose)
32
+
33
+ @property
34
+ def server_url(self) -> str:
35
+ return self._server_url
36
+
37
+ def _headers(self) -> dict[str, str]:
38
+ return {"Authorization": f"Bearer {self._credentials.access_token}"}
39
+
40
+ def _log_request(self, method: str, url: str, **kwargs: Any) -> None:
41
+ if self._verbose:
42
+ typer.echo(f" → {method} {url}", err=True)
43
+ if "json" in kwargs:
44
+ typer.echo(f" Body: {kwargs['json']}", err=True)
45
+
46
+ def _log_response(self, resp: httpx.Response) -> None:
47
+ if self._verbose:
48
+ typer.echo(f" ← {resp.status_code} ({len(resp.content)} bytes)", err=True)
49
+
50
+ def _refresh_token(self) -> bool:
51
+ if not self._credentials.refresh_token:
52
+ return False
53
+ try:
54
+ resp = self._client.post("/auth/refresh", json={"refresh_token": self._credentials.refresh_token})
55
+ if resp.status_code == 200:
56
+ data = resp.json()
57
+ self._credentials.access_token = data["access_token"]
58
+ self._credentials.refresh_token = data.get("refresh_token", self._credentials.refresh_token)
59
+ self._credentials.expires_at = data.get("expires_at", "")
60
+ save_credentials(self._credentials)
61
+ return True
62
+ except httpx.HTTPError:
63
+ pass
64
+ return False
65
+
66
+ def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
67
+ url = path
68
+ headers = kwargs.pop("headers", None) or {}
69
+ headers.update(self._headers())
70
+ kwargs["headers"] = headers
71
+ self._log_request(method, url, **kwargs)
72
+
73
+ try:
74
+ resp = self._client.request(method, url, **kwargs)
75
+ except httpx.ConnectError:
76
+ typer.echo(f"无法连接到服务端: {self._server_url}", err=True)
77
+ raise typer.Exit(code=2)
78
+ except httpx.TimeoutException:
79
+ typer.echo(f"请求超时: {method} {url}", err=True)
80
+ raise typer.Exit(code=2)
81
+
82
+ self._log_response(resp)
83
+
84
+ if resp.status_code == 401:
85
+ if self._refresh_token():
86
+ kwargs["headers"].update(self._headers())
87
+ self._log_request(method, url, **kwargs)
88
+ resp = self._client.request(method, url, **kwargs)
89
+ self._log_response(resp)
90
+ else:
91
+ raise AuthExpiredError("认证已过期,请重新执行 tars auth login")
92
+
93
+ return resp
94
+
95
+ def get(self, path: str, **kwargs: Any) -> httpx.Response:
96
+ try:
97
+ return self._request("GET", path, **kwargs)
98
+ except AuthExpiredError as e:
99
+ typer.echo(str(e), err=True)
100
+ raise typer.Exit(code=2)
101
+
102
+ def post(self, path: str, **kwargs: Any) -> httpx.Response:
103
+ try:
104
+ return self._request("POST", path, **kwargs)
105
+ except AuthExpiredError as e:
106
+ typer.echo(str(e), err=True)
107
+ raise typer.Exit(code=2)
108
+
109
+ def put(self, path: str, **kwargs: Any) -> httpx.Response:
110
+ try:
111
+ return self._request("PUT", path, **kwargs)
112
+ except AuthExpiredError as e:
113
+ typer.echo(str(e), err=True)
114
+ raise typer.Exit(code=2)
115
+
116
+ def close(self) -> None:
117
+ self._client.close()
File without changes
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+ import typer
5
+
6
+ from httpx import HTTPTransport
7
+
8
+ from tars_cli.app import auth_app, get_global_options
9
+ from tars_cli.auth import Credentials, clear_credentials, load_credentials, save_credentials
10
+ from tars_cli.client import TarsAPIClient
11
+ from tars_cli.config import resolve_server_url
12
+ from tars_cli.hints import print_hints
13
+ from tars_cli.output import render
14
+
15
+
16
+ @auth_app.command()
17
+ def login(
18
+ server: str = typer.Option("", "--server", "-s", help="服务端地址"),
19
+ ) -> None:
20
+ """登录 tars 平台"""
21
+ output_fmt, quiet, verbose, global_server = get_global_options()
22
+ server_url = resolve_server_url(server or global_server)
23
+
24
+ username = typer.prompt("用户名")
25
+ password = typer.prompt("密码", hide_input=True)
26
+
27
+ client = httpx.Client(base_url=f"{server_url}/api/v1", timeout=30.0, transport=HTTPTransport(proxy=None))
28
+ try:
29
+ resp = client.post("/auth/login", json={"username": username, "password": password})
30
+ if resp.status_code != 200:
31
+ typer.echo(f"登录失败: {resp.json().get('detail', resp.text)}", err=True)
32
+ raise typer.Exit(code=2)
33
+
34
+ data = resp.json()
35
+ creds = Credentials(
36
+ server_url=server_url,
37
+ access_token=data["access_token"],
38
+ refresh_token=data.get("refresh_token", ""),
39
+ expires_at=data.get("expires_at", ""),
40
+ )
41
+ save_credentials(creds)
42
+
43
+ me_resp = client.get("/auth/me", headers={"Authorization": f"Bearer {creds.access_token}"})
44
+ user_info = me_resp.json() if me_resp.status_code == 200 else {}
45
+
46
+ result = {
47
+ "message": "登录成功",
48
+ "username": user_info.get("username", username),
49
+ "server": server_url,
50
+ }
51
+ web_url, next_step = print_hints(server_url, "auth_login", quiet=quiet)
52
+ render(result, output_format=output_fmt, quiet=quiet, web_url=web_url, next_step=next_step)
53
+ finally:
54
+ client.close()
55
+
56
+
57
+ @auth_app.command()
58
+ def logout() -> None:
59
+ """登出并清除本地凭证"""
60
+ output_fmt, quiet, verbose, global_server = get_global_options()
61
+ creds = load_credentials(global_server)
62
+ if creds and creds.refresh_token:
63
+ try:
64
+ client = TarsAPIClient(server_url=creds.server_url, credentials=creds, verbose=verbose)
65
+ client.post("/auth/logout", json={"refresh_token": creds.refresh_token})
66
+ client.close()
67
+ except Exception:
68
+ pass
69
+
70
+ clear_credentials()
71
+ typer.echo("已登出")
@@ -0,0 +1,258 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import typer
7
+
8
+ from tars_cli.app import dataset_app, get_global_options
9
+ from tars_cli.auth import load_credentials
10
+ from tars_cli.client import TarsAPIClient
11
+ from tars_cli.hints import print_hints
12
+ from tars_cli.output import render, render_table
13
+ from tars_cli.utils.polling import poll_task
14
+
15
+
16
+ @dataset_app.command()
17
+ def generate(
18
+ plugin_id: str = typer.Argument(help="Plugin ID"),
19
+ agent_name: Optional[str] = typer.Option(None, "--agent-name", help="Agent 名称"),
20
+ skill_name: Optional[str] = typer.Option(None, "--skill-name", help="Skill 名称"),
21
+ conversation_type: str = typer.Option("single_turn", "--conversation-type", "-t", help="会话类型: single_turn, multi_turn"),
22
+ publish: bool = typer.Option(False, "--publish", help="生成后自动发布"),
23
+ ) -> None:
24
+ """智能生成数据集(路线 A)"""
25
+ if agent_name and skill_name:
26
+ typer.echo("--agent-name 和 --skill-name 不能同时使用", err=True)
27
+ raise typer.Exit(code=2)
28
+ if not agent_name and not skill_name:
29
+ typer.echo("请指定 --agent-name 或 --skill-name", err=True)
30
+ raise typer.Exit(code=2)
31
+
32
+ output_fmt, quiet, verbose, server = get_global_options()
33
+ creds = load_credentials(server)
34
+ if not creds:
35
+ typer.echo("未登录,请先执行 tars auth login", err=True)
36
+ raise typer.Exit(code=2)
37
+
38
+ client = TarsAPIClient(server_url=creds.server_url, credentials=creds, verbose=verbose)
39
+ try:
40
+ target_name = agent_name or skill_name
41
+ body: dict = {"plugin_id": plugin_id, "conversation_type": conversation_type, "agent_name": target_name}
42
+ if skill_name:
43
+ body["evaluation_target_type"] = "skill"
44
+ body["skill_name"] = skill_name
45
+
46
+ resp = client.post("/data-set-versions", json=body)
47
+ if not resp.is_success:
48
+ typer.echo(f"创建版本失败: {resp.text}", err=True)
49
+ raise typer.Exit(code=2)
50
+ version_data = resp.json()
51
+ version_id = version_data["id"]
52
+
53
+ plan_body: dict = {"version_id": version_id, "plugin_id": plugin_id, "agent_name": target_name}
54
+ resp = client.post("/datasets/generate/generate-plan", json=plan_body)
55
+ if not resp.is_success:
56
+ typer.echo(f"生成计划失败: {resp.text}", err=True)
57
+ raise typer.Exit(code=2)
58
+
59
+ plan_data = poll_task(
60
+ request_fn=lambda: client.get("/datasets/generate/generate-plan/status", params={"version_id": version_id}),
61
+ label="生成评估计划",
62
+ )
63
+
64
+ strategy = plan_data.get("test_strategy")
65
+ strategy_readable = plan_data.get("test_strategy_readable", "")
66
+ if strategy:
67
+ save_resp = client.put(
68
+ f"/data-set-versions/{version_id}/test-strategy",
69
+ json={"test_strategy": strategy, "test_strategy_readable": strategy_readable},
70
+ )
71
+ if not save_resp.is_success:
72
+ typer.echo(f"保存评估计划失败: {save_resp.text}", err=True)
73
+ raise typer.Exit(code=2)
74
+
75
+ resp = client.post(f"/data-set-versions/{version_id}/generate-cases")
76
+ if not resp.is_success:
77
+ typer.echo(f"生成用例失败: {resp.text}", err=True)
78
+ raise typer.Exit(code=2)
79
+
80
+ result_data = poll_task(
81
+ request_fn=lambda: client.get(f"/data-set-versions/{version_id}/generate-cases/status"),
82
+ label="生成评估用例",
83
+ )
84
+
85
+ if publish:
86
+ resp = client.post(f"/data-set-versions/{version_id}/publish")
87
+ if not resp.is_success:
88
+ typer.echo(f"发布失败: {resp.text}", err=True)
89
+ raise typer.Exit(code=2)
90
+
91
+ result = {
92
+ "version_id": version_id,
93
+ "status": "published" if publish else "draft",
94
+ "case_count": result_data.get("case_count", ""),
95
+ "message": "数据集生成完成",
96
+ }
97
+ web_url, next_step = print_hints(
98
+ creds.server_url, "dataset_generate", quiet=quiet,
99
+ plugin_id=plugin_id, version_id=str(version_id),
100
+ )
101
+ render(result, output_format=output_fmt, quiet=quiet, web_url=web_url, next_step=next_step)
102
+ finally:
103
+ client.close()
104
+
105
+
106
+ @dataset_app.command()
107
+ def upload(
108
+ plugin_id: str = typer.Argument(help="Plugin ID"),
109
+ agent_name: Optional[str] = typer.Option(None, "--agent-name", help="Agent 名称"),
110
+ skill_name: Optional[str] = typer.Option(None, "--skill-name", help="Skill 名称"),
111
+ plan: str = typer.Option(..., "--plan", help="评估计划文件路径"),
112
+ cases: str = typer.Option(..., "--cases", help="用例文件路径"),
113
+ conversation_type: str = typer.Option("single_turn", "--conversation-type", "-t", help="会话类型"),
114
+ ) -> None:
115
+ """手动上传数据集(路线 B)"""
116
+ if agent_name and skill_name:
117
+ typer.echo("--agent-name 和 --skill-name 不能同时使用", err=True)
118
+ raise typer.Exit(code=2)
119
+ if not agent_name and not skill_name:
120
+ typer.echo("请指定 --agent-name 或 --skill-name", err=True)
121
+ raise typer.Exit(code=2)
122
+
123
+ for f in [plan, cases]:
124
+ if not Path(f).exists():
125
+ typer.echo(f"文件不存在: {f}", err=True)
126
+ raise typer.Exit(code=2)
127
+
128
+ output_fmt, quiet, verbose, server = get_global_options()
129
+ creds = load_credentials(server)
130
+ if not creds:
131
+ typer.echo("未登录,请先执行 tars auth login", err=True)
132
+ raise typer.Exit(code=2)
133
+
134
+ client = TarsAPIClient(server_url=creds.server_url, credentials=creds, verbose=verbose)
135
+ try:
136
+ target_name = agent_name or skill_name
137
+ body: dict = {"plugin_id": plugin_id, "conversation_type": conversation_type, "agent_name": target_name}
138
+ if skill_name:
139
+ body["evaluation_target_type"] = "skill"
140
+ body["skill_name"] = skill_name
141
+
142
+ resp = client.post("/data-set-versions", json=body)
143
+ if not resp.is_success:
144
+ typer.echo(f"创建版本失败: {resp.text}", err=True)
145
+ raise typer.Exit(code=2)
146
+ version_id = resp.json()["id"]
147
+
148
+ with open(plan, "rb") as plan_f:
149
+ plan_resp = client.post(f"/data-set-versions/{version_id}/evaluation-plan/upload", files={"file": (Path(plan).name, plan_f)})
150
+ if not plan_resp.is_success:
151
+ typer.echo(f"上传计划失败: {plan_resp.text}", err=True)
152
+ raise typer.Exit(code=2)
153
+
154
+ with open(cases, "rb") as cases_f:
155
+ cases_resp = client.post(
156
+ f"/data-set-versions/{version_id}/import-cases",
157
+ files={"file": (Path(cases).name, cases_f)},
158
+ )
159
+ if not cases_resp.is_success:
160
+ typer.echo(f"上传用例失败: {cases_resp.text}", err=True)
161
+ raise typer.Exit(code=2)
162
+ cases_data = cases_resp.json()
163
+ imported_count = cases_data.get("imported_count", 0)
164
+ typer.echo(f"成功导入 {imported_count} 条用例", err=True)
165
+
166
+ resp = client.post(f"/data-set-versions/{version_id}/publish")
167
+ if not resp.is_success:
168
+ typer.echo(f"发布失败: {resp.text}", err=True)
169
+ raise typer.Exit(code=2)
170
+
171
+ result = {
172
+ "version_id": version_id,
173
+ "status": "published",
174
+ "message": "数据集上传并发布成功",
175
+ }
176
+ web_url, next_step = print_hints(
177
+ creds.server_url, "dataset_upload", quiet=quiet,
178
+ plugin_id=plugin_id, version_id=str(version_id),
179
+ )
180
+ render(result, output_format=output_fmt, quiet=quiet, web_url=web_url, next_step=next_step)
181
+ finally:
182
+ client.close()
183
+
184
+
185
+ @dataset_app.command("list")
186
+ def list_versions(
187
+ plugin_id: str = typer.Argument(help="Plugin ID"),
188
+ agent_name: Optional[str] = typer.Option(None, "--agent-name", help="Agent 名称"),
189
+ skill_name: Optional[str] = typer.Option(None, "--skill-name", help="Skill 名称"),
190
+ conversation_type: str = typer.Option("single_turn", "--conversation-type", "-t", help="会话类型"),
191
+ status: Optional[str] = typer.Option(None, "--status", help="筛选状态: draft, published"),
192
+ ) -> None:
193
+ """列出数据集版本"""
194
+ output_fmt, quiet, verbose, server = get_global_options()
195
+ creds = load_credentials(server)
196
+ if not creds:
197
+ typer.echo("未登录,请先执行 tars auth login", err=True)
198
+ raise typer.Exit(code=2)
199
+
200
+ client = TarsAPIClient(server_url=creds.server_url, credentials=creds, verbose=verbose)
201
+ try:
202
+ target_name = agent_name or skill_name
203
+ if not target_name:
204
+ typer.echo("请指定 --agent-name 或 --skill-name", err=True)
205
+ raise typer.Exit(code=2)
206
+
207
+ params: dict = {"plugin_id": plugin_id, "conversation_type": conversation_type, "agent_name": target_name}
208
+ if skill_name:
209
+ params["evaluation_target_type"] = "skill"
210
+ if status:
211
+ params["status"] = status
212
+
213
+ resp = client.get("/data-set-versions", params=params)
214
+ if not resp.is_success:
215
+ typer.echo(f"查询失败: {resp.text}", err=True)
216
+ raise typer.Exit(code=2)
217
+
218
+ data = resp.json()
219
+ items = data.get("items", data) if isinstance(data, dict) else data
220
+
221
+ if output_fmt == "table":
222
+ columns = ["版本 ID", "状态", "用例数", "创建时间"]
223
+ rows = [
224
+ [str(item.get("id", "")), item.get("status", ""), str(item.get("test_case_count", "")), str(item.get("created_at", ""))]
225
+ for item in items
226
+ ]
227
+ render_table("数据集版本列表", columns, rows)
228
+ else:
229
+ render({"items": items, "total": len(items)}, output_format=output_fmt, quiet=quiet)
230
+ finally:
231
+ client.close()
232
+
233
+
234
+ @dataset_app.command()
235
+ def publish(
236
+ version_id: str = typer.Argument(help="数据集版本 ID"),
237
+ ) -> None:
238
+ """发布草稿数据集版本"""
239
+ output_fmt, quiet, verbose, server = get_global_options()
240
+ creds = load_credentials(server)
241
+ if not creds:
242
+ typer.echo("未登录,请先执行 tars auth login", err=True)
243
+ raise typer.Exit(code=2)
244
+
245
+ client = TarsAPIClient(server_url=creds.server_url, credentials=creds, verbose=verbose)
246
+ try:
247
+ resp = client.post(f"/data-set-versions/{version_id}/publish")
248
+ if not resp.is_success:
249
+ typer.echo(f"发布失败: {resp.text}", err=True)
250
+ raise typer.Exit(code=2)
251
+
252
+ result = {"version_id": version_id, "status": "published", "message": "发布成功"}
253
+ web_url, next_step = print_hints(
254
+ creds.server_url, "dataset_publish", quiet=quiet, version_id=version_id,
255
+ )
256
+ render(result, output_format=output_fmt, quiet=quiet, web_url=web_url, next_step=next_step)
257
+ finally:
258
+ client.close()
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ import typer
6
+
7
+ from tars_cli.app import eval_app, get_global_options
8
+ from tars_cli.auth import load_credentials
9
+ from tars_cli.client import TarsAPIClient
10
+ from tars_cli.hints import print_hints
11
+ from tars_cli.output import render
12
+ from tars_cli.utils.polling import poll_task
13
+
14
+
15
+ @eval_app.command()
16
+ def run(
17
+ plugin_id: str = typer.Argument(help="Plugin ID"),
18
+ agent_name: Optional[str] = typer.Option(None, "--agent-name", help="Agent 名称"),
19
+ skill_name: Optional[str] = typer.Option(None, "--skill-name", help="Skill 名称"),
20
+ dataset_version: str = typer.Option(..., "--dataset-version", help="数据集版本 ID"),
21
+ threshold: Optional[float] = typer.Option(None, "--threshold", help="CI/CD 卡点阈值 (0-1)"),
22
+ timeout: int = typer.Option(1800, "--timeout", help="轮询超时时间(秒),默认 1800"),
23
+ ) -> None:
24
+ """运行评估任务"""
25
+ if agent_name and skill_name:
26
+ typer.echo("--agent-name 和 --skill-name 不能同时使用", err=True)
27
+ raise typer.Exit(code=2)
28
+
29
+ if threshold is not None and (threshold < 0 or threshold > 1):
30
+ typer.echo("--threshold 必须在 0-1 范围内", err=True)
31
+ raise typer.Exit(code=2)
32
+
33
+ output_fmt, quiet, verbose, server = get_global_options()
34
+ creds = load_credentials(server)
35
+ if not creds:
36
+ typer.echo("未登录,请先执行 tars auth login", err=True)
37
+ raise typer.Exit(code=2)
38
+
39
+ client = TarsAPIClient(server_url=creds.server_url, credentials=creds, verbose=verbose)
40
+ try:
41
+ body: dict = {"dataset_version_id": dataset_version}
42
+ target_name = agent_name or skill_name
43
+ if target_name:
44
+ body["agent_name"] = target_name
45
+ if skill_name and not agent_name:
46
+ body["evaluation_target_type"] = "skill"
47
+ body["skill_name"] = skill_name
48
+
49
+ resp = client.post(f"/plugins/{plugin_id}/evaluations", json=body)
50
+ if not resp.is_success:
51
+ typer.echo(f"创建评估任务失败: {resp.text}", err=True)
52
+ raise typer.Exit(code=2)
53
+
54
+ task_data = resp.json()
55
+ task_id = task_data["id"]
56
+ typer.echo(f"任务已创建: {task_id}", err=True)
57
+
58
+ try:
59
+ final_data = poll_task(
60
+ request_fn=lambda: client.get(f"/plugins/{plugin_id}/evaluations/{task_id}/progress"),
61
+ label="评估执行中",
62
+ timeout=timeout,
63
+ )
64
+ except typer.Exit:
65
+ typer.echo(f"\n轮询超时,任务仍在后台运行。可用以下命令查看结果:\n tars eval status {task_id}", err=True)
66
+ raise
67
+
68
+ detail_resp = client.get(f"/plugins/{plugin_id}/evaluations/{task_id}")
69
+ if detail_resp.status_code == 200:
70
+ final_data.update(detail_resp.json())
71
+
72
+ score = final_data.get("score", 0)
73
+ pass_rate = final_data.get("pass_rate", 0)
74
+ status = final_data.get("status", "")
75
+
76
+ result = {
77
+ "task_id": task_id,
78
+ "status": status,
79
+ "score": score,
80
+ "pass_rate": pass_rate,
81
+ "message": "评估完成",
82
+ }
83
+ web_url, next_step = print_hints(
84
+ creds.server_url, "eval_run", quiet=quiet, plugin_id=plugin_id, task_id=str(task_id),
85
+ )
86
+ render(result, output_format=output_fmt, quiet=quiet, web_url=web_url, next_step=next_step)
87
+
88
+ if threshold is not None:
89
+ normalized_score = score / 100.0 if score > 1 else score
90
+ if normalized_score < threshold:
91
+ raise typer.Exit(code=1)
92
+ finally:
93
+ client.close()
94
+
95
+
96
+ @eval_app.command()
97
+ def status(
98
+ task_id: str = typer.Argument(help="评估任务 ID"),
99
+ ) -> None:
100
+ """查看评估任务状态"""
101
+ output_fmt, quiet, verbose, server = get_global_options()
102
+ creds = load_credentials(server)
103
+ if not creds:
104
+ typer.echo("未登录,请先执行 tars auth login", err=True)
105
+ raise typer.Exit(code=2)
106
+
107
+ client = TarsAPIClient(server_url=creds.server_url, credentials=creds, verbose=verbose)
108
+ try:
109
+ resp = client.get(f"/evaluation-report/{task_id}")
110
+ if not resp.is_success:
111
+ typer.echo(f"查询失败: {resp.text}", err=True)
112
+ raise typer.Exit(code=2)
113
+
114
+ data = resp.json()
115
+ stats = data.get("test_case_stats", {})
116
+ result = {
117
+ "task_id": task_id,
118
+ "status": data.get("status", ""),
119
+ "overall_score": data.get("overall_score", 0),
120
+ "total_cases": stats.get("total_cases", 0),
121
+ "passed_cases": stats.get("passed_cases", 0),
122
+ "failed_cases": stats.get("failed_cases", 0),
123
+ }
124
+ render(result, output_format=output_fmt, quiet=quiet)
125
+ finally:
126
+ client.close()
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+
6
+ import typer
7
+
8
+ from tars_cli.app import get_global_options, plugin_app
9
+ from tars_cli.auth import load_credentials
10
+ from tars_cli.client import TarsAPIClient
11
+ from tars_cli.hints import print_hints
12
+ from tars_cli.output import render
13
+
14
+
15
+ def _is_git_url(source: str) -> bool:
16
+ return "://" in source or source.endswith(".git")
17
+
18
+
19
+ @plugin_app.command("import")
20
+ def import_plugin(
21
+ source: str = typer.Argument(help="Git URL 或本地文件路径"),
22
+ ) -> None:
23
+ """导入 Plugin(自动识别 Git URL 或文件)"""
24
+ output_fmt, quiet, verbose, server = get_global_options()
25
+ creds = load_credentials(server)
26
+ if not creds:
27
+ typer.echo("未登录,请先执行 tars auth login", err=True)
28
+ raise typer.Exit(code=2)
29
+
30
+ client = TarsAPIClient(server_url=creds.server_url, credentials=creds, verbose=verbose)
31
+ try:
32
+ if _is_git_url(source):
33
+ resp = client.post("/plugins/import-git/validate", json={"git_url": source})
34
+ if not resp.is_success:
35
+ typer.echo(f"Git URL 验证失败: {resp.text}", err=True)
36
+ raise typer.Exit(code=2)
37
+
38
+ resp = client.post("/plugins/import-git", json={"git_url": source})
39
+ else:
40
+ file_path = Path(source)
41
+ if not file_path.exists():
42
+ typer.echo(f"文件不存在: {source}", err=True)
43
+ raise typer.Exit(code=2)
44
+
45
+ with open(file_path, "rb") as f:
46
+ resp = client.post("/plugins/upload/validate", files={"file": (file_path.name, f)})
47
+ if not resp.is_success:
48
+ typer.echo(f"文件验证失败: {resp.text}", err=True)
49
+ raise typer.Exit(code=2)
50
+
51
+ with open(file_path, "rb") as f:
52
+ resp = client.post("/plugins/upload", files={"file": (file_path.name, f)})
53
+
54
+ if not resp.is_success:
55
+ typer.echo(f"导入失败: {resp.text}", err=True)
56
+ raise typer.Exit(code=2)
57
+
58
+ data = resp.json()
59
+ plugin_id = data.get("id", "")
60
+ result = {
61
+ "plugin_id": plugin_id,
62
+ "name": data.get("name", ""),
63
+ "message": "Plugin 导入成功",
64
+ }
65
+ web_url, next_step = print_hints(
66
+ creds.server_url, "plugin_import", quiet=quiet, plugin_id=str(plugin_id)
67
+ )
68
+ render(result, output_format=output_fmt, quiet=quiet, web_url=web_url, next_step=next_step)
69
+ finally:
70
+ client.close()
71
+
72
+
73
+ @plugin_app.command("export-report")
74
+ def export_report(
75
+ plugin_id: str = typer.Argument(help="Plugin ID"),
76
+ output_path: str | None = typer.Option(None, "-o", "--output-path", help="输出文件路径"),
77
+ ) -> None:
78
+ """导出 Plugin 评估报告(HTML)"""
79
+ if not output_path:
80
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
81
+ output_path = f"report_{plugin_id[:8]}_{timestamp}.html"
82
+ output_fmt, quiet, verbose, server = get_global_options()
83
+ creds = load_credentials(server)
84
+ if not creds:
85
+ typer.echo("未登录,请先执行 tars auth login", err=True)
86
+ raise typer.Exit(code=2)
87
+
88
+ client = TarsAPIClient(server_url=creds.server_url, credentials=creds, verbose=verbose)
89
+ try:
90
+ resp = client.get(f"/plugins/{plugin_id}/export-report/html")
91
+ if not resp.is_success:
92
+ typer.echo(f"导出失败: {resp.text}", err=True)
93
+ raise typer.Exit(code=2)
94
+
95
+ Path(output_path).write_bytes(resp.content)
96
+ abs_path = str(Path(output_path).resolve())
97
+ result = {"message": "报告导出成功", "path": abs_path}
98
+ web_url, next_step = print_hints(
99
+ creds.server_url, "plugin_report", quiet=quiet, plugin_id=plugin_id
100
+ )
101
+ render(result, output_format=output_fmt, quiet=quiet, web_url=web_url, next_step=next_step)
102
+ finally:
103
+ client.close()
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+
7
+ from tars_cli.app import get_global_options, template_app
8
+ from tars_cli.auth import load_credentials
9
+ from tars_cli.client import TarsAPIClient
10
+ from tars_cli.hints import print_hints
11
+ from tars_cli.output import render
12
+
13
+ TEMPLATE_MAP = {
14
+ "plan": ("evaluation-plan", "yaml"),
15
+ "testcase-single": ("dataset", None),
16
+ "testcase-multi": ("dataset/multi-turn-dataset", None),
17
+ }
18
+
19
+
20
+ @template_app.command()
21
+ def download(
22
+ template_type: str = typer.Argument(help="模板类型: plan, testcase-single, testcase-multi"),
23
+ format: str = typer.Option("yaml", "--format", "-f", help="文件格式: yaml, json"),
24
+ ) -> None:
25
+ """下载模板文件"""
26
+ if template_type not in TEMPLATE_MAP:
27
+ typer.echo(f"未知模板类型: {template_type},可选: {', '.join(TEMPLATE_MAP.keys())}", err=True)
28
+ raise typer.Exit(code=2)
29
+
30
+ output_fmt, quiet, verbose, server = get_global_options()
31
+ creds = load_credentials(server)
32
+ if not creds:
33
+ typer.echo("未登录,请先执行 tars auth login", err=True)
34
+ raise typer.Exit(code=2)
35
+
36
+ client = TarsAPIClient(server_url=creds.server_url, credentials=creds, verbose=verbose)
37
+ try:
38
+ path_prefix, default_format = TEMPLATE_MAP[template_type]
39
+ if template_type == "plan":
40
+ url = f"/templates/{path_prefix}/{format}"
41
+ elif template_type == "testcase-single":
42
+ url = f"/templates/{path_prefix}/{format}"
43
+ else:
44
+ url = f"/templates/{path_prefix}?format={format}"
45
+
46
+ resp = client.get(url)
47
+ if not resp.is_success:
48
+ typer.echo(f"下载失败: {resp.text}", err=True)
49
+ raise typer.Exit(code=2)
50
+
51
+ filename = f"{template_type}-template.{format}"
52
+ Path(filename).write_bytes(resp.content)
53
+ abs_path = str(Path(filename).resolve())
54
+
55
+ result = {"message": "模板下载成功", "file": abs_path}
56
+ web_url, next_step = print_hints(creds.server_url, "template_download", quiet=quiet)
57
+ render(result, output_format=output_fmt, quiet=quiet, web_url=web_url, next_step=next_step)
58
+ finally:
59
+ client.close()
tars_cli/config.py ADDED
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from platformdirs import user_config_dir
7
+
8
+ APP_NAME = "tars"
9
+ DEFAULT_SERVER_URL = "http://localhost:8001"
10
+
11
+ ENV_ACCESS_TOKEN = "TARS_ACCESS_TOKEN"
12
+ ENV_SERVER_URL = "TARS_SERVER_URL"
13
+
14
+
15
+ def get_config_dir() -> Path:
16
+ return Path(user_config_dir(APP_NAME))
17
+
18
+
19
+ def get_credentials_path() -> Path:
20
+ return get_config_dir() / "credentials.json"
21
+
22
+
23
+ def resolve_server_url(cli_server: str | None = None) -> str:
24
+ if cli_server:
25
+ return cli_server.rstrip("/")
26
+ env_url = os.environ.get(ENV_SERVER_URL)
27
+ if env_url:
28
+ return env_url.rstrip("/")
29
+ return DEFAULT_SERVER_URL
30
+
31
+
32
+ def get_env_token() -> str | None:
33
+ return os.environ.get(ENV_ACCESS_TOKEN)
tars_cli/hints.py ADDED
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ ROUTE_MAP: dict[str, str] = {
6
+ "plugin_import": "/plugins/{plugin_id}",
7
+ "plugin_report": "/plugins/{plugin_id}/report",
8
+ "dataset_version": "/plugins/{plugin_id}/datasets/{version_id}",
9
+ "dataset_generate": "/plugins/{plugin_id}/datasets/{version_id}",
10
+ "dataset_upload": "/plugins/{plugin_id}/datasets/{version_id}",
11
+ "dataset_publish": "/plugins/{plugin_id}/datasets/{version_id}",
12
+ "eval_task": "/plugins/{plugin_id}/evaluations/{task_id}",
13
+ "eval_run": "/plugins/{plugin_id}/evaluations/{task_id}",
14
+ }
15
+
16
+ NEXT_STEP_MAP: dict[str, str] = {
17
+ "auth_login": "tars plugin import <git_url_or_file_path>",
18
+ "plugin_import": "tars dataset generate {plugin_id} --agent-name <name> --conversation-type single_turn",
19
+ "dataset_generate": "tars eval run {plugin_id} --agent-name <name> --dataset-version {version_id}",
20
+ "dataset_upload": "tars eval run {plugin_id} --agent-name <name> --dataset-version {version_id}",
21
+ "dataset_publish": "tars eval run {plugin_id} --agent-name <name> --dataset-version {version_id}",
22
+ "eval_run": "tars plugin export-report {plugin_id}",
23
+ "template_download": "tars dataset upload {plugin_id} --agent-name <name> --plan plan.yaml --cases cases.yaml",
24
+ }
25
+
26
+
27
+ def build_web_url(server_url: str, command: str, **kwargs: str) -> str:
28
+ route = ROUTE_MAP.get(command, "")
29
+ if not route:
30
+ return ""
31
+ try:
32
+ path = route.format(**kwargs)
33
+ except KeyError:
34
+ return ""
35
+ web_base = server_url.rstrip("/").rsplit(":", 1)[0] + ":8000"
36
+ return f"{web_base}{path}"
37
+
38
+
39
+ def build_next_step(command: str, **kwargs: str) -> str:
40
+ template = NEXT_STEP_MAP.get(command, "")
41
+ if not template:
42
+ return ""
43
+ try:
44
+ return template.format(**kwargs)
45
+ except KeyError:
46
+ return template
47
+
48
+
49
+ def print_hints(server_url: str, command: str, quiet: bool = False, **kwargs: str) -> tuple[str, str]:
50
+ web_url = build_web_url(server_url, command, **kwargs)
51
+ next_step = build_next_step(command, **kwargs)
52
+
53
+ if not quiet:
54
+ if web_url:
55
+ typer.echo(f"\n📎 在 Web 端查看: {web_url}")
56
+ if next_step:
57
+ typer.echo(f"👉 下一步: {next_step}")
58
+
59
+ return web_url, next_step
tars_cli/output.py ADDED
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ import typer
7
+ import yaml
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ console = Console()
12
+
13
+ OutputFormat = str # "table" | "json" | "yaml"
14
+
15
+
16
+ def render_table(title: str, columns: list[str], rows: list[list[str]]) -> None:
17
+ table = Table(title=title, show_lines=True)
18
+ for col in columns:
19
+ table.add_column(col)
20
+ for row in rows:
21
+ table.add_row(*row)
22
+ console.print(table)
23
+
24
+
25
+ def render_json(data: dict[str, Any]) -> None:
26
+ typer.echo(json.dumps(data, indent=2, ensure_ascii=False, default=str))
27
+
28
+
29
+ def render_yaml(data: dict[str, Any]) -> None:
30
+ typer.echo(yaml.dump(data, allow_unicode=True, default_flow_style=False, sort_keys=False).rstrip())
31
+
32
+
33
+ def render(
34
+ data: dict[str, Any],
35
+ output_format: str = "table",
36
+ quiet: bool = False,
37
+ table_title: str = "",
38
+ table_columns: list[str] | None = None,
39
+ table_rows: list[list[str]] | None = None,
40
+ web_url: str = "",
41
+ next_step: str = "",
42
+ ) -> None:
43
+ if output_format == "json":
44
+ payload = dict(data)
45
+ if not quiet:
46
+ if web_url:
47
+ payload["web_url"] = web_url
48
+ if next_step:
49
+ payload["next_step"] = next_step
50
+ render_json(payload)
51
+ elif output_format == "yaml":
52
+ payload = dict(data)
53
+ if not quiet:
54
+ if web_url:
55
+ payload["web_url"] = web_url
56
+ if next_step:
57
+ payload["next_step"] = next_step
58
+ render_yaml(payload)
59
+ else:
60
+ if table_columns and table_rows:
61
+ render_table(table_title, table_columns, table_rows)
62
+ else:
63
+ for k, v in data.items():
64
+ typer.echo(f"{k}: {v}")
File without changes
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Any, Callable
5
+
6
+ import httpx
7
+ import typer
8
+
9
+ from tars_cli.utils.progress import ProgressDisplay
10
+
11
+ TERMINAL_STATES = {"completed", "failed", "partial", "timeout", "cancelled"}
12
+
13
+
14
+ def poll_task(
15
+ request_fn: Callable[[], httpx.Response],
16
+ status_extractor: Callable[[dict[str, Any]], str] | None = None,
17
+ progress_extractor: Callable[[dict[str, Any]], int] | None = None,
18
+ stage_extractor: Callable[[dict[str, Any]], str] | None = None,
19
+ terminal_states: set[str] | None = None,
20
+ timeout: int = 0,
21
+ label: str = "处理中",
22
+ ) -> dict[str, Any]:
23
+ terminal = terminal_states or TERMINAL_STATES
24
+ _status_fn = status_extractor or (lambda d: d.get("status", ""))
25
+ _stage_fn = stage_extractor or (lambda d: d.get("current_stage", ""))
26
+
27
+ def _extract_progress(data: dict[str, Any]) -> int:
28
+ if progress_extractor:
29
+ return progress_extractor(data)
30
+ pct = data.get("overall_progress")
31
+ if pct is not None:
32
+ return int(pct)
33
+ progress_obj = data.get("progress")
34
+ if isinstance(progress_obj, dict):
35
+ pct = progress_obj.get("percentage")
36
+ if pct is not None:
37
+ return int(pct)
38
+ status = _status_fn(data)
39
+ stage_map = {"idle": 0, "pending": 5, "generating": 30, "processing": 50, "publishing": 80, "completed": 100, "failed": 100, "partial": 100}
40
+ return stage_map.get(status, 10)
41
+
42
+ start = time.monotonic()
43
+
44
+ with ProgressDisplay(label) as progress:
45
+ while True:
46
+ elapsed = time.monotonic() - start
47
+ if timeout > 0 and elapsed >= timeout:
48
+ typer.echo(f"任务超时({timeout}秒)", err=True)
49
+ raise typer.Exit(code=2)
50
+
51
+ resp = request_fn()
52
+ if not resp.is_success:
53
+ typer.echo(f"轮询失败: HTTP {resp.status_code}", err=True)
54
+ raise typer.Exit(code=2)
55
+
56
+ data = resp.json()
57
+ status = _status_fn(data)
58
+ pct = _extract_progress(data)
59
+ stage = _stage_fn(data)
60
+
61
+ progress.update(pct, stage or status)
62
+
63
+ if status in terminal:
64
+ return data
65
+
66
+ interval = 2.0 if elapsed < 30 else 5.0
67
+ time.sleep(interval)
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from types import TracebackType
4
+ from typing import Any
5
+
6
+ from rich.console import Console
7
+ from rich.progress import BarColumn, Progress, SpinnerColumn, TaskID, TextColumn, TimeElapsedColumn
8
+
9
+ console = Console()
10
+
11
+
12
+ class ProgressDisplay:
13
+ def __init__(self, label: str = "处理中") -> None:
14
+ self._label = label
15
+ self._progress = Progress(
16
+ SpinnerColumn(),
17
+ TextColumn("[bold blue]{task.description}"),
18
+ BarColumn(),
19
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
20
+ TimeElapsedColumn(),
21
+ console=console,
22
+ )
23
+ self._task_id: TaskID | None = None
24
+
25
+ def __enter__(self) -> ProgressDisplay:
26
+ self._progress.__enter__()
27
+ self._task_id = self._progress.add_task(self._label, total=100)
28
+ return self
29
+
30
+ def __exit__(
31
+ self,
32
+ exc_type: type[BaseException] | None,
33
+ exc_val: BaseException | None,
34
+ exc_tb: TracebackType | None,
35
+ ) -> None:
36
+ self._progress.__exit__(exc_type, exc_val, exc_tb)
37
+
38
+ def update(self, percentage: int, description: str = "") -> None:
39
+ if self._task_id is not None:
40
+ self._progress.update(
41
+ self._task_id,
42
+ completed=percentage,
43
+ description=description or self._label,
44
+ )
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: tars-cli
3
+ Version: 0.1.0
4
+ Summary: tars 平台命令行工具
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: typer>=0.12
7
+ Requires-Dist: httpx>=0.27
8
+ Requires-Dist: rich>=13.0
9
+ Requires-Dist: pyyaml>=6.0
10
+ Requires-Dist: platformdirs>=4.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=8.0; extra == "dev"
13
+ Requires-Dist: pytest-mock>=3.12; extra == "dev"
14
+ Requires-Dist: pytest-cov>=5.0; extra == "dev"
15
+ Requires-Dist: respx>=0.21; extra == "dev"
16
+ Requires-Dist: mypy>=1.8; extra == "dev"
17
+ Requires-Dist: ruff>=0.3; extra == "dev"
18
+ Requires-Dist: build>=1.0; extra == "dev"
@@ -0,0 +1,22 @@
1
+ tars_cli/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ tars_cli/__main__.py,sha256=Rsczv9C7Joo10Rh7j2bbHOlT32N3eh6vDZTvJv0rk2I,67
3
+ tars_cli/app.py,sha256=NU5f5I1suovHydso62FlwzILMP4BNLhyDFkJroc5-Jk,5289
4
+ tars_cli/auth.py,sha256=hfrTps-ESdR4y4FO_oQ1h-tBFgZuHKbV-goO3_CH6wg,1609
5
+ tars_cli/client.py,sha256=8FV7TAy2ShEXe12Tb8afVbJL43kqpHvStiqI8tUY4B8,4294
6
+ tars_cli/config.py,sha256=y7mtl9J5A2LiSMGep6Txsd00ZQiXPKkyJz4xMMs3rGY,742
7
+ tars_cli/hints.py,sha256=1o1Sf0f12Lcj5dywxPLYmy0enUiqx2g6SPZcJDVao0E,2268
8
+ tars_cli/output.py,sha256=5-xP0APs9bePTNfKvYB0Qp4ButKfhlzg1f_Q4HRxegk,1764
9
+ tars_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ tars_cli/commands/auth_cmd.py,sha256=stncfZVKl0qxRDMuVwuso65-D2cSXolfqVGKW3i8nTc,2557
11
+ tars_cli/commands/dataset_cmd.py,sha256=-jyDbiNvBc8C04NK-1RDYvxyesrkSjOrju9YBGPisYk,10939
12
+ tars_cli/commands/eval_cmd.py,sha256=51vI3TC1ZMXuGvSEJmA7xUvwWCVwwghQkFUu5s02GNA,4828
13
+ tars_cli/commands/plugin_cmd.py,sha256=uJ9jDjB64hZktsgZW_WlrgeRAcJBVywcI4OQ2PXN0d4,3935
14
+ tars_cli/commands/template_cmd.py,sha256=sye4f5xRS3x2ahGFOTeIgwTg-tFVY5H7gj4_5JBxKYU,2196
15
+ tars_cli/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
+ tars_cli/utils/polling.py,sha256=L0X56mWJa5BbWvcMb0TLbi-gtW7upVPfnLUcRvdgX18,2321
17
+ tars_cli/utils/progress.py,sha256=8p1gZ0K_dNQpYz87Lo3l7n1Q69w7hbqBhIZNajBe2VI,1377
18
+ tars_cli-0.1.0.dist-info/METADATA,sha256=W_rqC0qPSQNX03xUIw1nL8vqpgvFYZ-ZzrmG1aRq1sY,578
19
+ tars_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
20
+ tars_cli-0.1.0.dist-info/entry_points.txt,sha256=tuhRjiF7IqnCY1nCxapF0i6_ZjQIIOzE6qX1V4_N8O8,42
21
+ tars_cli-0.1.0.dist-info/top_level.txt,sha256=adbikxYObQ-84gvQ5Fe_XM01Q5Oufhy5NPrZD1nbCVQ,9
22
+ tars_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tars = tars_cli.app:app
@@ -0,0 +1 @@
1
+ tars_cli