web-presentation-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.
wp/commands/common.py ADDED
@@ -0,0 +1,182 @@
1
+ """文件功能:提供 CLI 命令共享的客户端、文件载荷、异步任务和输出辅助函数。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any, NoReturn
8
+
9
+ import click
10
+
11
+ from wp.client import ApiClient, ApiClientError
12
+ from wp.config import get_profile, load_config
13
+ from wp.formatter import print_error, print_json
14
+
15
+
16
+ def get_client(ctx: click.Context) -> ApiClient:
17
+ """根据当前命令上下文创建绑定 Profile 和 Workspace 的 API Client。"""
18
+
19
+ profile = get_profile(load_config(), ctx.obj.get("profile"))
20
+ return ApiClient(
21
+ profile,
22
+ workspace_id=ctx.obj.get("workspace_id"),
23
+ idempotency_key=ctx.obj.get("idempotency_key"),
24
+ )
25
+
26
+
27
+ def _validate_idempotency_key(
28
+ ctx: click.Context,
29
+ _: click.Parameter,
30
+ value: str | None,
31
+ ) -> str | None:
32
+ """校验并保存命令级幂等键,供当前命令创建的 API Client 使用。"""
33
+
34
+ if value is None:
35
+ return None
36
+ normalized = value.strip()
37
+ if not normalized:
38
+ raise click.BadParameter("幂等键不能为空。")
39
+ if len(normalized) > 128 or not normalized.isascii():
40
+ raise click.BadParameter("幂等键必须是不超过 128 个字符的 ASCII 字符串。")
41
+ ctx.ensure_object(dict)
42
+ ctx.obj["idempotency_key"] = normalized
43
+ return normalized
44
+
45
+
46
+ def idempotency_key_option(command: Any) -> Any:
47
+ """为写命令增加可复用的 `--idempotency-key` 选项。"""
48
+
49
+ return click.option(
50
+ "--idempotency-key",
51
+ callback=_validate_idempotency_key,
52
+ expose_value=False,
53
+ metavar="KEY",
54
+ help="写操作幂等键;请求超时后可用同一键安全重放。",
55
+ )(command)
56
+
57
+
58
+ def read_json_file(file_path: str, *, label: str = "JSON 文件") -> Any:
59
+ """读取 UTF-8 JSON 文件,并把文件错误转换为 Click 参数错误。"""
60
+
61
+ try:
62
+ content = Path(file_path).read_text(encoding="utf-8")
63
+ return json.loads(content)
64
+ except (OSError, UnicodeDecodeError) as exc:
65
+ raise click.ClickException(f"无法读取{label} '{file_path}': {exc}") from exc
66
+ except json.JSONDecodeError as exc:
67
+ raise click.ClickException(f"{label} '{file_path}' 不是合法 JSON: {exc}") from exc
68
+
69
+
70
+ def read_text_file(file_path: str, *, label: str = "文本文件") -> str:
71
+ """读取 UTF-8 文本文件,保留原始内容。"""
72
+
73
+ try:
74
+ return Path(file_path).read_text(encoding="utf-8")
75
+ except (OSError, UnicodeDecodeError) as exc:
76
+ raise click.ClickException(f"无法读取{label} '{file_path}': {exc}") from exc
77
+
78
+
79
+ def require_object(value: Any, *, label: str) -> dict[str, Any]:
80
+ """校验载荷根节点必须为 JSON 对象。"""
81
+
82
+ if not isinstance(value, dict):
83
+ raise click.ClickException(f"{label}必须是 JSON 对象。")
84
+ return value
85
+
86
+
87
+ def require_array(value: Any, *, label: str) -> list[Any]:
88
+ """校验载荷根节点必须为 JSON 数组。"""
89
+
90
+ if not isinstance(value, list):
91
+ raise click.ClickException(f"{label}必须是 JSON 数组。")
92
+ return value
93
+
94
+
95
+ def require_ids(value: Any, *, label: str = "ID 列表") -> list[int]:
96
+ """校验批量归档文件是只包含正整数 ID 的 JSON 数组。"""
97
+
98
+ items = require_array(value, label=label)
99
+ if any(isinstance(item, bool) or not isinstance(item, int) or item < 1 for item in items):
100
+ raise click.ClickException(f"{label}必须是只包含正整数的 JSON 数组。")
101
+ return items
102
+
103
+
104
+ def output_result(ctx: click.Context, value: Any) -> None:
105
+ """输出命令结果;复杂响应统一使用 JSON,保证 CLI 与 Agent 消费一致。"""
106
+
107
+ print_json(value)
108
+
109
+
110
+ def handle_api_error(message: str, error: ApiClientError) -> NoReturn:
111
+ """统一打印结构化 API 错误并以非零状态退出。"""
112
+
113
+ print_error(
114
+ f"{message}: {error.message}",
115
+ code=error.code,
116
+ details=error.details,
117
+ request_id=error.request_id,
118
+ retry_after=error.retry_after,
119
+ )
120
+ raise SystemExit(1)
121
+
122
+
123
+ def resolve_wait_job(
124
+ client: ApiClient,
125
+ job: dict[str, Any],
126
+ *,
127
+ wait: bool,
128
+ timeout: float,
129
+ ) -> dict[str, Any]:
130
+ """按命令的 wait 选择返回入队结果或任务终态。"""
131
+
132
+ if not wait:
133
+ return job
134
+ job_id = str(job.get("job_id") or "")
135
+ if not job_id:
136
+ raise click.ClickException("服务端未返回 job_id,无法等待异步任务。")
137
+ return client.poll_mutation_job(job_id, timeout_seconds=timeout)
138
+
139
+
140
+ def require_success_job(job: dict[str, Any], *, ctx: click.Context | None = None) -> dict[str, Any]:
141
+ """将失败或取消的任务转换为 CLI 失败退出。"""
142
+
143
+ if job.get("status") in {"failed", "canceled"}:
144
+ raw_error = job.get("error") or {}
145
+ error = raw_error if isinstance(raw_error, dict) else {"message": str(raw_error)}
146
+ if ctx is not None:
147
+ if ctx.obj.get("as_json"):
148
+ print_json(job)
149
+ else:
150
+ print_error(
151
+ f"异步任务执行失败: {error.get('message') or '未提供错误信息。'}",
152
+ code=error.get("code"),
153
+ details=error,
154
+ )
155
+ raise SystemExit(1)
156
+ raise click.ClickException(str(error.get("message") or "异步任务执行失败。"))
157
+ return job
158
+
159
+
160
+ def confirm_archive(ids: list[int], *, yes: bool, label: str) -> None:
161
+ """对单项或批量归档执行统一确认。"""
162
+
163
+ if yes:
164
+ return
165
+ if not click.confirm(f"确定要归档 {label} {', '.join(map(str, ids))} 吗?"):
166
+ raise click.Abort()
167
+
168
+
169
+ __all__ = [
170
+ "confirm_archive",
171
+ "get_client",
172
+ "handle_api_error",
173
+ "idempotency_key_option",
174
+ "output_result",
175
+ "read_json_file",
176
+ "read_text_file",
177
+ "require_array",
178
+ "require_ids",
179
+ "require_object",
180
+ "require_success_job",
181
+ "resolve_wait_job",
182
+ ]
@@ -0,0 +1,293 @@
1
+ """文件功能:提供工作空间组件的查询、创建、编辑、校验、发布与归档命令。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from wp.client import ApiClientError
8
+ from wp.commands.common import (
9
+ confirm_archive,
10
+ get_client,
11
+ handle_api_error,
12
+ idempotency_key_option,
13
+ output_result,
14
+ read_json_file,
15
+ read_text_file,
16
+ require_array,
17
+ require_ids,
18
+ require_object,
19
+ require_success_job,
20
+ resolve_wait_job,
21
+ )
22
+ from wp.config import get_profile, load_config
23
+ from wp.formatter import print_table
24
+
25
+
26
+ @click.group("component")
27
+ def component_group() -> None:
28
+ """工作空间组件管理与 Mutation 任务。"""
29
+
30
+
31
+ @component_group.command("list")
32
+ @click.option("--page", default=1, type=int)
33
+ @click.option("--page-size", default=50, type=int)
34
+ @click.option("--keyword")
35
+ @click.option("--scope", type=click.Choice(["all", "suggested"]), default="all")
36
+ @click.option("--project-id", type=int)
37
+ @click.pass_context
38
+ def list_components_cmd(ctx: click.Context, page: int, page_size: int, keyword: str | None, scope: str, project_id: int | None) -> None:
39
+ """查询工作空间组件或项目建议组件。"""
40
+
41
+ if scope == "suggested" and project_id is None:
42
+ raise click.UsageError("scope=suggested 时必须提供 --project-id。")
43
+ params = {"page": page, "page_size": page_size, "keyword": keyword, "scope": scope, "project_id": project_id}
44
+ try:
45
+ result = get_client(ctx).get("/components", params={key: value for key, value in params.items() if value is not None})
46
+ if ctx.obj.get("as_json"):
47
+ output_result(ctx, result)
48
+ return
49
+ rows = [
50
+ [item.get("id"), item.get("import_name", "-"), item.get("name", "-"), item.get("component_type", "-"), item.get("status", "-")]
51
+ for item in result.get("items", [])
52
+ ]
53
+ print_table("组件列表", ["ID", "导入标识", "名称", "类型", "状态"], rows)
54
+ except ApiClientError as err:
55
+ handle_api_error("获取组件列表失败", err)
56
+
57
+
58
+ @component_group.command("get")
59
+ @click.argument("component_id", type=int)
60
+ @click.pass_context
61
+ def get_component_cmd(ctx: click.Context, component_id: int) -> None:
62
+ """获取组件详情。"""
63
+
64
+ try:
65
+ output_result(ctx, get_client(ctx).get(f"/components/{component_id}"))
66
+ except ApiClientError as err:
67
+ handle_api_error("获取组件失败", err)
68
+
69
+
70
+ @component_group.command("create")
71
+ @click.option("--name")
72
+ @click.option("--import-name")
73
+ @click.option("--file", "file_path", type=click.Path(exists=True, dir_okay=False))
74
+ @click.option("--type", "component_type", default="content")
75
+ @click.option("--description")
76
+ @click.option("--preview-schema-file", type=click.Path(exists=True, dir_okay=False))
77
+ @click.option("--payload-file", type=click.Path(exists=True, dir_okay=False))
78
+ @click.option("--wait/--no-wait", default=True)
79
+ @click.option("--timeout", type=float, default=120.0, show_default=True)
80
+ @idempotency_key_option
81
+ @click.pass_context
82
+ def create_component_cmd(
83
+ ctx: click.Context,
84
+ name: str | None,
85
+ import_name: str | None,
86
+ file_path: str | None,
87
+ component_type: str,
88
+ description: str | None,
89
+ preview_schema_file: str | None,
90
+ payload_file: str | None,
91
+ wait: bool,
92
+ timeout: float,
93
+ ) -> None:
94
+ """提交组件创建 Mutation Job。"""
95
+
96
+ profile = get_profile(load_config(), ctx.obj.get("profile"))
97
+ workspace_id = ctx.obj.get("workspace_id") or profile.default_workspace_id
98
+ if not workspace_id:
99
+ raise click.UsageError("必须通过 --workspace 或 Profile 默认配置指定工作空间。")
100
+ if payload_file:
101
+ payload = require_object(read_json_file(payload_file, label="组件创建载荷"), label="组件创建载荷")
102
+ else:
103
+ if not name or not import_name or not file_path:
104
+ raise click.UsageError("未使用 --payload-file 时必须提供 --name、--import-name 和 --file。")
105
+ payload = {
106
+ "workspace_id": workspace_id,
107
+ "import_name": import_name,
108
+ "name": name,
109
+ "component_type": component_type,
110
+ "source_code": read_text_file(file_path, label="组件源码"),
111
+ "description": description,
112
+ }
113
+ if preview_schema_file:
114
+ payload["preview_schema"] = read_json_file(preview_schema_file, label="Preview Schema")
115
+ try:
116
+ client = get_client(ctx)
117
+ result = resolve_wait_job(client, client.create_component(payload), wait=wait, timeout=timeout)
118
+ if wait:
119
+ require_success_job(result, ctx=ctx)
120
+ output_result(ctx, result)
121
+ except ApiClientError as err:
122
+ handle_api_error("提交组件创建失败", err)
123
+
124
+
125
+ @component_group.command("update")
126
+ @click.argument("component_id", type=int)
127
+ @click.option("--payload-file", type=click.Path(exists=True, dir_okay=False), required=True)
128
+ @click.option("--preview-schema-file", type=click.Path(exists=True, dir_okay=False), help="覆盖 payload 中的 Preview Schema JSON")
129
+ @click.option("--wait/--no-wait", default=True)
130
+ @click.option("--timeout", type=float, default=120.0, show_default=True)
131
+ @idempotency_key_option
132
+ @click.pass_context
133
+ def update_component_cmd(
134
+ ctx: click.Context,
135
+ component_id: int,
136
+ payload_file: str,
137
+ preview_schema_file: str | None,
138
+ wait: bool,
139
+ timeout: float,
140
+ ) -> None:
141
+ """更新组件元数据,复杂字段自动进入异步校验任务。"""
142
+
143
+ payload = require_object(read_json_file(payload_file, label="组件更新载荷"), label="组件更新载荷")
144
+ if preview_schema_file:
145
+ payload["preview_schema"] = require_object(
146
+ read_json_file(preview_schema_file, label="Preview Schema"),
147
+ label="Preview Schema",
148
+ )
149
+ try:
150
+ client = get_client(ctx)
151
+ if any(field in payload for field in ("import_name", "component_type", "preview_schema")):
152
+ job = client.update_component_metadata_async({"component_id": component_id, **payload})
153
+ result = resolve_wait_job(client, job, wait=wait, timeout=timeout)
154
+ if wait:
155
+ require_success_job(result, ctx=ctx)
156
+ output_result(ctx, result)
157
+ return
158
+ output_result(ctx, client.patch(f"/components/{component_id}", json_data=payload))
159
+ except ApiClientError as err:
160
+ handle_api_error("更新组件失败", err)
161
+
162
+
163
+ @component_group.command("edit")
164
+ @click.argument("component_id", type=int)
165
+ @click.option("--edits-file", type=click.Path(exists=True, dir_okay=False), required=True)
166
+ @click.option("--base-version-no", type=int, required=True)
167
+ @click.option("--base-draft-hash", required=True)
168
+ @click.option("--wait/--no-wait", default=True)
169
+ @click.option("--timeout", type=float, default=120.0, show_default=True)
170
+ @idempotency_key_option
171
+ @click.pass_context
172
+ def edit_component_cmd(ctx: click.Context, component_id: int, edits_file: str, base_version_no: int, base_draft_hash: str, wait: bool, timeout: float) -> None:
173
+ """提交组件源码结构化编辑任务。"""
174
+
175
+ edits = require_array(read_json_file(edits_file, label="组件编辑操作"), label="组件编辑操作")
176
+ try:
177
+ client = get_client(ctx)
178
+ payload = {"component_id": component_id, "base_version_no": base_version_no, "base_draft_hash": base_draft_hash, "edits": edits}
179
+ result = resolve_wait_job(client, client.edit_component(component_id, payload), wait=wait, timeout=timeout)
180
+ if wait:
181
+ require_success_job(result, ctx=ctx)
182
+ output_result(ctx, result)
183
+ except ApiClientError as err:
184
+ handle_api_error("提交组件编辑失败", err)
185
+
186
+
187
+ @component_group.group("version")
188
+ def component_version_group() -> None:
189
+ """组件历史发布版本。"""
190
+
191
+
192
+ @component_version_group.command("list")
193
+ @click.argument("component_id", type=int)
194
+ @click.pass_context
195
+ def list_component_versions_cmd(ctx: click.Context, component_id: int) -> None:
196
+ """列出组件版本。"""
197
+
198
+ try:
199
+ output_result(ctx, get_client(ctx).get(f"/components/{component_id}/versions"))
200
+ except ApiClientError as err:
201
+ handle_api_error("获取组件版本失败", err)
202
+
203
+
204
+ @component_version_group.command("get")
205
+ @click.argument("component_id", type=int)
206
+ @click.argument("version_no", type=int)
207
+ @click.pass_context
208
+ def get_component_version_cmd(ctx: click.Context, component_id: int, version_no: int) -> None:
209
+ """获取组件指定版本。"""
210
+
211
+ try:
212
+ output_result(ctx, get_client(ctx).get(f"/components/{component_id}/versions/{version_no}"))
213
+ except ApiClientError as err:
214
+ handle_api_error("获取组件版本内容失败", err)
215
+
216
+
217
+ @component_group.command("dependencies")
218
+ @click.argument("component_id", type=int)
219
+ @click.pass_context
220
+ def component_dependencies_cmd(ctx: click.Context, component_id: int) -> None:
221
+ """获取组件当前版本依赖。"""
222
+
223
+ try:
224
+ output_result(ctx, get_client(ctx).get(f"/components/{component_id}/dependencies"))
225
+ except ApiClientError as err:
226
+ handle_api_error("获取组件依赖失败", err)
227
+
228
+
229
+ @component_group.command("validate")
230
+ @click.argument("component_id", type=int)
231
+ @click.option("--mode", type=click.Choice(["current", "content", "edits"]), default="current")
232
+ @click.option("--source-file", type=click.Path(exists=True, dir_okay=False))
233
+ @click.option("--edits-file", type=click.Path(exists=True, dir_okay=False))
234
+ @click.option("--preview-schema-file", type=click.Path(exists=True, dir_okay=False))
235
+ @click.option("--detail", is_flag=True)
236
+ @click.pass_context
237
+ def validate_component_cmd(ctx: click.Context, component_id: int, mode: str, source_file: str | None, edits_file: str | None, preview_schema_file: str | None, detail: bool) -> None:
238
+ """校验组件当前或候选源码。"""
239
+
240
+ payload: dict[str, object] = {"entity_type": "component", "entity_id": component_id, "mode": mode, "detail": detail}
241
+ if mode == "content":
242
+ if not source_file:
243
+ raise click.UsageError("content 模式必须提供 --source-file。")
244
+ payload["source_code"] = read_text_file(source_file, label="组件源码")
245
+ if mode == "edits":
246
+ if not edits_file:
247
+ raise click.UsageError("edits 模式必须提供 --edits-file。")
248
+ payload["edits"] = require_array(read_json_file(edits_file, label="组件编辑操作"), label="组件编辑操作")
249
+ if preview_schema_file:
250
+ payload["preview_schema"] = require_object(read_json_file(preview_schema_file, label="Preview Schema"), label="Preview Schema")
251
+ try:
252
+ output_result(ctx, get_client(ctx).validate_entity(payload))
253
+ except ApiClientError as err:
254
+ handle_api_error("组件校验失败", err)
255
+
256
+
257
+ @component_group.command("publish")
258
+ @click.argument("component_id", type=int)
259
+ @click.option("--release-name")
260
+ @click.option("--change-note")
261
+ @idempotency_key_option
262
+ @click.pass_context
263
+ def publish_component_cmd(ctx: click.Context, component_id: int, release_name: str | None, change_note: str | None) -> None:
264
+ """发布组件当前草稿。"""
265
+
266
+ try:
267
+ output_result(ctx, get_client(ctx).post(f"/components/{component_id}/publish", json_data={"release_name": release_name, "change_note": change_note}))
268
+ except ApiClientError as err:
269
+ handle_api_error("发布组件失败", err)
270
+
271
+
272
+ @component_group.command("archive")
273
+ @click.argument("component_id", type=int, required=False)
274
+ @click.option("--ids-file", type=click.Path(exists=True, dir_okay=False))
275
+ @click.option("--yes", is_flag=True)
276
+ @idempotency_key_option
277
+ @click.pass_context
278
+ def archive_component_cmd(ctx: click.Context, component_id: int | None, ids_file: str | None, yes: bool) -> None:
279
+ """归档单个或一批组件。"""
280
+
281
+ try:
282
+ client = get_client(ctx)
283
+ if ids_file:
284
+ ids = require_ids(read_json_file(ids_file, label="归档 ID"))
285
+ confirm_archive(ids, yes=yes, label="组件")
286
+ output_result(ctx, client.post("/components/batch-archive", json_data={"ids": ids}))
287
+ return
288
+ if component_id is None:
289
+ raise click.UsageError("必须提供 component_id 或 --ids-file。")
290
+ confirm_archive([component_id], yes=yes, label="组件")
291
+ output_result(ctx, client.post(f"/components/{component_id}/archive"))
292
+ except ApiClientError as err:
293
+ handle_api_error("归档组件失败", err)
wp/commands/doctor.py ADDED
@@ -0,0 +1,84 @@
1
+ """文件功能:执行 CLI 诊断(连通性、PAT 有效性、默认空间与权限检测)。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+ import httpx
7
+
8
+ import wp
9
+ from wp.client import ApiClient, ApiClientError
10
+ from wp.config import get_profile, load_config
11
+ from wp.formatter import print_json, print_table
12
+
13
+
14
+ @click.command("doctor")
15
+ @click.pass_context
16
+ def doctor_cmd(ctx: click.Context) -> None:
17
+ """全面诊断本地 CLI 环境、服务连通性与 PAT 授权状态。"""
18
+
19
+ cfg = load_config()
20
+ profile = get_profile(cfg, ctx.obj.get("profile"))
21
+ diagnostics: list[dict[str, str]] = []
22
+
23
+ # 1. CLI 版本
24
+ diagnostics.append({"check": "CLI 版本", "value": f"v{wp.__version__}", "status": "ok"})
25
+
26
+ # 2. 服务端探活
27
+ endpoint = profile.endpoint.rstrip("/")
28
+ health_value = "不可达"
29
+ health_status = "error"
30
+ try:
31
+ r = httpx.get(f"{endpoint}/api/v1/system/health", timeout=5.0)
32
+ if r.status_code == 200:
33
+ health = r.json()
34
+ health_data = health if isinstance(health, dict) else {}
35
+ backend_status = str(health_data.get("status", "unknown"))
36
+ health_value = (
37
+ f"{backend_status}(database={health_data.get('database')}, "
38
+ f"redis={health_data.get('redis')})"
39
+ )
40
+ health_status = "ok" if backend_status == "ok" else "warning"
41
+ else:
42
+ health_value = f"HTTP {r.status_code}"
43
+ except (httpx.RequestError, ValueError) as exc:
44
+ health_value = str(exc) or health_value
45
+ diagnostics.append({"check": "Backend 地址", "value": f"{endpoint}:{health_value}", "status": health_status})
46
+
47
+ # 3. PAT 凭证检测
48
+ token_status = "未配置"
49
+ if profile.token:
50
+ token_status = "已配置"
51
+ diagnostics.append(
52
+ {
53
+ "check": "访问令牌 (PAT)",
54
+ "value": token_status,
55
+ "status": "ok" if profile.token else "warning",
56
+ }
57
+ )
58
+
59
+ # 4. API 连通与权限校验
60
+ if profile.token:
61
+ client = ApiClient(profile)
62
+ try:
63
+ workspaces = client.get("/workspaces")
64
+ diagnostics.append({"check": "授权工作空间", "value": f"{len(workspaces)} 个可用空间", "status": "ok"})
65
+ except ApiClientError as err:
66
+ diagnostics.append({"check": "API 认证", "value": f"认证失败: {err.message}", "status": "error"})
67
+
68
+ ws_id = profile.default_workspace_id
69
+ if ws_id:
70
+ try:
71
+ ws = client.get(f"/workspaces/{ws_id}")
72
+ diagnostics.append({"check": "默认工作空间", "value": f"{ws.get('name')} (ID: {ws_id})", "status": "ok"})
73
+ except ApiClientError:
74
+ diagnostics.append({"check": "默认工作空间", "value": f"访问受限 (ID: {ws_id})", "status": "error"})
75
+ else:
76
+ diagnostics.append({"check": "默认工作空间", "value": "未设置,请使用 wp workspace use <id>", "status": "warning"})
77
+
78
+ if ctx.obj.get("as_json"):
79
+ print_json(diagnostics)
80
+ return
81
+
82
+ status_labels = {"ok": "正常", "warning": "警告", "error": "失败"}
83
+ rows = [[item["check"], item["value"], status_labels.get(item["status"], item["status"])] for item in diagnostics]
84
+ print_table("CLI 诊断检查报告", ["检查项", "当前状态", "判定结果"], rows)
wp/commands/job.py ADDED
@@ -0,0 +1,84 @@
1
+ """文件功能:提供统一的异步 Mutation Job 查询、等待、取消与重试命令。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from wp.client import ApiClientError
8
+ from wp.commands.common import (
9
+ get_client,
10
+ handle_api_error,
11
+ idempotency_key_option,
12
+ output_result,
13
+ require_success_job,
14
+ )
15
+
16
+
17
+ @click.group("job")
18
+ def job_group() -> None:
19
+ """异步任务管理。"""
20
+
21
+
22
+ @job_group.command("get")
23
+ @click.argument("job_id")
24
+ @click.pass_context
25
+ def get_job_cmd(ctx: click.Context, job_id: str) -> None:
26
+ """查询 Mutation Job。"""
27
+
28
+ try:
29
+ output_result(ctx, get_client(ctx).get_mutation_job(job_id))
30
+ except ApiClientError as err:
31
+ handle_api_error("查询 Job 失败", err)
32
+
33
+
34
+ @job_group.command("wait")
35
+ @click.argument("job_id")
36
+ @click.option("--timeout", default=120.0, type=float, show_default=True)
37
+ @click.pass_context
38
+ def wait_job_cmd(ctx: click.Context, job_id: str, timeout: float) -> None:
39
+ """等待 Mutation Job 进入终态。"""
40
+
41
+ try:
42
+ result = get_client(ctx).poll_mutation_job(job_id, timeout_seconds=timeout)
43
+ require_success_job(result, ctx=ctx)
44
+ output_result(ctx, result)
45
+ except ApiClientError as err:
46
+ handle_api_error("等待 Job 失败", err)
47
+
48
+
49
+ @job_group.command("cancel")
50
+ @click.argument("job_id")
51
+ @idempotency_key_option
52
+ @click.pass_context
53
+ def cancel_job_cmd(ctx: click.Context, job_id: str) -> None:
54
+ """取消 pending 或 running Mutation Job。"""
55
+
56
+ try:
57
+ output_result(
58
+ ctx,
59
+ get_client(ctx).cancel_mutation_job(
60
+ job_id,
61
+ idempotency_key=ctx.obj.get("idempotency_key"),
62
+ ),
63
+ )
64
+ except ApiClientError as err:
65
+ handle_api_error("取消 Job 失败", err)
66
+
67
+
68
+ @job_group.command("retry")
69
+ @click.argument("job_id")
70
+ @idempotency_key_option
71
+ @click.pass_context
72
+ def retry_job_cmd(ctx: click.Context, job_id: str) -> None:
73
+ """重试一个明确允许人工重试的失败 Job。"""
74
+
75
+ try:
76
+ output_result(
77
+ ctx,
78
+ get_client(ctx).retry_mutation_job(
79
+ job_id,
80
+ idempotency_key=ctx.obj.get("idempotency_key"),
81
+ ),
82
+ )
83
+ except ApiClientError as err:
84
+ handle_api_error("重试 Job 失败", err)