| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201 |
- # -*- coding: utf-8 -*-
- r"""入口脚本的"引用闭合"检查 —— 防止"包内少带一个被引用的文件"(2026-09-16 实炸)。
- ## 为什么要这个模块
- 交付包 v0.4.0 第一次打出来时, 目标机 (D:\产品\app) 双击 `start.bat` 报:
- 无法找到脚本文件 "D:\产品\app\start_hidden.vbs"
- 原因不是 start.bat 写错, 而是打包器的 `INCLUDE_FILES` 是**手工清单**: 新加根目录文件
- `start_hidden.vbs`(当时的无窗口启动器) 时忘了把它写进去, 于是包里 `start.bat` 指向一个
- 不存在的文件。手工清单这种东西**必然会漏** —— 所以这里改成**从被引用的文件反推**:
- 凡是包内入口脚本 (.bat/.ps1/.sh) 里出现的一个路径, 且该路径在**本机源码树里真实存在**,
- 它就**必须**出现在包里(或被打包器自动补进去)。
- · 本机不存在的路径 (`.venv\Scripts\pythonw.exe`, `python.exe`, `vendor\ollama\OllamaSetup.exe` 之类)
- 按"目标机安装后才有的东西"忽略 —— 它们本来就不该在包里;
- · `%~dp0xxx` 这类批处理展开写法会自动去掉 `%~dp0` 前缀再判断;
- · 只做**存在性**判断(不做内容比对): 这份检查的目的只有一个 —— 包内不留"指向空气"的入口。
- ★ 该 `.vbs` 已于 2026-09-16 按用户令删除 (无窗口启动改用 `pythonw.exe` + Python 启动器),
- 但这份检查留着: 它守的是"入口引用的文件必须齐全"这条性质, 与用什么语言实现无关。
- 单一实现, 三处共用: `scripts/pack_dist.py`(打包时 + 开箱验证)、`guanlan.py check`(装机后自检)。
- """
- from __future__ import annotations
- import pathlib
- import re
- # 包内入口脚本 (相对安装根)。这些是"用户会直接双击 / 直接敲"的东西。
- # ★ 2026-09-16 用户令"把 VBScript 替换掉"之后 `start_hidden.vbs` 已删除: 无窗口启动改由
- # `pythonw.exe` + `scripts/guanlan_start_hidden.py` 承担, 不再经过 Windows 脚本宿主。
- ENTRY_FILES = ('start.bat', 'check.bat', 'stop.bat',
- 'install.bat', 'install.ps1', 'install.sh')
- # 引用形式: 允许中文名、路径分隔符、- 与 _ ; 只要"看起来是个文件"就抓(后缀限定, 免得把 URL/域名当文件)
- _REF = re.compile(r'[A-Za-z0-9_.\u4e00-\u9fff-]+'
- r'(?:[\\/][A-Za-z0-9_.\u4e00-\u9fff-]+)*'
- r'\.(?:bat|vbs|ps1|sh|py|txt|json|yaml|yml|md|html|zip|csv|parquet)')
- _STRIP = ('dp0', 'DP0') # %~dp0 / %dp0 展开后的残前缀
- def _candidates(tok: str):
- """一个 token 可能对应的"安装根相对路径"候选 (从最具体到最宽松)。"""
- t = tok.replace('\\', '/').lstrip('./')
- cands = [t]
- for p in _STRIP:
- if t.startswith(p) and len(t) > len(p):
- cands.append(t[len(p):])
- parts = t.split('/')
- cands += ['/'.join(parts[i:]) for i in range(1, len(parts))]
- return [c for c in dict.fromkeys(cands) if c]
- def _norm(name: str) -> str:
- return name.replace('\\', '/').lstrip('./').lower()
- def entry_refs(path: pathlib.Path) -> set[str]:
- """一个入口脚本里出现的全部"像文件"的路径 token。"""
- try:
- txt = path.read_text(encoding='utf-8-sig', errors='replace')
- except OSError:
- return set()
- return {m.group(0) for m in _REF.finditer(txt)}
- def referenced_tree_files(root: pathlib.Path) -> dict[str, set[str]]:
- """→ {入口脚本: {安装根相对路径…}} —— 只留**本机源码树里真实存在**的那些。
- 本机不存在的 token 直接丢掉: 它们是"目标机安装后才有的"(venv/系统 exe)或纯噪声(`sys.exe`)。
- """
- root = pathlib.Path(root)
- out: dict[str, set[str]] = {}
- for name in ENTRY_FILES:
- p = root / name
- if not p.is_file():
- continue
- found: set[str] = set()
- for tok in entry_refs(p):
- for cand in _candidates(tok):
- if (root / cand).is_file():
- found.add(_norm(cand))
- break
- if found:
- out[name] = found
- return out
- _SKIP_WALK = {'.venv', '.git', '.github', '__pycache__', 'node_modules', 'wheels', 'vendor',
- 'outputs', 'data', 'logs', 'run'}
- def _iter_scripts(root: pathlib.Path, exts: tuple[str, ...]):
- """自带脚本文件的遍历 (跳过 .venv/node_modules/outputs 等大目录)。"""
- import os
- for dp, dn, fns in os.walk(root):
- dn[:] = [d for d in dn if d not in _SKIP_WALK]
- for fn in fns:
- if fn.lower().endswith(exts):
- yield pathlib.Path(dp) / fn
- def encoding_problems(root: pathlib.Path) -> list[str]:
- r"""入口脚本的**编码守则**检查 —— 这几条都是实机踩出来的, 违反了就是"双击/一跑即报错"。
- · `install.ps1` 必须 **UTF-8 带 BOM + CRLF**: Windows PowerShell 5.1 对没有 BOM 的 .ps1 按 ANSI
- 代码页(中文机 = GBK)解码 → 中文变乱码, 相邻的转义反引号被吞 → 引号不配对 → 级联 ParserError
- (报在看起来没问题的行上)。2026-09-16 真的发出去过一个这样的包: 开箱验证里
- `install 退出码 1, 耗时 1s`, 报的正是 `The '<' operator is reserved for future use`。
- ★ 起因很隐蔽: 编辑工具保存 .ps1 **不会替你保留 BOM** —— 在规范化之后再改一次文件, BOM 就没了。
- 所以这条必须由机器守, 不能靠"我记得"。
- · `.bat` 必须 CRLF 且**不能**带 BOM: 批处理按字节读, 行尾 LF 出怪问题; 开头 BOM 会让第一行
- (`@echo off`) 失效并被当命令执行。
- · `.sh` 必须 LF: CRLF 会让 `#!/bin/sh` 的 shebang 与每个词尾都粘上一个 `\r` ——
- POSIX 里只有空格/Tab/换行分隔词, 所以 `set -e` 变成 `set "-e\r"`(非法选项)、
- `RT=""` 变成 `RT="\r"`(后面对 `-n "$RT"` 的判断直接翻面)。
- 2026-09-16 实测发现 `install.sh` **从写出来那天起就是 CRLF**(git HEAD blob 就是 CRLF, 不是某个编辑器改的),
- 也就是说此前所有交付包的 Linux/macOS 安装脚本都是坏的。已改 LF, 并用 .gitattributes 钉住。
- """
- root = pathlib.Path(root)
- bad: list[str] = []
- for p in _iter_scripts(root, ('.ps1',)):
- b = p.read_bytes()
- rel = p.relative_to(root).as_posix()
- if b[:3] != b'\xef\xbb\xbf':
- bad.append(f'{rel} 缺 UTF-8 BOM (PS 5.1 会按 ANSI/GBK 解码 → 中文乱码 + ParserError)')
- if b.count(b'\n') != b.count(b'\r\n'):
- bad.append(f'{rel} 有裸 LF (PowerShell 脚本按 CRLF 交付)')
- for p in _iter_scripts(root, ('.bat',)):
- b = p.read_bytes()
- rel = p.relative_to(root).as_posix()
- if b.count(b'\n') != b.count(b'\r\n'):
- bad.append(f'{rel} 有裸 LF (.bat 行尾必须是 CRLF)')
- if b[:3] == b'\xef\xbb\xbf':
- bad.append(f'{rel} 带 UTF-8 BOM (.bat 不能有 BOM, 否则第一行失效)')
- for p in _iter_scripts(root, ('.sh',)):
- b = p.read_bytes()
- if b'\r\n' in b:
- bad.append(f'{p.relative_to(root).as_posix()} 含 CRLF '
- f'(POSIX shell 需要 LF: 词尾会粘 \\r, 判断与 shebang 都会出错)')
- return bad
- def missing_refs(root: pathlib.Path, provided=None) -> list[tuple[str, str]]:
- """→ [(入口脚本, 缺失的安装根相对路径)] —— provided=None 时按 root 下的真实文件判断。
- provided 给一组"包内已有条目名"(zip 的 namelist 或解压后的相对路径), 用于核对**包**而不是磁盘。
- """
- have = None if provided is None else {_norm(x.rstrip('/')) for x in provided}
- bad: list[tuple[str, str]] = []
- root = pathlib.Path(root)
- for entry, refs in sorted(referenced_tree_files(root).items()):
- for rel in sorted(refs):
- ok = (rel in have) if have is not None else (root / rel).is_file()
- if not ok:
- bad.append((entry, rel))
- return bad
- def auto_include(root: pathlib.Path, already: set[str]) -> list[pathlib.Path]:
- """被入口脚本引用、且本机存在、但不在 already 里的**根目录**文件 —— 打包器自动补进去。
- 只自动补**根目录**文件 (如曾经的 `start_hidden.vbs`); 子目录里的引用交给 INCLUDE_DIRS 管,
- 缺了会由 `missing_refs` 报出来, 但不会静默漏掉任何入口。
- """
- got = {_norm(x) for x in already}
- root = pathlib.Path(root)
- add: list[pathlib.Path] = []
- for _, refs in referenced_tree_files(root).items():
- for rel in refs:
- if '/' in rel or rel in got:
- continue
- p = root / rel
- if p.is_file():
- add.append(p)
- got.add(rel)
- return sorted(set(add), key=lambda p: p.name)
- if __name__ == '__main__': # 直接跑 = 对当前安装目录做一次闭合检查 + 编码守则检查
- import sys
- r = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else '.').resolve()
- refs = referenced_tree_files(r)
- miss = missing_refs(r)
- enc = encoding_problems(r)
- for e in sorted(refs):
- print(f' {e:18s} → ' + ', '.join(sorted(refs[e])))
- for e, rel in miss:
- print(f'[X] {e} 引用了 {rel}, 但 {r} 下没有')
- for m in enc:
- print(f'[X] 编码守则: {m}')
- if not miss:
- print(f'[OK] 入口脚本引用闭合 ({len(refs)} 个入口, '
- f'{sum(len(v) for v in refs.values())} 条引用全部在位)')
- if not enc:
- print('[OK] 入口脚本编码守则 (install.ps1 = UTF-8 BOM + CRLF; .bat = CRLF 无 BOM; .sh = LF)')
- sys.exit(1 if (miss or enc) else 0)
|