# -*- 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"""(?', '<现场包目录>', '<场站名称>', '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())