rime_ice_wanxiang.py 17 KB

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