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/client.py ADDED
@@ -0,0 +1,534 @@
1
+ # pylint: disable=broad-except
2
+ """精简的 Cheso Manager HTTP 客户端,使用 X-Cheso-Token 认证。"""
3
+
4
+ from __future__ import annotations
5
+
6
+ import contextlib
7
+ import queue
8
+ import threading
9
+ from collections.abc import Callable, Iterator
10
+ from dataclasses import dataclass
11
+ from typing import Any, Protocol
12
+ from urllib.parse import quote
13
+
14
+ import httpx
15
+
16
+ from cheso_cli.config import USER_AGENT
17
+
18
+ # 查询忙碌项目中运行任务时的默认分页大小;列表按 updated_at 降序排列。
19
+ PLAN_LIST_PAGE_SIZE = 20
20
+ # 发起任务需要分配运行环境,比其他接口调用更慢。
21
+ CREATE_PLAN_TIMEOUT = 60.0
22
+
23
+
24
+ class ApiError(Exception):
25
+ """Manager API 返回的业务错误或传输错误。"""
26
+
27
+ def __init__(
28
+ self,
29
+ message: str,
30
+ *,
31
+ code: int | None = None,
32
+ http_status: int | None = None,
33
+ span_id: str | None = None,
34
+ body: Any = None,
35
+ retry_after: int | None = None,
36
+ ) -> None:
37
+ super().__init__(message)
38
+ self.message = message
39
+ self.code = code
40
+ self.http_status = http_status
41
+ self.span_id = span_id
42
+ self.body = body
43
+ self.retry_after = retry_after
44
+
45
+ def __str__(self) -> str:
46
+ parts = [self.message]
47
+ if self.code is not None:
48
+ parts.append(f"code={self.code}")
49
+ if self.http_status is not None:
50
+ parts.append(f"http={self.http_status}")
51
+ if self.span_id:
52
+ parts.append(f"span_id={self.span_id}")
53
+ return " ".join(parts)
54
+
55
+ @property
56
+ def details(self) -> dict[str, Any]:
57
+ """错误响应中的结构化详情,即响应封装里的 ``data`` 字段;没有时返回空字典。"""
58
+ if isinstance(self.body, dict) and isinstance(self.body.get("data"), dict):
59
+ return self.body["data"]
60
+ return {}
61
+
62
+
63
+ class TimeLimitReached(httpx.TimeoutException):
64
+ """命令的 ``--timeout`` 已到,不再发送下一个请求。"""
65
+
66
+ def __init__(self) -> None:
67
+ super().__init__("--timeout reached")
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class Deadline:
72
+ """命令 ``--timeout`` 对应的截止时刻,使用 ``clock`` 计时。"""
73
+
74
+ at: float
75
+ clock: Callable[[], float]
76
+
77
+ def left(self) -> float:
78
+ """返回剩余秒数;没有剩余时间时抛出 TimeLimitReached。"""
79
+ left = self.at - self.clock()
80
+ if left <= 0:
81
+ raise TimeLimitReached()
82
+ return left
83
+
84
+ def expired(self) -> bool:
85
+ return self.clock() >= self.at
86
+
87
+ def cap(self, timeout: httpx.Timeout) -> httpx.Timeout:
88
+ """将 ``timeout`` 的每个阶段都限制在剩余时间内。"""
89
+ left = self.left()
90
+ return httpx.Timeout(
91
+ connect=min(timeout.connect or left, left),
92
+ read=min(timeout.read or left, left),
93
+ write=min(timeout.write or left, left),
94
+ pool=min(timeout.pool or left, left),
95
+ )
96
+
97
+
98
+ _END = object()
99
+
100
+
101
+ def iterate_until(deadline: Deadline, items: Callable[[], Iterator[Any]]) -> Iterator[Any]:
102
+ """依次返回 ``items()`` 产生的数据,每次最多等到 ``deadline``。
103
+
104
+ httpx 的超时限制每次套接字读取,而非整个响应;服务端持续少量发送数据时,请求仍可能超过截止时间。
105
+ 因此,将 ``items()`` 中阻塞的网络 I/O 放到守护线程执行,当前线程只等到截止时间。
106
+ 超时后抛出 TimeLimitReached,并通知工作线程停止;工作线程自行关闭响应,后续产出的数据直接丢弃。
107
+ """
108
+ deadline.left() # 超过截止时间后不再启动操作。
109
+ handoff: queue.Queue[tuple[bool, Any]] = queue.Queue(maxsize=4)
110
+ stop = threading.Event()
111
+
112
+ def hand_over(entry: tuple[bool, Any]) -> bool:
113
+ while not stop.is_set():
114
+ try:
115
+ handoff.put(entry, timeout=0.1)
116
+ return True
117
+ except queue.Full:
118
+ continue
119
+ return False
120
+
121
+ def produce() -> None:
122
+ source: Iterator[Any] | None = None
123
+ try:
124
+ source = items()
125
+ for item in source:
126
+ if not hand_over((True, item)):
127
+ return
128
+ hand_over((True, _END))
129
+ except Exception as e: # pylint: disable=broad-except # 异常会在等待线程中重新抛出
130
+ hand_over((False, e))
131
+ finally:
132
+ close = getattr(source, "close", None)
133
+ if close is not None:
134
+ with contextlib.suppress(Exception):
135
+ close()
136
+
137
+ threading.Thread(target=produce, name="cheso-io", daemon=True).start()
138
+ try:
139
+ while True:
140
+ try:
141
+ ok, value = handoff.get(timeout=deadline.left())
142
+ except queue.Empty:
143
+ raise TimeLimitReached() from None
144
+ if not ok:
145
+ raise value
146
+ if value is _END:
147
+ return
148
+ yield value
149
+ finally:
150
+ stop.set()
151
+
152
+
153
+ def call_until(deadline: Deadline, fn: Callable[[], Any]) -> Any:
154
+ """等待阻塞网络操作 ``fn()``,最多等到 ``deadline``;实现见 iterate_until。"""
155
+ with contextlib.closing(iterate_until(deadline, lambda: iter((fn(),)))) as results:
156
+ return next(results)
157
+
158
+
159
+ class TokenSource(Protocol):
160
+ """提供 X-Cheso-Token;OAuth 登录收到 401 后,可提供刷新后的令牌。"""
161
+
162
+ def token(self) -> str:
163
+ """返回下一个请求要发送的令牌。"""
164
+ ...
165
+
166
+ def refresh_after_unauthorized(self, rejected: str) -> str | None:
167
+ """返回新令牌供调用方重试一次;无法续期时返回 None。"""
168
+ ...
169
+
170
+
171
+ class StaticToken:
172
+ """固定令牌,来自 ``--token`` 或 ``CHESO_API_TOKEN``,无需刷新。"""
173
+
174
+ def __init__(self, value: str) -> None:
175
+ self._value = value
176
+
177
+ def token(self) -> str:
178
+ return self._value
179
+
180
+ def refresh_after_unauthorized(self, rejected: str) -> str | None:
181
+ return None
182
+
183
+
184
+ def _parse_retry_after(value: str | None) -> int | None:
185
+ try:
186
+ seconds = int((value or "").strip())
187
+ except ValueError:
188
+ return None
189
+ return seconds if seconds >= 0 else None
190
+
191
+
192
+ def _path_id(value: str) -> str:
193
+ return quote(value, safe="")
194
+
195
+
196
+ class ChesoClient:
197
+ """封装 Manager 公开 API 请求和响应的精简客户端。"""
198
+
199
+ def __init__(
200
+ self,
201
+ base_url: str,
202
+ token: str | None = None,
203
+ *,
204
+ auth: TokenSource | None = None,
205
+ timeout: float = 30.0,
206
+ transport: httpx.BaseTransport | None = None,
207
+ headers: dict[str, str] | None = None,
208
+ ) -> None:
209
+ if auth is None:
210
+ if not token:
211
+ raise ValueError("token is required (CHESO_API_TOKEN / --token)")
212
+ auth = StaticToken(token)
213
+ self._auth = auth
214
+ self.base_url = base_url.rstrip("/")
215
+ default_headers = {
216
+ "Accept": "application/json",
217
+ "Content-Type": "application/json",
218
+ "User-Agent": USER_AGENT,
219
+ }
220
+ default_headers.update(headers or {})
221
+ self._timeout = timeout
222
+ self._deadline: Deadline | None = None
223
+ # API 调用不跟随重定向,避免将令牌发送到其他地址。
224
+ self._client = httpx.Client(
225
+ base_url=self.base_url,
226
+ headers=default_headers,
227
+ timeout=timeout,
228
+ transport=transport,
229
+ follow_redirects=False,
230
+ )
231
+
232
+ def close(self) -> None:
233
+ self._client.close()
234
+
235
+ def __enter__(self) -> ChesoClient:
236
+ return self
237
+
238
+ def __exit__(self, *args: object) -> None:
239
+ self.close()
240
+
241
+ @contextlib.contextmanager
242
+ def time_limit(self, deadline: Deadline) -> Iterator[None]:
243
+ """作用域内不在 ``deadline`` 之后发起请求,等待请求也不超过该时刻,见 call_until。
244
+
245
+ 令牌刷新不会被截断:丢失刷新响应后就无法知道轮换后的刷新令牌,下一次刷新会使登录失效。
246
+ """
247
+ previous, self._deadline = self._deadline, deadline
248
+ try:
249
+ yield
250
+ finally:
251
+ self._deadline = previous
252
+
253
+ def request(
254
+ self,
255
+ method: str,
256
+ path: str,
257
+ *,
258
+ params: dict[str, Any] | None = None,
259
+ json_body: dict[str, Any] | None = None,
260
+ timeout: float | None = None,
261
+ ) -> Any:
262
+ if self._deadline is not None:
263
+ self._deadline.left()
264
+ token = self._auth.token()
265
+ resp = self._send(method, path, token, params=params, json_body=json_body, timeout=timeout)
266
+ if resp.status_code == 401:
267
+ fresh = self._auth.refresh_after_unauthorized(token)
268
+ if fresh and fresh != token:
269
+ resp = self._send(method, path, fresh, params=params, json_body=json_body, timeout=timeout)
270
+ return self._decode(resp)
271
+
272
+ def _send(
273
+ self,
274
+ method: str,
275
+ path: str,
276
+ token: str,
277
+ *,
278
+ params: dict[str, Any] | None,
279
+ json_body: dict[str, Any] | None,
280
+ timeout: float | None,
281
+ ) -> httpx.Response:
282
+ extra: dict[str, Any] = {} if timeout is None else {"timeout": timeout}
283
+ deadline = self._deadline
284
+ if deadline is not None:
285
+ # 同时限制各阶段超时,避免已放弃的请求在后台滞留过久。
286
+ extra["timeout"] = deadline.cap(httpx.Timeout(self._timeout if timeout is None else timeout))
287
+
288
+ def send() -> httpx.Response:
289
+ return self._client.request(
290
+ method, path, params=params, json=json_body, headers={"X-Cheso-Token": token}, **extra
291
+ )
292
+
293
+ return send() if deadline is None else call_until(deadline, send)
294
+
295
+ @staticmethod
296
+ def _decode(resp: httpx.Response) -> Any:
297
+ retry_after = _parse_retry_after(resp.headers.get("Retry-After"))
298
+ try:
299
+ payload = resp.json()
300
+ except ValueError as e:
301
+ span_hdr = resp.headers.get("X-Span-Id")
302
+ raise ApiError(
303
+ "request failed",
304
+ http_status=resp.status_code,
305
+ span_id=span_hdr if isinstance(span_hdr, str) and span_hdr else None,
306
+ body=resp.text[:500] if resp.text else None,
307
+ retry_after=retry_after,
308
+ ) from e
309
+
310
+ if not isinstance(payload, dict):
311
+ raise ApiError(
312
+ "unexpected response shape",
313
+ http_status=resp.status_code,
314
+ body=payload,
315
+ )
316
+
317
+ code = payload.get("code")
318
+ message = str(payload.get("message") or "")
319
+ span_id = payload.get("span_id")
320
+ if resp.status_code >= 400 or (isinstance(code, int) and code != 0):
321
+ raise ApiError(
322
+ message or "request failed",
323
+ code=code if isinstance(code, int) else None,
324
+ http_status=resp.status_code,
325
+ span_id=span_id if isinstance(span_id, str) else None,
326
+ body=payload,
327
+ retry_after=retry_after,
328
+ )
329
+ return payload.get("data")
330
+
331
+ # ---- 账号 ----
332
+
333
+ def profile(self) -> Any:
334
+ return self.request("GET", "/api/v1/profile")
335
+
336
+ def whoami(self) -> Any:
337
+ return self.request("GET", "/api/v1/whoami")
338
+
339
+ def credit_grants(self) -> Any:
340
+ """当前可用的积分授权:``{total, grants: [{amount_available, valid_from, expires_at, ...}]}``。"""
341
+ return self.request("GET", "/api/v1/billing/credits")
342
+
343
+ # ---- 项目 ----
344
+
345
+ def project_create(self, *, description: str | None = None) -> Any:
346
+ body: dict[str, Any] = {}
347
+ if description is not None:
348
+ body["description"] = description
349
+ return self.request("POST", "/api/v1/projects/create", json_body=body)
350
+
351
+ def project_list(
352
+ self,
353
+ *,
354
+ status: str | None = None,
355
+ starred: bool | None = None,
356
+ sort_by: str | None = None,
357
+ page: int | None = None,
358
+ page_size: int | None = None,
359
+ ) -> Any:
360
+ params: dict[str, Any] = {}
361
+ if status is not None:
362
+ params["status"] = status
363
+ if starred is not None:
364
+ params["starred"] = "true" if starred else "false"
365
+ if sort_by is not None:
366
+ params["sort_by"] = sort_by
367
+ if page is not None:
368
+ params["page"] = page
369
+ if page_size is not None:
370
+ params["page_size"] = page_size
371
+ return self.request("GET", "/api/v1/projects", params=params or None)
372
+
373
+ def project_get(self, project_id: str) -> Any:
374
+ return self.request("GET", f"/api/v1/projects/{project_id}")
375
+
376
+ def project_update(
377
+ self,
378
+ project_id: str,
379
+ *,
380
+ description: str | None = None,
381
+ starred: bool | None = None,
382
+ cover_file_id: str | None = None,
383
+ cover_file_version: int | None = None,
384
+ ) -> Any:
385
+ body: dict[str, Any] = {}
386
+ if description is not None:
387
+ body["description"] = description
388
+ if starred is not None:
389
+ body["starred"] = starred
390
+ if cover_file_id is not None:
391
+ body["cover_file_id"] = cover_file_id
392
+ if cover_file_version is not None:
393
+ body["cover_file_version"] = cover_file_version
394
+ return self.request("PUT", f"/api/v1/projects/{project_id}", json_body=body)
395
+
396
+ def project_delete(self, project_id: str) -> Any:
397
+ return self.request("DELETE", f"/api/v1/projects/{project_id}")
398
+
399
+ def project_fork(self, project_id: str, *, description: str | None = None) -> Any:
400
+ body: dict[str, Any] = {}
401
+ if description is not None:
402
+ body["description"] = description
403
+ return self.request(
404
+ "POST",
405
+ f"/api/v1/projects/{project_id}/fork",
406
+ json_body=body,
407
+ )
408
+
409
+ def project_list_all(
410
+ self,
411
+ *,
412
+ status: str | None = "active",
413
+ page_size: int = 100,
414
+ ) -> list[dict[str, Any]]:
415
+ """逐页读取项目列表,直到没有更多结果。"""
416
+ items: list[dict[str, Any]] = []
417
+ page = 1
418
+ while True:
419
+ data = self.project_list(status=status, page=page, page_size=page_size)
420
+ batch = list((data or {}).get("projects") or [])
421
+ items.extend(p for p in batch if isinstance(p, dict))
422
+ total = int((data or {}).get("total") or 0)
423
+ if len(items) >= total or not batch:
424
+ break
425
+ page += 1
426
+ return items
427
+
428
+ def session_create(self, *, project_id: str, title: str | None = None) -> Any:
429
+ body: dict[str, Any] = {"project_id": project_id}
430
+ if title is not None:
431
+ body["title"] = title
432
+ return self.request("POST", "/api/v1/sessions/create", json_body=body)
433
+
434
+ # ---- Plan(任务)----
435
+
436
+ def plan_create(
437
+ self,
438
+ *,
439
+ prompt: str,
440
+ project_id: str | None = None,
441
+ session_id: str | None = None,
442
+ contexts: list[dict[str, Any]] | None = None,
443
+ agent_type: str | None = None,
444
+ ) -> Any:
445
+ """发起 Plan。始终不发送 ``on_running``;项目忙时返回 2614,不排队。"""
446
+ body: dict[str, Any] = {"prompt": prompt}
447
+ if project_id is not None:
448
+ body["project_id"] = project_id
449
+ if session_id is not None:
450
+ body["session_id"] = session_id
451
+ if contexts:
452
+ body["contexts"] = contexts
453
+ if agent_type:
454
+ body["agent_type"] = agent_type
455
+ return self.request("POST", "/api/v1/plans/create", json_body=body, timeout=CREATE_PLAN_TIMEOUT)
456
+
457
+ def plan_get(self, plan_id: str) -> Any:
458
+ return self.request("GET", f"/api/v1/plans/{plan_id}")
459
+
460
+ def plan_list(self, *, project_id: str, page_size: int = PLAN_LIST_PAGE_SIZE) -> Any:
461
+ return self.request("GET", "/api/v1/plans", params={"project_id": project_id, "page_size": page_size})
462
+
463
+ def plan_result(self, plan_id: str) -> Any:
464
+ """返回字段:``{plan_id, status, result{text,text_truncated,files[]}, interaction, next_plan_id, reason}``。"""
465
+ return self.request("GET", f"/api/v1/plans/{_path_id(plan_id)}/result")
466
+
467
+ def plan_interact(self, plan_id: str, *, args: dict[str, Any], skip: bool) -> Any:
468
+ return self.request(
469
+ "POST",
470
+ f"/api/v1/plans/{_path_id(plan_id)}/interact",
471
+ json_body={"args": args, "skip": skip},
472
+ )
473
+
474
+ def plan_terminate(self, plan_id: str) -> Any:
475
+ return self.request("POST", f"/api/v1/plans/{_path_id(plan_id)}/terminate", json_body={})
476
+
477
+ def plan_credit_get(self, *, project_id: str, plan_id: str) -> Any:
478
+ return self.request(
479
+ "GET",
480
+ "/api/v1/billing/credit-transactions/plans",
481
+ params={"project_id": project_id, "plan_id": plan_id},
482
+ )
483
+
484
+ # ---- 文件 ----
485
+
486
+ def file_presign(self, body: dict[str, Any]) -> Any:
487
+ """返回 ``{file_id, version, upload_url, instant}``;``instant`` 表示内容已经存储,可秒传。"""
488
+ return self.request("POST", "/api/v1/files/presign", json_body=body)
489
+
490
+ def file_confirm(self, *, file_id: str, version: int) -> Any:
491
+ return self.request("POST", "/api/v1/files/confirm", json_body={"file_id": file_id, "version": version})
492
+
493
+ def file_download_url(self, file_id: str, *, version: int | None = None) -> Any:
494
+ """返回 ``{file_id, download_url, attachment_url}``;这些 URL 带预签名,6 小时后过期。"""
495
+ body: dict[str, Any] = {"file_id": file_id}
496
+ if version:
497
+ body["version"] = version
498
+ return self.request("POST", "/api/v1/files/download_url", json_body=body)
499
+
500
+ def file_get(self, file_id: str) -> Any:
501
+ return self.request("GET", f"/api/v1/files/{_path_id(file_id)}")
502
+
503
+ def project_file_tree(self, project_id: str) -> Any:
504
+ return self.request("GET", "/api/v1/project/files/tree", params={"project_id": project_id})
505
+
506
+ # ---- 转换与翻译 ----
507
+
508
+ def convert_supported(self) -> Any:
509
+ return self.request("GET", "/api/v1/converts/supported")
510
+
511
+ def convert_create(self, *, file_id: str, source_type: str, target_type: str) -> Any:
512
+ return self.request(
513
+ "POST",
514
+ "/api/v1/converts/create",
515
+ json_body={"file_id": file_id, "source_type": source_type, "target_type": target_type},
516
+ )
517
+
518
+ def convert_get(self, job_id: str) -> Any:
519
+ return self.request("GET", f"/api/v1/converts/{_path_id(job_id)}")
520
+
521
+ def translation_create(
522
+ self,
523
+ *,
524
+ file_id: str,
525
+ target_language: str,
526
+ source_language: str | None = None,
527
+ ) -> Any:
528
+ body: dict[str, Any] = {"file_id": file_id, "target_language": target_language}
529
+ if source_language:
530
+ body["source_language"] = source_language
531
+ return self.request("POST", "/api/v1/translations/create", json_body=body)
532
+
533
+ def translation_get(self, job_id: str) -> Any:
534
+ return self.request("GET", f"/api/v1/translations/{_path_id(job_id)}")
cheso_cli/config.py ADDED
@@ -0,0 +1,108 @@
1
+ """站点、基础 URL 的解析及其他进程级配置。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shlex
7
+ from collections.abc import Callable, Mapping
8
+ from dataclasses import dataclass
9
+ from importlib.metadata import version
10
+ from urllib.parse import urlsplit
11
+
12
+ from cheso_cli.errors import UsageError
13
+
14
+ # ``pyproject.toml`` 是唯一版本来源;安装包和可编辑安装均通过发行包 metadata 读取。
15
+ VERSION = version("cheso-cli")
16
+ USER_AGENT = f"cheso-cli/{VERSION}"
17
+
18
+ # 公开站点:API 网关、OAuth issuer 和网站使用同一源。
19
+ SITES: dict[str, str] = {
20
+ "global": "https://cheso.ai",
21
+ "cn": "https://zhishi.qq.com",
22
+ }
23
+ DEFAULT_SITE = "global"
24
+
25
+ ENV_BASE_URL = "CHESO_BASE_URL"
26
+ ENV_TOKEN = "CHESO_API_TOKEN"
27
+ # 仅供测试:将请求路由到测试环境中的开发容器。
28
+ ENV_DEV_SET = "CHESO_DEV_SET"
29
+ HEADER_DEV_SET = "X-Dev-Set"
30
+
31
+ # 网站页面路径,见 services/frontend/src/router/routes.ts。
32
+ PROJECT_PAGE = "/agent/"
33
+ SUBSCRIBE_PAGE = "/agent/subscribe"
34
+
35
+
36
+ def normalize_base_url(raw: str) -> str:
37
+ """校验基础 URL 并去掉末尾斜杠;只接受 http(s) 地址,可以带路径。"""
38
+ value = raw.strip().rstrip("/")
39
+ parts = urlsplit(value)
40
+ if parts.scheme not in ("https", "http") or not parts.hostname:
41
+ raise UsageError(f"invalid base URL {raw!r}: expected https://host")
42
+ if parts.query or parts.fragment or parts.username or parts.password:
43
+ raise UsageError(f"invalid base URL {raw!r}: must not contain credentials, query or fragment")
44
+ return value
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class Settings:
49
+ """目标服务地址,以及可选的测试路由请求头。"""
50
+
51
+ base_url: str
52
+ dev_set: str = ""
53
+ # 记录本次选择站点的 --site / --base-url;来自 CHESO_BASE_URL、上次登录或默认值时为空。
54
+ # 给调用方建议的后续命令会重复这些选项,确保仍然访问同一站点。
55
+ option: str = ""
56
+
57
+ def command(self, args: str) -> str:
58
+ """生成访问当前站点的 ``cheso`` 命令行。"""
59
+ return f"cheso {self.option} {args}" if self.option else f"cheso {args}"
60
+
61
+ @property
62
+ def web_url(self) -> str:
63
+ return self.base_url
64
+
65
+ @property
66
+ def topup_url(self) -> str:
67
+ return self.web_url + SUBSCRIBE_PAGE
68
+
69
+ def project_url(self, project_id: str | None) -> str:
70
+ return f"{self.web_url}{PROJECT_PAGE}{project_id}" if project_id else ""
71
+
72
+ def extra_headers(self) -> dict[str, str]:
73
+ """向 Cheso 发送请求时附加的请求头;预签名存储 URL 不使用这些请求头。"""
74
+ headers = {"User-Agent": USER_AGENT}
75
+ if self.dev_set:
76
+ headers[HEADER_DEV_SET] = self.dev_set
77
+ return headers
78
+
79
+
80
+ def resolve_settings(
81
+ *,
82
+ site: str | None,
83
+ base_url: str | None,
84
+ env: Mapping[str, str] | None = None,
85
+ logged_in_default: Callable[[], str] | None = None,
86
+ ) -> Settings:
87
+ """优先级:``--base-url`` > ``--site`` > ``CHESO_BASE_URL`` > 上次 ``cheso login`` 的站点 > 国际站。"""
88
+ env = os.environ if env is None else env
89
+ option = ""
90
+ if base_url:
91
+ resolved = normalize_base_url(base_url)
92
+ option = f"--base-url {shlex.quote(resolved)}"
93
+ elif site:
94
+ if site not in SITES:
95
+ raise UsageError(f"unknown site {site!r}; choose one of: {', '.join(SITES)}")
96
+ resolved = SITES[site]
97
+ option = f"--site {site}"
98
+ elif env.get(ENV_BASE_URL, "").strip():
99
+ resolved = normalize_base_url(env[ENV_BASE_URL])
100
+ else:
101
+ resolved = SITES[DEFAULT_SITE]
102
+ remembered = logged_in_default() if logged_in_default else ""
103
+ if remembered:
104
+ try:
105
+ resolved = normalize_base_url(remembered)
106
+ except UsageError:
107
+ pass
108
+ return Settings(base_url=resolved, dev_set=env.get(ENV_DEV_SET, "").strip(), option=option)