check_transferable.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  1. # -*- coding: utf-8 -*-
  2. r"""移植性大扫描 (2026-09-12) —— 换电脑/换盘/换系统之前跑它。
  3. 比 `scripts/check_portability.py`(只看 .py 的路径写法) 查得更宽, 因为"能不能独立运行"还取决于:
  4. ① **所有文件类型**里的机器相关绝对路径 (.py/.bat/.sh/.ps1/.json/.md/.txt/.html/.yaml/.cfg …)
  5. —— 不只是源码; 文档里的示例路径写死也会误导现场, 产物里烘进去的路径换机后就是死链。
  6. ② **虚拟环境的可移植性**: `.venv/pyvenv.cfg` 的 `home` 指向基础解释器 —— 换台没有同路径 Python 的
  7. 机器, `.venv\Scripts\python.exe` 直接起不来(这是"拷包就能跑"最常见的误解)。
  8. ③ **离线依赖是否为本平台备齐**: `wheels/<平台>` 有没有; 缺了就只能联网装。
  9. ④ **便携运行时**是否在 (`vendor/python/…`), 以及 `vendor/MANIFEST.json` 有没有把它登记全。
  10. ⑤ **配置与端口**是否相对/可改, 有没有写死本机 IP。
  11. 用法:
  12. python scripts/check_transferable.py # 全量检查 + 结论
  13. python scripts/check_transferable.py --brief # 只报问题
  14. python scripts/check_transferable.py --simulate # **模拟移植**(推荐换机前跑): 把包拷成一份副本 →
  15. # 在副本里跑安装 → 起服务核验页面 → 报结果 → 清理副本。
  16. # 默认拷全量(除 data/.git/.venv/暂存区); --keep 保留副本。
  17. """
  18. from __future__ import annotations
  19. try:
  20. from app_common.app_common_guanlan.api import install_root as _install_root
  21. except ImportError: # 理论不可达;包结构异常时回退到按位置上跳
  22. from pathlib import Path as _P
  23. def _install_root(_f): return _P(_f).resolve().parents[4]
  24. import argparse
  25. import json
  26. import os
  27. import pathlib
  28. import re
  29. import shutil
  30. import socket
  31. import subprocess
  32. import sys
  33. import tempfile
  34. import time
  35. import urllib.request
  36. ROOT = _install_root(__file__)
  37. sys.path.insert(0, str(ROOT))
  38. from src import paths as P # 配置/路径唯一取用口 (2026-09-17 用户令 2)
  39. # 机器相关绝对路径: 盘符路径 / mac 用户目录 / 家目录 / 本机用户名
  40. RE_ABS = re.compile(r"""(?<![\w:/])(?:[A-Za-z]:[\\/](?![\\/])|/Users/[A-Za-z0-9_.\-]+|/home/[a-z][A-Za-z0-9_.\-]*|/opt/[A-Za-z0-9_.\-]+|/Volumes/[A-Za-z0-9_\- ]+)""")
  41. # 扫描范围: 源码/脚本/配置/文档/外壳页面 (跳过 .venv、依赖、二进制、大体积产物里的二进制)
  42. TEXT_EXT = {'.py', '.bat', '.sh', '.ps1', '.cmd', '.json', '.md', '.txt', '.html', '.htm', '.yaml', '.yml', '.cfg', '.ini', '.toml'}
  43. SKIP_DIRS = {'.venv', '.git', '__pycache__', 'node_modules', '_products_off', 'logs', 'run'}
  44. SKIP_PATH_HINT = ('_products_off_prev',)
  45. # 扫描范围里要**排除现场原始件**: data/raw/** 是现场给的测量导出 (SCADA csv、Brande TCM JSON ——
  46. # 2026-09-12 落位后单这一个目录就是 2.5 万个 .json / 150 GB), 且**不随包分发** (pack_dist.py 的
  47. # EXCLUDE_GLOBS 含 'data')。文本闸门扫它: ①对"换机可用"零信息量 (那些文件里就算有绝对路径也不是我们的
  48. # 接线) ②每次验收要多读几十 GB 文本 (8 MB 以下的一律会读进内存), 把闸门从秒级拖成小时级。
  49. SKIP_RAW_PREFIX = ('data/raw/', 'data/_incoming/')
  50. # 允许的例外: 文档里明确在讲"路径约定"或演示用的占位
  51. ALLOW_LINE = ('portability-allow', '<安装目录>', '<现场包目录>', '<场站名称>', 'D:\\guanlan', '/opt/guanlan',
  52. 'D:/guanlan', '示例', '例:', '例如')
  53. def walk_files():
  54. for p in ROOT.rglob('*'):
  55. if not p.is_file() or p.suffix.lower() not in TEXT_EXT:
  56. continue
  57. rel = p.relative_to(ROOT).as_posix()
  58. parts = set(p.relative_to(ROOT).parts)
  59. if parts & SKIP_DIRS or any(h in rel for h in SKIP_PATH_HINT):
  60. continue
  61. if rel.startswith(SKIP_RAW_PREFIX):
  62. continue
  63. if rel.startswith(SKIP_RAW_PREFIX):
  64. continue
  65. if p.stat().st_size > 8 * 1024 * 1024: # 超大文本(如 20MB 门户)另算, 见下
  66. continue
  67. yield p
  68. def scan_paths(verbose=True):
  69. hits = []
  70. for p in walk_files():
  71. try:
  72. text = p.read_text(encoding='utf-8', errors='replace')
  73. except Exception:
  74. continue
  75. for i, line in enumerate(text.splitlines(), 1):
  76. if any(a in line for a in ALLOW_LINE):
  77. continue
  78. if line.lstrip().startswith(('#', 'rem ', '::')) and 'src' not in line and '--' in line:
  79. continue
  80. m = RE_ABS.search(line)
  81. if m:
  82. hits.append((p.relative_to(ROOT).as_posix(), i, m.group(0), line.strip()[:110]))
  83. print(f'① 文本文件里的机器相关绝对路径: {len(hits)} 处')
  84. if hits and verbose:
  85. for rel, i, found, line in hits[:25]:
  86. print(f' {rel}:{i} ← {found}\n {line}')
  87. if len(hits) > 25:
  88. print(f' … 另有 {len(hits) - 25} 处')
  89. return hits
  90. def scan_big_text():
  91. """大文件(门户 20MB 等)单独扫: 里面烘进的绝对路径换机后就是死链。"""
  92. out = []
  93. for p in ROOT.rglob('*.html'):
  94. rel = p.relative_to(ROOT).as_posix()
  95. if any(x in rel for x in SKIP_PATH_HINT) or '.venv' in rel or p.stat().st_size < 1024:
  96. continue
  97. try:
  98. t = p.read_text(encoding='utf-8', errors='replace')
  99. except Exception:
  100. continue
  101. n = len(RE_ABS.findall(t))
  102. if n:
  103. out.append((rel, n, p.stat().st_size))
  104. if out:
  105. print(f'② HTML 里烘进的绝对路径: {len(out)} 个文件')
  106. for rel, n, sz in out[:10]:
  107. print(f' {rel} {n} 处 ({sz/1e6:.1f} MB)')
  108. else:
  109. print('② HTML 里没有烘进的绝对路径')
  110. return out
  111. def check_venv():
  112. cfg = ROOT / '.venv' / 'pyvenv.cfg'
  113. print('③ 虚拟环境')
  114. if not cfg.is_file():
  115. print(' [--] 没有 .venv/pyvenv.cfg (还没装? 目标机上跑 install.bat / sh install.sh)')
  116. return 'absent'
  117. kv = {}
  118. for l in cfg.read_text(encoding='utf-8', errors='replace').splitlines():
  119. if '=' in l:
  120. k, v = l.split('=', 1)
  121. kv[k.strip()] = v.strip() # ★键要去空格: pyvenv.cfg 写的是 "home = D:\..." (等号前有空格)
  122. home = (kv.get('home') or '').strip()
  123. print(f' home(基础解释器) = {home}')
  124. print(f' 版本 {kv.get("version", "?").strip()}')
  125. here = pathlib.Path(home).is_dir() if home else False
  126. print(f' 本机存在该目录: {here}')
  127. print(' ★ 换台机器若没有同路径的 Python, .venv 里的解释器起不来 —— 移植时**必须在目标机重跑一次安装**'
  128. '(install.bat / sh install.sh; 离线, 用包内轮子或便携运行时)。')
  129. return kv
  130. def check_wheels():
  131. w = ROOT / 'wheels'
  132. print('④ 离线依赖轮子')
  133. if not w.is_dir():
  134. print(' [X] 没有 wheels/ 目录 —— 装依赖需要联网')
  135. return []
  136. dirs = sorted(d.name for d in w.iterdir() if d.is_dir())
  137. print(f' 平台目录: {dirs or "(无)"}')
  138. req = [l.split('#')[0].strip() for l in (ROOT / 'requirements.txt').read_text(encoding='utf-8').splitlines()]
  139. req = [r for r in req if r]
  140. names = {r.split('==')[0].lower().replace('-', '_') for r in req}
  141. for d in dirs:
  142. have = {p.name.split('-')[0].lower().replace('-', '_') for p in (w / d).glob('*.whl')}
  143. miss = sorted(n for n in names if n not in have)
  144. tag = 'OK' if not miss else f'缺 {len(miss)}: {miss[:6]}'
  145. print(f' {d}: {len(list((w / d).glob("*.whl")))} 个轮子 {tag}')
  146. plat = 'win_amd64' if os.name == 'nt' else ('linux_x86_64' if sys.platform.startswith('linux') else 'macos')
  147. if plat not in dirs:
  148. print(f' [!] 本机平台 {plat} 没有对应轮子目录: Linux/macOS 上 install.sh 会自动**改成联网安装**')
  149. return dirs
  150. def check_vendor():
  151. v = ROOT / 'vendor'
  152. print('⑤ 便携运行时 / 离线件')
  153. if not v.is_dir():
  154. print(' [--] 没有 vendor/')
  155. return
  156. for p in sorted(v.rglob('*')):
  157. if p.is_file():
  158. print(f' {p.relative_to(ROOT).as_posix()} {p.stat().st_size/1e6:.1f} MB')
  159. man = v / 'MANIFEST.json'
  160. if man.is_file():
  161. m = json.loads(man.read_text(encoding='utf-8'))
  162. files = {x.relative_to(v).as_posix() for x in v.rglob('*') if x.is_file() and x.name != 'MANIFEST.json'}
  163. undoc = sorted(files - set(m))
  164. if undoc:
  165. print(f' [!] MANIFEST.json 没登记: {undoc} (只影响校验, 不影响运行)')
  166. def check_configs():
  167. print('⑥ 配置与端口')
  168. p = P.SERVE_JSON # 配置唯一取用口
  169. if p.is_file():
  170. c = json.loads(p.read_text(encoding='utf-8-sig'))
  171. print(f' serve.json: host={c.get("host")} gateway={c.get("gateway")} python={c.get("python")!r} '
  172. f'raw_dir={c.get("raw_dir")!r}')
  173. for k in ('python', 'raw_dir', 'release_dir', 'viewer_dir', 'sim_dir'):
  174. v = str(c.get(k) or '')
  175. if re.match(r'^[A-Za-z]:[\\/]|^/', v):
  176. print(f' [!] {k} 是绝对路径: {v}')
  177. # 写死的本机端口/入口(允许出现, 但要知道)
  178. n_port = sum(1 for p in walk_files() if '28084' in p.read_text(encoding='utf-8', errors='replace'))
  179. print(f' 提到 28084 的文本文件: {n_port} 个 (端口真源是 configs/serve.json, 其余多为文档)')
  180. def _free_port(start=28700, tries=40):
  181. for p in range(start, start + tries):
  182. with socket.socket() as s:
  183. try:
  184. s.bind(('127.0.0.1', p)); return p
  185. except OSError:
  186. continue
  187. return None
  188. def _busy(port, host='127.0.0.1'):
  189. with socket.socket() as s:
  190. s.settimeout(0.3)
  191. return s.connect_ex((host, port)) == 0
  192. IGNORE = shutil.ignore_patterns('data', '.git', '.venv', '__pycache__', 'logs', 'run',
  193. '_products_off', '_products_off_prev_*', '*.pyc')
  194. def install_in(dst: pathlib.Path) -> pathlib.Path | None:
  195. """在 dst 里跑一次离线安装 (Windows: install.ps1; POSIX: install.sh), 返回该副本的解释器路径。"""
  196. print(f' 在 {dst} 里跑安装 (离线) …')
  197. # -NoDesktopShortcut: install.ps1 默认会在**桌面**建「观澜·如东样板 v2」快捷方式, 而这里是临时副本,
  198. # 建出来的快捷方式指向马上要被删掉的目录 (2026-09-16 加)。副本里的 `启动观澜.lnk` 照建 —— 那正是要核验的东西。
  199. cmd = (['powershell', '-ExecutionPolicy', 'Bypass', '-File', str(dst / 'install.ps1'), '-NoDesktopShortcut']
  200. if os.name == 'nt' else ['sh', str(dst / 'install.sh')])
  201. t0 = time.time()
  202. r = subprocess.run(cmd, cwd=str(dst))
  203. print(f' install 退出码 {r.returncode}, 耗时 {time.time() - t0:.0f}s')
  204. py = dst / ('.venv/Scripts/python.exe' if os.name == 'nt' else '.venv/bin/python')
  205. if not py.is_file():
  206. print(f' [X] 副本里没有 {py} —— 安装没成')
  207. return None
  208. v = subprocess.run([str(py), '-c', 'import sys,pandas,pyarrow,polars,yaml;print(sys.version.split()[0])'],
  209. capture_output=True, text=True, errors='replace')
  210. print(f' 副本解释器: {v.stdout.strip() or v.stderr.strip()[:120]}')
  211. return py
  212. LAST_GATEWAY_PORT: int | None = None # run_and_probe 实际用的网关端口(卸载核验要拿它查"释放了没")
  213. def run_and_probe(dst: pathlib.Path, py: pathlib.Path, base_port=28084) -> int:
  214. """在副本里起服务并核验页面 —— **完全不动正在跑的实例**: 副本整套换端口跑。
  215. ★ 2026-09-17 改法 (现场事故驱动): 以前网关 ROUTES 的上游端口是写死的, 所以副本**必须**占用
  216. 18033/18020/18791/18792/64292 —— 于是"开箱验证"就得先把用户正在跑的服务停掉; 实测后果:
  217. 用户在验证窗口里点门户「登录」, `/detail/` 报"模块未启动 (127.0.0.1:18033)"。
  218. 现在 `guanlan_gateway.ROUTES` 的上游端口可被 `configs/serve.json` 覆盖, 于是副本可以**整套**
  219. 换到空闲端口 (网关 + 5 个组件), 主实例照常跑, 谁也不打扰谁。
  220. 端口占用仍会兜底: 找不到空闲端口就跳过启动核验 (不计失败)。
  221. """
  222. global LAST_GATEWAY_PORT
  223. comp_keys = ('detail', 'cms', 'sim', 'sim_sys', 'viewer')
  224. cfg_p = dst / 'configs' / P.SERVE_JSON.name # config-path-allow: 改的是**副本**里的那份配置
  225. try:
  226. cfg = json.loads(cfg_p.read_text(encoding='utf-8-sig'))
  227. except Exception as e:
  228. print(f' [!] 副本配置读不出来 ({type(e).__name__}) —— 跳过启动核验'); return 0
  229. # ① 主实例在不在: 决定要不要给副本换端口, 以及能否做"门户同字节"比对
  230. main_up = not _busy(base_port)
  231. gw = base_port if not _busy(base_port) else _free_port(28700)
  232. if gw is None:
  233. print(' [!] 找不到空闲网关端口 —— 跳过启动核验'); return 0
  234. LAST_GATEWAY_PORT = gw
  235. changed = {}
  236. if gw != base_port:
  237. changed['gateway'] = gw
  238. for k in comp_keys:
  239. want = int(cfg.get(k) or 0)
  240. if want and _busy(want):
  241. free = _free_port(28800 + 100 * (len(changed) % 5))
  242. if free is None:
  243. print(f' [!] 找不到空闲端口给组件 {k} —— 跳过启动核验'); return 0
  244. changed[k] = free
  245. if changed:
  246. cfg.update(changed)
  247. cfg_p.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding='utf-8')
  248. print(' 副本改用独立端口 (不打扰正在跑的实例): ' +
  249. ', '.join(f'{k}={v}' for k, v in changed.items()))
  250. else:
  251. print(' 主实例未在跑 → 副本直接用默认端口')
  252. t0 = time.time()
  253. rs = subprocess.run([str(py), 'guanlan.py', 'serve'], cwd=str(dst), capture_output=True, text=True, errors='replace')
  254. print(f' serve 退出码 {rs.returncode} (1 = 有模块未就绪, 属正常), 耗时 {time.time() - t0:.0f}s')
  255. ok = 0
  256. try:
  257. # ★门户期望字节数**不写死** (2026-09-16): 原来常量 20225828 是某一版门户"服务出来"的字节数,
  258. # 门户外壳一改 (例如按用户令在菜单里加「数据重算」) 它就过期, 于是核验会打印一条永远不成立的
  259. # "★与主包不同", 看起来像移植出了问题。改为**从正在跑的主实例现量**: 主实例没起就不比这一项。
  260. exp_portal = None
  261. try:
  262. with urllib.request.urlopen(f'http://127.0.0.1:{base_port}/', timeout=10) as rr:
  263. exp_portal = len(rr.read())
  264. print(f' 参照: 主实例门户 {exp_portal:,} B (端口 {base_port})')
  265. except Exception as e:
  266. print(f' 参照: 主实例 ({base_port}) 未响应 → 门户字节数不做同字节比对 ({type(e).__name__})')
  267. urls = [('/', exp_portal), ('/detail/', None), ('/cms/', None), ('/ops', None), ('/healthz', None)]
  268. for path, exp_size in urls:
  269. try:
  270. with urllib.request.urlopen(f'http://127.0.0.1:{gw}{path}', timeout=30) as rr:
  271. body = rr.read()
  272. tag = '' if exp_size is None else (' 与主包同字节' if len(body) == exp_size else f' ★与主包不同(主包 {exp_size})')
  273. if exp_size is not None and len(body) != exp_size:
  274. # 副本换了网关端口时, 门户里的绝对地址(127.0.0.1:<端口>)会被网关改写成副本自己的
  275. # 端口 ⇒ 字节数**必然**不同。这是预期, 不是移植问题 (2026-09-17 加这句, 免得看错)。
  276. tag += f' ← 副本网关端口 {gw} ≠ 主实例 {base_port}, 门户内的绝对地址被改写, 字节不同属预期'
  277. print(f' {path:10s} HTTP {rr.status} {len(body):,} B{tag}')
  278. ok += 1
  279. except Exception as e:
  280. print(f' {path:10s} 失败: {type(e).__name__} {e}')
  281. print(f' 页面可用 {ok}/{len(urls)}')
  282. finally:
  283. # 只停**副本**自己起的那些 (用副本里的 pids.json), 绝不碰主实例
  284. subprocess.run([str(py), 'guanlan.py', 'stop'], cwd=str(dst), capture_output=True)
  285. return ok
  286. def _files_of(p: pathlib.Path) -> int:
  287. """目录/文件的件数(卸载核验里"数据原样在位"的比对面)。"""
  288. if not p.exists():
  289. return -1
  290. return 1 if p.is_file() else sum(1 for _ in p.rglob('*') if _.is_file())
  291. def uninstall_probe(dst: pathlib.Path, py: pathlib.Path, gw: int | None = None) -> int:
  292. """卸载核验(用户令 2026-09-17:增加卸载脚本)——在**副本**上真跑一遍卸载器。
  293. 为什么放在开箱验证里:卸载脚本这类东西"在开发机上跑一次看着没事"不算数,它要证明的是
  294. **在刚装好的那份副本上**:① 计划齐全且 --dry-run 一个字节都不动;② 真卸载会把服务/运行态/
  295. 安装记录/快捷方式拆掉;③ **数据与产物一件不少**(默认口径不碰用户数据)。
  296. → 返回失败条数(0 = 全过)
  297. """
  298. bad = 0
  299. say = lambda s: print(' ' + s) # noqa: E731
  300. # ★ 子进程输出的编码必须点名 utf-8:卸载器写中文, Windows 上默认按 cp936 解会得到
  301. # "�ƻ���ȫ" 这种乱码(2026-09-17 实测: 核验打印成了天书, 证据就没法看了)。
  302. sub_env = dict(os.environ, PYTHONUTF8='1')
  303. r = subprocess.run([str(py), 'scripts/guanlan_uninstall.py', '--selftest'], cwd=str(dst),
  304. capture_output=True, text=True, encoding='utf-8', errors='replace',
  305. env=sub_env, timeout=600)
  306. for ln in (r.stdout or '').strip().splitlines():
  307. if '[FAIL]' in ln or '结论' in ln or '[OK]' in ln:
  308. say(ln.strip())
  309. if r.returncode != 0:
  310. bad += 1
  311. say('[X] 卸载器 --selftest 未通过')
  312. keep = [d for d in ('data', 'outputs') if (dst / d).exists()]
  313. before = {d: _files_of(dst / d) for d in keep}
  314. r2 = subprocess.run([str(py), 'scripts/guanlan_uninstall.py', '--yes'], cwd=str(dst),
  315. capture_output=True, text=True, encoding='utf-8', errors='replace',
  316. env=sub_env, timeout=900)
  317. tail = (r2.stdout or '').strip().splitlines()
  318. for ln in tail[-8:]:
  319. say(ln.rstrip())
  320. if r2.returncode != 0:
  321. bad += 1
  322. say(f'[X] 卸载器退出码 {r2.returncode}')
  323. checks = [('安装记录 install-info.json 已删', not (dst / 'install-info.json').exists()),
  324. ('运行态 run/ 已清', not (dst / 'run').exists()),
  325. ('快捷方式已撤', not list(dst.glob('*.lnk')))]
  326. for d in keep:
  327. checks.append((f'数据 {d}/ 原样在位({before[d]} 件)', _files_of(dst / d) == before[d]))
  328. if gw:
  329. checks.append((f'卸载后端口 {gw} 已释放', not _busy(gw)))
  330. for name, good in checks:
  331. say(('[OK] ' if good else '[X] ') + name)
  332. bad += 0 if good else 1
  333. return bad
  334. def simulate(into=None, keep=False) -> int:
  335. """模拟移植: 拷副本 → 副本内安装 → 起服务核验 → 清理。"""
  336. dst = pathlib.Path(into) if into else pathlib.Path(tempfile.gettempdir()) / 'guanlan_portsim'
  337. if dst.exists():
  338. print(f' 清理旧副本 {dst}'); shutil.rmtree(dst, ignore_errors=True)
  339. print(f'① 拷副本 → {dst} (排除 data/.git/.venv/暂存区/logs/run)')
  340. t0 = time.time()
  341. shutil.copytree(ROOT, dst, ignore=IGNORE, symlinks=False)
  342. n = sum(1 for _ in dst.rglob('*') if _.is_file())
  343. mb = sum(_.stat().st_size for _ in dst.rglob('*') if _.is_file()) / 1e6
  344. print(f' 完成: {n} 件 / {mb:.0f} MB, 耗时 {time.time() - t0:.0f}s')
  345. # 产物: 主包当前可能被"清除产物"挪走了 → 从暂存区补进副本, 好让页面有数可验
  346. prod_dst = dst / 'outputs' / 'rudong'
  347. if not (prod_dst / 'windscada' / 'alarms.parquet').is_file():
  348. for src in sorted(ROOT.glob('_products_off*/rudong')):
  349. if (src / 'windscada' / 'alarms.parquet').is_file():
  350. print(f'② 主包产物不在位 → 从 {src.relative_to(ROOT)} 拷进副本')
  351. shutil.copytree(src, prod_dst, dirs_exist_ok=True)
  352. break
  353. else:
  354. print('② 找不到可用产物(主包与暂存区都没有) —— 页面会是"无产物", 属预期')
  355. else:
  356. print('② 副本已带产物 (从主包拷来)')
  357. print('③ 在副本里跑安装 (离线)')
  358. py = install_in(dst)
  359. if py is None:
  360. return 1
  361. print('④ 起服务核验 (副本独占端口; 组件端口被占则跳过这一步)')
  362. ok = run_and_probe(dst, py)
  363. print('⑤ 收尾')
  364. if keep:
  365. print(f' 副本保留在 {dst} (--keep); 用完手工删掉即可')
  366. else:
  367. shutil.rmtree(dst, ignore_errors=True)
  368. print(' 副本已删除')
  369. print('\n模拟移植结论: 见上面各步 —— 安装成 + 页面与主包同字节 = 换机可行(记得在目标机也跑一次 install)')
  370. return 0
  371. def main() -> int:
  372. ap = argparse.ArgumentParser()
  373. ap.add_argument('--brief', action='store_true')
  374. ap.add_argument('--simulate', action='store_true', help='拷副本→副本内安装→起服务核验→清理')
  375. ap.add_argument('--into', default=None, help='--simulate 的副本目录 (默认 %%TEMP%%/guanlan_portsim)')
  376. ap.add_argument('--keep', action='store_true', help='--simulate 后保留副本')
  377. a = ap.parse_args()
  378. print(f'移植性大扫描 @ {ROOT}\n')
  379. if a.simulate:
  380. rc = simulate(a.into, a.keep)
  381. print()
  382. hits = scan_paths(verbose=not a.brief)
  383. big = scan_big_text()
  384. kv = check_venv()
  385. dirs = check_wheels()
  386. check_vendor()
  387. check_configs()
  388. bad = len(hits) + len(big)
  389. print(f'\n结论: {"路径层面干净" if not bad else f"{bad} 处机器相关路径要处理"};'
  390. f' 虚拟环境/轮子/便携运行时见上面各节 —— '
  391. f'"拷包就能跑"只在**同路径同基础 Python**时成立, 换机请按 README 先跑一次 install。')
  392. return 0 if not bad else 1
  393. if __name__ == '__main__':
  394. for _s in (sys.stdout, sys.stderr):
  395. try: _s.reconfigure(errors='replace')
  396. except Exception: pass
  397. sys.exit(main())