| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200 |
- # -*- 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())
|