cheso-cli 0.2.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.
- cheso_cli/__init__.py +12 -0
- cheso_cli/__main__.py +5 -0
- cheso_cli/auth.py +128 -0
- cheso_cli/cli.py +864 -0
- cheso_cli/client.py +534 -0
- cheso_cli/config.py +108 -0
- cheso_cli/credentials.py +235 -0
- cheso_cli/errors.py +56 -0
- cheso_cli/legacy_commands.py +93 -0
- cheso_cli/oauth.py +797 -0
- cheso_cli/skill/SKILL.md +83 -0
- cheso_cli/skill_install.py +119 -0
- cheso_cli/tasks.py +834 -0
- cheso_cli/transfer.py +294 -0
- cheso_cli-0.2.0.dist-info/METADATA +164 -0
- cheso_cli-0.2.0.dist-info/RECORD +18 -0
- cheso_cli-0.2.0.dist-info/WHEEL +4 -0
- cheso_cli-0.2.0.dist-info/entry_points.txt +4 -0
cheso_cli/tasks.py
ADDED
|
@@ -0,0 +1,834 @@
|
|
|
1
|
+
"""任务(Plan)、转换和翻译:发起、等待、查询、回答、取消及获取结果。
|
|
2
|
+
|
|
3
|
+
各函数返回普通字典作为结果视图,由 CLI 输出为 JSON,或格式化后展示给用户。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import posixpath
|
|
9
|
+
import re
|
|
10
|
+
import shlex
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, TextIO
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from cheso_cli.client import ApiError, ChesoClient, Deadline, TimeLimitReached
|
|
21
|
+
from cheso_cli.config import Settings
|
|
22
|
+
from cheso_cli.errors import CliError, ExitCode, UsageError
|
|
23
|
+
from cheso_cli.transfer import download_url_to_dir, upload_file
|
|
24
|
+
|
|
25
|
+
KIND_TASK = "task"
|
|
26
|
+
KIND_CONVERSION = "conversion"
|
|
27
|
+
KIND_TRANSLATION = "translation"
|
|
28
|
+
# Manager 的 typeid 前缀。
|
|
29
|
+
ID_PREFIXES = {"plan_": KIND_TASK, "cvt_": KIND_CONVERSION, "tlj_": KIND_TRANSLATION}
|
|
30
|
+
|
|
31
|
+
PLAN_INTERACT = "interact"
|
|
32
|
+
PLAN_COMPLETED = "completed"
|
|
33
|
+
PLAN_TERMINAL = {"completed", "failed", "terminated", "rolled_back"}
|
|
34
|
+
PLAN_RUNNING = {"in_progress", "interact", "pending"}
|
|
35
|
+
CONVERT_COMPLETED = "completed"
|
|
36
|
+
CONVERT_DONE = {"completed", "failed"}
|
|
37
|
+
TRANSLATION_SUCCEEDED = "SUCCEEDED"
|
|
38
|
+
TRANSLATION_DONE = {"SUCCEEDED", "FAILED", "CANCELED"}
|
|
39
|
+
# 翻译过程中的中间产物,例如结构化文本块和提取的图片,不作为最终交付文件。
|
|
40
|
+
TRANSLATION_SKIP_ROLES = {"blocks", "image"}
|
|
41
|
+
|
|
42
|
+
# Agent 可能将执行交给后续 Plan,最多沿接续链跟进以下跳数。
|
|
43
|
+
MAX_CHAIN_HOPS = 10
|
|
44
|
+
DEFAULT_POLL_INTERVAL = 3.0
|
|
45
|
+
DEFAULT_WAIT_TIMEOUT = 1800.0
|
|
46
|
+
HEARTBEAT_SECONDS = 60.0
|
|
47
|
+
# 等待期间允许连续出现的临时错误次数,包括网络错误、5xx 和 429。
|
|
48
|
+
MAX_TRANSIENT_ERRORS = 5
|
|
49
|
+
|
|
50
|
+
CODE_PROJECT_BUSY = 2614
|
|
51
|
+
CODE_CONVERT_UNSUPPORTED = 1801
|
|
52
|
+
AGENT_TYPES = ("cheso", "image_edit")
|
|
53
|
+
# 网站支持的目标语言,见 services/frontend/src/config/translation-languages.ts。
|
|
54
|
+
TRANSLATION_LANGUAGES = ("zh", "zh-Hant", "en", "ko", "ja", "es", "fr", "ar", "ru", "pt", "de", "th")
|
|
55
|
+
# 按网站 convert-download-request.ts 的规则由扩展名确定源类型;其他类型直接使用扩展名。
|
|
56
|
+
CONVERT_SOURCE_BY_EXT = {"md": "md", "markdown": "md", "html": "html", "htm": "html", "ppt": "pptx", "pptx": "pptx"}
|
|
57
|
+
# Agent 积分不足时,失败原因中会包含以下任一标记。
|
|
58
|
+
CREDIT_FAILURE_MARKERS = ("insufficient_credits", "insufficient credits", "额度不足", "积分不足")
|
|
59
|
+
|
|
60
|
+
_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
|
|
61
|
+
_FRACTION = re.compile(r"\.(\d+)")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# 任务、转换或翻译成功时对应的状态。
|
|
65
|
+
SUCCESS_STATUS = {
|
|
66
|
+
KIND_TASK: PLAN_COMPLETED,
|
|
67
|
+
KIND_CONVERSION: CONVERT_COMPLETED,
|
|
68
|
+
KIND_TRANSLATION: TRANSLATION_SUCCEEDED,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def succeeded(view: dict[str, Any]) -> bool:
|
|
73
|
+
return bool(view.get("done")) and view.get("status") == SUCCESS_STATUS.get(view.get("kind", ""))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def validate_id(label: str, value: str) -> str:
|
|
77
|
+
value = (value or "").strip()
|
|
78
|
+
if not _ID_PATTERN.match(value):
|
|
79
|
+
raise UsageError(f"{label} {value!r} is not a valid Cheso id")
|
|
80
|
+
return value
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def task_kind(task_id: str) -> str:
|
|
84
|
+
task_id = validate_id("task id", task_id)
|
|
85
|
+
for prefix, kind in ID_PREFIXES.items():
|
|
86
|
+
if task_id.startswith(prefix):
|
|
87
|
+
return kind
|
|
88
|
+
raise UsageError(f"unknown task id {task_id!r}: expected plan_..., cvt_... or tlj_...")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def parse_time(value: Any) -> datetime | None:
|
|
92
|
+
"""解析 Go 输出的 RFC 3339 时间,支持任意长度的小数秒及 ``Z``;零值时间 ``0001-01-01`` 表示未设置。"""
|
|
93
|
+
if not isinstance(value, str) or not value.strip():
|
|
94
|
+
return None
|
|
95
|
+
text = value.strip()
|
|
96
|
+
if text.endswith(("Z", "z")):
|
|
97
|
+
text = text[:-1] + "+00:00"
|
|
98
|
+
text = _FRACTION.sub(lambda m: "." + (m.group(1) + "000000")[:6], text, count=1)
|
|
99
|
+
try:
|
|
100
|
+
parsed = datetime.fromisoformat(text)
|
|
101
|
+
except ValueError:
|
|
102
|
+
return None
|
|
103
|
+
if parsed.tzinfo is None:
|
|
104
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
105
|
+
return None if parsed.year <= 1 else parsed
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def available_credits(data: Any, now: datetime) -> int:
|
|
109
|
+
"""汇总当前有效积分授权中的 ``amount_available``。"""
|
|
110
|
+
total = 0
|
|
111
|
+
grants = (data or {}).get("grants") if isinstance(data, dict) else None
|
|
112
|
+
for grant in grants or []:
|
|
113
|
+
if not isinstance(grant, dict):
|
|
114
|
+
continue
|
|
115
|
+
valid_from = parse_time(grant.get("valid_from"))
|
|
116
|
+
expires_at = parse_time(grant.get("expires_at"))
|
|
117
|
+
if valid_from and valid_from > now:
|
|
118
|
+
continue
|
|
119
|
+
if expires_at and expires_at <= now:
|
|
120
|
+
continue
|
|
121
|
+
try:
|
|
122
|
+
amount = int(grant.get("amount_available") or 0)
|
|
123
|
+
except (TypeError, ValueError):
|
|
124
|
+
continue
|
|
125
|
+
if amount > 0:
|
|
126
|
+
total += amount
|
|
127
|
+
return total
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def is_credit_failure(reason: str) -> bool:
|
|
131
|
+
lower = (reason or "").lower()
|
|
132
|
+
return any(marker in lower for marker in CREDIT_FAILURE_MARKERS)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def question_from(interaction: Any) -> dict[str, Any] | None:
|
|
136
|
+
"""将 Agent 提问(user_interact 数据)整理为精简结构;不认识的结构通过 ``raw`` 原样保留。"""
|
|
137
|
+
if not isinstance(interaction, dict) or not interaction:
|
|
138
|
+
return None
|
|
139
|
+
questions = interaction.get("questions")
|
|
140
|
+
if not isinstance(questions, list) or not questions:
|
|
141
|
+
return {"tool_call_id": str(interaction.get("tool_call_id") or ""), "questions": [], "raw": interaction}
|
|
142
|
+
items = []
|
|
143
|
+
for q in questions:
|
|
144
|
+
if not isinstance(q, dict):
|
|
145
|
+
continue
|
|
146
|
+
options = [
|
|
147
|
+
{"label": str(o.get("label") or ""), "description": str(o.get("description") or "")}
|
|
148
|
+
for o in q.get("options") or []
|
|
149
|
+
if isinstance(o, dict)
|
|
150
|
+
]
|
|
151
|
+
items.append(
|
|
152
|
+
{
|
|
153
|
+
"question": str(q.get("question") or ""),
|
|
154
|
+
"header": str(q.get("header") or ""),
|
|
155
|
+
"options": options,
|
|
156
|
+
"multi_select": bool(q.get("multi_select")),
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
out: dict[str, Any] = {"tool_call_id": str(interaction.get("tool_call_id") or ""), "questions": items}
|
|
160
|
+
if isinstance(interaction.get("timeout"), (int, float)):
|
|
161
|
+
out["timeout_seconds"] = interaction["timeout"]
|
|
162
|
+
return out
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def parse_answers(pairs: list[str], question: dict[str, Any] | None) -> dict[str, str]:
|
|
166
|
+
"""将 ``--answer`` 参数转换为 ``{问题文本: 回答}``。
|
|
167
|
+
|
|
168
|
+
支持 ``QUESTION=ANSWER``(精确问题文本,其中也可能包含 ``=``)、``N=ANSWER``(从 1 开始的问题编号)、
|
|
169
|
+
``HEADER=ANSWER``(问题的简短标题);只有一个问题时,也支持直接传入 ``ANSWER``。
|
|
170
|
+
"""
|
|
171
|
+
items = (question or {}).get("questions") or []
|
|
172
|
+
texts = [q["question"] for q in items]
|
|
173
|
+
answers: dict[str, str] = {}
|
|
174
|
+
for raw in pairs:
|
|
175
|
+
key = value = None
|
|
176
|
+
for index, char in enumerate(raw):
|
|
177
|
+
if char == "=" and raw[:index].strip() in texts:
|
|
178
|
+
key, value = raw[:index].strip(), raw[index + 1 :].strip()
|
|
179
|
+
break
|
|
180
|
+
if key is None and "=" in raw:
|
|
181
|
+
left, value = (part.strip() for part in raw.split("=", 1))
|
|
182
|
+
if left.isdigit() and 1 <= int(left) <= len(texts):
|
|
183
|
+
key = texts[int(left) - 1]
|
|
184
|
+
else:
|
|
185
|
+
matches = [q["question"] for q in items if q["header"] and q["header"].casefold() == left.casefold()]
|
|
186
|
+
if len(matches) == 1:
|
|
187
|
+
key = matches[0]
|
|
188
|
+
elif not texts:
|
|
189
|
+
key = left
|
|
190
|
+
if key is None and "=" not in raw and len(texts) == 1:
|
|
191
|
+
key, value = texts[0], raw.strip()
|
|
192
|
+
if key is None or value is None:
|
|
193
|
+
listed = "; ".join(f"{i}. {t}" for i, t in enumerate(texts, 1)) or "(none)"
|
|
194
|
+
raise UsageError(
|
|
195
|
+
f"--answer {raw!r} does not match a question. Use QUESTION=ANSWER or N=ANSWER. Questions: {listed}"
|
|
196
|
+
)
|
|
197
|
+
answers[key] = value
|
|
198
|
+
return answers
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _detail_str(data: Any, key: str) -> str:
|
|
202
|
+
value = data.get(key) if isinstance(data, dict) else None
|
|
203
|
+
return str(value) if value is not None else ""
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class Tasks:
|
|
207
|
+
"""使用同一站点和令牌操作任务、转换及翻译。"""
|
|
208
|
+
|
|
209
|
+
def __init__(
|
|
210
|
+
self,
|
|
211
|
+
client: ChesoClient,
|
|
212
|
+
settings: Settings,
|
|
213
|
+
storage: httpx.Client,
|
|
214
|
+
*,
|
|
215
|
+
progress: TextIO | None = None,
|
|
216
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
217
|
+
clock: Callable[[], float] = time.monotonic,
|
|
218
|
+
now: Callable[[], float] = time.time,
|
|
219
|
+
poll_interval: float = DEFAULT_POLL_INTERVAL,
|
|
220
|
+
) -> None:
|
|
221
|
+
self.client = client
|
|
222
|
+
self.settings = settings
|
|
223
|
+
self.storage = storage
|
|
224
|
+
self.progress = progress or sys.stderr
|
|
225
|
+
self.sleep = sleep
|
|
226
|
+
self.clock = clock
|
|
227
|
+
self.now = now
|
|
228
|
+
self.poll_interval = poll_interval
|
|
229
|
+
# 转换详情中已有的带签名结果 URL,以结果文件 ID 为键保存。
|
|
230
|
+
self._known_urls: dict[str, str] = {}
|
|
231
|
+
|
|
232
|
+
def say(self, message: str) -> None:
|
|
233
|
+
print(message, file=self.progress, flush=True)
|
|
234
|
+
|
|
235
|
+
# ---- 积分 ----
|
|
236
|
+
|
|
237
|
+
def credits(self) -> dict[str, Any]:
|
|
238
|
+
data = self.client.credit_grants()
|
|
239
|
+
now = datetime.fromtimestamp(self.now(), tz=timezone.utc)
|
|
240
|
+
return {"available": available_credits(data, now), "topup_url": self.settings.topup_url}
|
|
241
|
+
|
|
242
|
+
def ensure_credits(self) -> None:
|
|
243
|
+
"""账号没有积分时,在发起任务前停止,避免服务器执行到一半才失败。
|
|
244
|
+
查询余额失败不会阻止发起任务,只会跳过这次提前检查。
|
|
245
|
+
"""
|
|
246
|
+
try:
|
|
247
|
+
info = self.credits()
|
|
248
|
+
except ApiError as e:
|
|
249
|
+
if e.http_status == 401:
|
|
250
|
+
raise
|
|
251
|
+
self.say(f"warning: could not check the credit balance ({e}); starting the task anyway")
|
|
252
|
+
return
|
|
253
|
+
except httpx.TransportError:
|
|
254
|
+
self.say("warning: could not check the credit balance; starting the task anyway")
|
|
255
|
+
return
|
|
256
|
+
if info["available"] <= 0:
|
|
257
|
+
raise CliError(
|
|
258
|
+
f"Your Cheso account has no credits left, so the task was not started. Top up at {info['topup_url']}",
|
|
259
|
+
exit_code=ExitCode.INSUFFICIENT_CREDITS,
|
|
260
|
+
kind="insufficient_credits",
|
|
261
|
+
extra={"available": info["available"], "topup_url": info["topup_url"]},
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# ---- 创建 ----
|
|
265
|
+
|
|
266
|
+
def create_task(
|
|
267
|
+
self,
|
|
268
|
+
*,
|
|
269
|
+
prompt: str,
|
|
270
|
+
paths: list[Path],
|
|
271
|
+
file_ids: list[str],
|
|
272
|
+
project_id: str | None,
|
|
273
|
+
agent_type: str | None,
|
|
274
|
+
) -> dict[str, Any]:
|
|
275
|
+
prompt = (prompt or "").strip()
|
|
276
|
+
if not prompt:
|
|
277
|
+
raise UsageError("--prompt must not be empty")
|
|
278
|
+
project_id = validate_id("project id", project_id) if project_id else None
|
|
279
|
+
ids = [validate_id("file id", f) for f in file_ids]
|
|
280
|
+
for path in paths:
|
|
281
|
+
if not path.is_file():
|
|
282
|
+
raise UsageError(f"not a file: {path}")
|
|
283
|
+
self.ensure_credits()
|
|
284
|
+
uploads = []
|
|
285
|
+
for path in paths:
|
|
286
|
+
self.say(f"Uploading {path} ...")
|
|
287
|
+
result = upload_file(self.client, self.storage, path, project_id=project_id, now=self.now)
|
|
288
|
+
uploads.append(result.to_dict())
|
|
289
|
+
ids.append(result.file_id)
|
|
290
|
+
contexts = [{"file_id": file_id, "category": "attachment"} for file_id in dict.fromkeys(ids)]
|
|
291
|
+
try:
|
|
292
|
+
data = self.client.plan_create(
|
|
293
|
+
prompt=prompt, project_id=project_id, contexts=contexts or None, agent_type=agent_type or None
|
|
294
|
+
)
|
|
295
|
+
except ApiError as e:
|
|
296
|
+
if e.code == CODE_PROJECT_BUSY and project_id:
|
|
297
|
+
raise self.project_busy(project_id) from e
|
|
298
|
+
raise
|
|
299
|
+
data = data or {}
|
|
300
|
+
plan_id = _detail_str(data, "plan_id")
|
|
301
|
+
if not plan_id:
|
|
302
|
+
raise CliError("the task was not started (no task id returned)")
|
|
303
|
+
new_project = _detail_str(data, "project_id") or project_id or ""
|
|
304
|
+
view: dict[str, Any] = {
|
|
305
|
+
"task_id": plan_id,
|
|
306
|
+
"kind": KIND_TASK,
|
|
307
|
+
"status": _detail_str(data, "plan_status") or "in_progress",
|
|
308
|
+
"done": False,
|
|
309
|
+
"needs_input": False,
|
|
310
|
+
"project_id": new_project,
|
|
311
|
+
"project_url": self.settings.project_url(new_project),
|
|
312
|
+
}
|
|
313
|
+
if uploads:
|
|
314
|
+
view["uploaded_files"] = uploads
|
|
315
|
+
view["next"] = self.settings.command(f"task wait {plan_id}")
|
|
316
|
+
return view
|
|
317
|
+
|
|
318
|
+
def project_busy(self, project_id: str) -> CliError:
|
|
319
|
+
running: dict[str, Any] | None = None
|
|
320
|
+
try:
|
|
321
|
+
data = self.client.plan_list(project_id=project_id) or {}
|
|
322
|
+
for plan in data.get("plans") or []:
|
|
323
|
+
if isinstance(plan, dict) and plan.get("status") in PLAN_RUNNING:
|
|
324
|
+
running = plan
|
|
325
|
+
break
|
|
326
|
+
except (ApiError, httpx.TransportError):
|
|
327
|
+
running = None
|
|
328
|
+
extra: dict[str, Any] = {"project_id": project_id}
|
|
329
|
+
if running:
|
|
330
|
+
task_id = str(running.get("plan_id") or "")
|
|
331
|
+
extra["running_task_id"] = task_id
|
|
332
|
+
message = (
|
|
333
|
+
f"Project {project_id} already has a running task {task_id} (status {running.get('status')}). "
|
|
334
|
+
f"Wait for it with `{self.settings.command(f'task wait {task_id}')}` "
|
|
335
|
+
f"(or cancel it with `{self.settings.command(f'task cancel {task_id}')}`), "
|
|
336
|
+
"then retry."
|
|
337
|
+
)
|
|
338
|
+
else:
|
|
339
|
+
message = f"Project {project_id} already has a running task. Wait for it to finish, then retry."
|
|
340
|
+
return CliError(message, exit_code=ExitCode.RETRYABLE, kind="project_busy", extra=extra)
|
|
341
|
+
|
|
342
|
+
# ---- Plan 接续链 ----
|
|
343
|
+
|
|
344
|
+
def _follow(self, plan_id: str, budget: int) -> tuple[dict[str, Any], int, bool]:
|
|
345
|
+
"""沿 ``next_plan_id`` 跟进至最后一个 Plan,最多 ``budget`` 跳;返回 (result, hops, chain_ended)。"""
|
|
346
|
+
current = plan_id
|
|
347
|
+
hops = 0
|
|
348
|
+
while True:
|
|
349
|
+
result = self.client.plan_result(current) or {}
|
|
350
|
+
following = _detail_str(result, "next_plan_id")
|
|
351
|
+
if not following:
|
|
352
|
+
return result, hops, True
|
|
353
|
+
if hops >= budget:
|
|
354
|
+
return result, hops, False
|
|
355
|
+
current = validate_id("next task id", following)
|
|
356
|
+
hops += 1
|
|
357
|
+
|
|
358
|
+
def _plan_view(self, requested_id: str, result: dict[str, Any], chain_ended: bool) -> dict[str, Any]:
|
|
359
|
+
plan_id = _detail_str(result, "plan_id") or requested_id
|
|
360
|
+
status = _detail_str(result, "status")
|
|
361
|
+
following = _detail_str(result, "next_plan_id")
|
|
362
|
+
needs_input = status == PLAN_INTERACT
|
|
363
|
+
done = status in PLAN_TERMINAL and not following
|
|
364
|
+
view: dict[str, Any] = {
|
|
365
|
+
"task_id": plan_id,
|
|
366
|
+
"kind": KIND_TASK,
|
|
367
|
+
"status": status,
|
|
368
|
+
"done": done,
|
|
369
|
+
"needs_input": needs_input,
|
|
370
|
+
}
|
|
371
|
+
if plan_id != requested_id:
|
|
372
|
+
view["continued_from"] = requested_id
|
|
373
|
+
try:
|
|
374
|
+
detail = self.client.plan_get(plan_id) or {}
|
|
375
|
+
except ApiError as e:
|
|
376
|
+
if e.http_status == 401:
|
|
377
|
+
raise
|
|
378
|
+
detail = {}
|
|
379
|
+
except httpx.TransportError:
|
|
380
|
+
detail = {}
|
|
381
|
+
project_id = _detail_str(detail, "project_id")
|
|
382
|
+
if project_id:
|
|
383
|
+
view["project_id"] = project_id
|
|
384
|
+
view["project_url"] = self.settings.project_url(project_id)
|
|
385
|
+
if _detail_str(detail, "summary"):
|
|
386
|
+
view["title"] = detail["summary"]
|
|
387
|
+
body = result.get("result") if isinstance(result.get("result"), dict) else None
|
|
388
|
+
if body:
|
|
389
|
+
view["answer"] = str(body.get("text") or "")
|
|
390
|
+
if body.get("text_truncated"):
|
|
391
|
+
view["answer_truncated"] = True
|
|
392
|
+
view["files"] = [self._plan_file(f) for f in body.get("files") or [] if isinstance(f, dict)]
|
|
393
|
+
if needs_input:
|
|
394
|
+
view["question"] = question_from(result.get("interaction"))
|
|
395
|
+
reason = _detail_str(result, "reason")
|
|
396
|
+
if status in PLAN_TERMINAL and status != PLAN_COMPLETED and reason:
|
|
397
|
+
view["failure_reason"] = reason
|
|
398
|
+
if done and status != PLAN_COMPLETED and is_credit_failure(reason):
|
|
399
|
+
view["topup_url"] = self.settings.topup_url
|
|
400
|
+
if following and not chain_ended:
|
|
401
|
+
view["next_task_id"] = following
|
|
402
|
+
view["next"] = self._plan_next(view)
|
|
403
|
+
return view
|
|
404
|
+
|
|
405
|
+
@staticmethod
|
|
406
|
+
def _plan_file(raw: dict[str, Any]) -> dict[str, Any]:
|
|
407
|
+
keys = ("file_id", "name", "path", "title", "description", "mime_type", "size", "version")
|
|
408
|
+
item = {k: raw[k] for k in keys if raw.get(k) not in (None, "")}
|
|
409
|
+
if not item.get("name") and item.get("path"):
|
|
410
|
+
item["name"] = posixpath.basename(str(item["path"]))
|
|
411
|
+
return item
|
|
412
|
+
|
|
413
|
+
def _plan_next(self, view: dict[str, Any]) -> str:
|
|
414
|
+
task_id = view.get("next_task_id") or view["task_id"]
|
|
415
|
+
if view.get("needs_input"):
|
|
416
|
+
return self.settings.command(f'task reply {view["task_id"]} --answer "QUESTION=ANSWER"') + " (or --skip)"
|
|
417
|
+
if not view["done"]:
|
|
418
|
+
return self.settings.command(f"task wait {task_id}")
|
|
419
|
+
if view["status"] == PLAN_COMPLETED:
|
|
420
|
+
project = view.get("project_id")
|
|
421
|
+
return (
|
|
422
|
+
self.settings.command(f'task create --project {project} --prompt "..."') + " to revise"
|
|
423
|
+
if project
|
|
424
|
+
else ""
|
|
425
|
+
)
|
|
426
|
+
return ""
|
|
427
|
+
|
|
428
|
+
# ---- 查询 / 等待 ----
|
|
429
|
+
|
|
430
|
+
def get(self, task_id: str) -> dict[str, Any]:
|
|
431
|
+
kind = task_kind(task_id)
|
|
432
|
+
if kind == KIND_TASK:
|
|
433
|
+
result, _, ended = self._follow(task_id, MAX_CHAIN_HOPS)
|
|
434
|
+
return self._plan_view(task_id, result, ended)
|
|
435
|
+
if kind == KIND_CONVERSION:
|
|
436
|
+
return self._conversion_view(self.client.convert_get(task_id) or {})
|
|
437
|
+
return self._translation_view(self.client.translation_get(task_id) or {})
|
|
438
|
+
|
|
439
|
+
def wait(
|
|
440
|
+
self, task_id: str, *, timeout: float, output_dir: Path | None, started: float | None = None
|
|
441
|
+
) -> dict[str, Any]:
|
|
442
|
+
"""持续轮询,直到任务结束、等待用户回答,或从 ``started``(默认当前时刻)起经过 ``timeout`` 秒。
|
|
443
|
+
已结束任务的结果文件在同一时限内下载到 ``output_dir``,为 None 时跳过下载。
|
|
444
|
+
截止时间后不再发起请求,每次请求最多使用剩余时间。
|
|
445
|
+
"""
|
|
446
|
+
kind = task_kind(task_id)
|
|
447
|
+
started = self.clock() if started is None else started
|
|
448
|
+
deadline = Deadline(started + max(timeout, 0.0), self.clock)
|
|
449
|
+
with self.client.time_limit(deadline):
|
|
450
|
+
view = self._poll(kind, task_id, started, deadline)
|
|
451
|
+
if view["done"] and output_dir is not None:
|
|
452
|
+
self.download_results(view, output_dir, deadline=deadline)
|
|
453
|
+
return view
|
|
454
|
+
|
|
455
|
+
def _poll(self, kind: str, task_id: str, started: float, deadline: Deadline) -> dict[str, Any]:
|
|
456
|
+
current, hops_left = task_id, MAX_CHAIN_HOPS
|
|
457
|
+
last_status = ""
|
|
458
|
+
last_report = started
|
|
459
|
+
transient = 0
|
|
460
|
+
snapshot: Any = None
|
|
461
|
+
while True:
|
|
462
|
+
try:
|
|
463
|
+
if kind == KIND_TASK:
|
|
464
|
+
result, hops, ended = self._follow(current, hops_left)
|
|
465
|
+
hops_left -= hops
|
|
466
|
+
current = _detail_str(result, "plan_id") or current
|
|
467
|
+
status = _detail_str(result, "status")
|
|
468
|
+
following = _detail_str(result, "next_plan_id")
|
|
469
|
+
settled = status == PLAN_INTERACT or (status in PLAN_TERMINAL and not following)
|
|
470
|
+
if not ended:
|
|
471
|
+
settled = True # 接续跳数已用完,返回当前结果,让调用方从此处继续。
|
|
472
|
+
snapshot = (result, ended)
|
|
473
|
+
elif kind == KIND_CONVERSION:
|
|
474
|
+
snapshot = self.client.convert_get(task_id) or {}
|
|
475
|
+
status = _detail_str(snapshot, "status")
|
|
476
|
+
settled = status in CONVERT_DONE
|
|
477
|
+
else:
|
|
478
|
+
snapshot = self.client.translation_get(task_id) or {}
|
|
479
|
+
status = _detail_str(snapshot, "status")
|
|
480
|
+
settled = status in TRANSLATION_DONE
|
|
481
|
+
transient = 0
|
|
482
|
+
except (ApiError, httpx.TransportError) as e:
|
|
483
|
+
if isinstance(e, TimeLimitReached) or (isinstance(e, httpx.TransportError) and deadline.expired()):
|
|
484
|
+
break # 时间已到:抛出 TimeLimitReached,或请求被剩余时限截断。
|
|
485
|
+
if not _transient(e) or transient >= MAX_TRANSIENT_ERRORS:
|
|
486
|
+
raise
|
|
487
|
+
transient += 1
|
|
488
|
+
retry_after = e.retry_after if isinstance(e, ApiError) and e.retry_after else self.poll_interval
|
|
489
|
+
self.say(f"[{_elapsed(self.clock() - started)}] temporary error ({_brief(e)}); retrying")
|
|
490
|
+
if self.clock() + retry_after > deadline.at:
|
|
491
|
+
break
|
|
492
|
+
self.sleep(retry_after)
|
|
493
|
+
continue
|
|
494
|
+
elapsed = self.clock() - started
|
|
495
|
+
label = current if kind == KIND_TASK else task_id
|
|
496
|
+
if status != last_status:
|
|
497
|
+
self.say(f"[{_elapsed(elapsed)}] {label}: {status or 'unknown'}")
|
|
498
|
+
last_status, last_report = status, self.clock()
|
|
499
|
+
elif self.clock() - last_report >= HEARTBEAT_SECONDS:
|
|
500
|
+
self.say(f"[{_elapsed(elapsed)}] {label}: still {status} ...")
|
|
501
|
+
last_report = self.clock()
|
|
502
|
+
if settled:
|
|
503
|
+
return self._view_of(kind, task_id, snapshot)
|
|
504
|
+
if deadline.at - self.clock() <= self.poll_interval:
|
|
505
|
+
break # 已没有时间再轮询一次,剩余时间用于补齐返回结果的详情。
|
|
506
|
+
self.sleep(self.poll_interval)
|
|
507
|
+
# 时间已到,直接返回最后已知状态,不再发起查询。
|
|
508
|
+
if snapshot is not None:
|
|
509
|
+
view = self._view_of(kind, task_id, snapshot)
|
|
510
|
+
else:
|
|
511
|
+
view = {"task_id": task_id, "kind": kind, "status": "unknown"}
|
|
512
|
+
view["done"] = False
|
|
513
|
+
view["timed_out"] = True
|
|
514
|
+
view["next"] = self.settings.command(f"task wait {view.get('next_task_id') or view['task_id']}")
|
|
515
|
+
return view
|
|
516
|
+
|
|
517
|
+
def _view_of(self, kind: str, requested_id: str, snapshot: Any) -> dict[str, Any]:
|
|
518
|
+
if kind == KIND_TASK:
|
|
519
|
+
result, ended = snapshot
|
|
520
|
+
return self._plan_view(requested_id, result, ended)
|
|
521
|
+
if kind == KIND_CONVERSION:
|
|
522
|
+
return self._conversion_view(snapshot)
|
|
523
|
+
return self._translation_view(snapshot)
|
|
524
|
+
|
|
525
|
+
# ---- 转换 ----
|
|
526
|
+
|
|
527
|
+
def _conversion_view(self, detail: dict[str, Any]) -> dict[str, Any]:
|
|
528
|
+
status = _detail_str(detail, "status")
|
|
529
|
+
job_id = _detail_str(detail, "job_id")
|
|
530
|
+
view: dict[str, Any] = {
|
|
531
|
+
"task_id": job_id,
|
|
532
|
+
"kind": KIND_CONVERSION,
|
|
533
|
+
"status": status,
|
|
534
|
+
"done": status in CONVERT_DONE,
|
|
535
|
+
"source_file_id": _detail_str(detail, "source_file_id"),
|
|
536
|
+
"source_type": _detail_str(detail, "source_type"),
|
|
537
|
+
"target_type": _detail_str(detail, "target_type"),
|
|
538
|
+
}
|
|
539
|
+
result_id = _detail_str(detail, "result_file_id")
|
|
540
|
+
if status == CONVERT_COMPLETED and result_id:
|
|
541
|
+
item: dict[str, Any] = {"file_id": result_id}
|
|
542
|
+
if isinstance(detail.get("result_file_version"), int):
|
|
543
|
+
item["version"] = detail["result_file_version"]
|
|
544
|
+
url = _detail_str(detail, "attachment_url") or _detail_str(detail, "download_url")
|
|
545
|
+
if url:
|
|
546
|
+
self._known_urls[result_id] = url
|
|
547
|
+
view["files"] = [item]
|
|
548
|
+
if status == "failed":
|
|
549
|
+
view["failure_reason"] = _detail_str(detail, "error_message") or "conversion failed"
|
|
550
|
+
view["next"] = "" if view["done"] else self.settings.command(f"task wait {job_id}")
|
|
551
|
+
return view
|
|
552
|
+
|
|
553
|
+
def start_conversion(self, file_id: str, target: str) -> dict[str, Any]:
|
|
554
|
+
file_id = validate_id("file id", file_id)
|
|
555
|
+
target = (target or "").strip().lower().lstrip(".")
|
|
556
|
+
if not target:
|
|
557
|
+
raise UsageError("--to must name a target format, e.g. pdf")
|
|
558
|
+
info = self.client.file_get(file_id) or {}
|
|
559
|
+
name = _detail_str(info, "name")
|
|
560
|
+
source = _convert_source_type(name) or _convert_source_type(_detail_str(info, "file_path"))
|
|
561
|
+
try:
|
|
562
|
+
supported = (self.client.convert_supported() or {}).get("conversions") or []
|
|
563
|
+
except (ApiError, httpx.TransportError):
|
|
564
|
+
supported = None
|
|
565
|
+
if supported is not None and not any(
|
|
566
|
+
str(c.get("source_type", "")).lower() == source and str(c.get("target_type", "")).lower() == target
|
|
567
|
+
for c in supported
|
|
568
|
+
if isinstance(c, dict)
|
|
569
|
+
):
|
|
570
|
+
raise _unsupported_conversion(supported, name or file_id, source, target)
|
|
571
|
+
try:
|
|
572
|
+
data = self.client.convert_create(file_id=file_id, source_type=source, target_type=target) or {}
|
|
573
|
+
except ApiError as e:
|
|
574
|
+
if e.code == CODE_CONVERT_UNSUPPORTED and supported is not None:
|
|
575
|
+
raise _unsupported_conversion(supported, name or file_id, source, target) from e
|
|
576
|
+
raise
|
|
577
|
+
job_id = _detail_str(data, "job_id")
|
|
578
|
+
status = _detail_str(data, "status")
|
|
579
|
+
return {
|
|
580
|
+
"task_id": job_id,
|
|
581
|
+
"kind": KIND_CONVERSION,
|
|
582
|
+
"status": status,
|
|
583
|
+
"done": status in CONVERT_DONE,
|
|
584
|
+
"reused": bool(data.get("reused")),
|
|
585
|
+
"source_file_id": file_id,
|
|
586
|
+
"source_type": source,
|
|
587
|
+
"target_type": target,
|
|
588
|
+
"next": self.settings.command(f"task wait {job_id}"),
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
# ---- 翻译 ----
|
|
592
|
+
|
|
593
|
+
def _translation_view(self, detail: dict[str, Any]) -> dict[str, Any]:
|
|
594
|
+
status = _detail_str(detail, "status")
|
|
595
|
+
job_id = _detail_str(detail, "job_id")
|
|
596
|
+
view: dict[str, Any] = {
|
|
597
|
+
"task_id": job_id,
|
|
598
|
+
"kind": KIND_TRANSLATION,
|
|
599
|
+
"status": status,
|
|
600
|
+
"done": status in TRANSLATION_DONE,
|
|
601
|
+
"source_file_id": _detail_str(detail, "source_file_id"),
|
|
602
|
+
"target_language": _detail_str(detail, "target_language"),
|
|
603
|
+
}
|
|
604
|
+
if status == TRANSLATION_SUCCEEDED:
|
|
605
|
+
files = []
|
|
606
|
+
for raw in detail.get("result_files") or []:
|
|
607
|
+
if not isinstance(raw, dict) or raw.get("is_temp") or not raw.get("file_id"):
|
|
608
|
+
continue
|
|
609
|
+
if raw.get("role") in TRANSLATION_SKIP_ROLES:
|
|
610
|
+
continue
|
|
611
|
+
item = {"file_id": raw["file_id"], "name": posixpath.basename(str(raw.get("path") or ""))}
|
|
612
|
+
if isinstance(raw.get("version"), int) and raw["version"]:
|
|
613
|
+
item["version"] = raw["version"]
|
|
614
|
+
files.append(item)
|
|
615
|
+
view["files"] = files
|
|
616
|
+
elif status in TRANSLATION_DONE:
|
|
617
|
+
failure = detail.get("failure") if isinstance(detail.get("failure"), dict) else {}
|
|
618
|
+
view["failure_reason"] = str(failure.get("message") or status.lower())
|
|
619
|
+
view["next"] = "" if view["done"] else self.settings.command(f"task wait {job_id}")
|
|
620
|
+
return view
|
|
621
|
+
|
|
622
|
+
def start_translation(self, file_id: str, target: str, source: str | None) -> dict[str, Any]:
|
|
623
|
+
file_id = validate_id("file id", file_id)
|
|
624
|
+
target = (target or "").strip()
|
|
625
|
+
if not target:
|
|
626
|
+
raise UsageError("--to must name a language, e.g. en")
|
|
627
|
+
data = self.client.translation_create(
|
|
628
|
+
file_id=file_id, target_language=target, source_language=(source or "").strip() or None
|
|
629
|
+
)
|
|
630
|
+
data = data or {}
|
|
631
|
+
job_id = _detail_str(data, "job_id")
|
|
632
|
+
status = _detail_str(data, "status")
|
|
633
|
+
return {
|
|
634
|
+
"task_id": job_id,
|
|
635
|
+
"kind": KIND_TRANSLATION,
|
|
636
|
+
"status": status,
|
|
637
|
+
"done": status in TRANSLATION_DONE,
|
|
638
|
+
"reused": bool(data.get("reused")),
|
|
639
|
+
"source_file_id": file_id,
|
|
640
|
+
"target_language": target,
|
|
641
|
+
"next": self.settings.command(f"task wait {job_id}"),
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
# ---- 回答 / 取消 ----
|
|
645
|
+
|
|
646
|
+
def reply(self, task_id: str, answers: list[str], skip: bool) -> dict[str, Any]:
|
|
647
|
+
if task_kind(task_id) != KIND_TASK:
|
|
648
|
+
raise UsageError("only tasks (plan_... ids) ask questions")
|
|
649
|
+
if skip and answers:
|
|
650
|
+
raise UsageError("use either --answer or --skip, not both")
|
|
651
|
+
if not skip and not answers:
|
|
652
|
+
raise UsageError("give at least one --answer, or --skip to let Cheso decide")
|
|
653
|
+
result, _, _ = self._follow(task_id, MAX_CHAIN_HOPS)
|
|
654
|
+
plan_id = _detail_str(result, "plan_id") or task_id
|
|
655
|
+
status = _detail_str(result, "status")
|
|
656
|
+
if status != PLAN_INTERACT:
|
|
657
|
+
raise CliError(
|
|
658
|
+
f"Task {plan_id} is not waiting for an answer (status {status}). "
|
|
659
|
+
f"Use `{self.settings.command(f'task wait {plan_id}')}`.",
|
|
660
|
+
kind="not_waiting",
|
|
661
|
+
extra={"task_id": plan_id, "status": status},
|
|
662
|
+
)
|
|
663
|
+
question = question_from(result.get("interaction"))
|
|
664
|
+
args: dict[str, Any] = {}
|
|
665
|
+
if question and question.get("tool_call_id"):
|
|
666
|
+
args["tool_call_id"] = question["tool_call_id"]
|
|
667
|
+
parsed: dict[str, str] = {}
|
|
668
|
+
if not skip:
|
|
669
|
+
parsed = parse_answers(answers, question)
|
|
670
|
+
args["answers"] = parsed
|
|
671
|
+
self.client.plan_interact(plan_id, args=args, skip=skip)
|
|
672
|
+
view: dict[str, Any] = {"task_id": plan_id, "kind": KIND_TASK, "status": "in_progress", "skipped": skip}
|
|
673
|
+
if parsed:
|
|
674
|
+
view["answers"] = parsed
|
|
675
|
+
view["next"] = self.settings.command(f"task wait {plan_id}")
|
|
676
|
+
return view
|
|
677
|
+
|
|
678
|
+
def cancel(self, task_id: str) -> dict[str, Any]:
|
|
679
|
+
if task_kind(task_id) != KIND_TASK:
|
|
680
|
+
raise UsageError(
|
|
681
|
+
"only tasks (plan_... ids) can be cancelled; conversions and translations finish on their own"
|
|
682
|
+
)
|
|
683
|
+
result, _, _ = self._follow(task_id, MAX_CHAIN_HOPS)
|
|
684
|
+
plan_id = _detail_str(result, "plan_id") or task_id
|
|
685
|
+
status = _detail_str(result, "status")
|
|
686
|
+
if status in PLAN_TERMINAL and not _detail_str(result, "next_plan_id"):
|
|
687
|
+
return {"task_id": plan_id, "kind": KIND_TASK, "status": status, "cancelled": False}
|
|
688
|
+
self.client.plan_terminate(plan_id)
|
|
689
|
+
return {"task_id": plan_id, "kind": KIND_TASK, "status": "terminated", "cancelled": True}
|
|
690
|
+
|
|
691
|
+
# ---- 文件 ----
|
|
692
|
+
|
|
693
|
+
def list_files(self, project_id: str) -> dict[str, Any]:
|
|
694
|
+
project_id = validate_id("project id", project_id)
|
|
695
|
+
data = self.client.project_file_tree(project_id) or {}
|
|
696
|
+
files: list[dict[str, Any]] = []
|
|
697
|
+
_collect_tree(files, "", data.get("tree") or [], 0)
|
|
698
|
+
return {
|
|
699
|
+
"project_id": project_id,
|
|
700
|
+
"project_url": self.settings.project_url(project_id),
|
|
701
|
+
"files": files,
|
|
702
|
+
"total": len(files),
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
def download_file(self, file_id: str, output_dir: Path, *, version: int | None = None) -> dict[str, Any]:
|
|
706
|
+
file_id = validate_id("file id", file_id)
|
|
707
|
+
info = self.client.file_get(file_id) or {}
|
|
708
|
+
item: dict[str, Any] = {"file_id": file_id, "name": _detail_str(info, "name") or file_id}
|
|
709
|
+
if version:
|
|
710
|
+
item["version"] = version
|
|
711
|
+
self._download_item(item, output_dir, strict=True)
|
|
712
|
+
return item
|
|
713
|
+
|
|
714
|
+
def download_results(self, view: dict[str, Any], output_dir: Path, *, deadline: Deadline | None = None) -> None:
|
|
715
|
+
"""下载结果文件,下载失败的文件保留 ``error``。
|
|
716
|
+
任务本身成功时,视图额外包含 ``download_failed`` 错误(退出码 8),并用 ``next`` 只补下载失败文件,
|
|
717
|
+
避免调用方为此重新发起任务。任务本身失败时保留其原始结果,下载错误仅记录在各文件上。
|
|
718
|
+
"""
|
|
719
|
+
files = view.get("files") or []
|
|
720
|
+
for item in files:
|
|
721
|
+
if view["kind"] == KIND_CONVERSION and not item.get("name"):
|
|
722
|
+
try:
|
|
723
|
+
info = self.client.file_get(item["file_id"]) or {}
|
|
724
|
+
item["name"] = _detail_str(info, "name")
|
|
725
|
+
except (ApiError, httpx.TransportError):
|
|
726
|
+
pass
|
|
727
|
+
item["name"] = item.get("name") or f"converted.{view.get('target_type') or 'out'}"
|
|
728
|
+
self._download_item(item, output_dir, strict=False, deadline=deadline)
|
|
729
|
+
failed = [item for item in files if item.get("error")]
|
|
730
|
+
if failed and succeeded(view):
|
|
731
|
+
view["error"] = {
|
|
732
|
+
"kind": "download_failed",
|
|
733
|
+
"message": f"The {view['kind']} finished, but {len(failed)} of {len(files)} result file(s) were not "
|
|
734
|
+
"downloaded. Download them with the `next` command; do not start the task again.",
|
|
735
|
+
"exit_code": int(ExitCode.DOWNLOAD_FAILED),
|
|
736
|
+
"file_ids": [item["file_id"] for item in failed],
|
|
737
|
+
}
|
|
738
|
+
view["next"] = " && ".join(self._download_command(item, output_dir) for item in failed)
|
|
739
|
+
|
|
740
|
+
def _download_command(self, item: dict[str, Any], output_dir: Path) -> str:
|
|
741
|
+
version = f" --version {item['version']}" if item.get("version") else ""
|
|
742
|
+
directory = shlex.quote(str(output_dir.absolute()))
|
|
743
|
+
return self.settings.command(f"file download {item['file_id']}{version} --output-dir {directory}")
|
|
744
|
+
|
|
745
|
+
def _download_item(
|
|
746
|
+
self, item: dict[str, Any], output_dir: Path, *, strict: bool, deadline: Deadline | None = None
|
|
747
|
+
) -> None:
|
|
748
|
+
try:
|
|
749
|
+
url = self._known_urls.pop(item["file_id"], "")
|
|
750
|
+
if not url:
|
|
751
|
+
link = self.client.file_download_url(item["file_id"], version=item.get("version")) or {}
|
|
752
|
+
url = _detail_str(link, "attachment_url") or _detail_str(link, "download_url")
|
|
753
|
+
if not url:
|
|
754
|
+
raise CliError(f"no download link for {item['file_id']}")
|
|
755
|
+
saved = download_url_to_dir(
|
|
756
|
+
self.storage, url, output_dir, item.get("name") or item["file_id"], deadline=deadline
|
|
757
|
+
)
|
|
758
|
+
item["saved_to"] = str(saved)
|
|
759
|
+
if not strict:
|
|
760
|
+
self.say(f"Saved {saved}")
|
|
761
|
+
except (ApiError, CliError, httpx.TransportError) as e:
|
|
762
|
+
if strict:
|
|
763
|
+
raise
|
|
764
|
+
item["error"] = getattr(e, "message", None) or str(e)
|
|
765
|
+
self.say(f"warning: could not download {item.get('name') or item['file_id']}: {item['error']}")
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _collect_tree(out: list[dict[str, Any]], parent: str, nodes: list[Any], depth: int) -> None:
|
|
769
|
+
if depth > 32:
|
|
770
|
+
return
|
|
771
|
+
for node in nodes:
|
|
772
|
+
if not isinstance(node, dict):
|
|
773
|
+
continue
|
|
774
|
+
name = str(node.get("name") or "")
|
|
775
|
+
path = f"{parent}/{name}" if parent else name
|
|
776
|
+
detail = node.get("file") if isinstance(node.get("file"), dict) else None
|
|
777
|
+
if node.get("file_id") and (node.get("type") == "file" or detail):
|
|
778
|
+
item: dict[str, Any] = {"path": path, "file_id": node["file_id"]}
|
|
779
|
+
if node.get("kind"):
|
|
780
|
+
item["kind"] = node["kind"]
|
|
781
|
+
if detail:
|
|
782
|
+
if detail.get("file_path"):
|
|
783
|
+
item["path"] = detail["file_path"]
|
|
784
|
+
for key in ("file_type", "category", "mime_type", "size", "updated_at"):
|
|
785
|
+
if detail.get(key) not in (None, ""):
|
|
786
|
+
item[key] = detail[key]
|
|
787
|
+
out.append(item)
|
|
788
|
+
_collect_tree(out, path, node.get("children") or [], depth + 1)
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _convert_source_type(name: str) -> str:
|
|
792
|
+
ext = posixpath.splitext(name or "")[1].lower().lstrip(".")
|
|
793
|
+
return CONVERT_SOURCE_BY_EXT.get(ext, ext)
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def _unsupported_conversion(supported: list[Any], name: str, source: str, target: str) -> CliError:
|
|
797
|
+
pairs = [c for c in supported if isinstance(c, dict)]
|
|
798
|
+
targets = sorted({str(c.get("target_type")) for c in pairs if str(c.get("source_type", "")).lower() == source})
|
|
799
|
+
if targets:
|
|
800
|
+
message = (
|
|
801
|
+
f"Cheso can't convert {name} ({source}) to {target}. Supported targets for this file: {', '.join(targets)}."
|
|
802
|
+
)
|
|
803
|
+
else:
|
|
804
|
+
sources = sorted({str(c.get("source_type")) for c in pairs})
|
|
805
|
+
message = (
|
|
806
|
+
f"Cheso can't convert {name}: type {source or 'unknown'!r} has no conversions. "
|
|
807
|
+
f"Convertible types: {', '.join(sources)}."
|
|
808
|
+
)
|
|
809
|
+
return CliError(
|
|
810
|
+
message,
|
|
811
|
+
exit_code=ExitCode.USAGE,
|
|
812
|
+
kind="unsupported_conversion",
|
|
813
|
+
extra={"source_type": source, "target_type": target, "supported_targets": targets},
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def _transient(e: Exception) -> bool:
|
|
818
|
+
if isinstance(e, httpx.TransportError):
|
|
819
|
+
return True
|
|
820
|
+
if isinstance(e, ApiError):
|
|
821
|
+
status = e.http_status or 0
|
|
822
|
+
return status >= 500 or status == 429
|
|
823
|
+
return False
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def _brief(e: Exception) -> str:
|
|
827
|
+
if isinstance(e, ApiError):
|
|
828
|
+
return f"http {e.http_status}" if e.http_status else e.message
|
|
829
|
+
return e.__class__.__name__
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _elapsed(seconds: float) -> str:
|
|
833
|
+
seconds = max(int(seconds), 0)
|
|
834
|
+
return f"{seconds // 60:02d}:{seconds % 60:02d}"
|