233 lines
12 KiB
Python
233 lines
12 KiB
Python
"""Run with build Python -I -S; all ASR code/libraries/model must come from dist.
|
|
|
|
Example:
|
|
C:\\wechat_rpa\\.build-venv\\Scripts\\python.exe -I -S verify_frozen_offline_asr.py
|
|
|
|
Only PyInstaller's archive reader is bootstrapped from the build environment.
|
|
Its site-packages path is then removed before importing any tested dependency.
|
|
Inputs are the three explicitly generated synthetic-chinese test audio files.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import contextlib
|
|
import ctypes
|
|
import hashlib
|
|
import importlib
|
|
import importlib.abc
|
|
import importlib.machinery
|
|
import importlib.resources.readers
|
|
import importlib.util
|
|
import io
|
|
import json
|
|
import marshal
|
|
import os
|
|
from pathlib import Path
|
|
import socket
|
|
import sys
|
|
import time
|
|
import traceback
|
|
import types
|
|
from unittest import mock
|
|
|
|
|
|
def inside(path, root):
|
|
try:
|
|
return Path(path).resolve().is_relative_to(root.resolve())
|
|
except (ValueError, TypeError, OSError):
|
|
return False
|
|
|
|
|
|
class FrozenFinder(importlib.abc.MetaPathFinder, importlib.abc.Loader):
|
|
def __init__(self, pyz, internal, stdlib):
|
|
self.pyz, self.internal, self.stdlib = pyz, internal, stdlib
|
|
self.loaded = {}
|
|
|
|
def find_spec(self, fullname, path=None, target=None):
|
|
entry = self.pyz.toc.get(fullname)
|
|
if entry is not None:
|
|
package = entry[0] in (1, 3)
|
|
file = self.internal.joinpath(*fullname.split("."))
|
|
file = file / "__init__.py" if package else file.with_suffix(".py")
|
|
spec = importlib.util.spec_from_loader(fullname, self, origin=str(file), is_package=package)
|
|
spec.has_location = True
|
|
if package:
|
|
spec.submodule_search_locations = [str(file.parent)]
|
|
return spec
|
|
spec = importlib.machinery.PathFinder.find_spec(fullname, path, target)
|
|
if spec is not None and spec.origin not in (None, "built-in", "frozen"):
|
|
if not inside(spec.origin, self.internal) and not any(inside(spec.origin, root) for root in self.stdlib):
|
|
raise ImportError("Dependency escaped packaged/stdlib roots: " + fullname)
|
|
return None
|
|
|
|
def create_module(self, spec):
|
|
return None
|
|
|
|
def exec_module(self, module):
|
|
module.__file__ = module.__spec__.origin
|
|
if module.__spec__.submodule_search_locations is not None:
|
|
module.__path__ = list(module.__spec__.submodule_search_locations)
|
|
code = self.pyz.extract(module.__name__)
|
|
if code is None and self.pyz.toc[module.__name__][0] == 3:
|
|
return
|
|
if not isinstance(code, types.CodeType):
|
|
raise ImportError("PYZ entry is not Python code: " + module.__name__)
|
|
self.loaded[module.__name__] = hashlib.sha256(marshal.dumps(code)).hexdigest()
|
|
exec(code, module.__dict__)
|
|
|
|
def get_resource_reader(self, fullname):
|
|
path = self.internal.joinpath(*fullname.split("."), "__init__.py")
|
|
return importlib.resources.readers.FileReader(types.SimpleNamespace(path=str(path)))
|
|
|
|
|
|
def process_dlls():
|
|
kernel, psapi = ctypes.WinDLL("kernel32"), ctypes.WinDLL("psapi")
|
|
kernel.GetCurrentProcess.restype = ctypes.c_void_p
|
|
process = kernel.GetCurrentProcess()
|
|
modules = (ctypes.c_void_p * 4096)()
|
|
needed = ctypes.c_ulong()
|
|
psapi.EnumProcessModules.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p), ctypes.c_ulong, ctypes.POINTER(ctypes.c_ulong)]
|
|
psapi.GetModuleFileNameExW.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_wchar_p, ctypes.c_ulong]
|
|
if not psapi.EnumProcessModules(process, modules, ctypes.sizeof(modules), ctypes.byref(needed)):
|
|
raise RuntimeError("Cannot audit actual loaded DLL origins")
|
|
if needed.value > ctypes.sizeof(modules):
|
|
raise RuntimeError("DLL origin audit truncated")
|
|
result = []
|
|
for index in range(needed.value // ctypes.sizeof(ctypes.c_void_p)):
|
|
name = ctypes.create_unicode_buffer(32768)
|
|
if psapi.GetModuleFileNameExW(process, modules[index], name, len(name)):
|
|
result.append(name.value)
|
|
return result
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--dist", type=Path, default=Path(r"C:\wechat_rpa\dist\ZhenAI-WeCom-Assistant-v1.4.27"))
|
|
parser.add_argument("--output", type=Path, default=Path(__file__).with_name("frozen-offline-asr.json"))
|
|
args = parser.parse_args()
|
|
output = args.output.resolve()
|
|
root = Path(__file__).resolve().parent
|
|
dist = args.dist.resolve()
|
|
internal = dist / "_internal"
|
|
exe = dist / (dist.name + ".exe")
|
|
log, attempts = io.StringIO(), []
|
|
report = {"ok": False, "dist": str(dist), "python": sys.version,
|
|
"executable": sys.executable, "network_or_external_attempts": attempts,
|
|
"results": [], "input_kind": "local synthetic Chinese speech only"}
|
|
sys.dont_write_bytecode = True
|
|
started = time.monotonic()
|
|
try:
|
|
with contextlib.redirect_stdout(log), contextlib.redirect_stderr(log), contextlib.ExitStack() as stack:
|
|
if not sys.flags.isolated or not sys.flags.no_site or sys.version_info[:2] != (3, 12):
|
|
raise RuntimeError("Must launch production build Python with -I -S")
|
|
if not exe.is_file() or not internal.is_dir():
|
|
raise FileNotFoundError("Finalized 1.4.27 dist is not available")
|
|
original_path = list(sys.path)
|
|
bootstrap_site = Path(r"C:\wechat_rpa\.build-venv\Lib\site-packages")
|
|
sys.path.append(str(bootstrap_site))
|
|
from PyInstaller.archive.readers import CArchiveReader
|
|
pyz = CArchiveReader(str(exe)).open_embedded_archive("PYZ.pyz")
|
|
tested = ("voice_transcription", "numpy", "av", "pysilk", "ctranslate2", "faster_whisper", "onnxruntime", "tokenizers")
|
|
if any(name in sys.modules for name in tested):
|
|
raise RuntimeError("Tested dependency was imported before packaged loader")
|
|
if "voice_transcription" not in pyz.toc:
|
|
raise RuntimeError("voice_transcription is absent from the EXE PYZ")
|
|
bootstrap_modules = []
|
|
for name, module in tuple(sys.modules.items()):
|
|
origin = getattr(module, "__file__", None)
|
|
if origin and inside(origin, bootstrap_site) and not name.startswith("PyInstaller"):
|
|
if name != "win32ctypes" and not name.startswith("win32ctypes."):
|
|
raise RuntimeError("Unexpected archive-reader bootstrap dependency: " + name)
|
|
bootstrap_modules.append(name)
|
|
del sys.modules[name]
|
|
report["removed_bootstrap_modules"] = bootstrap_modules
|
|
stdlib = [Path(sys.base_prefix) / "Lib", Path(sys.base_prefix) / "DLLs",
|
|
Path(sys.base_prefix) / "python312.zip"]
|
|
stdlib_paths = [item for item in original_path if "site-packages" not in item.casefold()
|
|
and (any(inside(item, path) for path in stdlib) or str(item).lower().endswith("python312.zip"))]
|
|
sys.path[:] = [str(internal), str(internal / "base_library.zip"), *stdlib_paths]
|
|
sys._MEIPASS = str(internal)
|
|
sys.frozen = True
|
|
os.environ["HF_HUB_OFFLINE"] = "1"
|
|
os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
|
finder = FrozenFinder(pyz, internal, stdlib)
|
|
sys.meta_path.insert(0, finder)
|
|
for directory in (internal, internal / "ctranslate2", internal / "av.libs", internal / "numpy.libs", internal / "onnxruntime/capi"):
|
|
if directory.is_dir():
|
|
stack.enter_context(os.add_dll_directory(str(directory)))
|
|
def denied(label):
|
|
def block(*args, **kwargs):
|
|
attempts.append(label)
|
|
raise RuntimeError("Frozen offline ASR forbids " + label)
|
|
return block
|
|
for name in ("connect", "connect_ex", "sendto"):
|
|
stack.enter_context(mock.patch.object(socket.socket, name, denied("socket." + name)))
|
|
stack.enter_context(mock.patch.object(socket, "getaddrinfo", denied("DNS")))
|
|
def audit(event, values):
|
|
if event in {"subprocess.Popen", "os.system", "os.startfile", "os.startfile/2"}:
|
|
denied(event)()
|
|
sys.addaudithook(audit)
|
|
voice = importlib.import_module("voice_transcription")
|
|
if voice.__loader__ is not finder:
|
|
raise RuntimeError("Product source fallback detected")
|
|
report["voice_code_sha256"] = finder.loaded["voice_transcription"]
|
|
model = internal / "assets/asr/faster-whisper-base"
|
|
if Path(voice.default_model_dir()).resolve() != model:
|
|
raise RuntimeError("Runtime model path is not the packaged model")
|
|
recognizer = voice.OfflineRecognizer(model)
|
|
for extension in ("wav", "silk", "amr"):
|
|
path = root / ("synthetic-chinese." + extension)
|
|
if not path.is_file():
|
|
raise FileNotFoundError("Synthetic audio fixture is missing")
|
|
task_started = time.monotonic()
|
|
result = voice.transcribe_file(path, allowed_roots=[root], recognizer=recognizer)
|
|
result.update(format=extension, elapsed_seconds=round(time.monotonic() - task_started, 3))
|
|
result["meaning_verified"] = "预约" in result["text"] and any(value in result["text"] for value in ("三点", "3点"))
|
|
report["results"].append(result)
|
|
report["dependencies"] = {}
|
|
for name in tested:
|
|
module = importlib.import_module(name)
|
|
origin = str(module.__file__)
|
|
if not inside(origin, internal):
|
|
raise RuntimeError("Dependency origin outside dist: " + name)
|
|
report["dependencies"][name] = {"path": origin, "from_pyz": module.__loader__ is finder}
|
|
leaked = []
|
|
extensions = []
|
|
for name, module in tuple(sys.modules.items()):
|
|
origin = getattr(module, "__file__", None)
|
|
if not origin:
|
|
continue
|
|
if "site-packages" in str(origin).casefold() and not name.startswith("PyInstaller"):
|
|
leaked.append({"module": name, "path": origin})
|
|
if Path(origin).suffix.lower() == ".pyd" and inside(origin, internal):
|
|
extensions.append({"module": name, "path": origin})
|
|
report["site_packages_leaks"] = leaked
|
|
report["packaged_extensions"] = extensions
|
|
expected_dlls = {"ctranslate2.dll", "cudnn64_9.dll", "libiomp5md.dll"}
|
|
for relative in ("av.libs", "numpy.libs", "onnxruntime/capi"):
|
|
expected_dlls.update(file.name.casefold() for file in (internal / relative).glob("*.dll"))
|
|
dlls = [path for path in process_dlls() if Path(path).name.casefold() in expected_dlls]
|
|
report["asr_loaded_dlls"] = dlls
|
|
escaped_dlls = [path for path in dlls if not inside(path, internal)]
|
|
report["escaped_dlls"] = escaped_dlls
|
|
report["sys_path"] = list(sys.path)
|
|
if not any(Path(path).name.casefold() == "ctranslate2.dll" for path in dlls):
|
|
raise RuntimeError("Actual CTranslate2 runtime DLL was not observed")
|
|
report["ok"] = (not attempts and not leaked and not escaped_dlls and len(report["results"]) == 3
|
|
and all(row["status"] == "ready" and row["meaning_verified"] for row in report["results"]))
|
|
except BaseException:
|
|
report["error"] = traceback.format_exc()
|
|
log.write(report["error"])
|
|
report["elapsed_seconds"] = round(time.monotonic() - started, 3)
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
output.with_suffix(".log").write_text(log.getvalue(), encoding="utf-8")
|
|
print(json.dumps({"ok": report["ok"], "formats_tested": len(report["results"]),
|
|
"external_attempts": attempts, "elapsed_seconds": report["elapsed_seconds"]}))
|
|
print(str(output))
|
|
return 0 if report["ok"] else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|