check_transferable.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  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. """
  15. from __future__ import annotations
  16. import argparse
  17. import json
  18. import os
  19. import pathlib
  20. import re
  21. import sys
  22. ROOT = pathlib.Path(__file__).resolve().parents[1]
  23. sys.path.insert(0, str(ROOT))
  24. # 机器相关绝对路径: 盘符路径 / mac 用户目录 / 家目录 / 本机用户名
  25. 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_\- ]+)""")
  26. # 扫描范围: 源码/脚本/配置/文档/外壳页面 (跳过 .venv、依赖、二进制、大体积产物里的二进制)
  27. TEXT_EXT = {'.py', '.bat', '.sh', '.ps1', '.cmd', '.json', '.md', '.txt', '.html', '.htm', '.yaml', '.yml', '.cfg', '.ini', '.toml'}
  28. SKIP_DIRS = {'.venv', '.git', '__pycache__', 'node_modules', '_products_off', 'logs', 'run'}
  29. SKIP_PATH_HINT = ('_products_off_prev',)
  30. # 允许的例外: 文档里明确在讲"路径约定"或演示用的占位
  31. ALLOW_LINE = ('portability-allow', '<安装目录>', '<现场包目录>', '<场站名称>', 'D:\\guanlan', '/opt/guanlan',
  32. 'D:/guanlan', '示例', '例:', '例如')
  33. def walk_files():
  34. for p in ROOT.rglob('*'):
  35. if not p.is_file() or p.suffix.lower() not in TEXT_EXT:
  36. continue
  37. rel = p.relative_to(ROOT).as_posix()
  38. parts = set(p.relative_to(ROOT).parts)
  39. if parts & SKIP_DIRS or any(h in rel for h in SKIP_PATH_HINT):
  40. continue
  41. if p.stat().st_size > 8 * 1024 * 1024: # 超大文本(如 20MB 门户)另算, 见下
  42. continue
  43. yield p
  44. def scan_paths(verbose=True):
  45. hits = []
  46. for p in walk_files():
  47. try:
  48. text = p.read_text(encoding='utf-8', errors='replace')
  49. except Exception:
  50. continue
  51. for i, line in enumerate(text.splitlines(), 1):
  52. if any(a in line for a in ALLOW_LINE):
  53. continue
  54. if line.lstrip().startswith(('#', 'rem ', '::')) and 'src' not in line and '--' in line:
  55. continue
  56. m = RE_ABS.search(line)
  57. if m:
  58. hits.append((p.relative_to(ROOT).as_posix(), i, m.group(0), line.strip()[:110]))
  59. print(f'① 文本文件里的机器相关绝对路径: {len(hits)} 处')
  60. if hits and verbose:
  61. for rel, i, found, line in hits[:25]:
  62. print(f' {rel}:{i} ← {found}\n {line}')
  63. if len(hits) > 25:
  64. print(f' … 另有 {len(hits) - 25} 处')
  65. return hits
  66. def scan_big_text():
  67. """大文件(门户 20MB 等)单独扫: 里面烘进的绝对路径换机后就是死链。"""
  68. out = []
  69. for p in ROOT.rglob('*.html'):
  70. rel = p.relative_to(ROOT).as_posix()
  71. if any(x in rel for x in SKIP_PATH_HINT) or '.venv' in rel or p.stat().st_size < 1024:
  72. continue
  73. try:
  74. t = p.read_text(encoding='utf-8', errors='replace')
  75. except Exception:
  76. continue
  77. n = len(RE_ABS.findall(t))
  78. if n:
  79. out.append((rel, n, p.stat().st_size))
  80. if out:
  81. print(f'② HTML 里烘进的绝对路径: {len(out)} 个文件')
  82. for rel, n, sz in out[:10]:
  83. print(f' {rel} {n} 处 ({sz/1e6:.1f} MB)')
  84. else:
  85. print('② HTML 里没有烘进的绝对路径')
  86. return out
  87. def check_venv():
  88. cfg = ROOT / '.venv' / 'pyvenv.cfg'
  89. print('③ 虚拟环境')
  90. if not cfg.is_file():
  91. print(' [--] 没有 .venv/pyvenv.cfg (还没装? 目标机上跑 install.bat / sh install.sh)')
  92. return 'absent'
  93. kv = {}
  94. for l in cfg.read_text(encoding='utf-8', errors='replace').splitlines():
  95. if '=' in l:
  96. k, v = l.split('=', 1)
  97. kv[k.strip()] = v.strip() # ★键要去空格: pyvenv.cfg 写的是 "home = D:\..." (等号前有空格)
  98. home = (kv.get('home') or '').strip()
  99. print(f' home(基础解释器) = {home}')
  100. print(f' 版本 {kv.get("version", "?").strip()}')
  101. here = pathlib.Path(home).is_dir() if home else False
  102. print(f' 本机存在该目录: {here}')
  103. print(' ★ 换台机器若没有同路径的 Python, .venv 里的解释器起不来 —— 移植时**必须在目标机重跑一次安装**'
  104. '(install.bat / sh install.sh; 离线, 用包内轮子或便携运行时)。')
  105. return kv
  106. def check_wheels():
  107. w = ROOT / 'wheels'
  108. print('④ 离线依赖轮子')
  109. if not w.is_dir():
  110. print(' [X] 没有 wheels/ 目录 —— 装依赖需要联网')
  111. return []
  112. dirs = sorted(d.name for d in w.iterdir() if d.is_dir())
  113. print(f' 平台目录: {dirs or "(无)"}')
  114. req = [l.split('#')[0].strip() for l in (ROOT / 'requirements.txt').read_text(encoding='utf-8').splitlines()]
  115. req = [r for r in req if r]
  116. names = {r.split('==')[0].lower().replace('-', '_') for r in req}
  117. for d in dirs:
  118. have = {p.name.split('-')[0].lower().replace('-', '_') for p in (w / d).glob('*.whl')}
  119. miss = sorted(n for n in names if n not in have)
  120. tag = 'OK' if not miss else f'缺 {len(miss)}: {miss[:6]}'
  121. print(f' {d}: {len(list((w / d).glob("*.whl")))} 个轮子 {tag}')
  122. plat = 'win_amd64' if os.name == 'nt' else ('linux_x86_64' if sys.platform.startswith('linux') else 'macos')
  123. if plat not in dirs:
  124. print(f' [!] 本机平台 {plat} 没有对应轮子目录: Linux/macOS 上 install.sh 会自动**改成联网安装**')
  125. return dirs
  126. def check_vendor():
  127. v = ROOT / 'vendor'
  128. print('⑤ 便携运行时 / 离线件')
  129. if not v.is_dir():
  130. print(' [--] 没有 vendor/')
  131. return
  132. for p in sorted(v.rglob('*')):
  133. if p.is_file():
  134. print(f' {p.relative_to(ROOT).as_posix()} {p.stat().st_size/1e6:.1f} MB')
  135. man = v / 'MANIFEST.json'
  136. if man.is_file():
  137. m = json.loads(man.read_text(encoding='utf-8'))
  138. files = {x.relative_to(v).as_posix() for x in v.rglob('*') if x.is_file() and x.name != 'MANIFEST.json'}
  139. undoc = sorted(files - set(m))
  140. if undoc:
  141. print(f' [!] MANIFEST.json 没登记: {undoc} (只影响校验, 不影响运行)')
  142. def check_configs():
  143. print('⑥ 配置与端口')
  144. p = ROOT / 'configs' / 'serve.json'
  145. if p.is_file():
  146. c = json.loads(p.read_text(encoding='utf-8-sig'))
  147. print(f' serve.json: host={c.get("host")} gateway={c.get("gateway")} python={c.get("python")!r} '
  148. f'raw_dir={c.get("raw_dir")!r}')
  149. for k in ('python', 'raw_dir', 'release_dir', 'viewer_dir', 'sim_dir'):
  150. v = str(c.get(k) or '')
  151. if re.match(r'^[A-Za-z]:[\\/]|^/', v):
  152. print(f' [!] {k} 是绝对路径: {v}')
  153. # 写死的本机端口/入口(允许出现, 但要知道)
  154. n_port = sum(1 for p in walk_files() if '28084' in p.read_text(encoding='utf-8', errors='replace'))
  155. print(f' 提到 28084 的文本文件: {n_port} 个 (端口真源是 configs/serve.json, 其余多为文档)')
  156. def main() -> int:
  157. ap = argparse.ArgumentParser()
  158. ap.add_argument('--brief', action='store_true')
  159. a = ap.parse_args()
  160. print(f'移植性大扫描 @ {ROOT}\n')
  161. hits = scan_paths(verbose=not a.brief)
  162. big = scan_big_text()
  163. kv = check_venv()
  164. dirs = check_wheels()
  165. check_vendor()
  166. check_configs()
  167. bad = len(hits) + len(big)
  168. print(f'\n结论: {"路径层面干净" if not bad else f"{bad} 处机器相关路径要处理"};'
  169. f' 虚拟环境/轮子/便携运行时见上面各节 —— '
  170. f'"拷包就能跑"只在**同路径同基础 Python**时成立, 换机请按 README 先跑一次 install。')
  171. return 0 if not bad else 1
  172. if __name__ == '__main__':
  173. for _s in (sys.stdout, sys.stderr):
  174. try: _s.reconfigure(errors='replace')
  175. except Exception: pass
  176. sys.exit(main())