check_transferable.py 16 KB

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