rime_ice_wanxiang.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """在 macOS 上安装、更新或卸载 Rime Ice 的万象语言模型。
  4. 要求:
  5. Python 3.9+,仅支持 macOS。
  6. 唯一第三方依赖:
  7. ruamel.yaml(用于保留 YAML 注释、键顺序和无关配置值)
  8. 推荐环境(pyenv + Python 3.13):
  9. brew install pyenv
  10. echo 'eval "$(pyenv init -)"' >> ~/.zshrc
  11. source ~/.zshrc
  12. pyenv install 3.13
  13. pyenv global 3.13
  14. python3 -m pip install ruamel.yaml
  15. 用法:
  16. python3 rime_ice_wanxiang.py # 显示中文菜单
  17. python3 rime_ice_wanxiang.py install # 安装/更新
  18. python3 rime_ice_wanxiang.py update # 安装/更新
  19. python3 rime_ice_wanxiang.py uninstall # 卸载
  20. python3 rime_ice_wanxiang.py --rime-dir DIR # 指定其他 Rime 目录(测试用)
  21. """
  22. from __future__ import annotations
  23. import argparse
  24. import os
  25. import shutil
  26. import stat
  27. import sys
  28. import uuid
  29. from datetime import datetime
  30. from io import StringIO
  31. from pathlib import Path
  32. from urllib.error import HTTPError, URLError
  33. from urllib.request import Request, urlopen
  34. try:
  35. from ruamel.yaml import YAML
  36. from ruamel.yaml.comments import CommentedMap
  37. from ruamel.yaml.error import YAMLError
  38. except ModuleNotFoundError: # 在真正执行操作时再给出友好提示
  39. YAML = None
  40. CommentedMap = None
  41. YAMLError = Exception
  42. DEFAULT_RIME_DIR = Path.home() / "Library" / "Rime"
  43. MODEL_FILENAME = "wanxiang-lts-zh-hans.gram"
  44. CUSTOM_YAML_FILENAME = "rime_ice.custom.yaml"
  45. MODEL_NAME = "wanxiang-lts-zh-hans"
  46. # CNB 优先,GitHub 备用。
  47. CNB_URL = (
  48. "https://cnb.cool/Mintimate/rime/oh-my-rime/-/releases/download/latest/"
  49. "wanxiang-lts-zh-hans.gram"
  50. )
  51. GITHUB_URL = (
  52. "https://github.com/amzxyz/RIME-LMDG/releases/download/LTS/"
  53. "wanxiang-lts-zh-hans.gram"
  54. )
  55. DOWNLOAD_TIMEOUT = 60 # 秒
  56. CHUNK_SIZE = 1024 * 1024
  57. # 顺序与 Rust/Go 版本保持一致;只补充缺失项,不覆盖已有调参。
  58. PATCH_ITEMS = (
  59. ("grammar/language", MODEL_NAME),
  60. ("grammar/collocation_max_length", 5),
  61. ("grammar/collocation_min_length", 2),
  62. ("translator/contextual_suggestions", True),
  63. ("translator/max_homophones", 7),
  64. ("translator/max_homographs", 7),
  65. )
  66. class RimeMateError(Exception):
  67. """脚本可直接向用户报告的错误。"""
  68. class ConfigError(RimeMateError):
  69. """YAML 配置无效或无法安全修改。"""
  70. class DownloadError(RimeMateError):
  71. """语言模型下载失败。"""
  72. def _require_ruamel() -> None:
  73. if YAML is None:
  74. raise RimeMateError(
  75. "缺少第三方依赖 ruamel.yaml。\n"
  76. "请确认当前使用的是 pyenv 安装的 Python 3.13:\n"
  77. " which python3\n"
  78. " python3 --version\n"
  79. " pyenv global 3.13\n"
  80. "然后在当前 Python 环境安装依赖:\n"
  81. " python3 -m pip install ruamel.yaml\n"
  82. "详细步骤见 README.python.md。"
  83. )
  84. def _new_yaml():
  85. yaml = YAML(typ="rt")
  86. yaml.preserve_quotes = True
  87. yaml.width = 4096
  88. yaml.allow_unicode = True
  89. yaml.indent(mapping=2, sequence=4, offset=2)
  90. return yaml
  91. def _ensure_macos() -> None:
  92. if sys.platform != "darwin":
  93. raise RimeMateError("本脚本只支持 macOS。")
  94. def _ensure_rime_dir(rime_dir: Path) -> None:
  95. if not rime_dir.is_dir():
  96. raise RimeMateError(f"Rime 配置目录不存在: {rime_dir}")
  97. def _load_config(path: Path):
  98. """读取配置并验证根节点、patch 节点,返回 round-trip 映射。"""
  99. if not path.exists():
  100. return CommentedMap()
  101. try:
  102. text = path.read_text(encoding="utf-8")
  103. except (OSError, UnicodeError) as exc:
  104. raise ConfigError(f"无法读取配置文件 {path}: {exc}") from exc
  105. # 空文件视为尚未写入任何配置。
  106. if not text.strip():
  107. return CommentedMap()
  108. yaml = _new_yaml()
  109. try:
  110. data = yaml.load(text)
  111. except YAMLError as exc:
  112. raise ConfigError(f"配置文件不是合法的 YAML: {path}\n{exc}") from exc
  113. if data is None:
  114. return CommentedMap()
  115. if not isinstance(data, CommentedMap):
  116. raise ConfigError(
  117. f"配置文件根节点必须是映射(键值对),但 {path} 的根节点是 "
  118. f"{type(data).__name__}。为避免破坏配置,已停止。"
  119. )
  120. patch = data.get("patch")
  121. if patch is not None and not isinstance(patch, CommentedMap):
  122. raise ConfigError(
  123. f"配置文件中的 patch 必须是映射(键值对),但实际是 "
  124. f"{type(patch).__name__}。为避免破坏配置,已停止。"
  125. )
  126. return data
  127. def _ensure_no_other_model(patch, action: str) -> None:
  128. """发现其他语言模型的明确设置时保守中止,避免覆盖或误删共享调参。"""
  129. if patch is None or "grammar/language" not in patch:
  130. return
  131. value = patch["grammar/language"]
  132. if value is None:
  133. return
  134. language = str(value).strip()
  135. if not language or language == MODEL_NAME:
  136. return
  137. raise ConfigError(
  138. "检测到 grammar/language 已明确设置为其他语言模型"
  139. f"({language})。为避免覆盖其他模型的设置,已停止{action}。"
  140. )
  141. def _dump_config(root) -> bytes:
  142. if len(root) == 0:
  143. # 保留配置文件本身,不删除用户文件。
  144. return b"{}\n"
  145. yaml = _new_yaml()
  146. buffer = StringIO()
  147. yaml.dump(root, buffer)
  148. return buffer.getvalue().encode("utf-8")
  149. def _backup_file(path: Path) -> Path:
  150. stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
  151. backup = path.with_name(f"{path.name}.bak-{stamp}-{uuid.uuid4().hex[:8]}")
  152. try:
  153. shutil.copy2(path, backup)
  154. except OSError as exc:
  155. raise ConfigError(f"备份配置文件失败,未做任何修改: {exc}") from exc
  156. return backup
  157. def _atomic_write(path: Path, data: bytes, mode: int) -> None:
  158. temp_path = path.with_name(f".{path.name}.tmp-{uuid.uuid4().hex}")
  159. try:
  160. fd = os.open(temp_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
  161. with os.fdopen(fd, "wb") as handle:
  162. handle.write(data)
  163. handle.flush()
  164. os.fsync(handle.fileno())
  165. os.chmod(temp_path, mode)
  166. os.replace(temp_path, path)
  167. except OSError as exc:
  168. try:
  169. temp_path.unlink()
  170. except OSError:
  171. pass
  172. raise ConfigError(f"写入配置文件失败: {exc}") from exc
  173. def _write_config(root, path: Path) -> None:
  174. """备份后原子写入配置,并保留原文件权限。"""
  175. mode = 0o644
  176. if path.exists():
  177. try:
  178. mode = stat.S_IMODE(path.stat().st_mode)
  179. except OSError as exc:
  180. raise ConfigError(f"无法读取配置文件权限: {exc}") from exc
  181. backup = _backup_file(path)
  182. print(f"已备份原配置: {backup}")
  183. _atomic_write(path, _dump_config(root), mode)
  184. def _safe_unlink(path: Path) -> None:
  185. try:
  186. path.unlink()
  187. except FileNotFoundError:
  188. pass
  189. except OSError:
  190. pass
  191. def _validate_payload(head: bytes) -> None:
  192. stripped = head.lstrip().lower()
  193. if stripped.startswith(b"<") or b"<html" in stripped[:256] or b"<!doctype" in stripped[:256]:
  194. raise DownloadError("下载内容疑似 HTML 错误页面,而不是语言模型文件")
  195. if stripped.startswith(b"version https://git-lfs"):
  196. raise DownloadError("下载内容是 Git LFS 指针文件,而不是语言模型文件")
  197. def _download_to(url: str, temp_path: Path, timeout: float) -> None:
  198. try:
  199. _download_to_inner(url, temp_path, timeout)
  200. except BaseException:
  201. _safe_unlink(temp_path)
  202. raise
  203. def _download_to_inner(url: str, temp_path: Path, timeout: float) -> None:
  204. request = Request(
  205. url,
  206. headers={"User-Agent": "rime-ice-wanxiang/1.0 (+https://github.com/hunter-ji/rime-mate)"},
  207. )
  208. try:
  209. response = urlopen(request, timeout=timeout)
  210. except HTTPError as exc:
  211. raise DownloadError(f"HTTP 错误 {exc.code}: {exc.reason}") from exc
  212. except (URLError, TimeoutError, OSError) as exc:
  213. raise DownloadError(f"网络请求失败: {exc}") from exc
  214. with response:
  215. status = getattr(response, "status", None)
  216. if status is None:
  217. status = response.getcode()
  218. if status != 200:
  219. raise DownloadError(f"服务器返回异常状态码: {status}")
  220. expected_length = None
  221. raw_length = response.headers.get("Content-Length")
  222. if raw_length:
  223. try:
  224. expected_length = int(raw_length)
  225. except (TypeError, ValueError):
  226. expected_length = None
  227. total = 0
  228. head = b""
  229. try:
  230. fd = os.open(temp_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
  231. except OSError as exc:
  232. raise DownloadError(f"无法创建临时文件: {exc}") from exc
  233. try:
  234. handle = os.fdopen(fd, "wb")
  235. except OSError as exc:
  236. os.close(fd)
  237. raise DownloadError(f"无法打开临时文件: {exc}") from exc
  238. with handle:
  239. while True:
  240. try:
  241. chunk = response.read(CHUNK_SIZE)
  242. except (URLError, TimeoutError, OSError) as exc:
  243. raise DownloadError(f"下载中断: {exc}") from exc
  244. if not chunk:
  245. break
  246. if len(head) < 512:
  247. head += chunk[: 512 - len(head)]
  248. try:
  249. handle.write(chunk)
  250. except OSError as exc:
  251. raise DownloadError(f"写入临时文件失败: {exc}") from exc
  252. total += len(chunk)
  253. try:
  254. handle.flush()
  255. os.fsync(handle.fileno())
  256. except OSError as exc:
  257. raise DownloadError(f"写入临时文件失败: {exc}") from exc
  258. if total == 0:
  259. raise DownloadError("下载内容为空")
  260. if expected_length is not None and total != expected_length:
  261. raise DownloadError(
  262. f"下载内容大小与 Content-Length 不一致({total} != {expected_length})"
  263. )
  264. _validate_payload(head)
  265. def _download_model(rime_dir: Path, timeout: float) -> Path:
  266. """依次尝试 CNB 和 GitHub,成功后原子替换旧模型。"""
  267. model_path = rime_dir / MODEL_FILENAME
  268. errors = []
  269. for label, url in (("CNB", CNB_URL), ("GitHub", GITHUB_URL)):
  270. temp_path = rime_dir / f".{MODEL_FILENAME}.tmp-{uuid.uuid4().hex}"
  271. try:
  272. print(f"尝试从 {label} 下载语言模型...")
  273. _download_to(url, temp_path, timeout)
  274. os.replace(temp_path, model_path)
  275. return model_path
  276. except (RimeMateError, OSError) as exc:
  277. _safe_unlink(temp_path)
  278. errors.append(f"{label}: {exc}")
  279. print(f"{label} 下载失败: {exc}", file=sys.stderr)
  280. raise DownloadError(
  281. "所有下载源均失败,已保留原有语言模型。\n" + "\n".join(errors)
  282. )
  283. def install(rime_dir: Path, timeout: float = DOWNLOAD_TIMEOUT) -> None:
  284. """安装或更新万象语言模型,只补充缺失的 patch 项。"""
  285. _require_ruamel()
  286. rime_dir = Path(rime_dir).expanduser()
  287. _ensure_rime_dir(rime_dir)
  288. print(f"Rime 配置目录: {rime_dir}")
  289. config_path = rime_dir / CUSTOM_YAML_FILENAME
  290. # 先解析并验证配置,再下载;避免配置有误时白白下载或破坏旧模型。
  291. root = _load_config(config_path)
  292. patch = root.get("patch")
  293. _ensure_no_other_model(patch, "安装")
  294. if patch is None:
  295. patch = CommentedMap()
  296. root["patch"] = patch
  297. added = []
  298. for key, value in PATCH_ITEMS:
  299. if key not in patch:
  300. patch[key] = value
  301. added.append(key)
  302. model_path = _download_model(rime_dir, timeout)
  303. print(f"语言模型已保存: {model_path}")
  304. if added:
  305. _write_config(root, config_path)
  306. print("配置已更新,新增: " + "、".join(added))
  307. else:
  308. print("配置中已包含全部万象模型设置,无需修改。")
  309. _print_redeploy_hint()
  310. def uninstall(rime_dir: Path) -> None:
  311. """清理六个精确 patch 项和目标模型文件。"""
  312. _require_ruamel()
  313. rime_dir = Path(rime_dir).expanduser()
  314. _ensure_rime_dir(rime_dir)
  315. print(f"Rime 配置目录: {rime_dir}")
  316. config_path = rime_dir / CUSTOM_YAML_FILENAME
  317. model_path = rime_dir / MODEL_FILENAME
  318. # 先解析并验证,配置不安全时绝不先删模型。
  319. root = _load_config(config_path)
  320. patch = root.get("patch")
  321. _ensure_no_other_model(patch, "卸载")
  322. removed = []
  323. if patch is not None:
  324. for key, _ in PATCH_ITEMS:
  325. if key in patch:
  326. del patch[key]
  327. removed.append(key)
  328. if removed and len(patch) == 0:
  329. del root["patch"]
  330. if removed:
  331. # 只有配置备份并成功写入后,才进入删除模型文件的步骤。
  332. _write_config(root, config_path)
  333. print("配置已清理,移除: " + "、".join(removed))
  334. else:
  335. print("配置中未发现万象模型相关设置,无需修改。")
  336. if model_path.exists() or model_path.is_symlink():
  337. try:
  338. model_path.unlink()
  339. except OSError as exc:
  340. raise RimeMateError(
  341. f"配置已清理,但删除语言模型文件失败: {exc}"
  342. ) from exc
  343. print(f"语言模型已删除: {model_path}")
  344. else:
  345. print("语言模型文件不存在,无需删除。")
  346. _print_redeploy_hint()
  347. def _print_redeploy_hint() -> None:
  348. print()
  349. print("请手动重新部署鼠须管(菜单栏图标 -> 重新部署,或按 Control+Option+`)使配置生效。")
  350. print("本脚本不会自动触发部署。")
  351. def _confirm(prompt: str) -> bool:
  352. try:
  353. answer = input(prompt).strip().lower()
  354. except EOFError:
  355. print()
  356. return False
  357. return answer in ("y", "yes")
  358. def interactive_menu(rime_dir: Path) -> int:
  359. print("Rime Ice 万象语言模型管理")
  360. print(f"Rime 配置目录: {rime_dir}")
  361. while True:
  362. print()
  363. print("1. 安装/更新")
  364. print("2. 卸载")
  365. print("3. 退出")
  366. try:
  367. choice = input("请选择 [1-3]: ").strip()
  368. except EOFError:
  369. print()
  370. return 0
  371. if choice == "1":
  372. try:
  373. install(rime_dir)
  374. except RimeMateError as exc:
  375. print(f"错误: {exc}", file=sys.stderr)
  376. elif choice == "2":
  377. if _confirm("确认卸载万象语言模型并清理相关配置?[y/N] "):
  378. try:
  379. uninstall(rime_dir)
  380. except RimeMateError as exc:
  381. print(f"错误: {exc}", file=sys.stderr)
  382. else:
  383. print("已取消。")
  384. elif choice == "3":
  385. return 0
  386. else:
  387. print("请输入 1、2 或 3。")
  388. def build_parser() -> argparse.ArgumentParser:
  389. parser = argparse.ArgumentParser(
  390. description="在 macOS 上安装、更新或卸载 Rime Ice 万象语言模型。",
  391. epilog="不指定操作时显示中文菜单。仅使用 ruamel.yaml 一个第三方依赖。",
  392. )
  393. parser.add_argument(
  394. "action",
  395. nargs="?",
  396. choices=("install", "update", "uninstall"),
  397. help="install/update 安装或更新;uninstall 卸载;省略则显示菜单",
  398. )
  399. parser.add_argument(
  400. "--rime-dir",
  401. type=Path,
  402. default=DEFAULT_RIME_DIR,
  403. help=f"Rime 配置目录(默认: {DEFAULT_RIME_DIR},主要用于测试)",
  404. )
  405. return parser
  406. def main(argv=None) -> int:
  407. parser = build_parser()
  408. args = parser.parse_args(argv)
  409. try:
  410. _ensure_macos()
  411. _require_ruamel()
  412. rime_dir = args.rime_dir.expanduser()
  413. _ensure_rime_dir(rime_dir)
  414. if args.action is None:
  415. return interactive_menu(rime_dir)
  416. if args.action in ("install", "update"):
  417. install(rime_dir)
  418. else:
  419. uninstall(rime_dir)
  420. return 0
  421. except RimeMateError as exc:
  422. print(f"错误: {exc}", file=sys.stderr)
  423. return 1
  424. except KeyboardInterrupt:
  425. print("\n已取消。", file=sys.stderr)
  426. return 130
  427. if __name__ == "__main__":
  428. sys.exit(main())