guanlan_uninstall.py 21 KB

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