|
|
@@ -0,0 +1,200 @@
|
|
|
+# -*- coding: utf-8 -*-
|
|
|
+r"""移植性大扫描 (2026-09-12) —— 换电脑/换盘/换系统之前跑它。
|
|
|
+
|
|
|
+比 `scripts/check_portability.py`(只看 .py 的路径写法) 查得更宽, 因为"能不能独立运行"还取决于:
|
|
|
+
|
|
|
+ ① **所有文件类型**里的机器相关绝对路径 (.py/.bat/.sh/.ps1/.json/.md/.txt/.html/.yaml/.cfg …)
|
|
|
+ —— 不只是源码; 文档里的示例路径写死也会误导现场, 产物里烘进去的路径换机后就是死链。
|
|
|
+ ② **虚拟环境的可移植性**: `.venv/pyvenv.cfg` 的 `home` 指向基础解释器 —— 换台没有同路径 Python 的
|
|
|
+ 机器, `.venv\Scripts\python.exe` 直接起不来(这是"拷包就能跑"最常见的误解)。
|
|
|
+ ③ **离线依赖是否为本平台备齐**: `wheels/<平台>` 有没有; 缺了就只能联网装。
|
|
|
+ ④ **便携运行时**是否在 (`vendor/python/…`), 以及 `vendor/MANIFEST.json` 有没有把它登记全。
|
|
|
+ ⑤ **配置与端口**是否相对/可改, 有没有写死本机 IP。
|
|
|
+
|
|
|
+用法:
|
|
|
+ python scripts/check_transferable.py # 全量检查 + 结论
|
|
|
+ python scripts/check_transferable.py --brief # 只报问题
|
|
|
+"""
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import argparse
|
|
|
+import json
|
|
|
+import os
|
|
|
+import pathlib
|
|
|
+import re
|
|
|
+import sys
|
|
|
+
|
|
|
+ROOT = pathlib.Path(__file__).resolve().parents[1]
|
|
|
+sys.path.insert(0, str(ROOT))
|
|
|
+
|
|
|
+# 机器相关绝对路径: 盘符路径 / mac 用户目录 / 家目录 / 本机用户名
|
|
|
+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_\- ]+)""")
|
|
|
+# 扫描范围: 源码/脚本/配置/文档/外壳页面 (跳过 .venv、依赖、二进制、大体积产物里的二进制)
|
|
|
+TEXT_EXT = {'.py', '.bat', '.sh', '.ps1', '.cmd', '.json', '.md', '.txt', '.html', '.htm', '.yaml', '.yml', '.cfg', '.ini', '.toml'}
|
|
|
+SKIP_DIRS = {'.venv', '.git', '__pycache__', 'node_modules', '_products_off', 'logs', 'run'}
|
|
|
+SKIP_PATH_HINT = ('_products_off_prev',)
|
|
|
+# 允许的例外: 文档里明确在讲"路径约定"或演示用的占位
|
|
|
+ALLOW_LINE = ('portability-allow', '<安装目录>', '<现场包目录>', '<场站名称>', 'D:\\guanlan', '/opt/guanlan',
|
|
|
+ 'D:/guanlan', '示例', '例:', '例如')
|
|
|
+
|
|
|
+
|
|
|
+def walk_files():
|
|
|
+ for p in ROOT.rglob('*'):
|
|
|
+ if not p.is_file() or p.suffix.lower() not in TEXT_EXT:
|
|
|
+ continue
|
|
|
+ rel = p.relative_to(ROOT).as_posix()
|
|
|
+ parts = set(p.relative_to(ROOT).parts)
|
|
|
+ if parts & SKIP_DIRS or any(h in rel for h in SKIP_PATH_HINT):
|
|
|
+ continue
|
|
|
+ if p.stat().st_size > 8 * 1024 * 1024: # 超大文本(如 20MB 门户)另算, 见下
|
|
|
+ continue
|
|
|
+ yield p
|
|
|
+
|
|
|
+
|
|
|
+def scan_paths(verbose=True):
|
|
|
+ hits = []
|
|
|
+ for p in walk_files():
|
|
|
+ try:
|
|
|
+ text = p.read_text(encoding='utf-8', errors='replace')
|
|
|
+ except Exception:
|
|
|
+ continue
|
|
|
+ for i, line in enumerate(text.splitlines(), 1):
|
|
|
+ if any(a in line for a in ALLOW_LINE):
|
|
|
+ continue
|
|
|
+ if line.lstrip().startswith(('#', 'rem ', '::')) and 'src' not in line and '--' in line:
|
|
|
+ continue
|
|
|
+ m = RE_ABS.search(line)
|
|
|
+ if m:
|
|
|
+ hits.append((p.relative_to(ROOT).as_posix(), i, m.group(0), line.strip()[:110]))
|
|
|
+ print(f'① 文本文件里的机器相关绝对路径: {len(hits)} 处')
|
|
|
+ if hits and verbose:
|
|
|
+ for rel, i, found, line in hits[:25]:
|
|
|
+ print(f' {rel}:{i} ← {found}\n {line}')
|
|
|
+ if len(hits) > 25:
|
|
|
+ print(f' … 另有 {len(hits) - 25} 处')
|
|
|
+ return hits
|
|
|
+
|
|
|
+
|
|
|
+def scan_big_text():
|
|
|
+ """大文件(门户 20MB 等)单独扫: 里面烘进的绝对路径换机后就是死链。"""
|
|
|
+ out = []
|
|
|
+ for p in ROOT.rglob('*.html'):
|
|
|
+ rel = p.relative_to(ROOT).as_posix()
|
|
|
+ if any(x in rel for x in SKIP_PATH_HINT) or '.venv' in rel or p.stat().st_size < 1024:
|
|
|
+ continue
|
|
|
+ try:
|
|
|
+ t = p.read_text(encoding='utf-8', errors='replace')
|
|
|
+ except Exception:
|
|
|
+ continue
|
|
|
+ n = len(RE_ABS.findall(t))
|
|
|
+ if n:
|
|
|
+ out.append((rel, n, p.stat().st_size))
|
|
|
+ if out:
|
|
|
+ print(f'② HTML 里烘进的绝对路径: {len(out)} 个文件')
|
|
|
+ for rel, n, sz in out[:10]:
|
|
|
+ print(f' {rel} {n} 处 ({sz/1e6:.1f} MB)')
|
|
|
+ else:
|
|
|
+ print('② HTML 里没有烘进的绝对路径')
|
|
|
+ return out
|
|
|
+
|
|
|
+
|
|
|
+def check_venv():
|
|
|
+ cfg = ROOT / '.venv' / 'pyvenv.cfg'
|
|
|
+ print('③ 虚拟环境')
|
|
|
+ if not cfg.is_file():
|
|
|
+ print(' [--] 没有 .venv/pyvenv.cfg (还没装? 目标机上跑 install.bat / sh install.sh)')
|
|
|
+ return 'absent'
|
|
|
+ kv = {}
|
|
|
+ for l in cfg.read_text(encoding='utf-8', errors='replace').splitlines():
|
|
|
+ if '=' in l:
|
|
|
+ k, v = l.split('=', 1)
|
|
|
+ kv[k.strip()] = v.strip() # ★键要去空格: pyvenv.cfg 写的是 "home = D:\..." (等号前有空格)
|
|
|
+ home = (kv.get('home') or '').strip()
|
|
|
+ print(f' home(基础解释器) = {home}')
|
|
|
+ print(f' 版本 {kv.get("version", "?").strip()}')
|
|
|
+ here = pathlib.Path(home).is_dir() if home else False
|
|
|
+ print(f' 本机存在该目录: {here}')
|
|
|
+ print(' ★ 换台机器若没有同路径的 Python, .venv 里的解释器起不来 —— 移植时**必须在目标机重跑一次安装**'
|
|
|
+ '(install.bat / sh install.sh; 离线, 用包内轮子或便携运行时)。')
|
|
|
+ return kv
|
|
|
+
|
|
|
+
|
|
|
+def check_wheels():
|
|
|
+ w = ROOT / 'wheels'
|
|
|
+ print('④ 离线依赖轮子')
|
|
|
+ if not w.is_dir():
|
|
|
+ print(' [X] 没有 wheels/ 目录 —— 装依赖需要联网')
|
|
|
+ return []
|
|
|
+ dirs = sorted(d.name for d in w.iterdir() if d.is_dir())
|
|
|
+ print(f' 平台目录: {dirs or "(无)"}')
|
|
|
+ req = [l.split('#')[0].strip() for l in (ROOT / 'requirements.txt').read_text(encoding='utf-8').splitlines()]
|
|
|
+ req = [r for r in req if r]
|
|
|
+ names = {r.split('==')[0].lower().replace('-', '_') for r in req}
|
|
|
+ for d in dirs:
|
|
|
+ have = {p.name.split('-')[0].lower().replace('-', '_') for p in (w / d).glob('*.whl')}
|
|
|
+ miss = sorted(n for n in names if n not in have)
|
|
|
+ tag = 'OK' if not miss else f'缺 {len(miss)}: {miss[:6]}'
|
|
|
+ print(f' {d}: {len(list((w / d).glob("*.whl")))} 个轮子 {tag}')
|
|
|
+ plat = 'win_amd64' if os.name == 'nt' else ('linux_x86_64' if sys.platform.startswith('linux') else 'macos')
|
|
|
+ if plat not in dirs:
|
|
|
+ print(f' [!] 本机平台 {plat} 没有对应轮子目录: Linux/macOS 上 install.sh 会自动**改成联网安装**')
|
|
|
+ return dirs
|
|
|
+
|
|
|
+
|
|
|
+def check_vendor():
|
|
|
+ v = ROOT / 'vendor'
|
|
|
+ print('⑤ 便携运行时 / 离线件')
|
|
|
+ if not v.is_dir():
|
|
|
+ print(' [--] 没有 vendor/')
|
|
|
+ return
|
|
|
+ for p in sorted(v.rglob('*')):
|
|
|
+ if p.is_file():
|
|
|
+ print(f' {p.relative_to(ROOT).as_posix()} {p.stat().st_size/1e6:.1f} MB')
|
|
|
+ man = v / 'MANIFEST.json'
|
|
|
+ if man.is_file():
|
|
|
+ m = json.loads(man.read_text(encoding='utf-8'))
|
|
|
+ files = {x.relative_to(v).as_posix() for x in v.rglob('*') if x.is_file() and x.name != 'MANIFEST.json'}
|
|
|
+ undoc = sorted(files - set(m))
|
|
|
+ if undoc:
|
|
|
+ print(f' [!] MANIFEST.json 没登记: {undoc} (只影响校验, 不影响运行)')
|
|
|
+
|
|
|
+
|
|
|
+def check_configs():
|
|
|
+ print('⑥ 配置与端口')
|
|
|
+ p = ROOT / 'configs' / 'serve.json'
|
|
|
+ if p.is_file():
|
|
|
+ c = json.loads(p.read_text(encoding='utf-8-sig'))
|
|
|
+ print(f' serve.json: host={c.get("host")} gateway={c.get("gateway")} python={c.get("python")!r} '
|
|
|
+ f'raw_dir={c.get("raw_dir")!r}')
|
|
|
+ for k in ('python', 'raw_dir', 'release_dir', 'viewer_dir', 'sim_dir'):
|
|
|
+ v = str(c.get(k) or '')
|
|
|
+ if re.match(r'^[A-Za-z]:[\\/]|^/', v):
|
|
|
+ print(f' [!] {k} 是绝对路径: {v}')
|
|
|
+ # 写死的本机端口/入口(允许出现, 但要知道)
|
|
|
+ n_port = sum(1 for p in walk_files() if '28084' in p.read_text(encoding='utf-8', errors='replace'))
|
|
|
+ print(f' 提到 28084 的文本文件: {n_port} 个 (端口真源是 configs/serve.json, 其余多为文档)')
|
|
|
+
|
|
|
+
|
|
|
+def main() -> int:
|
|
|
+ ap = argparse.ArgumentParser()
|
|
|
+ ap.add_argument('--brief', action='store_true')
|
|
|
+ a = ap.parse_args()
|
|
|
+ print(f'移植性大扫描 @ {ROOT}\n')
|
|
|
+ hits = scan_paths(verbose=not a.brief)
|
|
|
+ big = scan_big_text()
|
|
|
+ kv = check_venv()
|
|
|
+ dirs = check_wheels()
|
|
|
+ check_vendor()
|
|
|
+ check_configs()
|
|
|
+ bad = len(hits) + len(big)
|
|
|
+ print(f'\n结论: {"路径层面干净" if not bad else f"{bad} 处机器相关路径要处理"};'
|
|
|
+ f' 虚拟环境/轮子/便携运行时见上面各节 —— '
|
|
|
+ f'"拷包就能跑"只在**同路径同基础 Python**时成立, 换机请按 README 先跑一次 install。')
|
|
|
+ return 0 if not bad else 1
|
|
|
+
|
|
|
+
|
|
|
+if __name__ == '__main__':
|
|
|
+ for _s in (sys.stdout, sys.stderr):
|
|
|
+ try: _s.reconfigure(errors='replace')
|
|
|
+ except Exception: pass
|
|
|
+ sys.exit(main())
|