check_transferable.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  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. import argparse
  20. import json
  21. import os
  22. import pathlib
  23. import re
  24. import shutil
  25. import socket
  26. import subprocess
  27. import sys
  28. import tempfile
  29. import time
  30. import urllib.request
  31. ROOT = pathlib.Path(__file__).resolve().parents[1]
  32. sys.path.insert(0, str(ROOT))
  33. # 机器相关绝对路径: 盘符路径 / mac 用户目录 / 家目录 / 本机用户名
  34. 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_\- ]+)""")
  35. # 扫描范围: 源码/脚本/配置/文档/外壳页面 (跳过 .venv、依赖、二进制、大体积产物里的二进制)
  36. TEXT_EXT = {'.py', '.bat', '.sh', '.ps1', '.cmd', '.json', '.md', '.txt', '.html', '.htm', '.yaml', '.yml', '.cfg', '.ini', '.toml'}
  37. SKIP_DIRS = {'.venv', '.git', '__pycache__', 'node_modules', '_products_off', 'logs', 'run'}
  38. SKIP_PATH_HINT = ('_products_off_prev',)
  39. # 扫描范围里要**排除现场原始件**: data/raw/** 是现场给的测量导出 (SCADA csv、Brande TCM JSON ——
  40. # 2026-09-12 落位后单这一个目录就是 2.5 万个 .json / 150 GB), 且**不随包分发** (pack_dist.py 的
  41. # EXCLUDE_GLOBS 含 'data')。文本闸门扫它: ①对"换机可用"零信息量 (那些文件里就算有绝对路径也不是我们的
  42. # 接线) ②每次验收要多读几十 GB 文本 (8 MB 以下的一律会读进内存), 把闸门从秒级拖成小时级。
  43. SKIP_RAW_PREFIX = ('data/raw/', 'data/_incoming/')
  44. # 允许的例外: 文档里明确在讲"路径约定"或演示用的占位
  45. ALLOW_LINE = ('portability-allow', '<安装目录>', '<现场包目录>', '<场站名称>', 'D:\\guanlan', '/opt/guanlan',
  46. 'D:/guanlan', '示例', '例:', '例如')
  47. def walk_files():
  48. for p in ROOT.rglob('*'):
  49. if not p.is_file() or p.suffix.lower() not in TEXT_EXT:
  50. continue
  51. rel = p.relative_to(ROOT).as_posix()
  52. parts = set(p.relative_to(ROOT).parts)
  53. if parts & SKIP_DIRS or any(h in rel for h in SKIP_PATH_HINT):
  54. continue
  55. if rel.startswith(SKIP_RAW_PREFIX):
  56. continue
  57. if rel.startswith(SKIP_RAW_PREFIX):
  58. continue
  59. if p.stat().st_size > 8 * 1024 * 1024: # 超大文本(如 20MB 门户)另算, 见下
  60. continue
  61. yield p
  62. def scan_paths(verbose=True):
  63. hits = []
  64. for p in walk_files():
  65. try:
  66. text = p.read_text(encoding='utf-8', errors='replace')
  67. except Exception:
  68. continue
  69. for i, line in enumerate(text.splitlines(), 1):
  70. if any(a in line for a in ALLOW_LINE):
  71. continue
  72. if line.lstrip().startswith(('#', 'rem ', '::')) and 'src' not in line and '--' in line:
  73. continue
  74. m = RE_ABS.search(line)
  75. if m:
  76. hits.append((p.relative_to(ROOT).as_posix(), i, m.group(0), line.strip()[:110]))
  77. print(f'① 文本文件里的机器相关绝对路径: {len(hits)} 处')
  78. if hits and verbose:
  79. for rel, i, found, line in hits[:25]:
  80. print(f' {rel}:{i} ← {found}\n {line}')
  81. if len(hits) > 25:
  82. print(f' … 另有 {len(hits) - 25} 处')
  83. return hits
  84. def scan_big_text():
  85. """大文件(门户 20MB 等)单独扫: 里面烘进的绝对路径换机后就是死链。"""
  86. out = []
  87. for p in ROOT.rglob('*.html'):
  88. rel = p.relative_to(ROOT).as_posix()
  89. if any(x in rel for x in SKIP_PATH_HINT) or '.venv' in rel or p.stat().st_size < 1024:
  90. continue
  91. try:
  92. t = p.read_text(encoding='utf-8', errors='replace')
  93. except Exception:
  94. continue
  95. n = len(RE_ABS.findall(t))
  96. if n:
  97. out.append((rel, n, p.stat().st_size))
  98. if out:
  99. print(f'② HTML 里烘进的绝对路径: {len(out)} 个文件')
  100. for rel, n, sz in out[:10]:
  101. print(f' {rel} {n} 处 ({sz/1e6:.1f} MB)')
  102. else:
  103. print('② HTML 里没有烘进的绝对路径')
  104. return out
  105. def check_venv():
  106. cfg = ROOT / '.venv' / 'pyvenv.cfg'
  107. print('③ 虚拟环境')
  108. if not cfg.is_file():
  109. print(' [--] 没有 .venv/pyvenv.cfg (还没装? 目标机上跑 install.bat / sh install.sh)')
  110. return 'absent'
  111. kv = {}
  112. for l in cfg.read_text(encoding='utf-8', errors='replace').splitlines():
  113. if '=' in l:
  114. k, v = l.split('=', 1)
  115. kv[k.strip()] = v.strip() # ★键要去空格: pyvenv.cfg 写的是 "home = D:\..." (等号前有空格)
  116. home = (kv.get('home') or '').strip()
  117. print(f' home(基础解释器) = {home}')
  118. print(f' 版本 {kv.get("version", "?").strip()}')
  119. here = pathlib.Path(home).is_dir() if home else False
  120. print(f' 本机存在该目录: {here}')
  121. print(' ★ 换台机器若没有同路径的 Python, .venv 里的解释器起不来 —— 移植时**必须在目标机重跑一次安装**'
  122. '(install.bat / sh install.sh; 离线, 用包内轮子或便携运行时)。')
  123. return kv
  124. def check_wheels():
  125. w = ROOT / 'wheels'
  126. print('④ 离线依赖轮子')
  127. if not w.is_dir():
  128. print(' [X] 没有 wheels/ 目录 —— 装依赖需要联网')
  129. return []
  130. dirs = sorted(d.name for d in w.iterdir() if d.is_dir())
  131. print(f' 平台目录: {dirs or "(无)"}')
  132. req = [l.split('#')[0].strip() for l in (ROOT / 'requirements.txt').read_text(encoding='utf-8').splitlines()]
  133. req = [r for r in req if r]
  134. names = {r.split('==')[0].lower().replace('-', '_') for r in req}
  135. for d in dirs:
  136. have = {p.name.split('-')[0].lower().replace('-', '_') for p in (w / d).glob('*.whl')}
  137. miss = sorted(n for n in names if n not in have)
  138. tag = 'OK' if not miss else f'缺 {len(miss)}: {miss[:6]}'
  139. print(f' {d}: {len(list((w / d).glob("*.whl")))} 个轮子 {tag}')
  140. plat = 'win_amd64' if os.name == 'nt' else ('linux_x86_64' if sys.platform.startswith('linux') else 'macos')
  141. if plat not in dirs:
  142. print(f' [!] 本机平台 {plat} 没有对应轮子目录: Linux/macOS 上 install.sh 会自动**改成联网安装**')
  143. return dirs
  144. def check_vendor():
  145. v = ROOT / 'vendor'
  146. print('⑤ 便携运行时 / 离线件')
  147. if not v.is_dir():
  148. print(' [--] 没有 vendor/')
  149. return
  150. for p in sorted(v.rglob('*')):
  151. if p.is_file():
  152. print(f' {p.relative_to(ROOT).as_posix()} {p.stat().st_size/1e6:.1f} MB')
  153. man = v / 'MANIFEST.json'
  154. if man.is_file():
  155. m = json.loads(man.read_text(encoding='utf-8'))
  156. files = {x.relative_to(v).as_posix() for x in v.rglob('*') if x.is_file() and x.name != 'MANIFEST.json'}
  157. undoc = sorted(files - set(m))
  158. if undoc:
  159. print(f' [!] MANIFEST.json 没登记: {undoc} (只影响校验, 不影响运行)')
  160. def check_configs():
  161. print('⑥ 配置与端口')
  162. p = ROOT / 'configs' / 'serve.json'
  163. if p.is_file():
  164. c = json.loads(p.read_text(encoding='utf-8-sig'))
  165. print(f' serve.json: host={c.get("host")} gateway={c.get("gateway")} python={c.get("python")!r} '
  166. f'raw_dir={c.get("raw_dir")!r}')
  167. for k in ('python', 'raw_dir', 'release_dir', 'viewer_dir', 'sim_dir'):
  168. v = str(c.get(k) or '')
  169. if re.match(r'^[A-Za-z]:[\\/]|^/', v):
  170. print(f' [!] {k} 是绝对路径: {v}')
  171. # 写死的本机端口/入口(允许出现, 但要知道)
  172. n_port = sum(1 for p in walk_files() if '28084' in p.read_text(encoding='utf-8', errors='replace'))
  173. print(f' 提到 28084 的文本文件: {n_port} 个 (端口真源是 configs/serve.json, 其余多为文档)')
  174. def _free_port(start=28700, tries=40):
  175. for p in range(start, start + tries):
  176. with socket.socket() as s:
  177. try:
  178. s.bind(('127.0.0.1', p)); return p
  179. except OSError:
  180. continue
  181. return None
  182. def _busy(port, host='127.0.0.1'):
  183. with socket.socket() as s:
  184. s.settimeout(0.3)
  185. return s.connect_ex((host, port)) == 0
  186. IGNORE = shutil.ignore_patterns('data', '.git', '.venv', '__pycache__', 'logs', 'run',
  187. '_products_off', '_products_off_prev_*', '*.pyc')
  188. def install_in(dst: pathlib.Path) -> pathlib.Path | None:
  189. """在 dst 里跑一次离线安装 (Windows: install.ps1; POSIX: install.sh), 返回该副本的解释器路径。"""
  190. print(f' 在 {dst} 里跑安装 (离线) …')
  191. # -NoDesktopShortcut: install.ps1 默认会在**桌面**建「观澜·如东样板 v2」快捷方式, 而这里是临时副本,
  192. # 建出来的快捷方式指向马上要被删掉的目录 (2026-09-16 加)。副本里的 `启动观澜.lnk` 照建 —— 那正是要核验的东西。
  193. cmd = (['powershell', '-ExecutionPolicy', 'Bypass', '-File', str(dst / 'install.ps1'), '-NoDesktopShortcut']
  194. if os.name == 'nt' else ['sh', str(dst / 'install.sh')])
  195. t0 = time.time()
  196. r = subprocess.run(cmd, cwd=str(dst))
  197. print(f' install 退出码 {r.returncode}, 耗时 {time.time() - t0:.0f}s')
  198. py = dst / ('.venv/Scripts/python.exe' if os.name == 'nt' else '.venv/bin/python')
  199. if not py.is_file():
  200. print(f' [X] 副本里没有 {py} —— 安装没成')
  201. return None
  202. v = subprocess.run([str(py), '-c', 'import sys,pandas,pyarrow,polars,yaml;print(sys.version.split()[0])'],
  203. capture_output=True, text=True, errors='replace')
  204. print(f' 副本解释器: {v.stdout.strip() or v.stderr.strip()[:120]}')
  205. return py
  206. def run_and_probe(dst: pathlib.Path, py: pathlib.Path, base_port=28084) -> int:
  207. """在副本里起服务并核验页面 —— **不动正在跑的实例**: 网关改用空闲端口, 组件端口若被占则跳过。
  208. 原先网关端口被占就整段跳过(实测踩到: 主实例在跑 → 模拟移植只做了拷贝+安装)。现在:
  209. · 组件端口(18033 等)空闲 → 用它们; 网关端口(28084)被占 → 换成空闲端口(如 28700);
  210. · 网关 ROUTES 里上游端口是写死的(文档 §三 的已知限制), 所以组件端口**必须**用默认值, 不能随网关一起改。
  211. """
  212. comp_ports = (18033, 18020, 18791, 18792, 64292)
  213. busy_comp = [p for p in comp_ports if _busy(p)]
  214. if busy_comp:
  215. print(f' [!] 组件端口被占用 {busy_comp} —— 跳过启动核验(先停掉正在跑的实例再试)')
  216. return 0
  217. gw = base_port if not _busy(base_port) else _free_port(28700)
  218. if gw is None:
  219. print(' [!] 找不到空闲网关端口 —— 跳过启动核验'); return 0
  220. if gw != base_port:
  221. cfg_p = dst / 'configs' / 'serve.json'
  222. cfg = json.loads(cfg_p.read_text(encoding='utf-8-sig'))
  223. cfg['gateway'] = gw
  224. cfg_p.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding='utf-8')
  225. print(f' 网关端口 {base_port} 已被占用 → 副本改用 {gw} (不打扰正在跑的实例)')
  226. t0 = time.time()
  227. rs = subprocess.run([str(py), 'guanlan.py', 'serve'], cwd=str(dst), capture_output=True, text=True, errors='replace')
  228. print(f' serve 退出码 {rs.returncode} (1 = 有模块未就绪, 属正常), 耗时 {time.time() - t0:.0f}s')
  229. ok = 0
  230. try:
  231. # ★门户期望字节数**不写死** (2026-09-16): 原来常量 20225828 是某一版门户"服务出来"的字节数,
  232. # 门户外壳一改 (例如按用户令在菜单里加「数据重算」) 它就过期, 于是核验会打印一条永远不成立的
  233. # "★与主包不同", 看起来像移植出了问题。改为**从正在跑的主实例现量**: 主实例没起就不比这一项。
  234. exp_portal = None
  235. try:
  236. with urllib.request.urlopen(f'http://127.0.0.1:{base_port}/', timeout=10) as rr:
  237. exp_portal = len(rr.read())
  238. print(f' 参照: 主实例门户 {exp_portal:,} B (端口 {base_port})')
  239. except Exception as e:
  240. print(f' 参照: 主实例 ({base_port}) 未响应 → 门户字节数不做同字节比对 ({type(e).__name__})')
  241. urls = [('/', exp_portal), ('/detail/', None), ('/cms/', None), ('/ops', None), ('/healthz', None)]
  242. for path, exp_size in urls:
  243. try:
  244. with urllib.request.urlopen(f'http://127.0.0.1:{gw}{path}', timeout=30) as rr:
  245. body = rr.read()
  246. tag = '' if exp_size is None else (' 与主包同字节' if len(body) == exp_size else f' ★与主包不同(主包 {exp_size})')
  247. print(f' {path:10s} HTTP {rr.status} {len(body):,} B{tag}')
  248. ok += 1
  249. except Exception as e:
  250. print(f' {path:10s} 失败: {type(e).__name__} {e}')
  251. print(f' 页面可用 {ok}/{len(urls)}')
  252. finally:
  253. subprocess.run([str(py), 'guanlan.py', 'stop'], cwd=str(dst), capture_output=True)
  254. return ok
  255. def simulate(into=None, keep=False) -> int:
  256. """模拟移植: 拷副本 → 副本内安装 → 起服务核验 → 清理。"""
  257. dst = pathlib.Path(into) if into else pathlib.Path(tempfile.gettempdir()) / 'guanlan_portsim'
  258. if dst.exists():
  259. print(f' 清理旧副本 {dst}'); shutil.rmtree(dst, ignore_errors=True)
  260. print(f'① 拷副本 → {dst} (排除 data/.git/.venv/暂存区/logs/run)')
  261. t0 = time.time()
  262. shutil.copytree(ROOT, dst, ignore=IGNORE, symlinks=False)
  263. n = sum(1 for _ in dst.rglob('*') if _.is_file())
  264. mb = sum(_.stat().st_size for _ in dst.rglob('*') if _.is_file()) / 1e6
  265. print(f' 完成: {n} 件 / {mb:.0f} MB, 耗时 {time.time() - t0:.0f}s')
  266. # 产物: 主包当前可能被"清除产物"挪走了 → 从暂存区补进副本, 好让页面有数可验
  267. prod_dst = dst / 'outputs' / 'rudong'
  268. if not (prod_dst / 'windscada' / 'alarms.parquet').is_file():
  269. for src in sorted(ROOT.glob('_products_off*/rudong')):
  270. if (src / 'windscada' / 'alarms.parquet').is_file():
  271. print(f'② 主包产物不在位 → 从 {src.relative_to(ROOT)} 拷进副本')
  272. shutil.copytree(src, prod_dst, dirs_exist_ok=True)
  273. break
  274. else:
  275. print('② 找不到可用产物(主包与暂存区都没有) —— 页面会是"无产物", 属预期')
  276. else:
  277. print('② 副本已带产物 (从主包拷来)')
  278. print('③ 在副本里跑安装 (离线)')
  279. py = install_in(dst)
  280. if py is None:
  281. return 1
  282. print('④ 起服务核验 (副本独占端口; 组件端口被占则跳过这一步)')
  283. ok = run_and_probe(dst, py)
  284. print('⑤ 收尾')
  285. if keep:
  286. print(f' 副本保留在 {dst} (--keep); 用完手工删掉即可')
  287. else:
  288. shutil.rmtree(dst, ignore_errors=True)
  289. print(' 副本已删除')
  290. print('\n模拟移植结论: 见上面各步 —— 安装成 + 页面与主包同字节 = 换机可行(记得在目标机也跑一次 install)')
  291. return 0
  292. def main() -> int:
  293. ap = argparse.ArgumentParser()
  294. ap.add_argument('--brief', action='store_true')
  295. ap.add_argument('--simulate', action='store_true', help='拷副本→副本内安装→起服务核验→清理')
  296. ap.add_argument('--into', default=None, help='--simulate 的副本目录 (默认 %%TEMP%%/guanlan_portsim)')
  297. ap.add_argument('--keep', action='store_true', help='--simulate 后保留副本')
  298. a = ap.parse_args()
  299. print(f'移植性大扫描 @ {ROOT}\n')
  300. if a.simulate:
  301. rc = simulate(a.into, a.keep)
  302. print()
  303. hits = scan_paths(verbose=not a.brief)
  304. big = scan_big_text()
  305. kv = check_venv()
  306. dirs = check_wheels()
  307. check_vendor()
  308. check_configs()
  309. bad = len(hits) + len(big)
  310. print(f'\n结论: {"路径层面干净" if not bad else f"{bad} 处机器相关路径要处理"};'
  311. f' 虚拟环境/轮子/便携运行时见上面各节 —— '
  312. f'"拷包就能跑"只在**同路径同基础 Python**时成立, 换机请按 README 先跑一次 install。')
  313. return 0 if not bad else 1
  314. if __name__ == '__main__':
  315. for _s in (sys.stdout, sys.stderr):
  316. try: _s.reconfigure(errors='replace')
  317. except Exception: pass
  318. sys.exit(main())