guanlan_uninstall.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. r"""观澜卸载器 (2026-09-17 用户令: 增加卸载脚本 `uninstall.bat` / `uninstall.sh`)。
  4. 入口就是这两个壳脚本 + 本文件;三处共用**同一份**实现(与 install.bat/install.sh→install.ps1 同构),
  5. 免得"Windows 一套、Linux 一套"各写一遍逻辑、过两个月行为不一致。
  6. ## 卸载拆成哪几步(默认口径:只拆"本程序装上去的东西",绝不动用户数据)
  7. ① 停服务 先停服务化常驻体(Windows 服务 / systemd 单元),再停手工启动的那套
  8. ② 注销服务 `sc.exe delete guanlan` / `systemctl disable --now` + 删单元文件(需管理员/root)
  9. ③ 撤开机自启 HKCU\...\Run 的 guanlan 项 / systemd 用户单元(不需要管理员)
  10. ④ 删快捷方式 `启动观澜.lnk`、`start_guanlan.lnk`(安装根 + 桌面 + applications)
  11. ⑤ 清运行态 `run/pids.json`、残留 `*.pid`
  12. ⑥ 删安装记录 `install-info.json`("已装的是哪一版"的唯一凭据 —— 删掉它 = 这台机器回到"没装过")
  13. **保留**:`data/`(输入数据)· `outputs/`(产物)· `logs/`(日志)· `.venv/`(虚拟环境)· 整棵程序目录
  14. ## 为什么默认既不删数据、也不删程序目录
  15. · `data/` 与 `outputs/` 是**现场资产**(原始件十几 GB + 重算产物);"卸载程序"不等于"销毁数据"。
  16. 要连数据一起清,显式给 `--purge`(并且**再问一次**,默认答否;`--keep-logs` 可留下日志)。
  17. · 程序目录**不自己删**:Windows 上正在跑的 `python.exe` 就在 `.venv` 里,删自己必然部分失败,
  18. 留下"删了一半的目录"比留着整个目录更难排查。要清干净给 `--all`:本器尽力删,**删不掉的逐条列出**,
  19. 并打印一条收尾命令(`rmdir /s /q <根>` 或 `rm -rf <根>`)让你关掉本窗口后执行。
  20. ## 用法
  21. uninstall.bat 交互:先打印计划 → 问一次 → 执行
  22. uninstall.bat --dry-run 只打印计划,一个字节都不动(★自证用)
  23. uninstall.bat --yes 不问(脚本/无人值守)
  24. uninstall.bat --purge --yes 连 data/ outputs/ logs/ .venv/ 一起删
  25. uninstall.bat --all --yes 连程序文件一起删(最后给收尾命令)
  26. sh uninstall.sh [同样的开关] Linux / macOS
  27. 退出码: 0 完成 · 2 需要确认(非交互又没给 --yes) · 3 权限不足(②需管理员/root, 其余照做) · 5 有步骤失败
  28. """
  29. from __future__ import annotations
  30. try:
  31. from app_common.app_common_guanlan.api import install_root as _install_root
  32. except ImportError: # 理论不可达;包结构异常时回退到按位置上跳
  33. from pathlib import Path as _P
  34. def _install_root(_f): return _P(_f).resolve().parents[4]
  35. import argparse
  36. import os
  37. import pathlib
  38. import shutil
  39. import subprocess
  40. import sys
  41. ROOT = _install_root(__file__)
  42. sys.path.insert(0, str(ROOT))
  43. from src import version as V # noqa: E402
  44. WIN32 = os.name == 'nt'
  45. UNIT_PATH = pathlib.Path('/etc/systemd/system') / f'{V.SERVICE_NAME}.service'
  46. USER_UNIT = pathlib.Path.home() / '.config' / 'systemd' / 'user' / f'{V.SERVICE_NAME}.service'
  47. AUTOSTART = pathlib.Path.home() / '.config' / 'autostart' / f'{V.SERVICE_NAME}.desktop'
  48. SHORTCUTS = ('启动观澜.lnk', 'start_guanlan.lnk')
  49. # 卸载时要清掉的可重建件 / 用户数据(--purge 才动)
  50. PURGE_DIRS = ('data', 'outputs', 'logs', 'run', 'runtime')
  51. # --all 才动的"程序本体"
  52. PROGRAM_DIRS = ('src', 'scripts', 'configs', 'release', 'resources', 'reference', 'docs', 'wheels', 'vendor',
  53. 'app_common', 'app_ETL', 'app_algorithmModel', 'app_ontology', 'app_backEnd',
  54. 'app_frontEnd', 'app_qualityGate')
  55. PROGRAM_FILES = ('guanlan.py', 'requirements.txt', 'README_先读我.MD', '测试须知.txt',
  56. 'install.bat', 'install.ps1', 'install.sh', 'uninstall.bat', 'uninstall.sh',
  57. 'check.bat', 'start.bat', 'stop.bat', 'pack.bat', 'pack.sh', 'dist-manifest.json')
  58. RESULTS: list[tuple[str, int, str]] = [] # (步骤, rc, 说明) —— 结尾统一汇总
  59. def say(msg: str = '') -> None:
  60. print(msg)
  61. def py_exe() -> str:
  62. """优先用本安装的 .venv 解释器;它没了(比如已经被删)就退回当前解释器。"""
  63. cand = ROOT / '.venv' / ('Scripts/python.exe' if WIN32 else 'bin/python')
  64. return str(cand) if cand.is_file() else sys.executable
  65. def is_admin() -> bool:
  66. if not WIN32:
  67. try:
  68. return os.geteuid() == 0
  69. except AttributeError:
  70. return False
  71. try:
  72. import ctypes
  73. return bool(ctypes.windll.shell32.IsUserAnAdmin())
  74. except Exception:
  75. return False
  76. def run(cmd: list[str], dry: bool, note: str = '') -> int:
  77. printable = ' '.join(f'"{x}"' if ' ' in x else x for x in cmd)
  78. say(f' $ {printable}' + (f' ({note})' if note else ''))
  79. if dry:
  80. return 0
  81. try:
  82. r = subprocess.run(cmd, capture_output=True, text=True, errors='replace')
  83. except FileNotFoundError:
  84. say(f' [X] 找不到命令 {cmd[0]}')
  85. return 5
  86. for ln in (r.stdout or '').strip().splitlines():
  87. say(' ' + ln)
  88. for ln in (r.stderr or '').strip().splitlines():
  89. say(' [stderr] ' + ln)
  90. return 0 if r.returncode == 0 else 5
  91. def rm(path: pathlib.Path, dry: bool) -> int:
  92. """真删(目录递归 / 文件单件);不存在算成功(卸载必须幂等,跑第二遍不该报错)。"""
  93. if not path.exists():
  94. say(f' - 本来就没有: {rel(path)}')
  95. return 0
  96. size, n = size_of(path)
  97. say(f' $ 删除 {rel(path)}' + (f' ({n} 件, {size / 1e6:.1f} MB)' if path.is_dir() else f' ({size} B)'))
  98. if dry:
  99. return 0
  100. try:
  101. if path.is_dir():
  102. shutil.rmtree(path)
  103. else:
  104. path.unlink()
  105. return 0
  106. except OSError as e:
  107. say(f' [X] 删不掉: {e}')
  108. return 5
  109. def rel(p: pathlib.Path) -> str:
  110. try:
  111. return p.relative_to(ROOT).as_posix() or '.'
  112. except ValueError:
  113. return str(p)
  114. def size_of(p: pathlib.Path) -> tuple[int, int]:
  115. """→ (字节数, 件数)。"""
  116. if p.is_file():
  117. try:
  118. return p.stat().st_size, 1
  119. except OSError:
  120. return 0, 0
  121. tot = n = 0
  122. for dp, _dn, fns in os.walk(p):
  123. for fn in fns:
  124. try:
  125. tot += (pathlib.Path(dp) / fn).stat().st_size
  126. n += 1
  127. except OSError:
  128. pass
  129. return tot, n
  130. # ── 各步 ────────────────────────────────────────────────────────────────────────────
  131. def service_registered() -> bool:
  132. if WIN32:
  133. try:
  134. r = subprocess.run(['sc.exe', 'query', V.SERVICE_NAME], capture_output=True, text=True, errors='replace')
  135. return r.returncode == 0
  136. except FileNotFoundError:
  137. return False
  138. return UNIT_PATH.is_file()
  139. def step_stop(dry: bool) -> None:
  140. say('① 停服务')
  141. rc = 0
  142. note = []
  143. if service_registered():
  144. rc = run([py_exe(), str(ROOT / 'scripts' / 'service_ctl.py'), 'stop'], dry, '停服务化常驻体')
  145. note.append('服务化常驻体已停' if rc == 0 else '常驻体那步没成功(多为权限)')
  146. else:
  147. say(f' - 没注册过 {V.SERVICE_NAME} 服务(跳过常驻体)')
  148. note.append('无服务化常驻体')
  149. r2 = run([py_exe(), str(ROOT / 'guanlan.py'), 'stop'], dry, '停启动器起的组件')
  150. rc = rc or r2
  151. note.append('启动器组件已停' if r2 == 0 else '启动器组件停止失败')
  152. RESULTS.append(('停止服务', rc, ';'.join(note)))
  153. def step_unregister(dry: bool) -> None:
  154. say('② 注销服务(需管理员 / root)')
  155. if not service_registered():
  156. say(f' - 本机没注册过 {V.SERVICE_NAME} 服务(跳过)')
  157. RESULTS.append(('注销服务', 0, '本来就没注册'))
  158. return
  159. if not is_admin() and not dry:
  160. say(' [!] 权限不足:注销服务需要管理员/root —— 其余步骤照做,这一步留给你手工执行:')
  161. if WIN32:
  162. say(f' sc.exe stop {V.SERVICE_NAME} && sc.exe delete {V.SERVICE_NAME}')
  163. else:
  164. say(f' sudo systemctl disable --now {V.SERVICE_NAME} && sudo rm -f {UNIT_PATH}')
  165. RESULTS.append(('注销服务', 3, '权限不足(已给出手工命令)'))
  166. return
  167. rc = run([py_exe(), str(ROOT / 'scripts' / 'service_ctl.py'), 'uninstall'], dry, '停 + 删注册')
  168. RESULTS.append(('注销服务', rc, '服务已注销' if rc == 0 else '注销失败'))
  169. def step_autostart(dry: bool) -> None:
  170. say('③ 撤开机自启(不需要管理员)')
  171. rc = 0
  172. if WIN32:
  173. key = r'Software\Microsoft\Windows\CurrentVersion\Run'
  174. say(f' $ reg delete HKCU\\{key} /v {V.SERVICE_NAME} /f')
  175. if not dry:
  176. try:
  177. import winreg
  178. with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key, 0, winreg.KEY_SET_VALUE) as k:
  179. try:
  180. winreg.DeleteValue(k, V.SERVICE_NAME)
  181. say(' 已移除登录自启项')
  182. except FileNotFoundError:
  183. say(' - 本来就没有这项')
  184. except Exception as e: # 注册表异常不该中断整场卸载
  185. say(f' [X] {type(e).__name__}: {e}')
  186. rc = 5
  187. elif AUTOSTART.exists() or USER_UNIT.exists():
  188. rc = run(['systemctl', '--user', 'disable', '--now', V.SERVICE_NAME], dry, '用户级自启(可能没装)')
  189. rc = rc or rm(AUTOSTART, dry)
  190. rc = rc or rm(USER_UNIT, dry)
  191. else:
  192. say(' - 没有用户级自启项(跳过)')
  193. RESULTS.append(('撤开机自启', rc, '登录/开机自启项已清' if rc == 0 else '清理失败'))
  194. def step_shortcuts(dry: bool) -> None:
  195. say('④ 删快捷方式(安装根 + 桌面 + applications)')
  196. targets: list[pathlib.Path] = [ROOT / n for n in SHORTCUTS]
  197. if WIN32:
  198. up = os.environ.get('USERPROFILE')
  199. if up:
  200. targets += [pathlib.Path(up) / d / n for d in ('Desktop', '桌面') for n in SHORTCUTS]
  201. else:
  202. targets += [pathlib.Path.home() / d / f'{n[:-4]}.desktop'
  203. for d in ('Desktop', '.local/share/applications') for n in SHORTCUTS]
  204. hit = [p for p in targets if p.exists()]
  205. rc = 0
  206. for p in hit:
  207. rc = rc or rm(p, dry)
  208. if not hit:
  209. say(' - 没有安装时建的快捷方式(跳过)')
  210. RESULTS.append(('删快捷方式', rc, f'删了 {len(hit)} 个' if hit else '本来就没有'))
  211. def step_runtime(dry: bool) -> None:
  212. say('⑤ 清运行态(run/pids.json、残留 *.pid)')
  213. rc = rm(ROOT / 'run', dry)
  214. for p in sorted(ROOT.glob('*.pid')):
  215. rc = rc or rm(p, dry)
  216. RESULTS.append(('清运行态', rc, 'PID 记录已清' if rc == 0 else '清理失败'))
  217. def step_install_record(dry: bool) -> None:
  218. say('⑥ 删安装记录 install-info.json')
  219. ins = V.read_installed(ROOT)
  220. if ins:
  221. say(f" - 记录的是: {ins.get('name', '?')} v{ins.get('version', '?')}"
  222. f"(装于 {ins.get('installed_at', '?')},运行方式 {ins.get('mode', '?')})")
  223. else:
  224. say(' - 没有安装记录(本来就没装过 / 已被删)')
  225. rc = rm(V.info_path(ROOT), dry)
  226. RESULTS.append(('删安装记录', rc, '这台机器回到"没装过"状态' if rc == 0 else '删除失败'))
  227. def step_purge(dry: bool, yes: bool, skip: tuple[str, ...] = ()) -> None:
  228. say('⑦ --purge: 删数据与可重建件(输入数据 / 产物 / 日志 / 虚拟环境)')
  229. dirs = tuple(d for d in PURGE_DIRS if d not in skip)
  230. targets = [ROOT / d for d in dirs] + [ROOT / '.venv']
  231. present = [p for p in targets if p.exists()]
  232. if skip:
  233. say(f' (--keep-logs: 保留 {", ".join(skip)})')
  234. if not present:
  235. say(' - 没什么可清的(都不在)')
  236. RESULTS.append(('--purge 清数据', 0, '无对象'))
  237. return
  238. tot = sum(size_of(p)[0] for p in present)
  239. say(f' 将删除: {", ".join(rel(p) for p in present)} —— 合计 {tot / 1e9:.2f} GB')
  240. if not yes:
  241. if not sys.stdin or not sys.stdin.isatty():
  242. say(' [!] 非交互环境:--purge 需要显式 --yes 确认 —— 本步跳过(数据原样保留)')
  243. RESULTS.append(('--purge 清数据', 2, '非交互未确认,已跳过'))
  244. return
  245. ans = input(' 这会永久删除现场数据与产物,确认? 输入 yes 继续,其它键跳过: ').strip().lower()
  246. if ans not in ('yes', 'y'):
  247. say(' - 已跳过(数据原样保留)')
  248. RESULTS.append(('--purge 清数据', 0, '用户选择跳过'))
  249. return
  250. rc = 0
  251. for p in present:
  252. rc = rc or rm(p, dry)
  253. RESULTS.append(('--purge 清数据', rc, f'删除 {len(present)} 项' if rc == 0 else '有删不掉的'))
  254. def step_all(dry: bool) -> None:
  255. say('⑧ --all: 删程序本体(尽力而为,删不掉的逐条列出)')
  256. # ★ 先把自己的工作目录移出安装根:Windows 不允许删除"当前目录",也不允许删正在跑的 python.exe
  257. if not dry:
  258. try:
  259. os.chdir(os.environ.get('TEMP') or os.environ.get('TMP') or '/tmp')
  260. except OSError:
  261. pass
  262. failed: list[str] = []
  263. for name in PROGRAM_DIRS + PROGRAM_FILES:
  264. p = ROOT / name
  265. if p.exists() and rm(p, dry) != 0:
  266. failed.append(rel(p))
  267. if not dry and ROOT.exists():
  268. for leftover in sorted(ROOT.iterdir()): # 兜底:清单之外的零碎也清
  269. if rm(leftover, dry) != 0:
  270. failed.append(rel(leftover))
  271. try:
  272. ROOT.rmdir()
  273. say(f' 已删除安装根 {ROOT}')
  274. except OSError as e:
  275. say(f' [!] 安装根没删掉({e.__class__.__name__})—— 正常:本程序正在跑, 自己的目录被占用')
  276. say(' 关掉本窗口后手工执行: '
  277. + (f'rmdir /s /q "{ROOT}"' if WIN32 else f'rm -rf "{ROOT}"'))
  278. if failed:
  279. say(f' 删不掉的 {len(failed)} 项: {", ".join(failed[:8])}' + (' …' if len(failed) > 8 else ''))
  280. RESULTS.append(('--all 删程序', 0 if not failed else 5,
  281. '程序文件已清' if not failed else f'{len(failed)} 项留待手工删'))
  282. # ── 计划与自检 ──────────────────────────────────────────────────────────────────────
  283. def plan_lines() -> list[str]:
  284. """把"将要做什么"列出来(--dry-run 打印它;也是自检的断言对象)。"""
  285. ins = V.read_installed(ROOT)
  286. return [
  287. f'观澜卸载计划 @ {ROOT}',
  288. f' 本包版本: {V.NAME} v{V.VERSION}'
  289. + (f";安装记录写的: v{ins.get('version', '?')}(装于 {ins.get('installed_at', '?')})"
  290. if ins else ';没有安装记录(本来就没装过 / 已被删)'),
  291. ' ① 停服务: service_ctl stop → guanlan.py stop',
  292. ' ② 注销服务: '
  293. + (f'sc.exe stop/delete {V.SERVICE_NAME}' if WIN32 else f'systemctl disable --now {V.SERVICE_NAME} + 删 {UNIT_PATH}')
  294. + f' (已注册={service_registered()}, 有权限={is_admin()})',
  295. ' ③ 撤开机自启: ' + (r'HKCU\Software\Microsoft\Windows\CurrentVersion\Run' if WIN32 else str(USER_UNIT)),
  296. ' ④ 删快捷方式: ' + ' / '.join(SHORTCUTS) + '(安装根 + 桌面 + applications)',
  297. ' ⑤ 清运行态: run/ 与 *.pid',
  298. f' ⑥ 删安装记录: {V.INSTALL_INFO}',
  299. f' ⑦/⑧ 只有给了 --purge / --all 才动数据与程序本体',
  300. ' 保留(默认): ' + ' '.join(f'{d}/' + ('' if (ROOT / d).exists() else '(没有)')
  301. for d in ('data', 'outputs', 'logs', '.venv')) + ' 与整棵程序目录',
  302. ]
  303. def selftest() -> int:
  304. r"""自检(--selftest):证明"计划齐全"且"dry-run 一个字节都不动"。
  305. 一个是**内容断言**(六个步骤一个不缺),一个是**副作用断言**(跑完 --dry-run 后关键路径的
  306. 存在性与大小完全不变)—— 后者才是"不会误删"的证据。
  307. """
  308. ok = True
  309. need = ('① 停服务', '② 注销服务', '③ 撤开机自启', '④ 删快捷方式', '⑤ 清运行态', '⑥ 删安装记录', '保留')
  310. txt = '\n'.join(plan_lines())
  311. miss = [k for k in need if k not in txt]
  312. print(f' [{"OK" if not miss else "FAIL"}] 计划齐全(六个步骤 + 保留清单)' + ('' if not miss else f' — 缺 {miss}'))
  313. ok &= not miss
  314. watch = ['install-info.json', 'run', 'data', 'outputs', 'logs', '.venv']
  315. before = {w: (size_of(ROOT / w) if (ROOT / w).exists() else None) for w in watch}
  316. step_stop(True)
  317. step_unregister(True)
  318. step_autostart(True)
  319. step_shortcuts(True)
  320. step_runtime(True)
  321. step_install_record(True)
  322. after = {w: (size_of(ROOT / w) if (ROOT / w).exists() else None) for w in watch}
  323. same = before == after
  324. print(f' [{"OK" if same else "FAIL"}] dry-run 无副作用({len(watch)} 个关键路径的存在性与大小都没变)')
  325. ok &= same
  326. print(' [OK] 六步全部构建成功: ' + '; '.join(f'{n}={rc}' for n, rc, _ in RESULTS))
  327. RESULTS.clear()
  328. print(' 结论: ' + ('自检通过' if ok else '自检未通过'))
  329. return 0 if ok else 1
  330. def main() -> int:
  331. ap = argparse.ArgumentParser(description=f'{V.NAME} v{V.VERSION} 卸载器')
  332. ap.add_argument('--dry-run', action='store_true', help='只打印计划, 一个字节都不动')
  333. ap.add_argument('--yes', '-y', action='store_true', help='不问, 直接执行(脚本/无人值守用)')
  334. ap.add_argument('--purge', action='store_true', help='连 data/ outputs/ logs/ .venv/ 一起删(会再问一次)')
  335. ap.add_argument('--all', action='store_true', help='连程序本体一起删(尽力而为, 最后给收尾命令)')
  336. ap.add_argument('--keep-logs', action='store_true', help='--purge 时保留 logs/(排查用)')
  337. ap.add_argument('--selftest', action='store_true', help='自检: 计划齐全 + dry-run 无副作用')
  338. a = ap.parse_args()
  339. say(f'== {V.NAME} v{V.VERSION} 卸载 · {ROOT} ==')
  340. if a.selftest:
  341. return selftest()
  342. for ln in plan_lines():
  343. say(ln)
  344. say('')
  345. if not a.dry_run:
  346. if not a.yes:
  347. if not sys.stdin or not sys.stdin.isatty():
  348. say(' [!] 非交互环境:卸载需要确认。请显式加 --yes(或先在终端里手工跑一次看计划)')
  349. say(' 本次未做任何改动。')
  350. return 2
  351. ans = input(' 确认卸载? 输入 y 继续, 其它键退出: ').strip().lower()
  352. if ans not in ('y', 'yes'):
  353. say(' 已取消(未改动任何文件)')
  354. return 0
  355. say('')
  356. step_stop(a.dry_run)
  357. step_unregister(a.dry_run)
  358. step_autostart(a.dry_run)
  359. step_shortcuts(a.dry_run)
  360. step_runtime(a.dry_run)
  361. step_install_record(a.dry_run)
  362. if a.purge:
  363. step_purge(a.dry_run, a.yes, skip=('logs',) if a.keep_logs else ())
  364. if a.all:
  365. step_all(a.dry_run)
  366. say('')
  367. say('== 结果 ==')
  368. for name, rc, note in RESULTS:
  369. say(f' [{"OK" if rc == 0 else ("!" if rc == 3 else "X")}] {name}: {note}')
  370. worst = max((rc for _n, rc, _s in RESULTS), default=0)
  371. if a.dry_run:
  372. say(' (dry-run: 以上一个字节都没动 — 去掉 --dry-run 才真做)')
  373. return 0
  374. say(' 保留: ' + ('(--all 已连程序本体一起处理)' if a.all else 'data/ outputs/ logs/ .venv/ 与整棵程序目录'))
  375. if worst == 0:
  376. say(' 卸载完成。')
  377. if not a.all:
  378. say(' 想连目录一起清掉: ' + (f'rmdir /s /q "{ROOT}"' if WIN32 else f'rm -rf "{ROOT}"'))
  379. elif worst == 3:
  380. say(' 卸载基本完成,但**服务注销那一步缺权限** —— 用管理员/root 重跑一次即可(命令见上)。')
  381. else:
  382. say(' 有步骤失败,见上面的 [X]。')
  383. # 机器审计流水(卸载也要留痕;logs/ 被 --purge 删掉时这一步自然就没了,属预期)
  384. try:
  385. from src import logfile as LF
  386. LF.append_jsonl('uninstall', dict(root=str(ROOT), version=V.VERSION, purge=bool(a.purge),
  387. all=bool(a.all), worst_rc=worst,
  388. steps=[dict(name=n, rc=rc, note=s) for n, rc, s in RESULTS]))
  389. except Exception:
  390. pass
  391. return 0 if worst == 0 else worst
  392. if __name__ == '__main__':
  393. for _s in (sys.stdout, sys.stderr):
  394. try:
  395. _s.reconfigure(errors='replace')
  396. except Exception:
  397. pass
  398. sys.exit(main())