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/selfupdate.py
ADDED
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
"""尽力而为的自更新,查的是解析出来的那个包索引。
|
|
2
|
+
|
|
3
|
+
这里的每一个设计决定都是刻意的;改动之前请先读完:
|
|
4
|
+
|
|
5
|
+
* **绝不跟随重定向。** 内网索引会把自己没有托管的包 303 重定向到一个**公网**镜像
|
|
6
|
+
(``mirrors.ustc.edu.cn``)。跟随它就等于信任来自内网索引之外的版本数据:任何在
|
|
7
|
+
公共 PyPI 上注册了 ``tytcli`` 的人,都能借此把代码推进每一个安装。只相信来自
|
|
8
|
+
索引主机的 HTTP 200;3xx 一律读作"该索引上没有发布"。
|
|
9
|
+
* **失败开放(fail open)。** 索引不可达、权限错误、升级坏掉,都绝不能挡住用户
|
|
10
|
+
真正要跑的那条命令。警告走 stderr;退出码永远由业务命令决定。
|
|
11
|
+
* **有缓存。** 每个间隔(10 分钟,固定)最多一次网络调用,而不是每次调用都查。
|
|
12
|
+
* **索引的解析顺序。** ``TYTCLI_UPDATE_INDEX_URL`` > ``PIP_INDEX_URL`` >
|
|
13
|
+
pypi.org。继承 pip 的索引,能让检查落在"安装大概就是从那儿来的"那个镜像上,
|
|
14
|
+
这正是它在企业内网里足够快的原因。
|
|
15
|
+
* **绝不阻塞在 stdin 上。**
|
|
16
|
+
* **re-exec 有界。** 升级成功后会 re-exec 新代码去跑原来那条命令;一个环境变量
|
|
17
|
+
守卫保证这件事最多发生一次。
|
|
18
|
+
* **只接受纯数字版本。** ``0.2.0rc1`` 绝不会被自动装到 ``0.1.0`` 之上。凡是不能
|
|
19
|
+
完全匹配 ``\\d+(\\.\\d+)*`` 的一律忽略,所以索引上偶然出现的预发布版无法劫持
|
|
20
|
+
一次安装。
|
|
21
|
+
|
|
22
|
+
索引只提供 ``#md5=`` 碎片(没有 sha256),所以在这里无法做有意义的制品完整性校验。
|
|
23
|
+
信任链建立在"索引在内网"与上面那条不跟随重定向的规则之上。
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import shutil
|
|
32
|
+
import subprocess
|
|
33
|
+
import sys
|
|
34
|
+
import time
|
|
35
|
+
from collections.abc import Mapping
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Any, cast
|
|
39
|
+
|
|
40
|
+
import httpx
|
|
41
|
+
|
|
42
|
+
from . import output
|
|
43
|
+
from .errors import ApiError, NetworkError, TytCliError
|
|
44
|
+
|
|
45
|
+
PACKAGE_NAME = "tytcli"
|
|
46
|
+
|
|
47
|
+
#: 查新版本时使用的索引。**对所有人都是 pypi.org。**
|
|
48
|
+
#:
|
|
49
|
+
#: 制品只有一份(见 AGENTS.md):同一批字节由 CI 发到 pypi.org,并镜像到内网索引,
|
|
50
|
+
#: 所以一个默认值对两类使用者都是正确的。而且两者之中只有 pypi.org 是**每个**用户
|
|
51
|
+
#: 都能解析的——索引不可达会让检查永远 fail open(每十分钟一条被缓存的 stderr
|
|
52
|
+
#: 警告,外加一个永远不来的更新),这正是本项目判定为缺陷的"看起来健康其实是坏的"。
|
|
53
|
+
#:
|
|
54
|
+
#: 必须留在内网镜像上的安装请设 ``TYTCLI_UPDATE_INDEX_URL``;``manual_instruction()``
|
|
55
|
+
#: 届时也会指名那个索引,因为它是函数而不是在 import 时就冻结的常量。
|
|
56
|
+
#: 环境里普通的 ``PIP_INDEX_URL`` 同样会被采纳,这样一个处在快速本地镜像后面的用户
|
|
57
|
+
#: 就不必每次检查都付一次到 pypi.org 的往返。空值视为未设置。
|
|
58
|
+
DEFAULT_INDEX_URL = "https://pypi.org/simple/"
|
|
59
|
+
|
|
60
|
+
#: 两次索引查询之间的秒数。**刻意固定。**
|
|
61
|
+
#:
|
|
62
|
+
#: 这里曾经是 ``DEFAULT_CHECK_INTERVAL_SECONDS``,可用
|
|
63
|
+
#: ``TYTCLI_UPDATE_CHECK_INTERVAL`` 覆盖,并用 ``TYTCLI_NO_UPDATE_CHECK``
|
|
64
|
+
#: (或把间隔设成 ``0``)彻底关掉检查。三者都已删除:产品契约是"始终运行最新版",
|
|
65
|
+
#: 而一个用户可调的间隔只会让安装悄悄过期、同时看起来一切正常。
|
|
66
|
+
#: 十分钟这个值同时给"网络开销"和"最新能有多旧"封了顶。
|
|
67
|
+
CHECK_INTERVAL_SECONDS = 600.0
|
|
68
|
+
DEFAULT_CHECK_TIMEOUT_SECONDS = 5.0
|
|
69
|
+
UPGRADE_TIMEOUT_SECONDS = 300.0
|
|
70
|
+
|
|
71
|
+
ENV_TIMEOUT = "TYTCLI_UPDATE_CHECK_TIMEOUT"
|
|
72
|
+
ENV_INDEX = "TYTCLI_UPDATE_INDEX_URL"
|
|
73
|
+
#: 次选索引:pip 自己指向哪里就用哪里。**只读环境变量**,不读 ``pip config``
|
|
74
|
+
#: 配置文件——在这里重新实现 pip 的配置解析,等于去猜一个归 pip 所有的格式。
|
|
75
|
+
#: 不要与 :data:`ENV_INDEX` 混淆:后者是 tytcli 专属的覆盖项,因此优先级更高。
|
|
76
|
+
ENV_PIP_INDEX = "PIP_INDEX_URL"
|
|
77
|
+
#: 索引是被哪个旋钮选中的;会报在 ``upgrade`` 的输出里,这样一个出乎意料的
|
|
78
|
+
#: 查询目标不用靠猜就能解释清楚。
|
|
79
|
+
SOURCE_EXPLICIT = "TYTCLI_UPDATE_INDEX_URL"
|
|
80
|
+
SOURCE_PIP = "PIP_INDEX_URL"
|
|
81
|
+
SOURCE_DEFAULT = "default"
|
|
82
|
+
#: re-exec 的循环守卫:升级后的子进程会设置它,从而不再重复检查。
|
|
83
|
+
#: 前导下划线表示这是内部机制——它不是给用户用的旋钮。
|
|
84
|
+
ENV_REEXEC_GUARD = "_TYTCLI_REEXEC_GUARD"
|
|
85
|
+
#: 内部开关,让测试套件能使每一次 ``run_cli`` 都保持封闭(不联网)。
|
|
86
|
+
#: 刻意不对用户公开:公开的关闭开关已经删掉了,这是唯一能停住钩子的办法,
|
|
87
|
+
#: 而它的名字本身就说明了这一点。
|
|
88
|
+
ENV_HOOK_DISABLED = "_TYTCLI_SKIP_UPDATE_HOOK"
|
|
89
|
+
|
|
90
|
+
CACHE_APP_DIR = "tytcli"
|
|
91
|
+
CACHE_FILE_NAME = "update-check.json"
|
|
92
|
+
|
|
93
|
+
#: 匹配 ``tytcli-0.2.0-py3-none-any.whl`` / ``tytcli-0.2.0.tar.gz``。
|
|
94
|
+
#: 版本那一组只允许数字和点,这正是把预发布版挡在外面的机制。
|
|
95
|
+
_ARTIFACT_RE = re.compile(
|
|
96
|
+
r"tytcli-(?P<version>\d+(?:\.\d+)*)(?:-py[^\s\"'<>]*\.whl|\.tar\.gz)"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# --------------------------------------------------------------------------- #
|
|
101
|
+
# versions
|
|
102
|
+
# --------------------------------------------------------------------------- #
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def parse_version(text: str) -> tuple[int, ...] | None:
|
|
106
|
+
"""Parse a plain numeric version, or return ``None`` if it is not one.
|
|
107
|
+
|
|
108
|
+
Deliberately rejects prereleases (``1.0rc1``), epochs (``1!1.0``) and
|
|
109
|
+
anything else PEP 440 allows: auto-upgrade must never install one.
|
|
110
|
+
"""
|
|
111
|
+
value = text.strip()
|
|
112
|
+
if not value or not re.fullmatch(r"\d+(?:\.\d+)*", value):
|
|
113
|
+
return None
|
|
114
|
+
return tuple(int(part) for part in value.split("."))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def compare_versions(left: str, right: str) -> int:
|
|
118
|
+
"""Return -1/0/1. Unparseable versions compare as equal (no upgrade)."""
|
|
119
|
+
a, b = parse_version(left), parse_version(right)
|
|
120
|
+
if a is None or b is None:
|
|
121
|
+
return 0
|
|
122
|
+
width = max(len(a), len(b))
|
|
123
|
+
a_padded = a + (0,) * (width - len(a))
|
|
124
|
+
b_padded = b + (0,) * (width - len(b))
|
|
125
|
+
return (a_padded > b_padded) - (a_padded < b_padded)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def is_newer(candidate: str, current: str) -> bool:
|
|
129
|
+
return compare_versions(candidate, current) > 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# --------------------------------------------------------------------------- #
|
|
133
|
+
# index query
|
|
134
|
+
# --------------------------------------------------------------------------- #
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _resolve_index(env_map: Mapping[str, str]) -> tuple[str, str]:
|
|
138
|
+
"""Return ``(url, source)`` for the index to query.
|
|
139
|
+
|
|
140
|
+
Precedence: ``TYTCLI_UPDATE_INDEX_URL`` > ``PIP_INDEX_URL`` > pypi.org.
|
|
141
|
+
The first *non-blank* value wins: an exported-but-empty variable must not
|
|
142
|
+
degrade into the URL ``/`` (which is what a naive ``.get(name, default)``
|
|
143
|
+
does, since only a missing key falls back).
|
|
144
|
+
"""
|
|
145
|
+
for name, source in ((ENV_INDEX, SOURCE_EXPLICIT), (ENV_PIP_INDEX, SOURCE_PIP)):
|
|
146
|
+
raw = (env_map.get(name) or "").strip()
|
|
147
|
+
if raw:
|
|
148
|
+
return raw.rstrip("/") + "/", source
|
|
149
|
+
return DEFAULT_INDEX_URL, SOURCE_DEFAULT
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def index_url(environ: Mapping[str, str] | None = None) -> str:
|
|
153
|
+
"""Base URL of the PEP 503 index to query (trailing slash guaranteed)."""
|
|
154
|
+
return _resolve_index(os.environ if environ is None else environ)[0]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def index_source(environ: Mapping[str, str] | None = None) -> str:
|
|
158
|
+
"""Which knob chose the index: an env var name, or ``"default"``."""
|
|
159
|
+
return _resolve_index(os.environ if environ is None else environ)[1]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def latest_version_from_html(html: str) -> str | None:
|
|
163
|
+
"""Pick the highest numeric version out of a PEP 503 simple index page."""
|
|
164
|
+
best: str | None = None
|
|
165
|
+
for match in _ARTIFACT_RE.finditer(html):
|
|
166
|
+
version = match.group("version")
|
|
167
|
+
if best is None or is_newer(version, best):
|
|
168
|
+
best = version
|
|
169
|
+
return best
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def fetch_latest_version(
|
|
173
|
+
*, index: str | None = None, timeout: float = DEFAULT_CHECK_TIMEOUT_SECONDS
|
|
174
|
+
) -> str:
|
|
175
|
+
"""Query the index and return the newest version, or ``""`` if unknown.
|
|
176
|
+
|
|
177
|
+
Raises ``NetworkError`` on transport failure and ``ApiError`` when the index
|
|
178
|
+
answers with something that is not a usable listing. ``""`` means "the
|
|
179
|
+
package is not published on this index", which callers treat as no-op.
|
|
180
|
+
"""
|
|
181
|
+
base = index or index_url()
|
|
182
|
+
url = f"{base}{PACKAGE_NAME}/"
|
|
183
|
+
try:
|
|
184
|
+
# follow_redirects=False is the security control; see module docstring.
|
|
185
|
+
with httpx.Client(timeout=timeout, follow_redirects=False) as client:
|
|
186
|
+
response = client.get(url)
|
|
187
|
+
except httpx.TimeoutException as exc:
|
|
188
|
+
raise NetworkError(
|
|
189
|
+
f"版本检查超时({timeout}s):{url}",
|
|
190
|
+
code="NETWORK_TIMEOUT",
|
|
191
|
+
details={"url": url, "timeout_seconds": timeout},
|
|
192
|
+
) from exc
|
|
193
|
+
except httpx.HTTPError as exc:
|
|
194
|
+
raise NetworkError(
|
|
195
|
+
f"版本检查失败:{type(exc).__name__}",
|
|
196
|
+
code="NETWORK_ERROR",
|
|
197
|
+
details={"url": url, "error_type": type(exc).__name__},
|
|
198
|
+
) from exc
|
|
199
|
+
|
|
200
|
+
if response.status_code in (301, 302, 303, 307, 308):
|
|
201
|
+
# Redirect == not hosted internally. Do NOT follow: the target is a
|
|
202
|
+
# public mirror, and trusting it would allow a name-squatting attack.
|
|
203
|
+
return ""
|
|
204
|
+
if response.status_code == 404:
|
|
205
|
+
return ""
|
|
206
|
+
if response.status_code != 200:
|
|
207
|
+
raise ApiError(
|
|
208
|
+
f"索引返回 HTTP {response.status_code}",
|
|
209
|
+
code="INDEX_HTTP_ERROR",
|
|
210
|
+
details={"url": url, "http_status": response.status_code},
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
return latest_version_from_html(response.text) or ""
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# --------------------------------------------------------------------------- #
|
|
217
|
+
# cache
|
|
218
|
+
# --------------------------------------------------------------------------- #
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def cache_path(environ: Mapping[str, str] | None = None) -> Path:
|
|
222
|
+
env_map = os.environ if environ is None else environ
|
|
223
|
+
xdg = env_map.get("XDG_CACHE_HOME", "").strip()
|
|
224
|
+
base = Path(xdg).expanduser() if xdg else Path.home() / ".cache"
|
|
225
|
+
return base / CACHE_APP_DIR / CACHE_FILE_NAME
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@dataclass(frozen=True)
|
|
229
|
+
class CheckResult:
|
|
230
|
+
"""Outcome of a version check."""
|
|
231
|
+
|
|
232
|
+
latest_version: str # "" means unknown / not published
|
|
233
|
+
checked_at: float
|
|
234
|
+
source: str # index | cache | skipped | error
|
|
235
|
+
index_url: str
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def checked_age_seconds(self) -> float:
|
|
239
|
+
return max(0.0, time.time() - self.checked_at)
|
|
240
|
+
|
|
241
|
+
def is_newer_than(self, current: str) -> bool:
|
|
242
|
+
return bool(self.latest_version) and is_newer(self.latest_version, current)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _read_cache(path: Path) -> dict[str, Any] | None:
|
|
246
|
+
try:
|
|
247
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
248
|
+
except (OSError, ValueError):
|
|
249
|
+
return None
|
|
250
|
+
if not isinstance(data, dict):
|
|
251
|
+
return None
|
|
252
|
+
# json.loads() is Any; isinstance narrows it to dict[Unknown, Unknown],
|
|
253
|
+
# which would poison every caller's types.
|
|
254
|
+
return cast("dict[str, Any]", data)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _write_cache(path: Path, result: CheckResult) -> None:
|
|
258
|
+
payload = {
|
|
259
|
+
"checked_at": result.checked_at,
|
|
260
|
+
"latest_version": result.latest_version,
|
|
261
|
+
"index_url": result.index_url,
|
|
262
|
+
}
|
|
263
|
+
try:
|
|
264
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
265
|
+
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
266
|
+
except OSError:
|
|
267
|
+
# A cache we cannot write is not an error; we simply re-check next time.
|
|
268
|
+
pass
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def check_interval() -> float:
|
|
272
|
+
"""Seconds between index queries: :data:`CHECK_INTERVAL_SECONDS`, always.
|
|
273
|
+
|
|
274
|
+
Takes no environment argument because there is nothing left to read -- the
|
|
275
|
+
old ``TYTCLI_UPDATE_CHECK_INTERVAL`` / ``TYTCLI_NO_UPDATE_CHECK`` overrides
|
|
276
|
+
were removed. A test that wants a different cadence patches this function.
|
|
277
|
+
"""
|
|
278
|
+
return CHECK_INTERVAL_SECONDS
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def check_timeout(environ: Mapping[str, str] | None = None) -> float:
|
|
282
|
+
env_map = os.environ if environ is None else environ
|
|
283
|
+
raw = env_map.get(ENV_TIMEOUT)
|
|
284
|
+
if raw is None or not raw.strip():
|
|
285
|
+
return DEFAULT_CHECK_TIMEOUT_SECONDS
|
|
286
|
+
try:
|
|
287
|
+
value = float(raw)
|
|
288
|
+
except ValueError:
|
|
289
|
+
return DEFAULT_CHECK_TIMEOUT_SECONDS
|
|
290
|
+
return value if value > 0 else DEFAULT_CHECK_TIMEOUT_SECONDS
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def check_for_update(
|
|
294
|
+
current_version: str,
|
|
295
|
+
*,
|
|
296
|
+
force: bool = False,
|
|
297
|
+
environ: Mapping[str, str] | None = None,
|
|
298
|
+
) -> CheckResult:
|
|
299
|
+
"""Return the latest known version, using the cache when it is fresh enough.
|
|
300
|
+
|
|
301
|
+
Never raises: every failure degrades to ``latest_version=""`` so the caller
|
|
302
|
+
can fail open. Use :func:`fetch_latest_version` when the error matters.
|
|
303
|
+
"""
|
|
304
|
+
env_map = dict(os.environ if environ is None else environ)
|
|
305
|
+
index = index_url(env_map)
|
|
306
|
+
interval = check_interval()
|
|
307
|
+
path = cache_path(env_map)
|
|
308
|
+
now = time.time()
|
|
309
|
+
|
|
310
|
+
if not force:
|
|
311
|
+
cached = _read_cache(path)
|
|
312
|
+
if cached is not None and cached.get("index_url") == index:
|
|
313
|
+
try:
|
|
314
|
+
checked_at = float(cached.get("checked_at", 0))
|
|
315
|
+
except (TypeError, ValueError):
|
|
316
|
+
checked_at = 0.0
|
|
317
|
+
if now - checked_at < interval:
|
|
318
|
+
version = str(cached.get("latest_version") or "")
|
|
319
|
+
return CheckResult(version, checked_at, "cache", index)
|
|
320
|
+
|
|
321
|
+
try:
|
|
322
|
+
version = fetch_latest_version(
|
|
323
|
+
index=index, timeout=check_timeout(env_map)
|
|
324
|
+
)
|
|
325
|
+
except TytCliError:
|
|
326
|
+
# Cache the failure as well. Without this, an unreachable index would
|
|
327
|
+
# make *every* invocation pay the full connect timeout, which is exactly
|
|
328
|
+
# when the user is least able to wait. With it, the TTL applies to
|
|
329
|
+
# failures too: one warning and one slow command per interval.
|
|
330
|
+
failed = CheckResult("", now, "error", index)
|
|
331
|
+
_write_cache(path, failed)
|
|
332
|
+
return failed
|
|
333
|
+
|
|
334
|
+
result = CheckResult(version, now, "index", index)
|
|
335
|
+
_write_cache(path, result)
|
|
336
|
+
return result
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def record_check(
|
|
340
|
+
latest_version: str,
|
|
341
|
+
*,
|
|
342
|
+
index: str | None = None,
|
|
343
|
+
environ: Mapping[str, str] | None = None,
|
|
344
|
+
) -> CheckResult:
|
|
345
|
+
"""Persist a check result so the implicit hook can reuse it.
|
|
346
|
+
|
|
347
|
+
Used by the explicit ``upgrade`` command, which calls
|
|
348
|
+
:func:`fetch_latest_version` directly so that failures surface as proper
|
|
349
|
+
error codes instead of being swallowed the way the implicit check swallows
|
|
350
|
+
them.
|
|
351
|
+
"""
|
|
352
|
+
env_map = dict(os.environ if environ is None else environ)
|
|
353
|
+
base = index or index_url(env_map)
|
|
354
|
+
result = CheckResult(latest_version, time.time(), "index", base)
|
|
355
|
+
_write_cache(cache_path(env_map), result)
|
|
356
|
+
return result
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# --------------------------------------------------------------------------- #
|
|
360
|
+
# upgrade planning
|
|
361
|
+
# --------------------------------------------------------------------------- #
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@dataclass(frozen=True)
|
|
365
|
+
class UpgradePlan:
|
|
366
|
+
"""How to upgrade this installation."""
|
|
367
|
+
|
|
368
|
+
kind: str # uv-tool | venv-pip | venv-uv | manual
|
|
369
|
+
argv: list[str]
|
|
370
|
+
reason: str
|
|
371
|
+
|
|
372
|
+
@property
|
|
373
|
+
def can_upgrade(self) -> bool:
|
|
374
|
+
return bool(self.argv)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _executable_path() -> Path | None:
|
|
378
|
+
raw = sys.argv[0] if sys.argv and sys.argv[0] else None
|
|
379
|
+
if not raw:
|
|
380
|
+
return None
|
|
381
|
+
try:
|
|
382
|
+
return Path(raw).resolve()
|
|
383
|
+
except OSError: # pragma: no cover - defensive
|
|
384
|
+
return None
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _is_uv_tool_install(path: Path | None) -> bool:
|
|
388
|
+
if path is None:
|
|
389
|
+
return False
|
|
390
|
+
text = str(path).replace(os.sep, "/")
|
|
391
|
+
return "/uv/tools/" in text
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _venv_python() -> Path | None:
|
|
395
|
+
if sys.prefix == sys.base_prefix:
|
|
396
|
+
return None # not a virtualenv
|
|
397
|
+
name = "python.exe" if os.name == "nt" else "python"
|
|
398
|
+
candidate = Path(sys.prefix) / ("Scripts" if os.name == "nt" else "bin") / name
|
|
399
|
+
return candidate if candidate.exists() else None
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _venv_pip() -> Path | None:
|
|
403
|
+
if sys.prefix == sys.base_prefix:
|
|
404
|
+
return None
|
|
405
|
+
name = "pip.exe" if os.name == "nt" else "pip"
|
|
406
|
+
candidate = Path(sys.prefix) / ("Scripts" if os.name == "nt" else "bin") / name
|
|
407
|
+
return candidate if candidate.exists() else None
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def plan_upgrade(*, index: str | None = None) -> UpgradePlan:
|
|
411
|
+
"""Detect how tytcli was installed and return the matching upgrade command.
|
|
412
|
+
|
|
413
|
+
Order matters: a uv-tool install is also a virtualenv, so the ``/uv/tools/``
|
|
414
|
+
path check must come first. ``uv``-created venvs ship **without pip**, hence
|
|
415
|
+
the ``venv-uv`` fallback. Anything else (system site-packages) is refused
|
|
416
|
+
rather than upgraded, because it would need sudo or would pollute a shared
|
|
417
|
+
interpreter.
|
|
418
|
+
"""
|
|
419
|
+
base = (index or index_url()).rstrip("/")
|
|
420
|
+
|
|
421
|
+
if _is_uv_tool_install(_executable_path()):
|
|
422
|
+
uv = shutil.which("uv")
|
|
423
|
+
if uv:
|
|
424
|
+
return UpgradePlan(
|
|
425
|
+
kind="uv-tool",
|
|
426
|
+
argv=[uv, "tool", "upgrade", PACKAGE_NAME, "--default-index", base],
|
|
427
|
+
reason="uv tool 安装",
|
|
428
|
+
)
|
|
429
|
+
return UpgradePlan(
|
|
430
|
+
kind="manual",
|
|
431
|
+
argv=[],
|
|
432
|
+
reason="检测到 uv tool 安装,但 PATH 上找不到 uv",
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
python = _venv_python()
|
|
436
|
+
pip = _venv_pip()
|
|
437
|
+
if python is not None and pip is not None:
|
|
438
|
+
return UpgradePlan(
|
|
439
|
+
kind="venv-pip",
|
|
440
|
+
argv=[str(pip), "install", "-U", "--index-url", base, PACKAGE_NAME],
|
|
441
|
+
reason="虚拟环境(含 pip)",
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
if python is not None:
|
|
445
|
+
uv = shutil.which("uv")
|
|
446
|
+
if uv:
|
|
447
|
+
return UpgradePlan(
|
|
448
|
+
kind="venv-uv",
|
|
449
|
+
argv=[
|
|
450
|
+
uv, "pip", "install",
|
|
451
|
+
"--python", str(python),
|
|
452
|
+
"-U", "--default-index", base, PACKAGE_NAME,
|
|
453
|
+
],
|
|
454
|
+
reason="虚拟环境(无 pip,uv 创建的 venv 不带 pip)",
|
|
455
|
+
)
|
|
456
|
+
return UpgradePlan(
|
|
457
|
+
kind="manual",
|
|
458
|
+
argv=[],
|
|
459
|
+
reason="虚拟环境内既没有 pip,PATH 上也没有 uv",
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
return UpgradePlan(
|
|
463
|
+
kind="manual",
|
|
464
|
+
argv=[],
|
|
465
|
+
reason=(
|
|
466
|
+
"看起来安装在系统/共享解释器的 site-packages 中。"
|
|
467
|
+
"拒绝自动升级:那需要提权或会污染共享环境"
|
|
468
|
+
),
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def manual_instruction(environ: Mapping[str, str] | None = None) -> str:
|
|
473
|
+
"""Commands a user can run to upgrade by hand.
|
|
474
|
+
|
|
475
|
+
Deliberately a function rather than a module constant: the index honours
|
|
476
|
+
``TYTCLI_UPDATE_INDEX_URL``, and a constant frozen at import time would
|
|
477
|
+
keep naming the default even for a user who overrode it.
|
|
478
|
+
"""
|
|
479
|
+
index = index_url(environ)
|
|
480
|
+
return (
|
|
481
|
+
f"pip install -U --index-url {index} {PACKAGE_NAME}\n"
|
|
482
|
+
f" 或:uv tool install --default-index {index} {PACKAGE_NAME}"
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def run_upgrade(plan: UpgradePlan) -> str:
|
|
487
|
+
"""Execute the plan. Returns combined output; raises on failure."""
|
|
488
|
+
if not plan.can_upgrade:
|
|
489
|
+
raise ApiError(
|
|
490
|
+
f"无法自动升级:{plan.reason}",
|
|
491
|
+
code="UPDATE_UNSUPPORTED",
|
|
492
|
+
details={"kind": plan.kind, "reason": plan.reason, "hint": manual_instruction()},
|
|
493
|
+
)
|
|
494
|
+
try:
|
|
495
|
+
# argv is assembled by plan_upgrade() from shutil.which() results and
|
|
496
|
+
# fixed literals -- never from user input -- so no shell is involved.
|
|
497
|
+
completed = subprocess.run(
|
|
498
|
+
plan.argv,
|
|
499
|
+
capture_output=True,
|
|
500
|
+
text=True,
|
|
501
|
+
timeout=UPGRADE_TIMEOUT_SECONDS,
|
|
502
|
+
check=False,
|
|
503
|
+
)
|
|
504
|
+
except OSError as exc:
|
|
505
|
+
raise ApiError(
|
|
506
|
+
f"升级命令无法执行:{exc}",
|
|
507
|
+
code="UPDATE_FAILED",
|
|
508
|
+
details={"kind": plan.kind, "argv0": plan.argv[0]},
|
|
509
|
+
) from exc
|
|
510
|
+
except subprocess.TimeoutExpired as exc:
|
|
511
|
+
raise ApiError(
|
|
512
|
+
f"升级超时({UPGRADE_TIMEOUT_SECONDS}s)",
|
|
513
|
+
code="UPDATE_TIMEOUT",
|
|
514
|
+
details={"kind": plan.kind},
|
|
515
|
+
) from exc
|
|
516
|
+
|
|
517
|
+
output = f"{completed.stdout}\n{completed.stderr}".strip()
|
|
518
|
+
if completed.returncode != 0:
|
|
519
|
+
raise ApiError(
|
|
520
|
+
f"升级失败(退出码 {completed.returncode})",
|
|
521
|
+
code="UPDATE_FAILED",
|
|
522
|
+
details={
|
|
523
|
+
"kind": plan.kind,
|
|
524
|
+
"returncode": completed.returncode,
|
|
525
|
+
"output": output[-2000:],
|
|
526
|
+
},
|
|
527
|
+
)
|
|
528
|
+
return output
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
# --------------------------------------------------------------------------- #
|
|
532
|
+
# implicit hook used by cli.main()
|
|
533
|
+
# --------------------------------------------------------------------------- #
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _warn(message: str) -> None:
|
|
537
|
+
print(f"警告:{message}", file=sys.stderr)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def reexec_after_upgrade(argv: list[str]) -> None:
|
|
541
|
+
"""Replace this process with the freshly installed code.
|
|
542
|
+
|
|
543
|
+
Runs the module rather than ``sys.argv[0]``: the entry-point script may have
|
|
544
|
+
been rewritten by the upgrade, and ``-m`` also works when tytcli was launched
|
|
545
|
+
that way in the first place.
|
|
546
|
+
"""
|
|
547
|
+
environ = dict(os.environ)
|
|
548
|
+
environ[ENV_REEXEC_GUARD] = "1"
|
|
549
|
+
os.execve(sys.executable, [sys.executable, "-m", "tytcli.cli", *argv], environ)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def should_skip_implicit_check(argv: list[str], environ: Mapping[str, str] | None = None) -> str | None:
|
|
553
|
+
"""Return a reason to skip the implicit check, or ``None`` to proceed."""
|
|
554
|
+
env_map = os.environ if environ is None else environ
|
|
555
|
+
if env_map.get(ENV_REEXEC_GUARD):
|
|
556
|
+
return "already re-exec'd after an upgrade"
|
|
557
|
+
if env_map.get(ENV_HOOK_DISABLED):
|
|
558
|
+
# Internal, test-suite only. Users have no opt-out by design.
|
|
559
|
+
return f"{ENV_HOOK_DISABLED} set (internal)"
|
|
560
|
+
if not argv:
|
|
561
|
+
return "no command given"
|
|
562
|
+
if {"-h", "--help", "--version"} & set(argv):
|
|
563
|
+
return "help/version request"
|
|
564
|
+
# `tytcli help` is a pure local read of the bundled docs; phoning the index
|
|
565
|
+
# first would make reading documentation depend on the network.
|
|
566
|
+
if argv[0] == "help":
|
|
567
|
+
return "help command"
|
|
568
|
+
# The explicit command does its own check and must not be run twice.
|
|
569
|
+
if any(token == "upgrade" for token in argv[:2]):
|
|
570
|
+
return "explicit upgrade command"
|
|
571
|
+
return None
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def maybe_self_update(
|
|
575
|
+
current_version: str, argv: list[str], *, environ: Mapping[str, str] | None = None
|
|
576
|
+
) -> bool:
|
|
577
|
+
"""Check, upgrade and re-exec if a newer version exists. Fail open.
|
|
578
|
+
|
|
579
|
+
Returns ``True`` only in the (unusual) case where it returns at all after a
|
|
580
|
+
successful upgrade — normally :func:`reexec_after_upgrade` replaces the
|
|
581
|
+
process and this never returns.
|
|
582
|
+
|
|
583
|
+
The whole body is wrapped: this hook runs before ``main()``'s own error
|
|
584
|
+
handling, so an exception escaping here (e.g. ``os.execve`` failing) would
|
|
585
|
+
surface as a raw traceback and break the JSON-on-stdout contract. "Fail
|
|
586
|
+
open" has to cover bugs in the updater itself, not just network failures.
|
|
587
|
+
"""
|
|
588
|
+
try:
|
|
589
|
+
return _maybe_self_update(current_version, argv, environ=environ)
|
|
590
|
+
except Exception as exc: # last resort: the hook must never break a command
|
|
591
|
+
_warn(
|
|
592
|
+
f"版本检查出现未预期错误({type(exc).__name__}: {exc}),"
|
|
593
|
+
f"继续使用当前版本 {current_version}。"
|
|
594
|
+
)
|
|
595
|
+
return False
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _maybe_self_update(
|
|
599
|
+
current_version: str, argv: list[str], *, environ: Mapping[str, str] | None = None
|
|
600
|
+
) -> bool:
|
|
601
|
+
env_map = dict(os.environ if environ is None else environ)
|
|
602
|
+
if should_skip_implicit_check(argv, env_map) is not None:
|
|
603
|
+
return False
|
|
604
|
+
|
|
605
|
+
result = check_for_update(current_version, environ=env_map)
|
|
606
|
+
if result.source == "error":
|
|
607
|
+
# Fail open, but not silently: the user chose to be told.
|
|
608
|
+
_warn(f"版本检查失败(索引不可达),继续使用当前版本 {current_version}。")
|
|
609
|
+
if not result.is_newer_than(current_version):
|
|
610
|
+
return False
|
|
611
|
+
|
|
612
|
+
plan = plan_upgrade(index=result.index_url)
|
|
613
|
+
if not plan.can_upgrade:
|
|
614
|
+
# A newer version exists but this installation cannot take it. The
|
|
615
|
+
# command's own JSON document carries that as __tytcli_hint__ (same
|
|
616
|
+
# channel as the wechat field hint) instead of a second stderr copy:
|
|
617
|
+
# two channels for one fact is how automation gets lied to.
|
|
618
|
+
output.set_pending_hint(
|
|
619
|
+
f"发现新版本 {result.latest_version}(当前 {current_version}),"
|
|
620
|
+
f"但当前安装形式无法自动升级({plan.reason})。"
|
|
621
|
+
f"手动升级:{manual_instruction()}"
|
|
622
|
+
)
|
|
623
|
+
return False
|
|
624
|
+
|
|
625
|
+
try:
|
|
626
|
+
run_upgrade(plan)
|
|
627
|
+
except TytCliError as exc:
|
|
628
|
+
# Fail open: the user's command still runs on the current version, and
|
|
629
|
+
# the document explains that an upgrade was attempted and failed.
|
|
630
|
+
output.set_pending_hint(
|
|
631
|
+
f"发现新版本 {result.latest_version}(当前 {current_version}),"
|
|
632
|
+
f"自动升级失败({exc.code}:{exc.message})。"
|
|
633
|
+
f"运行 `tytcli upgrade` 重试,或检查包索引是否可达。"
|
|
634
|
+
)
|
|
635
|
+
return False
|
|
636
|
+
|
|
637
|
+
_warn(f"已升级到 {result.latest_version},重新执行原命令。")
|
|
638
|
+
reexec_after_upgrade(argv)
|
|
639
|
+
return True # pragma: no cover - execve does not return
|