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.
@@ -0,0 +1,235 @@
1
+ """保存 OAuth 凭据:每个用户一个 JSON 文件,按基础 URL 区分站点,并使用排他文件锁保护。
2
+
3
+ 文件结构(``$XDG_CONFIG_HOME/cheso/credentials.json``,默认 ``~/.config/cheso/credentials.json``)::
4
+
5
+ {"version": 1, "default_base_url": "https://cheso.ai",
6
+ "sites": {"https://cheso.ai": {"client_id": "...", "issuer": "...",
7
+ "token_endpoint": "...", "revocation_endpoint": "...",
8
+ "access_token": "...", "refresh_token": "...", "expires_at": 1767225600.0,
9
+ "pending_device": {...}}}}
10
+
11
+ ``pending_device`` 保存等待用户在浏览器输入短码的设备码登录(``cheso login --device``):
12
+ 设备码、用户码、页面、过期时间、轮询间隔 ``interval``(slow_down 增加的间隔会持续保留)、
13
+ 连续连接超时累计的退避间隔 ``backoff``(令牌端点正常答复后恢复为 ``interval``),以及最早下次轮询时间
14
+ ``next_poll_at``。``cheso login --wait`` 按原节奏继续等待;登录完成、失败或被替换时删除此记录。
15
+
16
+ 文件在权限为 0700 的目录中以 0600 创建,通过临时文件加重命名实现原子替换。
17
+ 刷新令牌每次使用后都会轮换;重复使用旧令牌会撤销整个授权,因此每次读改写都必须持有旁边的
18
+ ``credentials.json.lock`` 文件锁。
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import contextlib
24
+ import json
25
+ import os
26
+ import sys
27
+ import tempfile
28
+ import time
29
+ from collections.abc import Iterator, Mapping
30
+ from dataclasses import asdict, dataclass, field
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ from cheso_cli.errors import CliError
35
+
36
+ FILE_NAME = "credentials.json"
37
+ LOCK_SUFFIX = ".lock"
38
+ FORMAT_VERSION = 1
39
+ DEFAULT_KEY = "default_base_url"
40
+ # 一次刷新在整个 HTTP 往返期间持锁;超过此时长通常说明操作已经卡住。
41
+ DEFAULT_LOCK_TIMEOUT = 90.0
42
+ LOCK_POLL_INTERVAL = 0.05
43
+
44
+
45
+ def default_config_dir(env: Mapping[str, str] | None = None) -> Path:
46
+ env = os.environ if env is None else env
47
+ base = env.get("XDG_CONFIG_HOME", "").strip()
48
+ root = Path(base) if base and Path(base).is_absolute() else Path.home() / ".config"
49
+ return root / "cheso"
50
+
51
+
52
+ @dataclass
53
+ class SiteCredentials:
54
+ """CLI 为单个站点保存的凭据。退出登录后保留 ``client_id``,以便下次登录复用注册信息。"""
55
+
56
+ client_id: str = ""
57
+ issuer: str = ""
58
+ token_endpoint: str = ""
59
+ revocation_endpoint: str = ""
60
+ access_token: str = ""
61
+ refresh_token: str = ""
62
+ expires_at: float = 0.0
63
+ pending_device: dict[str, Any] = field(default_factory=dict)
64
+ extra: dict[str, Any] = field(default_factory=dict)
65
+
66
+ @classmethod
67
+ def from_dict(cls, raw: Any) -> SiteCredentials:
68
+ if not isinstance(raw, dict):
69
+ return cls()
70
+ known = {
71
+ "client_id",
72
+ "issuer",
73
+ "token_endpoint",
74
+ "revocation_endpoint",
75
+ "access_token",
76
+ "refresh_token",
77
+ "expires_at",
78
+ "pending_device",
79
+ }
80
+ try:
81
+ expires_at = float(raw.get("expires_at") or 0)
82
+ except (TypeError, ValueError):
83
+ expires_at = 0.0
84
+ pending = raw.get("pending_device")
85
+ return cls(
86
+ client_id=str(raw.get("client_id") or ""),
87
+ issuer=str(raw.get("issuer") or ""),
88
+ token_endpoint=str(raw.get("token_endpoint") or ""),
89
+ revocation_endpoint=str(raw.get("revocation_endpoint") or ""),
90
+ access_token=str(raw.get("access_token") or ""),
91
+ refresh_token=str(raw.get("refresh_token") or ""),
92
+ expires_at=expires_at,
93
+ pending_device=dict(pending) if isinstance(pending, dict) else {},
94
+ extra={k: v for k, v in raw.items() if k not in known},
95
+ )
96
+
97
+ def to_dict(self) -> dict[str, Any]:
98
+ data = asdict(self)
99
+ extra = data.pop("extra")
100
+ if not data["pending_device"]:
101
+ data.pop("pending_device")
102
+ return {**extra, **data}
103
+
104
+ @property
105
+ def logged_in(self) -> bool:
106
+ return bool(self.access_token or self.refresh_token)
107
+
108
+ def clear_tokens(self) -> None:
109
+ self.access_token = ""
110
+ self.refresh_token = ""
111
+ self.expires_at = 0.0
112
+
113
+
114
+ class CredentialStore:
115
+ """读写凭据文件;修改文件的调用方必须持有 :meth:`locked` 对应的锁。"""
116
+
117
+ def __init__(self, path: Path | None = None, *, lock_timeout: float = DEFAULT_LOCK_TIMEOUT) -> None:
118
+ self.path = Path(path) if path is not None else default_config_dir() / FILE_NAME
119
+ self.lock_path = self.path.with_name(self.path.name + LOCK_SUFFIX)
120
+ self.lock_timeout = lock_timeout
121
+
122
+ # ---- 读取 ----
123
+
124
+ def read(self) -> dict[str, Any]:
125
+ """读取完整文档;文件不存在或无法读取时返回空内容。"""
126
+ try:
127
+ raw = self.path.read_text(encoding="utf-8")
128
+ except FileNotFoundError:
129
+ return {"version": FORMAT_VERSION, "sites": {}}
130
+ except OSError as e:
131
+ raise CliError(f"cannot read {self.path}: {e.strerror or e}") from e
132
+ try:
133
+ data = json.loads(raw)
134
+ except ValueError:
135
+ print(f"warning: ignoring unreadable credentials file {self.path}", file=sys.stderr)
136
+ return {"version": FORMAT_VERSION, "sites": {}}
137
+ if not isinstance(data, dict) or not isinstance(data.get("sites"), dict):
138
+ return {"version": FORMAT_VERSION, "sites": {}}
139
+ return data
140
+
141
+ def get(self, base_url: str) -> SiteCredentials:
142
+ return SiteCredentials.from_dict(self.read()["sites"].get(base_url))
143
+
144
+ # ---- 写入(需要持锁)----
145
+
146
+ def default_base_url(self) -> str:
147
+ """最近一次 ``cheso login`` 的站点;命令未明确指定站点时使用它。"""
148
+ value = self.read().get(DEFAULT_KEY)
149
+ return value if isinstance(value, str) else ""
150
+
151
+ def put(self, base_url: str, creds: SiteCredentials, *, make_default: bool = False) -> None:
152
+ data = self.read()
153
+ data["version"] = FORMAT_VERSION
154
+ data["sites"][base_url] = creds.to_dict()
155
+ if make_default:
156
+ data[DEFAULT_KEY] = base_url
157
+ self._write(data)
158
+
159
+ def _write(self, data: dict[str, Any]) -> None:
160
+ self._ensure_dir()
161
+ payload = json.dumps(data, indent=2, sort_keys=True).encode("utf-8")
162
+ fd, tmp = tempfile.mkstemp(prefix=".credentials-", suffix=".tmp", dir=self.path.parent)
163
+ try:
164
+ with os.fdopen(fd, "wb") as f:
165
+ os.chmod(tmp, 0o600)
166
+ f.write(payload)
167
+ f.flush()
168
+ os.fsync(f.fileno())
169
+ os.replace(tmp, self.path)
170
+ except BaseException:
171
+ with contextlib.suppress(OSError):
172
+ os.unlink(tmp)
173
+ raise
174
+ with contextlib.suppress(OSError):
175
+ os.chmod(self.path, 0o600)
176
+
177
+ def _ensure_dir(self) -> None:
178
+ directory = self.path.parent
179
+ directory.mkdir(mode=0o700, parents=True, exist_ok=True)
180
+ with contextlib.suppress(OSError):
181
+ os.chmod(directory, 0o700)
182
+
183
+ # ---- 加锁 ----
184
+
185
+ @contextlib.contextmanager
186
+ def locked(self) -> Iterator[None]:
187
+ """跨进程、跨线程的排他锁;每个持锁者单独打开锁文件。"""
188
+ self._ensure_dir()
189
+ fd = os.open(self.lock_path, os.O_RDWR | os.O_CREAT, 0o600)
190
+ try:
191
+ _acquire(fd, self.lock_timeout, self.lock_path)
192
+ try:
193
+ yield
194
+ finally:
195
+ _release(fd)
196
+ finally:
197
+ os.close(fd)
198
+
199
+
200
+ if sys.platform == "win32": # pragma: no cover - 仅在 Windows 上执行
201
+ import msvcrt
202
+
203
+ def _try_lock(fd: int) -> bool:
204
+ os.lseek(fd, 0, os.SEEK_SET)
205
+ try:
206
+ msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
207
+ except OSError:
208
+ return False
209
+ return True
210
+
211
+ def _release(fd: int) -> None:
212
+ os.lseek(fd, 0, os.SEEK_SET)
213
+ with contextlib.suppress(OSError):
214
+ msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
215
+
216
+ else:
217
+ import fcntl
218
+
219
+ def _try_lock(fd: int) -> bool:
220
+ try:
221
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
222
+ except BlockingIOError:
223
+ return False
224
+ return True
225
+
226
+ def _release(fd: int) -> None:
227
+ fcntl.flock(fd, fcntl.LOCK_UN)
228
+
229
+
230
+ def _acquire(fd: int, timeout: float, path: Path) -> None:
231
+ deadline = time.monotonic() + timeout
232
+ while not _try_lock(fd):
233
+ if time.monotonic() >= deadline:
234
+ raise CliError(f"timed out waiting for the credentials lock {path}; another cheso command may be stuck")
235
+ time.sleep(LOCK_POLL_INTERVAL)
cheso_cli/errors.py ADDED
@@ -0,0 +1,56 @@
1
+ """定义退出码,以及各命令用于按指定退出码终止执行的错误类型。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+ from typing import Any
7
+
8
+
9
+ class ExitCode(enum.IntEnum):
10
+ """进程退出码,属于 README、SKILL.md 约定的公开接口,不能重新编号。"""
11
+
12
+ OK = 0
13
+ ERROR = 1
14
+ USAGE = 2
15
+ NEEDS_INPUT = 3
16
+ LOGIN_REQUIRED = 4
17
+ INSUFFICIENT_CREDITS = 5
18
+ WAIT_TIMEOUT = 6
19
+ RETRYABLE = 7
20
+ DOWNLOAD_FAILED = 8
21
+
22
+
23
+ class CliError(Exception):
24
+ """使当前命令以 ``exit_code`` 退出的错误。
25
+
26
+ ``kind`` 是稳定的机器可读标签,例如 ``login_required``、``project_busy``;
27
+ ``extra`` 为 ``--json`` 输出提供结构化字段,例如 ``running_task_id``、``topup_url``。
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ message: str,
33
+ *,
34
+ exit_code: ExitCode = ExitCode.ERROR,
35
+ kind: str = "error",
36
+ extra: dict[str, Any] | None = None,
37
+ ) -> None:
38
+ super().__init__(message)
39
+ self.message = message
40
+ self.exit_code = exit_code
41
+ self.kind = kind
42
+ self.extra = dict(extra or {})
43
+
44
+
45
+ class LoginRequired(CliError):
46
+ """没有可用凭据;用户需要运行 ``cheso login`` 或提供令牌。"""
47
+
48
+ def __init__(self, message: str) -> None:
49
+ super().__init__(message, exit_code=ExitCode.LOGIN_REQUIRED, kind="login_required")
50
+
51
+
52
+ class UsageError(CliError):
53
+ """命令行参数错误,例如选项值无效或缺少参数。"""
54
+
55
+ def __init__(self, message: str) -> None:
56
+ super().__init__(message, exit_code=ExitCode.USAGE, kind="usage")
@@ -0,0 +1,93 @@
1
+ """第一版留下的底层接口命令(profile / project / session / plan):原样输出接口返回的 JSON。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from cheso_cli.errors import ExitCode
9
+
10
+ if TYPE_CHECKING:
11
+ from cheso_cli.cli import Context
12
+
13
+
14
+ def _legacy_json(ctx: Context, data: Any) -> None:
15
+ print(json.dumps(data, ensure_ascii=False, indent=2), file=ctx.out)
16
+
17
+
18
+ def cmd_profile(ctx: Context) -> int:
19
+ _legacy_json(ctx, ctx.client.profile())
20
+ return ExitCode.OK
21
+
22
+
23
+ def cmd_project_create(ctx: Context) -> int:
24
+ data = ctx.client.project_create(description=ctx.args.description)
25
+ _legacy_json(ctx, data)
26
+ if isinstance(data, dict) and data.get("project_id"):
27
+ print(f"project_id={data['project_id']}", file=ctx.err)
28
+ return ExitCode.OK
29
+
30
+
31
+ def _tri_bool(value: str | None) -> bool | None:
32
+ return None if value is None else value == "true"
33
+
34
+
35
+ def cmd_project_list(ctx: Context) -> int:
36
+ a = ctx.args
37
+ _legacy_json(
38
+ ctx,
39
+ ctx.client.project_list(
40
+ status=a.status, starred=_tri_bool(a.starred), sort_by=a.sort_by, page=a.page, page_size=a.page_size
41
+ ),
42
+ )
43
+ return ExitCode.OK
44
+
45
+
46
+ def cmd_project_get(ctx: Context) -> int:
47
+ _legacy_json(ctx, ctx.client.project_get(ctx.args.project_id))
48
+ return ExitCode.OK
49
+
50
+
51
+ def cmd_project_update(ctx: Context) -> int:
52
+ a = ctx.args
53
+ _legacy_json(
54
+ ctx,
55
+ ctx.client.project_update(
56
+ a.project_id,
57
+ description=a.description,
58
+ starred=_tri_bool(a.starred),
59
+ cover_file_id=a.cover_file_id,
60
+ cover_file_version=a.cover_file_version,
61
+ ),
62
+ )
63
+ return ExitCode.OK
64
+
65
+
66
+ def cmd_project_delete(ctx: Context) -> int:
67
+ data = ctx.client.project_delete(ctx.args.project_id)
68
+ _legacy_json(ctx, data if data is not None else {"ok": True})
69
+ return ExitCode.OK
70
+
71
+
72
+ def cmd_project_fork(ctx: Context) -> int:
73
+ data = ctx.client.project_fork(ctx.args.project_id, description=ctx.args.description)
74
+ _legacy_json(ctx, data)
75
+ if isinstance(data, dict) and data.get("project_id"):
76
+ print(f"project_id={data['project_id']}", file=ctx.err)
77
+ return ExitCode.OK
78
+
79
+
80
+ def cmd_session_create(ctx: Context) -> int:
81
+ _legacy_json(ctx, ctx.client.session_create(project_id=ctx.args.project_id, title=ctx.args.title))
82
+ return ExitCode.OK
83
+
84
+
85
+ def cmd_plan_create(ctx: Context) -> int:
86
+ a = ctx.args
87
+ _legacy_json(ctx, ctx.client.plan_create(prompt=a.prompt, project_id=a.project_id, session_id=a.session_id))
88
+ return ExitCode.OK
89
+
90
+
91
+ def cmd_plan_get(ctx: Context) -> int:
92
+ _legacy_json(ctx, ctx.client.plan_get(ctx.args.plan_id))
93
+ return ExitCode.OK