tytcli 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.
- tytcli/__init__.py +7 -0
- tytcli/cli.py +174 -0
- tytcli/client.py +832 -0
- tytcli/commands/__init__.py +52 -0
- tytcli/commands/auth.py +147 -0
- tytcli/commands/config.py +61 -0
- tytcli/commands/env.py +103 -0
- tytcli/commands/help.py +105 -0
- tytcli/commands/roadshow.py +898 -0
- tytcli/commands/sentiment.py +200 -0
- tytcli/commands/shared.py +210 -0
- tytcli/commands/upgrade.py +95 -0
- tytcli/commands/wechat.py +253 -0
- tytcli/commands/whoami.py +76 -0
- tytcli/config.py +578 -0
- tytcli/errors.py +71 -0
- tytcli/output.py +82 -0
- tytcli/selfupdate.py +639 -0
- tytcli/skill/SKILL.md +122 -0
- tytcli/skill/references/output-contract.md +93 -0
- tytcli/skill/references/roadshow.md +176 -0
- tytcli/skill/references/selfupdate.md +62 -0
- tytcli/skill/references/sentiment.md +68 -0
- tytcli/skill/references/setup.md +105 -0
- tytcli/skill/references/wechat.md +87 -0
- tytcli/skill/references/whoami.md +42 -0
- tytcli/skill.py +118 -0
- tytcli-0.2.0.dist-info/METADATA +437 -0
- tytcli-0.2.0.dist-info/RECORD +32 -0
- tytcli-0.2.0.dist-info/WHEEL +4 -0
- tytcli-0.2.0.dist-info/entry_points.txt +2 -0
- tytcli-0.2.0.dist-info/licenses/LICENSE.txt +16 -0
tytcli/config.py
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
"""配置的发现、加载与解析。
|
|
2
|
+
|
|
3
|
+
**可配置**的那些设置,其优先级为::
|
|
4
|
+
|
|
5
|
+
环境变量 > 配置文件 > 内置默认值
|
|
6
|
+
|
|
7
|
+
**环境的 base URL 不可配置。**下面的 ``BASE_URLS`` 是唯一真相来源:配置文件里的键
|
|
8
|
+
和环境变量都不能覆盖它。若配置文件把 `base_url` 设成了**不同**的值,会大声报错而
|
|
9
|
+
不是静默忽略——被静默忽略的覆盖项正是那种要花几个小时才能查清的东西。若它只是
|
|
10
|
+
**复述**内置值,则容忍并忽略,这样旧模板写出来的配置文件仍然能加载。
|
|
11
|
+
|
|
12
|
+
两个 base URL 已于 2026-11-06 对着线上服务验证过。注意这个不对称:
|
|
13
|
+
**prod 没有 ``/api`` 路径前缀,test 有。**所以 ``client.py`` 里的端点一律写成
|
|
14
|
+
相对于 base URL、且不带前导 ``/api`` 的形式::
|
|
15
|
+
|
|
16
|
+
prod https://tytapi.1234567.com.cn + Login/GetLoginInfoV2
|
|
17
|
+
test https://tytapitest.1234567.com.cn/api + Login/GetLoginInfoV2
|
|
18
|
+
|
|
19
|
+
在某个 URL 上拿到 ``ErrorCode -100``(认证)而不是 ``-1``(地址错误),就是该路由
|
|
20
|
+
存在的证明。
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import contextlib
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import tomllib
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any, cast
|
|
32
|
+
from urllib.parse import urlsplit
|
|
33
|
+
|
|
34
|
+
from .errors import ConfigError
|
|
35
|
+
|
|
36
|
+
DEFAULT_ENV = "prod"
|
|
37
|
+
|
|
38
|
+
#: 仅有的两个环境及其 API base URL。固定在代码里。
|
|
39
|
+
BASE_URLS: dict[str, str] = {
|
|
40
|
+
"prod": "https://tytapi.1234567.com.cn",
|
|
41
|
+
"test": "https://tytapitest.1234567.com.cn/api",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#: 公众号(WeChat Official Account)模块所用的第二个 API 根。
|
|
45
|
+
#:
|
|
46
|
+
#: 2026-11-06 在 ``test`` 上实测:这些路由住在 ``/middleApi/`` 下面,**不在**
|
|
47
|
+
#: ``BASE_URLS`` 下面。``{test_base}/WechatPublic/getPostPage`` 回答
|
|
48
|
+
#: ``ErrorCode -1``(地址错误),而 ``/middleApi/WechatPublic/…`` 才能用。
|
|
49
|
+
#: 路径**大小写敏感**——小写的 ``wechatPublic`` 直接返回 HTTP 404。
|
|
50
|
+
#:
|
|
51
|
+
#: ``prod`` 那一条是按 test 的规律**外推**出来的,无法证实:2026-11-06 时 prod 上
|
|
52
|
+
#: 每一个 ``/middleApi/`` 路径都返回 HTTP 404,而 prod 已知可用的
|
|
53
|
+
#: ``Login/GetLoginInfoV2`` 返回 ``-100``——所以 prod 是可达的,只是该模块没有部署
|
|
54
|
+
#: 在那里。仍然把 URL 列出来,是为了让 404 **自然发生**(并带上提示),而不是在代码里
|
|
55
|
+
#: 写死"prod 不可用";若 prod 将来部署到这个路径,命令不改一行代码就能用。若部署到
|
|
56
|
+
#: 别处,要改的就是这一行。
|
|
57
|
+
#:
|
|
58
|
+
#: 与 ``BASE_URLS`` 同一策略:固定在代码里,配置文件和环境变量都不能覆盖。
|
|
59
|
+
MIDDLE_BASE_URLS: dict[str, str] = {
|
|
60
|
+
"prod": "https://tytapi.1234567.com.cn/middleApi",
|
|
61
|
+
"test": "https://tytapitest.1234567.com.cn/middleApi",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
#: 排序后的全部合法环境名。**只用于校验**——面向用户的输出必须改走
|
|
65
|
+
#: ``visible_envs()``。
|
|
66
|
+
KNOWN_ENVS: tuple[str, ...] = tuple(sorted(BASE_URLS))
|
|
67
|
+
|
|
68
|
+
#: CLI 允许向用户宣传的环境。
|
|
69
|
+
#:
|
|
70
|
+
#: ``test`` 确实存在、确实可用,也在 AGENTS.md 里为开发者记录了;但它是**开发**
|
|
71
|
+
#: 环境,不得出现在最终用户面前。
|
|
72
|
+
PUBLIC_ENVS: tuple[str, ...] = ("prod",)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def visible_envs(active_env: str | None = None) -> tuple[str, ...]:
|
|
76
|
+
"""返回面向用户的输出里可以列出的那些环境。
|
|
77
|
+
|
|
78
|
+
``test`` 保持隐藏,除非它正是当前生效的环境——这样一个已经切过去的开发者仍然
|
|
79
|
+
能得到自洽的输出,而普通用户永远不会从 CLI 里学到这个名字。所有用户可见的列举
|
|
80
|
+
(``env list``、``env show``、未知环境的报错、``--help``)都要走本函数,
|
|
81
|
+
而不是直接读 ``KNOWN_ENVS``。
|
|
82
|
+
"""
|
|
83
|
+
envs = list(PUBLIC_ENVS)
|
|
84
|
+
if active_env is not None and active_env not in envs:
|
|
85
|
+
envs.append(active_env)
|
|
86
|
+
return tuple(sorted(envs))
|
|
87
|
+
|
|
88
|
+
DEFAULT_TIMEOUT_SECONDS = 15.0
|
|
89
|
+
MIN_TIMEOUT_SECONDS = 0.1
|
|
90
|
+
MAX_TIMEOUT_SECONDS = 300.0
|
|
91
|
+
|
|
92
|
+
ENV_VAR_CONFIG = "TYTCLI_CONFIG"
|
|
93
|
+
ENV_VAR_ENV = "TYTCLI_ENV"
|
|
94
|
+
ENV_VAR_TOKEN = "TYTCLI_TOKEN"
|
|
95
|
+
ENV_VAR_XDG_CONFIG_HOME = "XDG_CONFIG_HOME"
|
|
96
|
+
|
|
97
|
+
APP_DIR_NAME = "tytcli"
|
|
98
|
+
CONFIG_FILE_NAME = "config.toml"
|
|
99
|
+
|
|
100
|
+
_SECTION_RE = re.compile(r"^\s*\[(?P<name>[^\]]+?)\]\s*(?:#.*)?$")
|
|
101
|
+
_KEY_RE = re.compile(r"^\s*(?P<key>[A-Za-z0-9_-]+)\s*=")
|
|
102
|
+
|
|
103
|
+
def _validate_base_urls() -> None:
|
|
104
|
+
"""在 import 时就对硬编码的 base URL 做一次合理性检查。
|
|
105
|
+
|
|
106
|
+
它们是唯一**绝不能**出错的东西,因为解析链上再没有别的环节能纠正它们。这里用
|
|
107
|
+
``RuntimeError`` 而不是 ``assert``:``python -O`` 会把 assert 全部剥掉,那样
|
|
108
|
+
这项检查就被静默关掉了。
|
|
109
|
+
"""
|
|
110
|
+
for name, url in BASE_URLS.items():
|
|
111
|
+
if not url.startswith("https://") or url.endswith("/"):
|
|
112
|
+
raise RuntimeError(
|
|
113
|
+
f"BASE_URLS[{name!r}] 必须是不带末尾斜杠的 https URL:{url!r}"
|
|
114
|
+
)
|
|
115
|
+
for name, url in MIDDLE_BASE_URLS.items():
|
|
116
|
+
if not url.startswith("https://") or url.endswith("/"):
|
|
117
|
+
raise RuntimeError(
|
|
118
|
+
f"MIDDLE_BASE_URLS[{name!r}] 必须是不带末尾斜杠的 https URL:"
|
|
119
|
+
f"{url!r}"
|
|
120
|
+
)
|
|
121
|
+
# middle 根如果与所属环境不同主机,就会把 TYT_Token cookie 送到意料之外的
|
|
122
|
+
# 地方。重定向本来就禁用了,但主机本身也值得钉住。
|
|
123
|
+
for name in BASE_URLS:
|
|
124
|
+
if name not in MIDDLE_BASE_URLS:
|
|
125
|
+
raise RuntimeError(f"MIDDLE_BASE_URLS 缺少环境 {name!r}")
|
|
126
|
+
core_host = urlsplit(BASE_URLS[name]).netloc
|
|
127
|
+
middle_host = urlsplit(MIDDLE_BASE_URLS[name]).netloc
|
|
128
|
+
if core_host != middle_host:
|
|
129
|
+
raise RuntimeError(
|
|
130
|
+
f"MIDDLE_BASE_URLS[{name!r}] 的主机 {middle_host!r} 与 "
|
|
131
|
+
f"BASE_URLS[{name!r}] 的主机 {core_host!r} 不同;TYT_Token cookie "
|
|
132
|
+
"绝不能被发往另一个主机"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
_validate_base_urls()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def default_config_path() -> Path:
|
|
140
|
+
"""返回 ``$XDG_CONFIG_HOME/tytcli/config.toml``(默认 ``~/.config``)。"""
|
|
141
|
+
xdg = os.environ.get(ENV_VAR_XDG_CONFIG_HOME, "").strip()
|
|
142
|
+
base = Path(xdg).expanduser() if xdg else Path.home() / ".config"
|
|
143
|
+
return base / APP_DIR_NAME / CONFIG_FILE_NAME
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True)
|
|
147
|
+
class ResolvedConfig:
|
|
148
|
+
"""一个命令需要的全部东西,均已解析并校验完毕。"""
|
|
149
|
+
|
|
150
|
+
path: Path
|
|
151
|
+
path_source: str # TYTCLI_CONFIG | default(默认路径)
|
|
152
|
+
path_exists: bool
|
|
153
|
+
active_env: str
|
|
154
|
+
env_source: str # TYTCLI_ENV | config | default(默认值)
|
|
155
|
+
base_url: str # 永远来自 BASE_URLS;不可配置
|
|
156
|
+
#: 公众号模块所用的根(``MIDDLE_BASE_URLS``);同样不可配置。
|
|
157
|
+
middle_base_url: str
|
|
158
|
+
token: str | None
|
|
159
|
+
token_source: str | None # TYTCLI_TOKEN | config(配置文件)| None
|
|
160
|
+
timeout_seconds: float
|
|
161
|
+
raw: dict[str, Any]
|
|
162
|
+
|
|
163
|
+
def require_token(self) -> str:
|
|
164
|
+
if self.token:
|
|
165
|
+
return self.token
|
|
166
|
+
|
|
167
|
+
message = (
|
|
168
|
+
f"环境 '{self.active_env}' 缺少 TYT_Token。"
|
|
169
|
+
f"运行 `tytcli auth --token <TOKEN>` 写入配置,"
|
|
170
|
+
f"或设置环境变量 {ENV_VAR_TOKEN}。"
|
|
171
|
+
)
|
|
172
|
+
details: dict[str, Any] = {
|
|
173
|
+
"env": self.active_env,
|
|
174
|
+
"config_path": str(self.path),
|
|
175
|
+
"path_exists": self.path_exists,
|
|
176
|
+
"env_var": ENV_VAR_TOKEN,
|
|
177
|
+
"hint": f"tytcli auth --env {self.active_env} --token <TOKEN>",
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
# 一个常见陷阱:用户跑了 `auth --env test`,但 active_env 仍是 prod,
|
|
181
|
+
# 于是刚设完 Token,下一条命令就说"缺少 Token"。这里直接指出**哪个**环境
|
|
182
|
+
# 确实有 Token,而不是让用户去猜。我们刻意不替用户切换环境——
|
|
183
|
+
# `auth --env` 只写那一个段落,别的什么都不动。
|
|
184
|
+
others = [
|
|
185
|
+
name
|
|
186
|
+
for name in KNOWN_ENVS
|
|
187
|
+
if name != self.active_env
|
|
188
|
+
and str(env_section(self.raw, name).get("tyt_token") or "").strip()
|
|
189
|
+
]
|
|
190
|
+
if others:
|
|
191
|
+
listed = ", ".join(others)
|
|
192
|
+
message += f"(注意:{listed} 环境已配置 Token,但当前生效的是 '{self.active_env}')"
|
|
193
|
+
details["envs_with_token"] = others
|
|
194
|
+
details["hint"] = (
|
|
195
|
+
f"切换默认环境:tytcli env use {others[0]};"
|
|
196
|
+
f"或单次运行:TYTCLI_ENV={others[0]} tytcli <command>;"
|
|
197
|
+
f"或为当前环境写入:tytcli auth --env {self.active_env} --token <TOKEN>"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
raise ConfigError(message, code="CONFIG_MISSING_TOKEN", details=details)
|
|
201
|
+
|
|
202
|
+
def masked_token(self) -> str | None:
|
|
203
|
+
return mask_token(self.token)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def mask_token(token: str | None) -> str | None:
|
|
207
|
+
"""首 4 位 / 末 4 位的掩码,可以安全打印。**绝不**返回完整值。"""
|
|
208
|
+
if not token:
|
|
209
|
+
return None
|
|
210
|
+
if len(token) <= 8:
|
|
211
|
+
return "*" * len(token)
|
|
212
|
+
return f"{token[:4]}…{token[-4:]} (len={len(token)})"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# --------------------------------------------------------------------------- #
|
|
216
|
+
# 加载
|
|
217
|
+
# --------------------------------------------------------------------------- #
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _config_path_from_env(environ: dict[str, str]) -> tuple[Path, str, bool]:
|
|
221
|
+
raw = environ.get(ENV_VAR_CONFIG)
|
|
222
|
+
if raw is not None:
|
|
223
|
+
if not raw.strip():
|
|
224
|
+
raise ConfigError(f"{ENV_VAR_CONFIG} 已设置但为空,无法确定配置文件位置。")
|
|
225
|
+
path = Path(raw.strip()).expanduser()
|
|
226
|
+
if not path.is_file():
|
|
227
|
+
raise ConfigError(
|
|
228
|
+
f"{ENV_VAR_CONFIG} 指定的配置文件不存在:{path}",
|
|
229
|
+
code="CONFIG_NOT_FOUND",
|
|
230
|
+
details={"path": str(path), "source": ENV_VAR_CONFIG},
|
|
231
|
+
)
|
|
232
|
+
return path, ENV_VAR_CONFIG, True
|
|
233
|
+
path = default_config_path()
|
|
234
|
+
return path, "default", path.is_file()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _read_toml(path: Path) -> dict[str, Any]:
|
|
238
|
+
try:
|
|
239
|
+
with path.open("rb") as handle:
|
|
240
|
+
data = tomllib.load(handle)
|
|
241
|
+
except tomllib.TOMLDecodeError as exc:
|
|
242
|
+
raise ConfigError(
|
|
243
|
+
f"配置文件不是合法的 TOML:{path}({exc})",
|
|
244
|
+
code="CONFIG_PARSE_ERROR",
|
|
245
|
+
details={"path": str(path)},
|
|
246
|
+
) from exc
|
|
247
|
+
except OSError as exc:
|
|
248
|
+
raise ConfigError(
|
|
249
|
+
f"配置文件无法读取:{path}({exc})",
|
|
250
|
+
code="CONFIG_UNREADABLE",
|
|
251
|
+
details={"path": str(path)},
|
|
252
|
+
) from exc
|
|
253
|
+
# tomllib.load() 的类型就是 `dict[str, Any]`,而 TOML 文档的根一定是一张表,
|
|
254
|
+
# 所以这里没有需要防的形状。
|
|
255
|
+
return data
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _as_table(value: object, *, error: str) -> dict[str, Any]:
|
|
259
|
+
"""把一个 TOML 值窄化成"键为字符串的表";缺失时返回 ``{}``。
|
|
260
|
+
|
|
261
|
+
``raw.get()`` 给出的是 ``Any``;没有这次 cast,下游每一次访问的类型都是
|
|
262
|
+
``Unknown``,而 basedpyright(正确地)拒绝在这种类型上做推理。
|
|
263
|
+
"""
|
|
264
|
+
if value is None:
|
|
265
|
+
return {}
|
|
266
|
+
if not isinstance(value, dict):
|
|
267
|
+
raise ConfigError(error)
|
|
268
|
+
return cast("dict[str, Any]", value)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def env_section(raw: dict[str, Any], name: str) -> dict[str, Any]:
|
|
272
|
+
"""返回 ``[env.<name>]`` 这张表;缺失时返回 ``{}``。"""
|
|
273
|
+
envs = _as_table(
|
|
274
|
+
raw.get("env"), error="配置项 [env] 必须是表(每个子表对应一个环境)。"
|
|
275
|
+
)
|
|
276
|
+
return _as_table(envs.get(name), error=f"[env.{name}] 必须是表。")
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _check_configured_base_url(raw: dict[str, Any], path: Path) -> None:
|
|
280
|
+
"""拒绝那些试图**改变** base URL 的配置文件。
|
|
281
|
+
|
|
282
|
+
若 `base_url` 的值与内置常量相同,则容忍并忽略:它在语义上是个空操作,而拒绝它
|
|
283
|
+
会让所有由"带这个键的旧模板"写出来的配置文件直接坏掉。只有值**真的不同**才是
|
|
284
|
+
值得失败的冲突——静默忽略那一种要糟得多,因为用户会以为自己的覆盖生效了。
|
|
285
|
+
"""
|
|
286
|
+
envs_raw = raw.get("env")
|
|
287
|
+
if not isinstance(envs_raw, dict):
|
|
288
|
+
return
|
|
289
|
+
envs = cast("dict[str, Any]", envs_raw)
|
|
290
|
+
|
|
291
|
+
conflicts: list[tuple[str, str]] = []
|
|
292
|
+
for name, section in envs.items():
|
|
293
|
+
if not isinstance(section, dict):
|
|
294
|
+
continue
|
|
295
|
+
table = cast("dict[str, Any]", section)
|
|
296
|
+
value = table.get("base_url")
|
|
297
|
+
if value is None:
|
|
298
|
+
continue
|
|
299
|
+
if not isinstance(value, str):
|
|
300
|
+
conflicts.append((name, f"<非字符串: {type(value).__name__}>"))
|
|
301
|
+
continue
|
|
302
|
+
normalized = value.strip().rstrip("/")
|
|
303
|
+
expected = BASE_URLS.get(name)
|
|
304
|
+
if expected is None or normalized != expected:
|
|
305
|
+
conflicts.append((name, value.strip() or "<空值>"))
|
|
306
|
+
|
|
307
|
+
if conflicts:
|
|
308
|
+
listed = ", ".join(f"[env.{n}] = {v!r}" for n, v in conflicts)
|
|
309
|
+
raise ConfigError(
|
|
310
|
+
"环境 API 地址固定在代码中,不可通过配置文件覆盖。"
|
|
311
|
+
f"{path} 中的这些值与内置地址不一致:{listed}。"
|
|
312
|
+
f"请删除这些 base_url 键(内置值:"
|
|
313
|
+
+ ", ".join(f"{n}={u}" for n, u in sorted(BASE_URLS.items()))
|
|
314
|
+
+ ")。可用 `tytcli config init --force` 重新生成模板。",
|
|
315
|
+
code="CONFIG_BASE_URL_NOT_CONFIGURABLE",
|
|
316
|
+
details={
|
|
317
|
+
"path": str(path),
|
|
318
|
+
"conflicts": [{"section": f"env.{n}", "value": v} for n, v in conflicts],
|
|
319
|
+
"base_urls": dict(BASE_URLS),
|
|
320
|
+
},
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def load_config(environ: dict[str, str] | None = None) -> ResolvedConfig:
|
|
325
|
+
"""加载并解析出实际生效的配置。"""
|
|
326
|
+
env_map = dict(os.environ if environ is None else environ)
|
|
327
|
+
|
|
328
|
+
path, path_source, path_exists = _config_path_from_env(env_map)
|
|
329
|
+
raw = _read_toml(path) if path_exists else {}
|
|
330
|
+
_check_configured_base_url(raw, path)
|
|
331
|
+
|
|
332
|
+
active_env = _resolve_active_env(env_map, raw)
|
|
333
|
+
section = env_section(raw, active_env)
|
|
334
|
+
token, token_source = _resolve_token(env_map, section)
|
|
335
|
+
|
|
336
|
+
return ResolvedConfig(
|
|
337
|
+
path=path,
|
|
338
|
+
path_source=path_source,
|
|
339
|
+
path_exists=path_exists,
|
|
340
|
+
active_env=active_env,
|
|
341
|
+
env_source=_active_env_source(env_map, raw),
|
|
342
|
+
base_url=BASE_URLS[active_env],
|
|
343
|
+
middle_base_url=MIDDLE_BASE_URLS[active_env],
|
|
344
|
+
token=token,
|
|
345
|
+
token_source=token_source,
|
|
346
|
+
timeout_seconds=_resolve_timeout(raw),
|
|
347
|
+
raw=raw,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _active_env_source(env_map: dict[str, str], raw: dict[str, Any]) -> str:
|
|
352
|
+
if env_map.get(ENV_VAR_ENV) is not None:
|
|
353
|
+
return ENV_VAR_ENV
|
|
354
|
+
if isinstance(raw.get("active_env"), str) and raw["active_env"].strip():
|
|
355
|
+
return "config"
|
|
356
|
+
return "default"
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _resolve_active_env(env_map: dict[str, str], raw: dict[str, Any]) -> str:
|
|
360
|
+
from_env = env_map.get(ENV_VAR_ENV)
|
|
361
|
+
if from_env is not None:
|
|
362
|
+
name, source = from_env.strip(), ENV_VAR_ENV
|
|
363
|
+
if not name:
|
|
364
|
+
raise ConfigError(f"{ENV_VAR_ENV} 已设置但为空。")
|
|
365
|
+
else:
|
|
366
|
+
configured = raw.get("active_env")
|
|
367
|
+
if configured is None:
|
|
368
|
+
return DEFAULT_ENV
|
|
369
|
+
if not isinstance(configured, str):
|
|
370
|
+
raise ConfigError("配置项 active_env 必须是字符串。")
|
|
371
|
+
if not configured.strip():
|
|
372
|
+
return DEFAULT_ENV
|
|
373
|
+
name, source = configured.strip(), "active_env"
|
|
374
|
+
|
|
375
|
+
if name not in BASE_URLS:
|
|
376
|
+
raise ConfigError(
|
|
377
|
+
f"未知环境 {name!r}(来源:{source})。tytcli 只支持这两个环境:{', '.join(KNOWN_ENVS)}",
|
|
378
|
+
code="CONFIG_UNKNOWN_ENV",
|
|
379
|
+
details={"env": name, "known_envs": list(KNOWN_ENVS), "source": source},
|
|
380
|
+
)
|
|
381
|
+
return name
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _resolve_token(env_map: dict[str, str], section: dict[str, Any]) -> tuple[str | None, str | None]:
|
|
385
|
+
override = env_map.get(ENV_VAR_TOKEN)
|
|
386
|
+
if override is not None:
|
|
387
|
+
# 显式的空值是一个错误,而不是"静默回退到配置文件里的值"。
|
|
388
|
+
if not override.strip():
|
|
389
|
+
raise ConfigError(
|
|
390
|
+
f"{ENV_VAR_TOKEN} 已设置但为空;不会静默回退到配置文件中的 Token。",
|
|
391
|
+
code="CONFIG_EMPTY_TOKEN",
|
|
392
|
+
details={"env_var": ENV_VAR_TOKEN},
|
|
393
|
+
)
|
|
394
|
+
return override.strip(), ENV_VAR_TOKEN
|
|
395
|
+
|
|
396
|
+
configured = section.get("tyt_token")
|
|
397
|
+
if configured is None:
|
|
398
|
+
return None, None
|
|
399
|
+
if not isinstance(configured, str):
|
|
400
|
+
raise ConfigError("tyt_token 必须是字符串。")
|
|
401
|
+
value = configured.strip()
|
|
402
|
+
return (value or None), ("config" if value else None)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _resolve_timeout(raw: dict[str, Any]) -> float:
|
|
406
|
+
value = raw.get("timeout_seconds", DEFAULT_TIMEOUT_SECONDS)
|
|
407
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
408
|
+
raise ConfigError("timeout_seconds 必须是数字(秒)。")
|
|
409
|
+
seconds = float(value)
|
|
410
|
+
if not MIN_TIMEOUT_SECONDS <= seconds <= MAX_TIMEOUT_SECONDS:
|
|
411
|
+
raise ConfigError(
|
|
412
|
+
f"timeout_seconds 必须在 {MIN_TIMEOUT_SECONDS}–{MAX_TIMEOUT_SECONDS} 之间,当前为 {seconds}。"
|
|
413
|
+
)
|
|
414
|
+
return seconds
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
# --------------------------------------------------------------------------- #
|
|
418
|
+
# writing
|
|
419
|
+
# --------------------------------------------------------------------------- #
|
|
420
|
+
|
|
421
|
+
CONFIG_TEMPLATE = """\
|
|
422
|
+
# tytcli 配置文件
|
|
423
|
+
#
|
|
424
|
+
# 环境的 API base URL 固定在代码里,**刻意不在此处提供配置项**。
|
|
425
|
+
# 若 base_url 与内置值不同,会直接报错。
|
|
426
|
+
#
|
|
427
|
+
# 可覆盖的设置,按优先级从高到低:
|
|
428
|
+
# TYTCLI_CONFIG 本文件的路径
|
|
429
|
+
# TYTCLI_ENV 当前生效的环境
|
|
430
|
+
# TYTCLI_TOKEN 当前生效环境的 tyt_token
|
|
431
|
+
#
|
|
432
|
+
# 写入 Token 最省事的方式: tytcli auth --token <TOKEN>
|
|
433
|
+
# 不要把本文件提交进版本库。权限:0600。
|
|
434
|
+
|
|
435
|
+
active_env = "prod"
|
|
436
|
+
timeout_seconds = 15.0
|
|
437
|
+
|
|
438
|
+
[env.prod]
|
|
439
|
+
tyt_token = ""
|
|
440
|
+
"""
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def validate_token(token: str) -> str:
|
|
444
|
+
"""规范化并校验用户提供的 Token。"""
|
|
445
|
+
value = token.strip()
|
|
446
|
+
if not value:
|
|
447
|
+
raise ConfigError("Token 为空。", code="AUTH_EMPTY_TOKEN")
|
|
448
|
+
if any(char.isspace() for char in value):
|
|
449
|
+
raise ConfigError(
|
|
450
|
+
"Token 中含有空白字符,很可能是复制粘贴出错。",
|
|
451
|
+
code="AUTH_MALFORMED_TOKEN",
|
|
452
|
+
details={"token_hint": mask_token(value)},
|
|
453
|
+
)
|
|
454
|
+
return value
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _toml_basic_string(value: str) -> str:
|
|
458
|
+
escaped = (
|
|
459
|
+
value.replace("\\", "\\\\")
|
|
460
|
+
.replace('"', '\\"')
|
|
461
|
+
.replace("\n", "\\n")
|
|
462
|
+
.replace("\r", "\\r")
|
|
463
|
+
.replace("\t", "\\t")
|
|
464
|
+
)
|
|
465
|
+
return f'"{escaped}"'
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _write_locked(path: Path, text: str) -> None:
|
|
469
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
470
|
+
with contextlib.suppress(OSError):
|
|
471
|
+
os.chmod(path.parent, 0o700)
|
|
472
|
+
path.write_text(text, encoding="utf-8")
|
|
473
|
+
with contextlib.suppress(OSError):
|
|
474
|
+
os.chmod(path, 0o600)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def write_default_config(path: Path, *, force: bool = False) -> Path:
|
|
478
|
+
"""创建一个 0600 权限的初始配置文件。"""
|
|
479
|
+
if path.exists() and not force:
|
|
480
|
+
raise ConfigError(
|
|
481
|
+
f"配置文件已存在:{path}(使用 --force 覆盖)",
|
|
482
|
+
code="CONFIG_EXISTS",
|
|
483
|
+
details={"path": str(path)},
|
|
484
|
+
)
|
|
485
|
+
_write_locked(path, CONFIG_TEMPLATE)
|
|
486
|
+
return path
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def ensure_config_file(config: ResolvedConfig) -> Path:
|
|
490
|
+
"""确保存在一个可写的配置文件;必要时按模板创建。"""
|
|
491
|
+
if config.path_exists:
|
|
492
|
+
return config.path
|
|
493
|
+
if config.path_source == ENV_VAR_CONFIG:
|
|
494
|
+
# load_config() 已经会拒绝"显式指定的路径不存在";这里是双保险。
|
|
495
|
+
raise ConfigError(
|
|
496
|
+
f"{ENV_VAR_CONFIG} 指定的配置文件不存在:{config.path}",
|
|
497
|
+
code="CONFIG_NOT_FOUND",
|
|
498
|
+
details={"path": str(config.path)},
|
|
499
|
+
)
|
|
500
|
+
write_default_config(config.path)
|
|
501
|
+
return config.path
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _split_sections(lines: list[str]) -> list[tuple[str | None, int, int]]:
|
|
505
|
+
"""返回 ``(段落名, 段落头行号, 段落正文结束行号)`` 这些区间。
|
|
506
|
+
|
|
507
|
+
第一个 ``[table]`` 头之前的那段顶层区域,其 ``section_name`` 为 ``None``。
|
|
508
|
+
"""
|
|
509
|
+
spans: list[tuple[str | None, int, int]] = [(None, -1, len(lines))]
|
|
510
|
+
for index, line in enumerate(lines):
|
|
511
|
+
match = _SECTION_RE.match(line)
|
|
512
|
+
if match:
|
|
513
|
+
spans[-1] = (spans[-1][0], spans[-1][1], index)
|
|
514
|
+
spans.append((match.group("name").strip(), index, len(lines)))
|
|
515
|
+
return spans
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _set_key(
|
|
519
|
+
path: Path,
|
|
520
|
+
key: str,
|
|
521
|
+
value_toml: str,
|
|
522
|
+
*,
|
|
523
|
+
section: str | None = None,
|
|
524
|
+
) -> None:
|
|
525
|
+
"""就地在 TOML 文件里设置 ``key = value``,其余内容原样保留。
|
|
526
|
+
|
|
527
|
+
``section=None`` 表示顶层(任何 ``[table]`` 头之前)。段落缺失时会创建它。
|
|
528
|
+
注释与无关的行都会被保留。
|
|
529
|
+
"""
|
|
530
|
+
text = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
531
|
+
trailing_newline = text.endswith("\n") or not text
|
|
532
|
+
lines = text.splitlines()
|
|
533
|
+
assignment = f"{key} = {value_toml}"
|
|
534
|
+
|
|
535
|
+
spans = _split_sections(lines)
|
|
536
|
+
target = next((span for span in spans if span[0] == section), None)
|
|
537
|
+
|
|
538
|
+
if target is None:
|
|
539
|
+
if section is None:
|
|
540
|
+
# 没找到顶层区域(文件以一张表开头):插到它上面。
|
|
541
|
+
insert_at = spans[1][1] if len(spans) > 1 else len(lines)
|
|
542
|
+
lines[insert_at:insert_at] = [assignment, ""]
|
|
543
|
+
else:
|
|
544
|
+
if lines and lines[-1].strip():
|
|
545
|
+
lines.append("")
|
|
546
|
+
lines.extend([f"[{section}]", assignment])
|
|
547
|
+
else:
|
|
548
|
+
_name, header_index, body_end = target
|
|
549
|
+
start = header_index + 1 if section is not None else 0
|
|
550
|
+
replaced = False
|
|
551
|
+
for index in range(start, body_end):
|
|
552
|
+
match = _KEY_RE.match(lines[index])
|
|
553
|
+
if match and match.group("key") == key:
|
|
554
|
+
lines[index] = assignment
|
|
555
|
+
replaced = True
|
|
556
|
+
break
|
|
557
|
+
if not replaced:
|
|
558
|
+
# 插到段落正文的末尾,并跳过尾部的空行,这样这个键就不会落在
|
|
559
|
+
# "下一个段落头之前的空档"后面。
|
|
560
|
+
insert_at = body_end
|
|
561
|
+
while insert_at > start and not lines[insert_at - 1].strip():
|
|
562
|
+
insert_at -= 1
|
|
563
|
+
lines.insert(insert_at, assignment)
|
|
564
|
+
|
|
565
|
+
new_text = "\n".join(lines)
|
|
566
|
+
if trailing_newline:
|
|
567
|
+
new_text += "\n"
|
|
568
|
+
_write_locked(path, new_text)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def set_active_env(path: Path, name: str) -> None:
|
|
572
|
+
"""把 ``active_env`` 持久化到配置文件的顶层。"""
|
|
573
|
+
_set_key(path, "active_env", _toml_basic_string(name))
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def set_env_token(path: Path, env_name: str, token: str) -> None:
|
|
577
|
+
"""把 ``tyt_token`` 持久化到 ``[env.<env_name>]`` 里。"""
|
|
578
|
+
_set_key(path, "tyt_token", _toml_basic_string(token), section=f"env.{env_name}")
|
tytcli/errors.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""tytcli 的错误类型体系。
|
|
2
|
+
|
|
3
|
+
每个错误都带一个稳定的、机器可读的 ``code`` 和 ``exit_code``,这样 agent 可以直接
|
|
4
|
+
按结果分支,而不需要去解析文案。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TytCliError(Exception):
|
|
13
|
+
"""所有"预期内失败"的基类。"""
|
|
14
|
+
|
|
15
|
+
default_code = "ERROR"
|
|
16
|
+
default_exit_code = 1
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
message: str,
|
|
21
|
+
*,
|
|
22
|
+
code: str | None = None,
|
|
23
|
+
exit_code: int | None = None,
|
|
24
|
+
details: dict[str, Any] | None = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.message = message
|
|
28
|
+
self.code = code or self.default_code
|
|
29
|
+
self.exit_code = self.default_exit_code if exit_code is None else exit_code
|
|
30
|
+
self.details = details or {}
|
|
31
|
+
|
|
32
|
+
def to_dict(self) -> dict[str, Any]:
|
|
33
|
+
payload: dict[str, Any] = {"code": self.code, "message": self.message}
|
|
34
|
+
if self.details:
|
|
35
|
+
payload["details"] = self.details
|
|
36
|
+
return payload
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class UsageError(TytCliError):
|
|
40
|
+
"""命令行用法错误。"""
|
|
41
|
+
|
|
42
|
+
default_code = "USAGE"
|
|
43
|
+
default_exit_code = 2
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ConfigError(TytCliError):
|
|
47
|
+
"""配置缺失、不可读或自相矛盾。"""
|
|
48
|
+
|
|
49
|
+
default_code = "CONFIG_INVALID"
|
|
50
|
+
default_exit_code = 2
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AuthError(TytCliError):
|
|
54
|
+
"""服务端拒绝了我们的凭证(TYT_Token 缺失或已过期)。"""
|
|
55
|
+
|
|
56
|
+
default_code = "AUTH_EXPIRED"
|
|
57
|
+
default_exit_code = 3
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class NetworkError(TytCliError):
|
|
61
|
+
"""HTTP 交换没能完成(DNS、TLS、超时等)。"""
|
|
62
|
+
|
|
63
|
+
default_code = "NETWORK"
|
|
64
|
+
default_exit_code = 4
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ApiError(TytCliError):
|
|
68
|
+
"""服务端有响应,但返回的是业务失败或无法解析的内容。"""
|
|
69
|
+
|
|
70
|
+
default_code = "API_ERROR"
|
|
71
|
+
default_exit_code = 1
|