From 35199eb863d9e564d88b138a2e8cc237955a9b53 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Thu, 14 May 2026 18:18:53 +0800 Subject: [PATCH] env installation fix --- unilabos/app/utils.py | 165 ++++++++++++++++++++++++++-- unilabos/utils/environment_check.py | 11 +- 2 files changed, 162 insertions(+), 14 deletions(-) diff --git a/unilabos/app/utils.py b/unilabos/app/utils.py index f6114a13..a225e3ae 100644 --- a/unilabos/app/utils.py +++ b/unilabos/app/utils.py @@ -10,29 +10,170 @@ import shutil import sys +_PATCH_MARKER = "# UniLabOS DLL Patch" +_PATCH_END_MARKER = "# End UniLabOS DLL Patch" + +# 75 = EX_TEMPFAIL: 临时失败、重试即可,避免与业务退出码冲突 +_RESTART_EXIT_CODE = 75 + + +def _build_dll_patch(lib_bin: str, preload_pyd: str = "") -> str: + """生成一段加在目标文件顶部的 DLL 加载补丁源码。 + + - 始终把 ``lib_bin`` 加入 DLL 搜索路径,并把 handle 挂在模块属性上, + 防止 GC 清掉搜索路径(``os.add_dll_directory`` 的句柄被回收时 + 目录会被移除)。 + - 可选地用 ``ctypes.CDLL`` 预加载一个 .pyd,把它的依赖 DLL 提前装入 + 进程内存,作为 ``rclpy._rclpy_pybind11`` 这类首次加载点的兜底。 + """ + # 用 repr() 序列化路径:Python 解析 repr 的结果会还原成原始字符串, + # 不需要也不能再叠加 raw-string 前缀(叠了反而会让 \\ 变成两个反斜杠)。 + lines = [ + _PATCH_MARKER, + "import os as _ulab_os", + f"_ulab_p = {lib_bin!r}", + 'if hasattr(_ulab_os, "add_dll_directory") and _ulab_os.path.isdir(_ulab_p):', + " try: _UNILAB_DLL_HANDLE = _ulab_os.add_dll_directory(_ulab_p)", + " except Exception: _UNILAB_DLL_HANDLE = None", + ] + if preload_pyd: + lines.extend( + [ + "import ctypes as _ulab_ctypes", + f"try: _ulab_ctypes.CDLL({preload_pyd!r})", + "except Exception: pass", + ] + ) + lines.append(_PATCH_END_MARKER) + return "\n".join(lines) + "\n" + + +def _apply_dll_patch(file_path: str, lib_bin: str, preload_pyd: str = "") -> bool: + """把 DLL 补丁前置到 ``file_path``。文件不存在或已打过补丁则返回 False。""" + if not os.path.isfile(file_path): + return False + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + if _PATCH_MARKER in content: + return False + shutil.copy2(file_path, file_path + ".bak") + with open(file_path, "w", encoding="utf-8") as f: + f.write(_build_dll_patch(lib_bin, preload_pyd) + content) + return True + + +def _print_restart_banner(patched_files): + """打印重启提示并以 EX_TEMPFAIL 退出。 + + - 不使用 ANSI 颜色码:Windows 旧版 cmd / PowerShell 5 默认不开 VT 处理, + 会把 ``\\033[1;33m`` 当做字面字符显示,反而让用户看不到正文。 + - 同时写入 stderr 与 stdout:某些上层 launcher / supervisor 只重定向 + 其中一路,写两遍能保证用户至少看到一份。 + - 写入前防御性把流切到 UTF-8 with replace:``main.py`` 里已经做过一次, + 但本模块也可能被绕过 ``main.py`` 的代码路径直接 import;reconfigure + 失败也只是退回 errors=replace,不影响整体流程。 + """ + if sys.platform == "win32": + for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined] + except (AttributeError, OSError): + pass + + bar = "#" * 78 + files_lines = [f"[UniLabOS] - {p}" for p in patched_files] + body = "\n".join( + [ + "", + bar, + bar, + "##", + "## [UniLabOS] Windows + conda 下检测到 DLL 加载失败,已自动打补丁。", + "## [UniLabOS] DLL load failure detected on Windows + conda;", + "## [UniLabOS] the following files have been auto-patched:", + "##", + *[f"## {line}" for line in files_lines], + "##", + "## [UniLabOS] 当前进程的 rclpy 状态已损坏,补丁需要在新进程才生效。", + "## [UniLabOS] The current process is unusable; the patch only takes", + "## [UniLabOS] effect on a fresh process.", + "##", + "## >>> 请重新运行刚才的命令 / Please re-run the same command. <<<", + "##", + bar, + bar, + "", + ] + ) + + for stream in (sys.stderr, sys.stdout): + try: + stream.write(body) + stream.flush() + except Exception: + try: + print(body, file=stream) + except Exception: + pass + + sys.exit(_RESTART_EXIT_CODE) + + def patch_rclpy_dll_windows(): - """在 Windows + conda 环境下为 rclpy 打 DLL 加载补丁""" + """在 Windows + conda 环境下修复 rclpy / rosidl typesupport 的 DLL 加载。 + + 背景:conda 安装的 ros 系列包,其原生扩展依赖 ``$CONDA_PREFIX/Library/bin`` + 下的 DLL;只有 conda 环境被正确激活、且 PATH 中含 ``Library/bin`` 时, + ``os.add_dll_directory`` 才能找到它们。当从快捷方式 / IDE / 子进程 / + 没激活的 shell 启动 ``unilab`` 时,会出现 ``DLL load failed``。 + + 本函数会: + 1) 修补 ``rclpy/impl/implementation_singleton.py`` —— rclpy 自身的 C 扩展入口; + 2) 修补 ``rpyutils/add_dll_directories.py`` —— 所有 ``*_s__rosidl_typesupport_c.pyd`` + (``geometry_msgs`` / ``std_msgs`` / ``sensor_msgs`` 等)的统一加载入口。 + + 打完补丁后**必须重启进程**才能生效(当前进程的 rclpy 已经发生过 + ``ImportError``,子模块仍处于损坏状态)。因此函数会主动退出,并在 + stdout/stderr 同时打印明显的重启提示,避免用户被后续报错淹没。 + """ if sys.platform != "win32" or not os.environ.get("CONDA_PREFIX"): return + try: - import rclpy + import rclpy # noqa: F401 return except ImportError as e: if not str(e).startswith("DLL load failed"): return + cp = os.environ["CONDA_PREFIX"] - impl = os.path.join(cp, "Lib", "site-packages", "rclpy", "impl", "implementation_singleton.py") - pyd = glob.glob(os.path.join(cp, "Lib", "site-packages", "rclpy", "_rclpy_pybind11*.pyd")) - if not os.path.exists(impl) or not pyd: + lib_bin = os.path.join(cp, "Library", "bin") + site_packages = os.path.join(cp, "Lib", "site-packages") + if not os.path.isdir(lib_bin): return - with open(impl, "r", encoding="utf-8") as f: - content = f.read() - lib_bin = os.path.join(cp, "Library", "bin").replace("\\", "/") - patch = f'# UniLabOS DLL Patch\nimport os,ctypes\nos.add_dll_directory("{lib_bin}") if hasattr(os,"add_dll_directory") else None\ntry: ctypes.CDLL("{pyd[0].replace(chr(92),"/")}")\nexcept: pass\n# End Patch\n' - shutil.copy2(impl, impl + ".bak") - with open(impl, "w", encoding="utf-8") as f: - f.write(patch + content) + + patched = [] + + # 1) rclpy 自身的入口 + rclpy_impl = os.path.join(site_packages, "rclpy", "impl", "implementation_singleton.py") + rclpy_pyd_matches = glob.glob(os.path.join(site_packages, "rclpy", "_rclpy_pybind11*.pyd")) + rclpy_pyd = rclpy_pyd_matches[0] if rclpy_pyd_matches else "" + if rclpy_pyd and _apply_dll_patch(rclpy_impl, lib_bin, preload_pyd=rclpy_pyd): + patched.append(rclpy_impl) + + # 2) rpyutils —— 所有 rosidl typesupport pyd 的加载点;放在 rclpy 之后 + # 例:geometry_msgs/geometry_msgs_s__rosidl_typesupport_c.pyd + rpyutils_dll = os.path.join(site_packages, "rpyutils", "add_dll_directories.py") + if _apply_dll_patch(rpyutils_dll, lib_bin): + patched.append(rpyutils_dll) + + if not patched: + # 已经打过补丁但 rclpy 仍然加载失败:原因不是缺 DLL 搜索路径, + # 不要再次打补丁污染文件,让上层看到真实的 ImportError。 + return + + _print_restart_banner(patched) patch_rclpy_dll_windows() diff --git a/unilabos/utils/environment_check.py b/unilabos/utils/environment_check.py index e3631fa3..5dcff22f 100644 --- a/unilabos/utils/environment_check.py +++ b/unilabos/utils/environment_check.py @@ -47,7 +47,10 @@ def _has_uv() -> bool: def _install_command(installer: str, package: str, upgrade: bool, is_chinese: bool) -> List[str]: if installer == "uv": - cmd = ["uv", "pip", "install"] + # uv >= 0.5 默认要求虚拟环境,对 conda env 会报 "No virtual environment found"。 + # 显式 --python sys.executable 让 uv 把当前解释器(conda/venv/system 都行) + # 视为目标环境,绕开 venv 检测。 + cmd = ["uv", "pip", "install", "--python", sys.executable] if upgrade: cmd.append("--upgrade") cmd.append(package) @@ -89,7 +92,11 @@ def _print_manual_git_install_hint(requirement: str) -> None: return repo_dir = _repo_dir_name(git_url) - install_cmd = "uv pip install -e ." if _has_uv() else f"{sys.executable} -m pip install -e ." + install_cmd = ( + f'uv pip install --python "{sys.executable}" -e .' + if _has_uv() + else f"{sys.executable} -m pip install -e ." + ) if _is_chinese_locale() and not _has_uv(): install_cmd += " -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"