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/formatter.py ADDED
@@ -0,0 +1,66 @@
1
+ """文件功能:提供 Rich 终端美化输出与 JSON 统一格式化。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+ from rich.syntax import Syntax
11
+ from rich.table import Table
12
+
13
+ console = Console()
14
+ err_console = Console(stderr=True)
15
+
16
+
17
+ def print_json(data: Any) -> None:
18
+ """输出 JSON 格式数据。"""
19
+
20
+ console.print_json(data=data if isinstance(data, (dict, list)) else json.loads(str(data)))
21
+
22
+
23
+ def print_success(message: str) -> None:
24
+ """输出成功提示。"""
25
+
26
+ console.print(f"[bold green]✔[/bold green] {message}")
27
+
28
+
29
+ def print_error(
30
+ message: str,
31
+ code: str | None = None,
32
+ details: Any = None,
33
+ request_id: str | None = None,
34
+ retry_after: str | None = None,
35
+ ) -> None:
36
+ """输出错误提示。"""
37
+
38
+ prefix = f"[{code}] " if code else ""
39
+ err_console.print(f"[bold red]✖[/bold red] [red]{prefix}{message}[/red]")
40
+ if details:
41
+ err_console.print(f"[dim]详细信息: {details}[/dim]")
42
+ if request_id:
43
+ err_console.print(f"[dim]请求 ID: {request_id}[/dim]")
44
+ if retry_after:
45
+ err_console.print(f"[dim]建议等待: {retry_after} 秒后重试[/dim]")
46
+
47
+
48
+ def print_table(title: str, columns: list[str], rows: list[list[Any]]) -> None:
49
+ """输出美化表格。"""
50
+
51
+ table = Table(title=title, show_header=True, header_style="bold cyan")
52
+ for col in columns:
53
+ table.add_column(col)
54
+ for row in rows:
55
+ table.add_row(*[str(cell) if cell is not None else "-" for cell in row])
56
+ console.print(table)
57
+
58
+
59
+ def print_code(code: str, lexer: str = "vue", title: str | None = None) -> None:
60
+ """输出带语法高亮的代码块。"""
61
+
62
+ syntax = Syntax(code, lexer, theme="monokai", line_numbers=True)
63
+ if title:
64
+ console.print(Panel(syntax, title=title, border_style="dim"))
65
+ else:
66
+ console.print(syntax)
@@ -0,0 +1,5 @@
1
+ """文件功能:导出 web-presentation External API v1 的共享客户端。"""
2
+
3
+ from wp_api_client.client import ApiClient, ApiClientError
4
+
5
+ __all__ = ["ApiClient", "ApiClientError"]
@@ -0,0 +1,431 @@
1
+ """文件功能:封装 External API v1 的 HTTP 客户端,处理认证、空间隔离、幂等头与任务轮询。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import uuid
7
+ from typing import Any, Mapping
8
+
9
+ import httpx
10
+
11
+ class ApiClientError(Exception):
12
+ """API 调用异常。"""
13
+
14
+ def __init__(
15
+ self,
16
+ message: str,
17
+ status_code: int = 500,
18
+ code: str = "ERROR",
19
+ details: Any = None,
20
+ *,
21
+ request_id: str | None = None,
22
+ retry_after: str | None = None,
23
+ ) -> None:
24
+ """保存结构化错误以及服务端提供的诊断、重试信息。"""
25
+
26
+ super().__init__(message)
27
+ self.message = message
28
+ self.status_code = status_code
29
+ self.code = code
30
+ self.details = details
31
+ self.request_id = request_id
32
+ self.retry_after = retry_after
33
+
34
+
35
+ class ApiClient:
36
+ """External API v1 交互客户端。"""
37
+
38
+ def __init__(
39
+ self,
40
+ endpoint: str,
41
+ token: str | None = None,
42
+ workspace_id: int | None = None,
43
+ user_agent: str = "web-presentation-agent-kit/0.1.0",
44
+ idempotency_key: str | None = None,
45
+ ) -> None:
46
+ """创建客户端;endpoint 不应包含 `/api/v1`,公共前缀由本类统一拼接。"""
47
+
48
+ self.endpoint = endpoint.rstrip("/")
49
+ self.token = token
50
+ self.workspace_id = workspace_id
51
+ self.user_agent = user_agent
52
+ self.default_idempotency_key = idempotency_key
53
+ self.client = httpx.Client(base_url=self.endpoint, timeout=30.0)
54
+
55
+ def _request(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
56
+ """发送 HTTP 请求并把网络层异常转换为统一的 API Client 错误。"""
57
+
58
+ try:
59
+ request_method = getattr(self.client, method.lower())
60
+ return request_method(url, **kwargs)
61
+ except httpx.TimeoutException as exc:
62
+ raise ApiClientError(
63
+ "请求服务端超时,请检查网络或稍后重试。",
64
+ status_code=504,
65
+ code="REQUEST_TIMEOUT",
66
+ ) from exc
67
+ except httpx.RequestError as exc:
68
+ raise ApiClientError(
69
+ f"无法连接服务端:{exc}",
70
+ status_code=503,
71
+ code="NETWORK_ERROR",
72
+ ) from exc
73
+
74
+ def _resolve_idempotency_key(self, idempotent: bool, key: str | None) -> str | None:
75
+ """解析本次请求的幂等键;显式参数优先于客户端默认值。"""
76
+
77
+ if not idempotent:
78
+ return None
79
+ return key or self.default_idempotency_key or uuid.uuid4().hex
80
+
81
+ def _get_headers(
82
+ self,
83
+ *,
84
+ idempotent: bool = False,
85
+ custom_idempotency_key: str | None = None,
86
+ override_workspace_id: int | None = None,
87
+ ) -> dict[str, str]:
88
+ headers: dict[str, str] = {
89
+ "Accept": "application/json",
90
+ "User-Agent": self.user_agent,
91
+ }
92
+ if self.token:
93
+ headers["Authorization"] = f"Bearer {self.token}"
94
+
95
+ ws_id = override_workspace_id or self.workspace_id
96
+ if ws_id is not None:
97
+ headers["X-Workspace-ID"] = str(ws_id)
98
+
99
+ if idempotent:
100
+ headers["Idempotency-Key"] = custom_idempotency_key or uuid.uuid4().hex
101
+
102
+ return headers
103
+
104
+ def _handle_response(self, response: httpx.Response, *, idempotency_key: str | None = None) -> Any:
105
+ """解析响应,并把请求 ID 与写操作幂等键作为客户端元数据回显。"""
106
+
107
+ if response.is_success:
108
+ if response.status_code == 204 or not response.content:
109
+ return {}
110
+ content_type = response.headers.get("content-type", "")
111
+ if "application/json" in content_type:
112
+ try:
113
+ result = response.json()
114
+ except (TypeError, ValueError):
115
+ return response.text
116
+ if isinstance(result, dict):
117
+ metadata = {"request_id": response.headers.get("X-Request-ID")}
118
+ if idempotency_key:
119
+ metadata["idempotency_key"] = idempotency_key
120
+ metadata = {key: value for key, value in metadata.items() if value}
121
+ if metadata:
122
+ result["_client"] = metadata
123
+ return result
124
+ return response.text
125
+
126
+ # 错误解析
127
+ err_msg = f"HTTP {response.status_code} 请求失败"
128
+ code = "HTTP_ERROR"
129
+ details = None
130
+ try:
131
+ err_json = response.json()
132
+ if isinstance(err_json, dict):
133
+ err_msg = err_json.get("message") or err_json.get("detail") or err_msg
134
+ code = err_json.get("code") or code
135
+ details = err_json.get("data", err_json.get("detail"))
136
+ else:
137
+ details = err_json
138
+ except (TypeError, ValueError):
139
+ err_msg = response.text or err_msg
140
+
141
+ raise ApiClientError(
142
+ err_msg,
143
+ status_code=response.status_code,
144
+ code=code,
145
+ details=details,
146
+ request_id=response.headers.get("X-Request-ID"),
147
+ retry_after=response.headers.get("Retry-After"),
148
+ )
149
+
150
+ def get(self, path: str, params: Mapping[str, Any] | None = None, workspace_id: int | None = None) -> Any:
151
+ url = f"/api/v1{path}"
152
+ headers = self._get_headers(override_workspace_id=workspace_id)
153
+ resp = self._request("GET", url, params=params, headers=headers)
154
+ return self._handle_response(resp)
155
+
156
+ def get_operation_guide(self, operation_key: str | None = None) -> Any:
157
+ """读取 Guides 索引或指定 operation 的版本化详情。"""
158
+
159
+ path = "/guides" if operation_key is None else f"/guides/{operation_key}"
160
+ return self.get(path)
161
+
162
+ def get_standard(self, entity_type: str) -> dict[str, Any]:
163
+ """读取页面或组件开发标准。"""
164
+
165
+ if entity_type not in {"page", "component"}:
166
+ raise ValueError("entity_type 必须为 page 或 component")
167
+ return self.get(f"/standards/{entity_type}")
168
+
169
+ def list_runtime_kit(self, params: Mapping[str, Any] | None = None) -> Any:
170
+ """读取 Runtime Kit 能力列表。"""
171
+
172
+ return self.get("/runtime-kit", params=params)
173
+
174
+ def get_runtime_kit_item(self, item: str) -> dict[str, Any]:
175
+ """读取单个 Runtime Kit 能力。"""
176
+
177
+ return self.get(f"/runtime-kit/{item}")
178
+
179
+ def list_fonts(self, params: Mapping[str, Any] | None = None) -> Any:
180
+ """读取当前工作空间字体列表。"""
181
+
182
+ return self.get("/fonts", params=params)
183
+
184
+ def validate_entity(self, payload: Mapping[str, Any]) -> dict[str, Any]:
185
+ """调用页面/组件实体候选内容校验接口。"""
186
+
187
+ return self.post("/validate/entity", json_data=dict(payload), idempotent=False)
188
+
189
+ def create_page(self, payload: Mapping[str, Any], *, idempotency_key: str | None = None) -> dict[str, Any]:
190
+ """提交页面创建 Mutation Job。"""
191
+
192
+ return self.post("/pages", json_data=dict(payload), idempotency_key=idempotency_key)
193
+
194
+ def create_component(self, payload: Mapping[str, Any], *, idempotency_key: str | None = None) -> dict[str, Any]:
195
+ """提交组件创建 Mutation Job。"""
196
+
197
+ return self.post("/components", json_data=dict(payload), idempotency_key=idempotency_key)
198
+
199
+ def copy_page(self, page_id: int, payload: Mapping[str, Any], *, idempotency_key: str | None = None) -> dict[str, Any]:
200
+ """复制页面到目标项目。"""
201
+
202
+ return self.post(f"/pages/{page_id}/copy", json_data=dict(payload), idempotency_key=idempotency_key)
203
+
204
+ def edit_page(self, page_id: int, payload: Mapping[str, Any], *, idempotency_key: str | None = None) -> dict[str, Any]:
205
+ """提交页面源码编辑 Mutation Job。"""
206
+
207
+ return self.post(f"/pages/{page_id}/edits", json_data=dict(payload), idempotency_key=idempotency_key)
208
+
209
+ def edit_component(self, component_id: int, payload: Mapping[str, Any], *, idempotency_key: str | None = None) -> dict[str, Any]:
210
+ """提交组件源码编辑 Mutation Job。"""
211
+
212
+ return self.post(f"/components/{component_id}/edits", json_data=dict(payload), idempotency_key=idempotency_key)
213
+
214
+ def update_component_metadata_async(
215
+ self,
216
+ payload: Mapping[str, Any],
217
+ *,
218
+ idempotency_key: str | None = None,
219
+ ) -> dict[str, Any]:
220
+ """提交组件元数据重校验 Mutation Job。"""
221
+
222
+ return self.post("/jobs/mutations/components/metadata", json_data=dict(payload), idempotency_key=idempotency_key)
223
+
224
+ def post(
225
+ self,
226
+ path: str,
227
+ json_data: Any | None = None,
228
+ idempotent: bool = True,
229
+ idempotency_key: str | None = None,
230
+ workspace_id: int | None = None,
231
+ ) -> Any:
232
+ url = f"/api/v1{path}"
233
+ resolved_key = self._resolve_idempotency_key(idempotent, idempotency_key)
234
+ headers = self._get_headers(
235
+ idempotent=idempotent,
236
+ custom_idempotency_key=resolved_key,
237
+ override_workspace_id=workspace_id,
238
+ )
239
+ resp = self._request("POST", url, json=json_data, headers=headers)
240
+ return self._handle_response(resp, idempotency_key=resolved_key)
241
+
242
+ def patch(
243
+ self,
244
+ path: str,
245
+ json_data: Any | None = None,
246
+ idempotent: bool = True,
247
+ idempotency_key: str | None = None,
248
+ workspace_id: int | None = None,
249
+ ) -> Any:
250
+ url = f"/api/v1{path}"
251
+ resolved_key = self._resolve_idempotency_key(idempotent, idempotency_key)
252
+ headers = self._get_headers(
253
+ idempotent=idempotent,
254
+ custom_idempotency_key=resolved_key,
255
+ override_workspace_id=workspace_id,
256
+ )
257
+ resp = self._request("PATCH", url, json=json_data, headers=headers)
258
+ return self._handle_response(resp, idempotency_key=resolved_key)
259
+
260
+ def put(
261
+ self,
262
+ path: str,
263
+ json_data: Any | None = None,
264
+ idempotent: bool = True,
265
+ idempotency_key: str | None = None,
266
+ workspace_id: int | None = None,
267
+ ) -> Any:
268
+ url = f"/api/v1{path}"
269
+ resolved_key = self._resolve_idempotency_key(idempotent, idempotency_key)
270
+ headers = self._get_headers(
271
+ idempotent=idempotent,
272
+ custom_idempotency_key=resolved_key,
273
+ override_workspace_id=workspace_id,
274
+ )
275
+ resp = self._request("PUT", url, json=json_data, headers=headers)
276
+ return self._handle_response(resp, idempotency_key=resolved_key)
277
+
278
+ def delete(
279
+ self,
280
+ path: str,
281
+ idempotent: bool = True,
282
+ idempotency_key: str | None = None,
283
+ workspace_id: int | None = None,
284
+ ) -> Any:
285
+ url = f"/api/v1{path}"
286
+ resolved_key = self._resolve_idempotency_key(idempotent, idempotency_key)
287
+ headers = self._get_headers(
288
+ idempotent=idempotent,
289
+ custom_idempotency_key=resolved_key,
290
+ override_workspace_id=workspace_id,
291
+ )
292
+ resp = self._request("DELETE", url, headers=headers)
293
+ return self._handle_response(resp, idempotency_key=resolved_key)
294
+
295
+ def upload(
296
+ self,
297
+ path: str,
298
+ files: dict[str, Any],
299
+ data: dict[str, Any] | None = None,
300
+ idempotent: bool = True,
301
+ idempotency_key: str | None = None,
302
+ workspace_id: int | None = None,
303
+ ) -> Any:
304
+ url = f"/api/v1{path}"
305
+ resolved_key = self._resolve_idempotency_key(idempotent, idempotency_key)
306
+ headers = self._get_headers(
307
+ idempotent=idempotent,
308
+ custom_idempotency_key=resolved_key,
309
+ override_workspace_id=workspace_id,
310
+ )
311
+ resp = self._request("POST", url, data=data, files=files, headers=headers)
312
+ return self._handle_response(resp, idempotency_key=resolved_key)
313
+
314
+ def poll_mutation_job(
315
+ self,
316
+ job_id: str,
317
+ timeout_seconds: float = 60.0,
318
+ interval: float = 1.0,
319
+ ) -> dict[str, Any]:
320
+ """轮询异步变更任务直到进入终态。"""
321
+
322
+ if timeout_seconds <= 0:
323
+ raise ApiClientError("等待 Mutation 任务超时:timeout 必须大于 0。", code="INVALID_TIMEOUT", status_code=400)
324
+
325
+ deadline = time.perf_counter() + timeout_seconds
326
+ while time.perf_counter() < deadline:
327
+ job = self.get(f"/jobs/mutations/{job_id}")
328
+ status = job.get("status")
329
+ if status in {"succeeded", "failed", "canceled"}:
330
+ return job
331
+ remaining = deadline - time.perf_counter()
332
+ if remaining > 0:
333
+ time.sleep(min(max(interval, 0), remaining))
334
+
335
+ raise ApiClientError(f"等待 Mutation 任务超时 ({timeout_seconds}s)", code="TIMEOUT")
336
+
337
+ def get_mutation_job(self, job_id: str) -> dict[str, Any]:
338
+ """查询 Mutation Job。"""
339
+
340
+ return self.get(f"/jobs/mutations/{job_id}")
341
+
342
+ def cancel_mutation_job(self, job_id: str, *, idempotency_key: str | None = None) -> dict[str, Any]:
343
+ """取消 Mutation Job,并支持调用方复用原幂等键。"""
344
+
345
+ return self.post(f"/jobs/mutations/{job_id}/cancel", idempotency_key=idempotency_key)
346
+
347
+ def retry_mutation_job(self, job_id: str, *, idempotency_key: str | None = None) -> dict[str, Any]:
348
+ """为可重试失败任务创建一个新 Job。"""
349
+
350
+ return self.post(f"/jobs/mutations/{job_id}/retry", idempotency_key=idempotency_key)
351
+
352
+ def _is_same_origin(self, target_url: str) -> bool:
353
+ """校验目标 URL 是否与 Client 配置的 API endpoint 同源。"""
354
+
355
+ from urllib.parse import urlsplit
356
+
357
+ target_parts = urlsplit(target_url)
358
+ endpoint_parts = urlsplit(self.endpoint)
359
+ return (
360
+ target_parts.scheme.lower() == endpoint_parts.scheme.lower()
361
+ and target_parts.netloc.lower() == endpoint_parts.netloc.lower()
362
+ )
363
+
364
+ def get_bytes(
365
+ self,
366
+ path_or_url: str,
367
+ params: Mapping[str, Any] | None = None,
368
+ workspace_id: int | None = None,
369
+ ) -> tuple[httpx.Headers, bytes]:
370
+ """获取原始响应 Headers 与二进制 Byte 内容(带有跨域 PAT 泄露防护)。"""
371
+
372
+ if path_or_url.startswith("http://") or path_or_url.startswith("https://"):
373
+ if not self._is_same_origin(path_or_url):
374
+ raise ApiClientError(
375
+ "安全拦截:禁止向非 Endpoint 同源地址发送 PAT 令牌",
376
+ code="CROSS_ORIGIN_PAT_BLOCKED",
377
+ )
378
+ url = path_or_url
379
+ elif path_or_url.startswith("/api/v1"):
380
+ url = path_or_url
381
+ else:
382
+ url = f"/api/v1{path_or_url}" if path_or_url.startswith("/") else f"/api/v1/{path_or_url}"
383
+
384
+ headers = self._get_headers(override_workspace_id=workspace_id)
385
+ resp = self._request("GET", url, params=params, headers=headers)
386
+ if not resp.is_success:
387
+ self._handle_response(resp)
388
+ return resp.headers, resp.content
389
+
390
+ def get_latest_page_screenshot(self, page_id: int) -> tuple[dict[str, Any], bytes]:
391
+ """请求 External API v1 获取最新 PNG 截图 (一次性 GET 请求)。"""
392
+
393
+ resp_headers, img_bytes = self.get_bytes(f"/pages/{page_id}/screenshot")
394
+ content_type = str(resp_headers.get("content-type") or "").split(";", 1)[0].strip().lower()
395
+ if "image/png" not in content_type:
396
+ raise ApiClientError(
397
+ f"服务端响应 Content-Type 异常 ('{content_type}'),预期为 image/png",
398
+ code="INVALID_CONTENT_TYPE",
399
+ )
400
+
401
+ version_no_str = resp_headers.get("x-page-version-no")
402
+ if not version_no_str:
403
+ raise ApiClientError(
404
+ "服务端未返回有效的页面版本响应头 (X-Page-Version-No)",
405
+ code="INVALID_HEADER",
406
+ )
407
+
408
+ try:
409
+ version_no = int(version_no_str)
410
+ except ValueError as exc:
411
+ raise ApiClientError(
412
+ f"服务端返回的页面版本响应头格式非法 ('{version_no_str}')",
413
+ code="INVALID_HEADER",
414
+ ) from exc
415
+
416
+ meta = {
417
+ "page_id": page_id,
418
+ "version_no": version_no,
419
+ }
420
+ return meta, img_bytes
421
+
422
+ def close(self) -> None:
423
+ """关闭底层 HTTP 连接池。"""
424
+
425
+ self.client.close()
426
+
427
+ def __enter__(self) -> "ApiClient":
428
+ return self
429
+
430
+ def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
431
+ self.close()