check_portability.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. r"""跨平台/相对路径 门禁 (2026-09-11 用户令) —— 换电脑、换盘、Windows↔Linux 之前先跑它。
  4. ## 三条硬规则 (ERROR = 必须修)
  5. 1. **不许出现机器相关绝对路径**: `/Users/…`、`/Volumes/…`、`/home/<某人>/…`、`C:\…`/`D:\…`/`F:\…`。
  6. 部署到另一台电脑/另一个盘时它们必然失配 —— 这类 bug 是**静默**的 (某块页面取不到数, 不报错)。
  7. 2. **不许出现 cwd 相对路径字面量**: `Path('outputs/…')`、`open('data/…')` 这类。
  8. 相对路径必须相对**安装根**, 由 `src/paths.py` 解析 (`P.store()` / `P.rel()`); 相对 cwd 会随调用位置漂移。
  9. 3. **不许用 `os.sep` 拼库存字符串**: 写进产物/清单/页面的要用 POSIX 形式 (`P.rel()`);
  10. `os.sep` 只允许出现在"给人看"的显示函数里。
  11. ## 其余是 WARN (不阻塞, 但列出来慢慢收敛)
  12. 4. 运行期仍写死 `ROOT / 'outputs/<场名>/…'` → 建议改 `src/paths.py` 的对应函数;
  13. 5. 交付侧离线工具 (`scripts/guanlan_cloud_*` 等) 里的同类写法;
  14. 6. **文本读写没写 `encoding=`** → 这是同一类"换机就坏"的坑: Python 文本 I/O 默认用**系统 locale 编码**,
  15. 中文 Windows 上是 cp936(GBK), 而本库的 JSON/HTML/索引全是 UTF-8。后果有两种, 都很隐蔽:
  16. · 读: UTF-8 文件按 GBK 解 → 中文乱码, 或直接 UnicodeDecodeError;
  17. · 写: 内容里出现 GBK 编不出的字符 (零宽空格 `\u200b`、emoji、罕见汉字) → UnicodeEncodeError 崩溃。
  18. 2026-09-11 从零重算时实逮: `src/ontology/store.py` 写 objects.json 崩在 `\u200b` 上 —— 也就是说
  19. **本体层在中文 Windows 上根本不可用**(开发机是 UTF-8 locale, 所以一直没暴露)。
  20. 本项扫三种形态: `.read_text(...)` / `.write_text(...)`、`open(..., 'w')`(文本模式)、`open(p)`(无 mode = 文本读);
  21. 二进制模式 (`'rb'`/`'wb'`) 不算。2026-09-11 已把当时存量 18 处全部清零, 新写的代码请一律 `encoding='utf-8'`。
  22. ## 例外必须**具名**登记
  23. `ALLOW` 里每一项都要写清"为什么可以例外"; 行内特例用注释 `# portability-allow: 原因`。
  24. 用法:
  25. python scripts/check_portability.py # 全量扫描 (默认扫运行期 + 交付侧)
  26. python scripts/check_portability.py --runtime # 只扫运行期 (交付门禁用这个)
  27. """
  28. from __future__ import annotations
  29. try:
  30. from app_common.app_common_guanlan.api import install_root as _install_root
  31. except ImportError: # 理论不可达;包结构异常时回退到按位置上跳
  32. from pathlib import Path as _P
  33. def _install_root(_f): return _P(_f).resolve().parents[4]
  34. import argparse
  35. import collections
  36. import pathlib
  37. import re
  38. import sys
  39. ROOT = _install_root(__file__)
  40. # 运行期代码 (服务、门禁、启动器、安装器、配置)
  41. RUNTIME = ['guanlan.py', 'install.ps1', 'install.sh']
  42. RUNTIME_GLOBS = ['src/**/*.py', 'scripts/*.py']
  43. # 交付侧 (离线工具; 不进服务路径, 但仍应相对、无机器路径)
  44. DELIVERY_GLOBS = ['scripts/sim_hub/*.py']
  45. # 机器相关绝对路径 (盘符形式要求前面是空白/引号/括号 —— 否则 'explanation:\n' 这种转义会被误判成 "n:\")
  46. RE_ABS = re.compile(r"""(/Users/[A-Za-z0-9_.\-]+|/Volumes/[A-Za-z0-9_\- ]+|/home/[a-z][A-Za-z0-9_.\-]*|(?<=[\s'"(\[=])[A-Za-z]:[\\/](?![\\/]))""")
  47. # cwd 相对路径字面量 (相对当前工作目录 —— 必须改成经 paths 解析)
  48. RE_CWD = re.compile(r"""(?:open|Path|pathlib\.Path)\(\s*['"](outputs|data|release|configs|src|scripts|reference|resources|logs|run|wheels|vendor)/""")
  49. # os.sep 出现在"拼字符串"里 (显示函数除外)
  50. RE_OSSEP = re.compile(r"""\+\s*os\.sep""")
  51. # 文本读写调用 (用于 WARN 项 6: 缺 encoding= 时按系统 locale 编解码, 中文 Windows 上是 GBK)
  52. # 三种形态都要逮: read_text/write_text · open(文本模式) · open(没给 mode = 文本读)
  53. RE_TXTIO = re.compile(r"""\.(read_text|write_text)\(|(?<![\w.])open\(""") # portability-allow: 本门禁的规则源里必须写出这些形态
  54. # 二进制模式 ('rb'/'wb'/'ab'/'r+b'…) 不需要 encoding
  55. RE_BINMODE = re.compile(r"""['"][rwa]\+?b['"]|['"][rwa]b\+?['"]""")
  56. def scan_encoding(globs, extra_files=()) -> list:
  57. """WARN: 找出没给 encoding= 的文本 I/O 调用。 # portability-allow: 说明文字即规则本身
  58. 覆盖 `.read_text(...)` / `.write_text(...)` / `open(..., 'w')` / `open(p)`(无 mode = 文本读);
  59. 二进制模式跳过。跨行调用 (如 retrieval.py 里分三行写的 write_text) 要把括号配对后再找 encoding,
  60. 所以不能只看一行。返回 [(相对路径, 行号, 片段)]。
  61. """
  62. out = []
  63. files = []
  64. for g in globs:
  65. files += sorted(ROOT.glob(g))
  66. files += [ROOT / f for f in extra_files if (ROOT / f).exists()]
  67. for f in files:
  68. rel = f.relative_to(ROOT).as_posix()
  69. # 与 ERROR 扫描同一份例外表: 具名登记过的文件整份跳过 (如本门禁自己 —— 它的规则/文档里
  70. # 必须写出 `open('data/…')`、`.read_text(...)` 这些形态, 否则规则没法表达)
  71. if rel in ALLOW or any(rel.startswith(k) for k in DELIVERY_ALLOW):
  72. continue
  73. try:
  74. text = f.read_text(encoding='utf-8')
  75. except Exception:
  76. continue
  77. for m in RE_TXTIO.finditer(text):
  78. i = m.end()
  79. depth, j = 1, i
  80. while j < len(text) and depth:
  81. if text[j] == '(':
  82. depth += 1
  83. elif text[j] == ')':
  84. depth -= 1
  85. j += 1
  86. call = text[m.start():j]
  87. if 'encoding' in call or RE_BINMODE.search(call):
  88. continue
  89. if 'portability-allow' in text[text.rfind('\n', 0, m.start()) + 1:text.find('\n', m.start())]:
  90. continue
  91. out.append((rel, text.count('\n', 0, m.start()) + 1, ' '.join(call.split())[:70]))
  92. return out
  93. ALLOW = {
  94. 'app_ontology/app_ontology_guanlan/sop/farm_paths.py': '它本身就是"win 盘符 → POSIX 盘"的映射表 (自带用例), 路径即测试数据',
  95. 'app_ontology/app_ontology_guanlan/sop/validate_pair.py': '其它场站 (华电/肥城) 的配置样例值, 非本场运行期路径',
  96. 'scripts/build_oem_lexicon.py': 'OEM 资料盘路径样例 (离线词库构建), 非运行期',
  97. 'scripts/ingest_ops_2025.py': '注释/文档里保留历史 mac 路径; 代码已改为 env + 安装目录相对默认',
  98. 'src/windscada/data_tpl_en.py': '英文映射表的**键**是页面显示形 (<安装目录>\\data\\raw\\…), 不是路径用法',
  99. 'src/windscada/deid_public.py': '文档字符串举例说明"产物里带着源文件路径"这一问题本身',
  100. 'src/windscada/maint/framework.py': '注释说明判据来源 (厂商培训分册), 非路径用法',
  101. 'app_ontology/app_ontology_guanlan/sop/discriminators.py': '证据文本里引用复跑脚本位置 (留痕), 非路径用法',
  102. 'app_ontology/app_ontology_guanlan/sop/vib_confidence.py': '证据文本 (与 discriminators 同源)',
  103. 'app_ontology/app_ontology_guanlan/ontology/retrieval.py': '文档字符串说明索引落盘位置',
  104. 'app_ontology/app_ontology_guanlan/ontology/seed_rudong29.py': '文档字符串说明产物位置',
  105. 'app_frontEnd/app_frontEnd_guanlan/pages/snapshot.py': '对外快照的**归一化改写规则** (outputs/rudong → outputs/<site>), 相对模式',
  106. 'src/windscada/fast_agent.py': '注释里的实测记录出处',
  107. 'src/paths.py': '本模块的文档字符串在讲这条约定本身',
  108. 'scripts/check_portability.py': '本门禁自己 (规则里必须写出这些模式)',
  109. 'scripts/check_transferable.py': '移植性大扫描自己 (规则里必须写出这些模式, 否则没法检出它们)',
  110. }
  111. DELIVERY_ALLOW = {
  112. 'scripts/sim_hub/': '开发期仿真合页生成器 (mac 检出路径), 不进交付/运行路径',
  113. 'outputs/rudong/sop/': '历史装配脚本随包留档 (含 mac 死路径), 非运行期; 已在文档标注',
  114. }
  115. def scan(globs, extra_files=()):
  116. hits = []
  117. files = []
  118. for g in globs:
  119. files += sorted(ROOT.glob(g))
  120. files += [ROOT / f for f in extra_files if (ROOT / f).exists()]
  121. for f in files:
  122. rel = f.relative_to(ROOT).as_posix()
  123. if rel in ALLOW or any(rel.startswith(k) for k in DELIVERY_ALLOW):
  124. continue
  125. try:
  126. lines = f.read_text(encoding='utf-8').splitlines()
  127. except Exception:
  128. continue
  129. for i, line in enumerate(lines, 1):
  130. if 'portability-allow' in line or line.lstrip().startswith('#'):
  131. continue # 行内已具名登记 / 纯注释行 (说明性文字不算路径用法)
  132. for kind, rx in (('机器绝对路径', RE_ABS), ('cwd 相对路径', RE_CWD), ('os.sep 拼串', RE_OSSEP)):
  133. m = rx.search(line)
  134. if m:
  135. hits.append((kind, rel, i, m.group(0)[:60], line.strip()[:90]))
  136. return hits
  137. def main() -> int:
  138. ap = argparse.ArgumentParser()
  139. ap.add_argument('--runtime', action='store_true', help='只扫运行期 (交付门禁)')
  140. a = ap.parse_args()
  141. print(f'根目录: {ROOT}')
  142. print(f'扫描: {"运行期" if a.runtime else "运行期 + 交付侧"}\n')
  143. errs = scan(RUNTIME_GLOBS, RUNTIME)
  144. if not a.runtime:
  145. errs += scan(DELIVERY_GLOBS)
  146. if not errs:
  147. print(' ✔ 无机器相关绝对路径、无 cwd 相对路径、未用 os.sep 拼库存字符串')
  148. else:
  149. for kind, rel, i, found, line in errs:
  150. print(f' [ERROR] {kind:12s} {rel}:{i} ← {found}')
  151. print(f' {line}')
  152. print(f'\n例外登记 (ALLOW, 共 {len(ALLOW)} 项 + {len(DELIVERY_ALLOW)} 类): 见本文件注释, 每项都写了理由')
  153. # ── WARN: 文本读写缺 encoding= (项 6; 不阻塞, 但中文 Windows 上会乱码/崩溃) ──
  154. warns = scan_encoding(RUNTIME_GLOBS, RUNTIME)
  155. if not a.runtime:
  156. warns += scan_encoding(DELIVERY_GLOBS)
  157. if warns:
  158. by_file = collections.Counter(w[0] for w in warns)
  159. print(f'\n[WARN] 文本读写缺 encoding= : {len(warns)} 处 / {len(by_file)} 个文件 '
  160. f'—— 默认按系统 locale 编解码 (中文 Windows = GBK), 换机/换内容就可能乱码或崩溃;')
  161. for rel, n in by_file.most_common(12):
  162. print(f' {n:3d} 处 {rel}')
  163. print(' 修法: read_text(encoding="utf-8") / write_text(..., encoding="utf-8")')
  164. if errs:
  165. print(f'\n结论: {len(errs)} 处 ERROR —— 换机部署前必须清零')
  166. return 1
  167. print('\n结论: 通过 ✔ (路径可移植: 只有相对路径 + 经 src/paths.py 解析)')
  168. return 0
  169. if __name__ == '__main__':
  170. # 控制台 GBK 编不出 ✔ 时降级为 '?' (见 src/console.py): 否则"通过 ✔"这行 print 会抛
  171. # UnicodeEncodeError、退出码变 1 —— 门禁报成失败而实际是通过的 (2026-09-11 踩过)。
  172. sys.path.insert(0, str(ROOT))
  173. from src import console
  174. console.soft()
  175. sys.exit(main())