diff --git a/.conda/base-humble/recipe.yaml b/.conda/base-humble/recipe.yaml deleted file mode 100644 index c60d85fbe..000000000 --- a/.conda/base-humble/recipe.yaml +++ /dev/null @@ -1,64 +0,0 @@ -# Humble build variant of the production package. - -package: - name: unilabos - version: 0.12.2 - -source: - path: ../../unilabos - target_directory: unilabos - -build: - number: 0 - string: humble_0 - python: - entry_points: - - unilab = unilabos.app.main:main - script: - - set PIP_NO_INDEX= - - if: win - then: - - copy %RECIPE_DIR%\..\..\MANIFEST.in %SRC_DIR% - - copy %RECIPE_DIR%\..\..\setup.cfg %SRC_DIR% - - copy %RECIPE_DIR%\..\..\setup.py %SRC_DIR% - - pip install %SRC_DIR% - - if: unix - then: - - cp $RECIPE_DIR/../../MANIFEST.in $SRC_DIR - - cp $RECIPE_DIR/../../setup.cfg $SRC_DIR - - cp $RECIPE_DIR/../../setup.py $SRC_DIR - - pip install $SRC_DIR - -requirements: - host: - - python ==3.12.13 - - pip - - setuptools - - zstd - - zstandard - run: - - python >=3.12,<3.13 - - zstd - - zstandard - - networkx - - typing_extensions - - websockets - - pint - - fastapi - - jinja2 - - requests - - uvicorn - - if: not osx - then: - - opcua - - pyserial - - pandas - - pymodbus - - matplotlib - - pylibftdi - - unilabos-env ==0.12.2 humble_0 - -about: - repository: https://github.com/deepmodeling/Uni-Lab-OS - license: GPL-3.0-only - description: "UniLabOS - Production package for ROS 2 Humble and CPython 3.12" diff --git a/.conda/base/recipe.yaml b/.conda/base/recipe.yaml index ea05fe9ff..e254acb25 100644 --- a/.conda/base/recipe.yaml +++ b/.conda/base/recipe.yaml @@ -1,65 +1,72 @@ -# unilabos: Production package (depends on unilabos-env + pip unilabos) -# For production deployment - +# 默认发行包,不依赖 ROS。 package: name: unilabos - version: 0.12.2 - + version: 0.12.3 source: path: ../../unilabos target_directory: unilabos - build: number: 0 - string: jazzy_0 + string: py312_0 python: entry_points: - - unilab = unilabos.app.main:main + - unilab = unilabos.app.main:main script: - - set PIP_NO_INDEX= - - if: win - then: - - copy %RECIPE_DIR%\..\..\MANIFEST.in %SRC_DIR% - - copy %RECIPE_DIR%\..\..\setup.cfg %SRC_DIR% - - copy %RECIPE_DIR%\..\..\setup.py %SRC_DIR% - - pip install %SRC_DIR% - - if: unix - then: - - cp $RECIPE_DIR/../../MANIFEST.in $SRC_DIR - - cp $RECIPE_DIR/../../setup.cfg $SRC_DIR - - cp $RECIPE_DIR/../../setup.py $SRC_DIR - - pip install $SRC_DIR - + - if: win + then: + - copy %RECIPE_DIR%\..\..\MANIFEST.in %SRC_DIR% + - copy %RECIPE_DIR%\..\..\setup.py %SRC_DIR% + - copy %RECIPE_DIR%\..\..\pyproject.toml %SRC_DIR% + else: + - cp $RECIPE_DIR/../../MANIFEST.in $SRC_DIR + - cp $RECIPE_DIR/../../setup.py $SRC_DIR + - cp $RECIPE_DIR/../../pyproject.toml $SRC_DIR + - python -m pip install . --no-deps --no-build-isolation requirements: host: - - python ==3.12.13 - - pip - - setuptools - - zstd - - zstandard + - python ==3.12.13 + - pip + - setuptools >=68 + - wheel run: - - python >=3.12,<3.13 - - zstd - - zstandard - - networkx - - typing_extensions - - websockets - - pint - - fastapi - - jinja2 - - requests - - uvicorn - - if: not osx - then: - - opcua - - pyserial - - pandas - - pymodbus - - matplotlib - - pylibftdi - - unilabos-env ==0.12.2 jazzy_0 - + - python >=3.12,<3.13 + - pip + - setuptools >=68 + - packaging >=24 + - pyyaml >=6 + - pydantic >=2.11,<3 + - numpy >=2,<3 + - networkx >=3.2 + - typing_extensions >=4.12 + - websockets >=12 + - msgcenterpy ==0.1.8 + - orjson >=3.11 + - pint >=0.23 + - fastapi >=0.115 + - sqlmodel >=0.0.39,<0.1 + - jinja2 >=3.1.4 + - requests >=2.32 + - httpx >=0.27 + - mcp >=1.30,<2 + - uvicorn >=0.30 + - pyserial >=3.5 + - crcmod-plus >=2.3.1 + - pymodbus >=3.6.9,<4 + - pylabrobot-unilab ==0.2.2+unilabos.6285d662effa py312_0 + - opentrons-shared-data ==9.1.0+unilabos.np2.1 py312_np2_1 +tests: + - python: + imports: + - unilabos.app.main + - unilabos.server.api.app + - unilabos.backend.hostlink.local_runtime + - opentrons_shared_data.labware.labware_definition + pip_check: true + - script: + - unilab --help + - python -c "import importlib.util; assert importlib.util.find_spec('rclpy') is None" + - python -c "import importlib.metadata as m; from opentrons_shared_data.labware import labware_definition as ld; from pylabrobot.resources.opentrons.plates import corning_96_wellplate_360ul_flat; assert m.version('opentrons-shared-data') == '9.1.0+unilabos.np2.1'; assert ld.trapezoid([1, 2, 3]) == 4; assert corning_96_wellplate_360ul_flat('install_check').num_items == 96" about: repository: https://github.com/deepmodeling/Uni-Lab-OS license: GPL-3.0-only - description: "UniLabOS - Production package for ROS 2 Jazzy and CPython 3.12" + description: UniLabOS HostLink and microbackend (Python 3.12, no ROS required) diff --git a/.conda/environment-humble/recipe.yaml b/.conda/environment-humble/recipe.yaml deleted file mode 100644 index c69fda042..000000000 --- a/.conda/environment-humble/recipe.yaml +++ /dev/null @@ -1,41 +0,0 @@ -# Humble variant of unilabos-env. Keep this package in an environment that -# only enables robostack-humble; ROS distro mutexes intentionally conflict. - -package: - name: unilabos-env - version: 0.12.2 - -build: - noarch: generic - number: 0 - string: humble_0 - -requirements: - run: - - zstd - - zstandard - - conda-forge::python ==3.12.13 - - conda-forge::numpy >=2,<3 - - uni-lab::opentrons-shared-data ==9.1.0+unilabos.np2.1 py312_np2_1 - - conda-forge::opencv - - robostack-humble::ros2-distro-mutex 0.9.* humble_* - - robostack-humble::ros-humble-ros-core - - robostack-humble::ros-humble-action-msgs - - robostack-humble::ros-humble-std-msgs - - robostack-humble::ros-humble-geometry-msgs - - robostack-humble::ros-humble-control-msgs - - robostack-humble::ros-humble-nav2-msgs - - robostack-humble::ros-humble-cv-bridge - - robostack-humble::ros-humble-vision-opencv - - robostack-humble::ros-humble-tf-transformations - - robostack-humble::ros-humble-moveit-msgs - - robostack-humble::ros-humble-tf2-ros - - robostack-humble::ros-humble-tf2-ros-py - - conda-forge::transforms3d - - conda-forge::uv - - uni-lab::ros-humble-unilabos-msgs ==0.12.1 - -about: - repository: https://github.com/deepmodeling/Uni-Lab-OS - license: GPL-3.0-only - description: "UniLabOS Environment - ROS 2 Humble, CPython 3.12, and NumPy 2" diff --git a/.conda/environment/recipe.yaml b/.conda/environment/recipe.yaml deleted file mode 100644 index 1e622cf45..000000000 --- a/.conda/environment/recipe.yaml +++ /dev/null @@ -1,44 +0,0 @@ -# unilabos-env: conda environment dependencies (ROS2 + conda packages) - -package: - name: unilabos-env - version: 0.12.2 - -build: - noarch: generic - number: 0 - string: jazzy_0 - -requirements: - run: - # Python - - zstd - - zstandard - - conda-forge::python ==3.12.13 - - conda-forge::numpy >=2,<3 - - uni-lab::opentrons-shared-data ==9.1.0+unilabos.np2.1 py312_np2_1 - - conda-forge::opencv - # ROS2 dependencies (from ci-check.yml) - - robostack-jazzy::ros2-distro-mutex 0.15.* jazzy_* - - robostack-jazzy::ros-jazzy-ros-core - - robostack-jazzy::ros-jazzy-action-msgs - - robostack-jazzy::ros-jazzy-std-msgs - - robostack-jazzy::ros-jazzy-geometry-msgs - - robostack-jazzy::ros-jazzy-control-msgs - - robostack-jazzy::ros-jazzy-nav2-msgs - - robostack-jazzy::ros-jazzy-cv-bridge - - robostack-jazzy::ros-jazzy-vision-opencv - - robostack-jazzy::ros-jazzy-tf-transformations - - robostack-jazzy::ros-jazzy-moveit-msgs - - robostack-jazzy::ros-jazzy-tf2-ros - - robostack-jazzy::ros-jazzy-tf2-ros-py - - conda-forge::transforms3d - - conda-forge::uv - - # UniLabOS custom messages - - uni-lab::ros-jazzy-unilabos-msgs ==0.12.1 - -about: - repository: https://github.com/deepmodeling/Uni-Lab-OS - license: GPL-3.0-only - description: "UniLabOS Environment - ROS 2 Jazzy, CPython 3.12, and conda dependencies" diff --git a/.conda/full-humble/recipe.yaml b/.conda/full-humble/recipe.yaml index 422f33d78..6eb72c882 100644 --- a/.conda/full-humble/recipe.yaml +++ b/.conda/full-humble/recipe.yaml @@ -2,34 +2,79 @@ package: name: unilabos-full - version: 0.12.2 + version: 0.12.3 build: - noarch: generic number: 0 string: humble_0 requirements: run: - - unilabos ==0.12.2 humble_0 - - sphinx - - sphinx_rtd_theme + - unilabos-ros2 ==0.12.3 humble_0 + - sphinx >=7 + - sphinx_rtd_theme >=2 + - myst-parser >=2 + - sphinxcontrib-mermaid + - myst-nb >=1 + - sphinx-copybutton >=0.5 + - sphinx-autobuild >=2024.2.4 + - rinohtype >=0.5.4 + - rinoh-typeface-dejavuserif + - rinoh-typeface-texgyrecursor + - rinoh-typeface-texgyreheros + - rinoh-typeface-texgyrepagella + - sphinx-simplepdf >=1.6 + - opcua >=0.98.13 + - pandas >=2.2 + - matplotlib-base >=3.8 + - pylibftdi >=0.22 + - pprp >=0.2.7 + - if: win + then: pyautogui >=0.9.54 + - if: win + then: pywinauto >=0.6.8 - gradio - flask - - ipython + - ipython >=8 + - jupyterlab >=4 + - pytest >=8 + - pytest-asyncio >=0.23 + - python-build >=1.2 + - ruff >=0.9 - jupyter - jupyros - colcon-common-extensions - - robostack-humble::ros-humble-desktop-full - - robostack-humble::ros-humble-navigation2 - - robostack-humble::ros-humble-ros2-control - - robostack-humble::ros-humble-robot-state-publisher - - robostack-humble::ros-humble-joint-state-publisher - - robostack-humble::ros-humble-moveit - - robostack-humble::ros-humble-moveit-servo - - robostack-humble::ros-humble-simulation + - colcon-notification + - ros-humble-desktop-full + - ros-humble-navigation2 + - ros-humble-ros2-control + - ros-humble-robot-state-publisher + - ros-humble-joint-state-publisher + - ros-humble-moveit + - ros-humble-moveit-servo + - ros-humble-simulation +tests: + - python: + imports: + - rclpy + - unilabos_msgs.action + - sphinx + - myst_parser + - pytest + - IPython + - jupyterlab + - opcua + pip_check: true + - script: + # 与 Jazzy 相同的 Windows 隔离用户目录初始化,字体导入测试不跳过。 + - if: win + then: + - if not exist "%USERPROFILE%\AppData\Local" mkdir "%USERPROFILE%\AppData\Local" + - if not exist "%USERPROFILE%\AppData\Roaming" mkdir "%USERPROFILE%\AppData\Roaming" + - python -c "import rinoh_typeface_dejavuserif, rinoh_typeface_texgyrecursor, rinoh_typeface_texgyreheros, rinoh_typeface_texgyrepagella" + - python -c "from importlib.metadata import requires, version; from packaging.requirements import Requirement; reqs = [Requirement(r) for r in requires('unilabos')]; missing = [str(r) for r in reqs if (r.marker is None or r.marker.evaluate({'extra':'full'})) and not r.specifier.contains(version(r.name))]; assert not missing, missing" about: repository: https://github.com/deepmodeling/Uni-Lab-OS license: GPL-3.0-only - description: "UniLabOS Full - ROS 2 Humble Desktop, MoveIt, Navigation2, Gazebo, and Jupyter" + description: "UniLabOS Full - runtime, docs, tests, development and ROS 2 Humble Desktop" diff --git a/.conda/full/recipe.yaml b/.conda/full/recipe.yaml index b354820c1..f7e78d9a1 100644 --- a/.conda/full/recipe.yaml +++ b/.conda/full/recipe.yaml @@ -3,42 +3,89 @@ package: name: unilabos-full - version: 0.12.2 + version: 0.12.3 build: - noarch: generic number: 0 string: jazzy_0 requirements: run: - # Base unilabos package (includes unilabos-env) - - unilabos ==0.12.2 jazzy_0 + # 完整 ROS2 运行时,默认依赖直接由 unilabos 声明,不再发布 unilabos-env。 + - unilabos-ros2 ==0.12.3 jazzy_0 # Documentation tools - - sphinx - - sphinx_rtd_theme + - sphinx >=7 + - sphinx_rtd_theme >=2 + - myst-parser >=2 + - sphinxcontrib-mermaid + - myst-nb >=1 + - sphinx-copybutton >=0.5 + - sphinx-autobuild >=2024.2.4 + - rinohtype >=0.5.4 + # 上游 Conda 元数据漏列字体依赖;由本发布链构建原始字体发行包。 + - rinoh-typeface-dejavuserif + - rinoh-typeface-texgyrecursor + - rinoh-typeface-texgyreheros + - rinoh-typeface-texgyrepagella + - sphinx-simplepdf >=1.6 + # 内置驱动 Python SDK + - opcua >=0.98.13 + - pandas >=2.2 + - matplotlib-base >=3.8 + - pylibftdi >=0.22 + - pprp >=0.2.7 + - if: win + then: pyautogui >=0.9.54 + - if: win + then: pywinauto >=0.6.8 # Web UI - gradio - flask # Interactive development - - ipython + - ipython >=8 + - jupyterlab >=4 + - pytest >=8 + - pytest-asyncio >=0.23 + - python-build >=1.2 + - ruff >=0.9 - jupyter - jupyros - colcon-common-extensions + - colcon-notification # ROS2 full desktop (includes rviz2, gazebo, etc.) - - robostack-jazzy::ros-jazzy-desktop-full + - ros-jazzy-desktop-full # Navigation and motion control - - robostack-jazzy::ros-jazzy-navigation2 - - robostack-jazzy::ros-jazzy-ros2-control - - robostack-jazzy::ros-jazzy-robot-state-publisher - - robostack-jazzy::ros-jazzy-joint-state-publisher + - ros-jazzy-navigation2 + - ros-jazzy-ros2-control + - ros-jazzy-robot-state-publisher + - ros-jazzy-joint-state-publisher # MoveIt motion planning - - robostack-jazzy::ros-jazzy-moveit - - robostack-jazzy::ros-jazzy-moveit-servo + - ros-jazzy-moveit + - ros-jazzy-moveit-servo # Simulation - - robostack-jazzy::ros-jazzy-simulation + - ros-jazzy-simulation +tests: + - python: + imports: + - rclpy + - unilabos_msgs.action + - sphinx + - myst_parser + - pytest + - IPython + - jupyterlab + - opcua + pip_check: true + - script: + # 构建器重定向 USERPROFILE,但不会创建 Windows 已知文件夹;不触碰真实用户目录。 + - if: win + then: + - if not exist "%USERPROFILE%\AppData\Local" mkdir "%USERPROFILE%\AppData\Local" + - if not exist "%USERPROFILE%\AppData\Roaming" mkdir "%USERPROFILE%\AppData\Roaming" + - python -c "import rinoh_typeface_dejavuserif, rinoh_typeface_texgyrecursor, rinoh_typeface_texgyreheros, rinoh_typeface_texgyrepagella" + - python -c "from importlib.metadata import requires, version; from packaging.requirements import Requirement; reqs = [Requirement(r) for r in requires('unilabos')]; missing = [str(r) for r in reqs if (r.marker is None or r.marker.evaluate({'extra':'full'})) and not r.specifier.contains(version(r.name))]; assert not missing, missing" about: repository: https://github.com/deepmodeling/Uni-Lab-OS license: GPL-3.0-only - description: "UniLabOS Full - ROS 2 Jazzy Desktop, MoveIt, Navigation2, Gazebo, and Jupyter" + description: "UniLabOS Full - runtime, docs, tests, development and ROS 2 Jazzy Desktop" diff --git a/.conda/mcp/recipe.yaml b/.conda/mcp/recipe.yaml new file mode 100644 index 000000000..67cf2c372 --- /dev/null +++ b/.conda/mcp/recipe.yaml @@ -0,0 +1,44 @@ +# conda-forge 的 1.x 尚无 1.30;保留既有安全基线,不降低版本或跳到不兼容的 2.x。 +package: + name: mcp + version: 1.30.0 +source: + url: https://files.pythonhosted.org/packages/ba/93/0142dc84a666daf8ad51a34268f34c12fd6fda4f3810c4be2504eecc8212/mcp-1.30.0.tar.gz + sha256: 445414625fce5c295faa505bb11bacece661ab6f4028d57c935db57820b7a3e4 +build: + number: 0 + string: py312_0 + script: python -m pip install . --no-deps --no-build-isolation +requirements: + host: + - python >=3.12,<3.13 + - pip + - hatchling + - uv-dynamic-versioning + run: + - python >=3.12,<3.13 + - anyio >=4.5 + - httpx-sse >=0.4 + - httpx >=0.27.1,<1 + - jsonschema >=4.20 + - pydantic-settings >=2.5.2 + - pydantic >=2.11,<3 + - pyjwt >=2.10.1 + - cryptography + - python-multipart >=0.0.9 + - if: win + then: pywin32 >=310 + - sse-starlette >=1.6.1 + - starlette >=0.27 + - typing_extensions >=4.9 + - typing-inspection >=0.4.1 + - uvicorn >=0.31.1 +tests: + - python: + imports: + - mcp.server.streamable_http_manager + - mcp.server.transport_security +about: + license: MIT + license_file: LICENSE + summary: Model Context Protocol SDK, maintained 1.x line for UniLabOS diff --git a/.conda/msgcenterpy/recipe.yaml b/.conda/msgcenterpy/recipe.yaml new file mode 100644 index 000000000..2fb275330 --- /dev/null +++ b/.conda/msgcenterpy/recipe.yaml @@ -0,0 +1,28 @@ +package: + name: msgcenterpy + version: 0.1.8 +source: + url: https://files.pythonhosted.org/packages/86/b8/24eb4cb85f435d7bbc2cfb50c1c2fcb1cd225f7a5df5d953a684091c6dad/msgcenterpy-0.1.8.tar.gz + sha256: 802aba5767298de43218b329b7a1fd3ccfe67904ecf6bab1905409caf75dae86 +build: + noarch: python + number: 0 + script: python -m pip install . --no-deps --no-build-isolation +requirements: + host: + - python >=3.12,<3.13 + - pip + - setuptools >=68 + - wheel + run: + - python >=3.12,<3.13 + - pydantic >=2 + - jsonschema >=4.25 + - pyyaml >=6 +tests: + - python: + imports: + - msgcenterpy.instances.json_schema_instance +about: + license: Apache-2.0 + summary: Backend-neutral message schema conversion without ROS extras diff --git a/.conda/opcua/recipe.yaml b/.conda/opcua/recipe.yaml new file mode 100644 index 000000000..abf6fe788 --- /dev/null +++ b/.conda/opcua/recipe.yaml @@ -0,0 +1,50 @@ +# 上游为纯 Python SDK;公共 channel 缺 osx-arm64,统一构建 noarch,保留原始源码。 +package: + name: opcua + version: 0.98.13 +source: + - url: https://files.pythonhosted.org/packages/77/b1/5788cf02d4527ec816998ecd8b066242877f1b1ca6fcc07d7d4c0317fd04/opcua-0.98.13.tar.gz + sha256: 3352f30b5fed863146a82778aaf09faa5feafcb9dd446a4f49ff34c0c3ebbde6 + # PyPI sdist 漏装许可文件;从同一版本的固定上游提交补入。 + - url: https://raw.githubusercontent.com/FreeOpcUa/python-opcua/bf734dfc00e96b3ba1c184c5f511589fa183166b/COPYING + sha256: da7eabb7bafdf7d3ae5e9f223aa5bdc1eece45ac569dc21b3b037520b4464768 +build: + noarch: python + number: 0 + script: python -m pip install . --no-deps --no-build-isolation + python: + entry_points: + - uaread = opcua.tools:uaread + - uals = opcua.tools:uals + - uabrowse = opcua.tools:uals + - uawrite = opcua.tools:uawrite + - uasubscribe = opcua.tools:uasubscribe + - uahistoryread = opcua.tools:uahistoryread + - uaclient = opcua.tools:uaclient + - uaserver = opcua.tools:uaserver + - uadiscover = opcua.tools:uadiscover + - uacall = opcua.tools:uacall +requirements: + host: + - python >=3.12,<3.13 + - pip + - setuptools >=68 + - wheel + run: + - python >=3.12,<3.13 + - lxml + - python-dateutil + - pytz + - cryptography +tests: + - python: + imports: + - opcua + pip_check: true + - script: + - uaread --help +about: + homepage: https://github.com/FreeOpcUa/python-opcua + license: LGPL-3.0-or-later + license_file: COPYING + summary: Pure Python OPC-UA client and server for all supported platforms diff --git a/.conda/pprp/recipe.yaml b/.conda/pprp/recipe.yaml new file mode 100644 index 000000000..12a02444a --- /dev/null +++ b/.conda/pprp/recipe.yaml @@ -0,0 +1,31 @@ +# full 内置驱动需要的纯 Python SDK;公共 Conda channel 暂无此包。 +package: + name: pprp + version: 0.2.7 +source: + url: https://files.pythonhosted.org/packages/7d/8a/6bcc32e0f6cf0a9e0132230cb41652bd3df3dbdb0e673e31b720b6111deb/pprp-0.2.7.tar.gz + sha256: d9e76779cc52b0938dbc7f5727a195dc4c6075c3eb504025611a59b7636b930d +build: + noarch: python + number: 0 + script: python -m pip install . --no-deps --no-build-isolation +requirements: + host: + - python >=3.12,<3.13 + - pip + - setuptools >=68 + - wheel + run: + - python >=3.12,<3.13 + # 上游包把测试工具声明成必需依赖,保留原始元数据,不用 --no-deps 绕过。 + - coverage + - nose +tests: + - python: + imports: + - pprp + pip_check: true +about: + homepage: https://github.com/dsoprea/RijndaelPbkdf + license: GPL-2.0-only + summary: Pure Python Rijndael and PBKDF2 library for optional device drivers diff --git a/.conda/pylabrobot/opentrons-dependency.patch b/.conda/pylabrobot/opentrons-dependency.patch new file mode 100644 index 000000000..99fc11607 --- /dev/null +++ b/.conda/pylabrobot/opentrons-dependency.patch @@ -0,0 +1,16 @@ +diff --git a/pyproject.toml b/pyproject.toml +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -9,7 +9,11 @@ + requires-python = ">=3.9" + license = {text = "MIT"} + dynamic = ["version"] +-dependencies = ["typing_extensions", "websockets"] ++dependencies = [ ++ "typing_extensions", ++ "websockets", ++ "opentrons-shared-data==9.1.0+unilabos.np2.1", ++] + + [project.optional-dependencies] + serial = ["pyserial"] diff --git a/.conda/pylabrobot/recipe.yaml b/.conda/pylabrobot/recipe.yaml new file mode 100644 index 000000000..d6c118f28 --- /dev/null +++ b/.conda/pylabrobot/recipe.yaml @@ -0,0 +1,37 @@ +# 与配套 wheel 共用提交和版本补丁,pip check 也能识别 fork,不能复用旧构建坐标。 +package: + name: pylabrobot-unilab + version: 0.2.2+unilabos.6285d662effa +source: + git: https://github.com/Xuwznln/pylabrobot.git + rev: 6285d662effa972be97a781ba7493553f2ba94ee + patches: + - unilab-version.patch + - opentrons-dependency.patch +build: + noarch: python + number: 0 + string: py312_0 + script: python -m pip install . --no-deps --no-build-isolation +requirements: + host: + - python >=3.12,<3.13 + - pip + - setuptools >=68 + - wheel + run: + - python >=3.12,<3.13 + - typing_extensions + - websockets + - opentrons-shared-data ==9.1.0+unilabos.np2.1 py312_np2_1 +tests: + - python: + imports: + - pylabrobot.resources + pip_check: true + - script: + - python -c "import importlib.metadata as m; assert m.version('pylabrobot') == '0.2.2+unilabos.6285d662effa'" + - python -c "import numpy as np; from opentrons_shared_data.labware import labware_definition as ld; from pylabrobot.resources.opentrons.plates import corning_96_wellplate_360ul_flat; assert int(np.__version__.split('.')[0]) == 2; assert ld.trapezoid([1, 2, 3]) == 4; assert corning_96_wellplate_360ul_flat('conda_check').num_items == 96" +about: + license: MIT + summary: Pinned UniLabOS PyLabRobot resource integration diff --git a/.conda/pylabrobot/unilab-version.patch b/.conda/pylabrobot/unilab-version.patch new file mode 100644 index 000000000..60ea17c32 --- /dev/null +++ b/.conda/pylabrobot/unilab-version.patch @@ -0,0 +1,6 @@ +diff --git a/pylabrobot/version.txt b/pylabrobot/version.txt +--- a/pylabrobot/version.txt ++++ b/pylabrobot/version.txt +@@ -1 +1 @@ +-0.2.2 ++0.2.2+unilabos.6285d662effa diff --git a/.conda/rinoh-typeface-dejavuserif/recipe.yaml b/.conda/rinoh-typeface-dejavuserif/recipe.yaml new file mode 100644 index 000000000..26a4321f4 --- /dev/null +++ b/.conda/rinoh-typeface-dejavuserif/recipe.yaml @@ -0,0 +1,29 @@ +# 保留上游发行包及字体许可;full 集成测试会导入全部字体并执行 pip check。 +package: + name: rinoh-typeface-dejavuserif + version: 0.1.3 +source: + url: https://files.pythonhosted.org/packages/ca/d1/e221568b41d6205356ffa6340dcee02acf86d92dec5e5b04798c141b3472/rinoh-typeface-dejavuserif-0.1.3.tar.gz + sha256: 8be129230ac98ab2ebfbf5b570575052ba7069e4087ce36a2d4c1d85182833ce +build: + noarch: python + number: 0 + script: python -m pip install . --no-deps --no-build-isolation +requirements: + host: + - python >=3.12,<3.13 + - pip + - setuptools >=68 + - wheel + run: + - python >=3.12,<3.13 + - rinohtype >=0.5.4 +tests: + # 单个字体构建时其余字体尚未构建;这里验证字体数据和入口,full 再验证完整依赖闭环。 + - script: + - python -c "from importlib.metadata import distribution; d = distribution('rinoh-typeface-dejavuserif'); assert d.version == '0.1.3'; assert any(str(p).endswith('.ttf') and d.locate_file(p).is_file() for p in d.files); assert any(p.group == 'rinoh.typefaces' for p in d.entry_points)" +about: + homepage: https://github.com/brechtm/rinoh-typeface-dejavuserif + license: LicenseRef-DejaVu + license_file: rinoh_typeface_dejavuserif/LICENSE + summary: Original dejavuserif font package for the complete documentation environment diff --git a/.conda/rinoh-typeface-texgyrecursor/recipe.yaml b/.conda/rinoh-typeface-texgyrecursor/recipe.yaml new file mode 100644 index 000000000..e99ceef8b --- /dev/null +++ b/.conda/rinoh-typeface-texgyrecursor/recipe.yaml @@ -0,0 +1,29 @@ +# 保留上游发行包及字体许可;full 集成测试会导入全部字体并执行 pip check。 +package: + name: rinoh-typeface-texgyrecursor + version: 0.1.1 +source: + url: https://files.pythonhosted.org/packages/68/70/b0a661118e7a93d1abf4375543644b054bb74b258924d62dd898b9860a71/rinoh-typeface-texgyrecursor-0.1.1.tar.gz + sha256: 076f7dbbd0201b0d44f4c77e4f7070474e1365152dc8509b5cded04c1648308a +build: + noarch: python + number: 0 + script: python -m pip install . --no-deps --no-build-isolation +requirements: + host: + - python >=3.12,<3.13 + - pip + - setuptools >=68 + - wheel + run: + - python >=3.12,<3.13 + - rinohtype >=0.5.4 +tests: + # 单个字体构建时其余字体尚未构建;这里验证字体数据和入口,full 再验证完整依赖闭环。 + - script: + - python -c "from importlib.metadata import distribution; d = distribution('rinoh-typeface-texgyrecursor'); assert d.version == '0.1.1'; assert any(str(p).endswith('.otf') and d.locate_file(p).is_file() for p in d.files); assert any(p.group == 'rinoh.typefaces' for p in d.entry_points)" +about: + homepage: https://github.com/brechtm/rinoh-typeface-texgyrecursor + license: LicenseRef-GUST-Font-License + license_file: rinoh_typeface_texgyrecursor/GUST-FONT-LICENSE.txt + summary: Original texgyrecursor font package for the complete documentation environment diff --git a/.conda/rinoh-typeface-texgyreheros/recipe.yaml b/.conda/rinoh-typeface-texgyreheros/recipe.yaml new file mode 100644 index 000000000..b97d50aa8 --- /dev/null +++ b/.conda/rinoh-typeface-texgyreheros/recipe.yaml @@ -0,0 +1,29 @@ +# 保留上游发行包及字体许可;full 集成测试会导入全部字体并执行 pip check。 +package: + name: rinoh-typeface-texgyreheros + version: 0.1.1 +source: + url: https://files.pythonhosted.org/packages/8e/62/3b28c30558fd53590797c455bb46ab47599c2b9145a09e7ae162dd625426/rinoh-typeface-texgyreheros-0.1.1.tar.gz + sha256: fd7082e917bccc292894447f11d15a3efce9f1386056118b57b66cc2f5a36bbd +build: + noarch: python + number: 0 + script: python -m pip install . --no-deps --no-build-isolation +requirements: + host: + - python >=3.12,<3.13 + - pip + - setuptools >=68 + - wheel + run: + - python >=3.12,<3.13 + - rinohtype >=0.5.4 +tests: + # 单个字体构建时其余字体尚未构建;这里验证字体数据和入口,full 再验证完整依赖闭环。 + - script: + - python -c "from importlib.metadata import distribution; d = distribution('rinoh-typeface-texgyreheros'); assert d.version == '0.1.1'; assert any(str(p).endswith('.otf') and d.locate_file(p).is_file() for p in d.files); assert any(p.group == 'rinoh.typefaces' for p in d.entry_points)" +about: + homepage: https://github.com/brechtm/rinoh-typeface-texgyreheros + license: LicenseRef-GUST-Font-License + license_file: rinoh_typeface_texgyreheros/GUST-FONT-LICENSE.txt + summary: Original texgyreheros font package for the complete documentation environment diff --git a/.conda/rinoh-typeface-texgyrepagella/recipe.yaml b/.conda/rinoh-typeface-texgyrepagella/recipe.yaml new file mode 100644 index 000000000..090308783 --- /dev/null +++ b/.conda/rinoh-typeface-texgyrepagella/recipe.yaml @@ -0,0 +1,29 @@ +# 保留上游发行包及字体许可;full 集成测试会导入全部字体并执行 pip check。 +package: + name: rinoh-typeface-texgyrepagella + version: 0.1.1 +source: + url: https://files.pythonhosted.org/packages/6a/2a/86db888c244f11bb0f6fed72c75b09dba9dbf8aa72e7b69c27cf7975340f/rinoh-typeface-texgyrepagella-0.1.1.tar.gz + sha256: 53f4dba338c6b1df758888f23ce1ed728e5be45746f161488ad3b944e5e79fd2 +build: + noarch: python + number: 0 + script: python -m pip install . --no-deps --no-build-isolation +requirements: + host: + - python >=3.12,<3.13 + - pip + - setuptools >=68 + - wheel + run: + - python >=3.12,<3.13 + - rinohtype >=0.5.4 +tests: + # 单个字体构建时其余字体尚未构建;这里验证字体数据和入口,full 再验证完整依赖闭环。 + - script: + - python -c "from importlib.metadata import distribution; d = distribution('rinoh-typeface-texgyrepagella'); assert d.version == '0.1.1'; assert any(str(p).endswith('.otf') and d.locate_file(p).is_file() for p in d.files); assert any(p.group == 'rinoh.typefaces' for p in d.entry_points)" +about: + homepage: https://github.com/brechtm/rinoh-typeface-texgyrepagella + license: LicenseRef-GUST-Font-License + license_file: rinoh_typeface_texgyrepagella/GUST-FONT-LICENSE.txt + summary: Original texgyrepagella font package for the complete documentation environment diff --git a/.conda/ros2-humble/recipe.yaml b/.conda/ros2-humble/recipe.yaml new file mode 100644 index 000000000..a3f935a1f --- /dev/null +++ b/.conda/ros2-humble/recipe.yaml @@ -0,0 +1,32 @@ +# 显式选装;CLI 仍需 --backend ros2。 +# channel 在安装/构建命令中指定,不能写入成品包的运行依赖。 +package: + name: unilabos-ros2 + version: 0.12.3 +build: + noarch: generic + number: 0 + string: humble_0 +requirements: + run: + - unilabos ==0.12.3 py312_0 + - python ==3.12.13 + - opencv + - transforms3d + - ros2-distro-mutex 0.9.* humble_* + - ros-humble-ros-core + - ros-humble-action-msgs + - ros-humble-std-msgs + - ros-humble-geometry-msgs + - ros-humble-control-msgs + - ros-humble-nav2-msgs + - ros-humble-cv-bridge + - ros-humble-vision-opencv + - ros-humble-tf-transformations + - ros-humble-moveit-msgs + - ros-humble-tf2-ros + - ros-humble-tf2-ros-py + - ros-humble-unilabos-msgs ==0.12.1 +about: + license: GPL-3.0-only + description: Optional UniLabOS ROS 2 humble transport (CPython 3.12) diff --git a/.conda/ros2/recipe.yaml b/.conda/ros2/recipe.yaml new file mode 100644 index 000000000..6b57d0d54 --- /dev/null +++ b/.conda/ros2/recipe.yaml @@ -0,0 +1,32 @@ +# 显式选装;CLI 仍需 --backend ros2。 +# channel 在安装/构建命令中指定,不能写入成品包的运行依赖。 +package: + name: unilabos-ros2 + version: 0.12.3 +build: + noarch: generic + number: 0 + string: jazzy_0 +requirements: + run: + - unilabos ==0.12.3 py312_0 + - python ==3.12.13 + - opencv + - transforms3d + - ros2-distro-mutex 0.15.* jazzy_* + - ros-jazzy-ros-core + - ros-jazzy-action-msgs + - ros-jazzy-std-msgs + - ros-jazzy-geometry-msgs + - ros-jazzy-control-msgs + - ros-jazzy-nav2-msgs + - ros-jazzy-cv-bridge + - ros-jazzy-vision-opencv + - ros-jazzy-tf-transformations + - ros-jazzy-moveit-msgs + - ros-jazzy-tf2-ros + - ros-jazzy-tf2-ros-py + - ros-jazzy-unilabos-msgs ==0.12.1 +about: + license: GPL-3.0-only + description: Optional UniLabOS ROS 2 jazzy transport (CPython 3.12) diff --git a/.github/tests/test_conda_workflow_contract.py b/.github/tests/test_conda_workflow_contract.py index c4f725351..f8980e59b 100644 --- a/.github/tests/test_conda_workflow_contract.py +++ b/.github/tests/test_conda_workflow_contract.py @@ -18,7 +18,7 @@ def workflow(name): def script_result(filename, job, *, event="workflow_run", conclusion="success", - upstream_event="workflow_run", artifacts=()): + upstream_event="push", artifacts=(), requested_ref="dev"): script = workflow(filename)["jobs"][job]["steps"][0]["with"]["script"] harness = """ const fs = require("fs"); @@ -31,7 +31,8 @@ def script_result(filename, job, *, event="workflow_run", conclusion="success", }; const context = { eventName: data.event, repo: {owner: "deepmodeling", repo: "Uni-Lab-OS"}, - payload: {workflow_run: {id: 123, conclusion: data.conclusion, event: data.upstream_event}} + payload: {workflow_run: {id: 123, conclusion: data.conclusion, event: data.upstream_event, + head_branch: "dev", head_sha: data.sha}} }; const github = { rest: {actions: {listWorkflowRunArtifacts: "list"}, repos: { @@ -55,26 +56,45 @@ def script_result(filename, job, *, event="workflow_run", conclusion="success", "script": script, "event": event, "conclusion": conclusion, "upstream_event": upstream_event, "artifacts": list(artifacts), "sha": SHA, }), text=True, capture_output=True, check=True, - env={**os.environ, "REQUESTED_REF": "dev"}, + env={**os.environ, "REQUESTED_REF": requested_ref}, ) return json.loads(process.stdout) def artifact(prefix, sha=SHA, state="published", expired=False): - return {"name": f"{prefix}-source-{state}-{sha}-jazzy-linux-64", "expired": expired} + return {"name": f"{prefix}-source-{state}-{sha}-hostlink-linux-64", "expired": expired} class CondaWorkflowContractTests(unittest.TestCase): - def test_ci_only_main_sync_does_not_build_old_main_source(self): + def test_companion_wheels_are_release_assets_not_pypi_uploads(self): + definition = workflow("wheel-release.yml") + entries = definition["jobs"]["build"]["strategy"]["matrix"]["include"] + self.assertEqual({x["platform"] for x in entries}, {"linux-64", "osx-64", "osx-arm64", "win-64"}) + upload = definition["jobs"]["attach-release"] + self.assertEqual(upload["needs"], "build") + self.assertIn("github.event_name == 'release'", upload["if"]) + self.assertIn("gh release upload", upload["steps"][-1]["run"]) + source = (WORKFLOWS / "wheel-release.yml").read_text(encoding="utf-8") + self.assertNotIn("pypa/gh-action-pypi-publish", source) + self.assertNotIn("twine upload", source) + self.assertNotIn("--clobber", source) + + def test_default_build_is_independent_of_ros_messages(self): # BaseLoader 避免 YAML 1.1 把 GitHub 的 on 键当成布尔值。 definition = yaml.load( - (WORKFLOWS / "multi-platform-build.yml").read_text(encoding="utf-8"), + (WORKFLOWS / "unilabos-conda-build.yml").read_text(encoding="utf-8"), Loader=yaml.BaseLoader, ) self.assertEqual(definition["on"]["workflow_run"]["branches"], ["dev"]) + self.assertEqual(definition["on"]["workflow_run"]["workflows"], ["CI Check"]) + self.assertEqual(definition["on"]["workflow_dispatch"]["inputs"]["ros_distros"]["default"], "") + ros = yaml.load((WORKFLOWS / "multi-platform-build.yml").read_text(encoding="utf-8"), Loader=yaml.BaseLoader) + self.assertNotIn("workflow_run", ros["on"]) + self.assertNotIn("release", ros["on"]) + self.assertEqual(ros["on"]["push"]["tags"], ["msgs-v*"]) def test_ros_matrix_channels_and_recipes(self): - for name in ("multi-platform-build.yml", "unilabos-conda-build.yml"): + for name in ("multi-platform-build.yml",): definition = workflow(name) entries = definition["jobs"]["build"]["strategy"]["matrix"]["include"] self.assertEqual(len(entries), 8) @@ -85,6 +105,9 @@ def test_ros_matrix_channels_and_recipes(self): ) for entry in entries: self.assertEqual(entry["ros_channel"], f"robostack-{entry['ros_distro']}") + core = workflow("unilabos-conda-build.yml")["jobs"]["build"]["strategy"]["matrix"]["include"] + self.assertEqual(len(core), 4) + self.assertTrue(all("ros_distro" not in item for item in core)) def test_provenance_is_after_builds_and_uploads(self): for name in ("multi-platform-build.yml", "unilabos-conda-build.yml"): @@ -99,24 +122,22 @@ def test_provenance_is_after_builds_and_uploads(self): if step.get("uses", "").startswith("actions/upload-artifact"): self.assertEqual(step["with"]["if-no-files-found"], "error") - def test_upstream_ci_success_is_not_a_release(self): - result = script_result("unilabos-conda-build.yml", "wait-for-upstream") - self.assertEqual(result["outputs"]["should_continue"], "false") - - def test_release_requires_unique_published_provenance(self): - for items in ([], [artifact("msgs", state="tested")], - [artifact("msgs"), artifact("msgs", OTHER)]): - with self.subTest(items=items): - result = script_result("unilabos-conda-build.yml", "wait-for-upstream", - upstream_event="release", artifacts=items) - self.assertEqual(result["outputs"]["should_continue"], "false") - self.assertTrue(result["errors"]) + def test_upstream_ci_builds_exact_source_without_publishing(self): + result = script_result("unilabos-conda-build.yml", "resolve-source") + self.assertEqual(result["outputs"], {"should_continue": "true", "source_sha": SHA}) + steps = workflow("unilabos-conda-build.yml")["jobs"]["build"]["steps"] + upload = next(x for x in steps if x.get("name") == "Upload packages after successful tests") + self.assertNotIn("workflow_run", upload["if"]) - def test_release_uses_built_source(self): - result = script_result("unilabos-conda-build.yml", "wait-for-upstream", - upstream_event="release", artifacts=[artifact("msgs")]) + def test_release_resolves_requested_source(self): + result = script_result("unilabos-conda-build.yml", "resolve-source", event="release") self.assertEqual(result["outputs"], {"should_continue": "true", "source_sha": SHA}) + def test_message_release_does_not_republish_core(self): + result = script_result("unilabos-conda-build.yml", "resolve-source", event="release", + requested_ref="msgs-v0.12.1") + self.assertEqual(result["outputs"], {"should_continue": "false"}) + def test_conda_pack_skips_wait_only_or_unpublished_build(self): for items in ([], [artifact("unilabos", state="tested")], [artifact("unilabos", expired=True)]): @@ -134,11 +155,15 @@ def test_conda_pack_rejects_conflicting_sources(self): def test_conda_pack_uses_built_source(self): result = script_result("conda-pack-build.yml", "resolve-source", artifacts=[artifact("unilabos")]) - self.assertEqual(result["outputs"], {"should_continue": "true", "source_sha": SHA}) + self.assertEqual(result["outputs"], {"should_continue": "true", "source_sha": SHA, "platforms": "linux-64"}) + steps = workflow("conda-pack-build.yml")["jobs"]["build-conda-pack"]["steps"] + download = next(x for x in steps if x.get("uses", "").startswith("actions/download-artifact")) + self.assertIn("github.event.workflow_run.id", download["with"]["run-id"]) def test_conda_pack_manual_branch_is_resolved_once(self): result = script_result("conda-pack-build.yml", "resolve-source", event="workflow_dispatch") - self.assertEqual(result["outputs"], {"should_continue": "true", "source_sha": SHA}) + self.assertEqual(result["outputs"]["source_sha"], SHA) + self.assertEqual(result["outputs"]["should_continue"], "true") build = workflow("conda-pack-build.yml")["jobs"]["build-conda-pack"] self.assertIn("resolve-source.outputs.source_sha", build["env"]["PACKAGE_REF"]) checkout = next(step for step in build["steps"] @@ -147,7 +172,7 @@ def test_conda_pack_manual_branch_is_resolved_once(self): def test_failed_upstream_never_proceeds(self): for filename, job, prefix in ( - ("unilabos-conda-build.yml", "wait-for-upstream", "msgs"), + ("unilabos-conda-build.yml", "resolve-source", "msgs"), ("conda-pack-build.yml", "resolve-source", "unilabos"), ): result = script_result(filename, job, conclusion="failure", diff --git a/.github/tests/test_release_versions.py b/.github/tests/test_release_versions.py index a36093057..9af758f48 100644 --- a/.github/tests/test_release_versions.py +++ b/.github/tests/test_release_versions.py @@ -3,6 +3,7 @@ import ast from pathlib import Path import unittest +import tomllib import xml.etree.ElementTree as ET import yaml @@ -35,7 +36,7 @@ def test_python_package_versions_match(self): version = next(ast.literal_eval(item.value) for item in setup_call.keywords if item.arg == "version") self.assertEqual(version, python_version()) - self.assertEqual(recipe("recipes/unilabos/recipe.yaml")["package"]["version"], version) + self.assertEqual(recipe(".conda/base/recipe.yaml")["package"]["version"], version) def test_ros_recipe_versions_match_package_xml(self): for name in ("msgs", "msgs-humble", "ros-humble-unilabos-msgs"): @@ -43,25 +44,58 @@ def test_ros_recipe_versions_match_package_xml(self): self.assertEqual(recipe(f"recipes/{name}/recipe.yaml")["package"]["version"], message_version()) - def test_conda_variants_pin_matching_environment_and_messages(self): + def test_default_package_does_not_depend_on_ros(self): + core = recipe(".conda/base/recipe.yaml") + self.assertFalse((ROOT / ".conda/environment/recipe.yaml").exists()) + for dependency in core["requirements"]["run"]: + self.assertNotIn("ros-", dependency) + self.assertNotIn("robostack", dependency) + self.assertNotIn("unilabos-env", dependency) + self.assertEqual(core["build"]["string"], "py312_0") + + def test_optional_ros_variants_pin_matching_core_and_messages(self): for distro, suffix in (("jazzy", ""), ("humble", "-humble")): with self.subTest(distro=distro): recipes = {kind: recipe(f".conda/{kind}{suffix}/recipe.yaml") - for kind in ("base", "environment", "full")} + for kind in ("ros2", "full")} for item in recipes.values(): self.assertEqual(item["package"]["version"], python_version()) self.assertEqual(item["build"]["string"], f"{distro}_{item['build']['number']}") self.assertIn( - f"uni-lab::ros-{distro}-unilabos-msgs =={message_version()}", - recipes["environment"]["requirements"]["run"], + f"ros-{distro}-unilabos-msgs =={message_version()}", + recipes["ros2"]["requirements"]["run"], ) - for parent, dependency, name in (("base", "environment", "unilabos-env"), - ("full", "base", "unilabos")): - self.assertIn( - f"{name} =={python_version()} {recipes[dependency]['build']['string']}", - recipes[parent]["requirements"]["run"], - ) + self.assertIn(f"unilabos =={python_version()} py312_0", recipes["ros2"]["requirements"]["run"]) + self.assertIn(f"unilabos-ros2 =={python_version()} {distro}_0", recipes["full"]["requirements"]["run"]) + for item in recipes.values(): + # Conda 包的 depends 不能携带构建器专用的 channel 匹配信息。 + for dep in item["requirements"]["run"]: + self.assertNotIn("::", str(dep)) + + def test_mcp_security_baseline_is_not_lowered_for_conda(self): + self.assertIn("mcp>=1.30,<2", (ROOT / "unilabos/utils/requirements.txt").read_text()) + self.assertIn("mcp >=1.30,<2", recipe(".conda/base/recipe.yaml")["requirements"]["run"]) + self.assertEqual(recipe(".conda/mcp/recipe.yaml")["package"]["version"], "1.30.0") + + def test_numpy2_opentrons_is_a_default_material_dependency(self): + patched = recipe("recipes/opentrons-shared-data/recipe.yaml") + dependency = (f"opentrons-shared-data =={patched['package']['version']} " + f"{patched['build']['string']}") + self.assertIn(dependency, recipe(".conda/base/recipe.yaml")["requirements"]["run"]) + self.assertIn(dependency, recipe(".conda/pylabrobot/recipe.yaml")["requirements"]["run"]) + for name in ("ros2", "ros2-humble"): + self.assertNotIn(dependency, recipe(f".conda/{name}/recipe.yaml")["requirements"]["run"]) + + def test_pip_and_conda_use_identical_downstream_versions(self): + packages = tomllib.loads((ROOT / "recipes/wheels.toml").read_text(encoding="utf-8"))["packages"] + dependencies = (ROOT / "unilabos/utils/requirements.txt").read_text(encoding="utf-8") + for name, spec in packages.items(): + self.assertIn(f"{name}=={spec['version']}", dependencies) + plr = recipe(".conda/pylabrobot/recipe.yaml") + self.assertEqual(plr["package"]["version"], packages["pylabrobot"]["version"]) + self.assertIn(f"pylabrobot-unilab =={plr['package']['version']} {plr['build']['string']}", + recipe(".conda/base/recipe.yaml")["requirements"]["run"]) if __name__ == "__main__": diff --git a/.github/workflows/ci-check.yml b/.github/workflows/ci-check.yml index 1527d06ef..1e609ed97 100644 --- a/.github/workflows/ci-check.yml +++ b/.github/workflows/ci-check.yml @@ -7,6 +7,44 @@ on: branches: [main, dev] jobs: + hostlink-no-ros: + name: hostlink-no-ros (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-2022, macos-latest] + env: + PYTHONUTF8: '1' + PYTHONIOENCODING: utf-8 + UNILABOS_E2E_BACKEND: hostlink + UNILABOS_E2E_TOPOLOGY: split + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - name: Build companion wheels without uploading to PyPI + run: python scripts/build_wheel_release.py --dependencies-only + - name: Install only the default Python distribution + run: python -m pip install --find-links dist/wheel-release/wheelhouse . "pytest>=8" "pytest-asyncio>=0.23" + - name: Verify installation without ROS or auto-install + run: | + python -m pip check + python scripts/verify_installation.py --assert-no-ros + unilab --help + - name: Test Python-only API and runtime contracts + run: python -m pytest -q tests/app/test_no_ros_installation.py tests/app/test_wheel_release.py tests/app/test_docs_build.py tests/server tests/protocol tests/backend/hostlink + - name: Test seven complete demos including graph CLI and split Host/Slave + run: python -m pytest -q tests/e2e/test_readme_demos.py --basetemp="${{ runner.temp }}/no-ros-demos" --junitxml="${{ runner.temp }}/no-ros-demos.xml" + - uses: actions/upload-artifact@v6 + if: always() + with: + name: no-ros-evidence-${{ matrix.os }} + path: | + ${{ runner.temp }}/no-ros-demos + ${{ runner.temp }}/no-ros-demos.xml + registry-check: name: registry-check (${{ matrix.ros_distro }}) runs-on: windows-2022 @@ -75,11 +113,11 @@ jobs: call conda activate check-env call install\unilabos_msgs\setup.bat echo Installing pip dependencies... - uv pip install -r unilabos/utils/requirements.txt - uv pip install pywinauto git+https://github.com/Xuwznln/pylabrobot.git@rebase-official-main + python scripts/build_wheel_release.py --dependencies-only + uv pip install --find-links dist/wheel-release/wheelhouse ".[full]" uv pip uninstall enum34 || echo enum34 not installed, skipping uv pip install pytest - uv pip install . + uv pip install --find-links dist/wheel-release/wheelhouse . # README 中列出的七个外部设备示例随主仓库接口一起端到端验证: # tests/e2e/readme_demos.py 固定引用七个仓库的已验证提交(示例仓库 CI 反向 diff --git a/.github/workflows/ci-workflows-check.yml b/.github/workflows/ci-workflows-check.yml index e3c1366e4..fd567e135 100644 --- a/.github/workflows/ci-workflows-check.yml +++ b/.github/workflows/ci-workflows-check.yml @@ -3,10 +3,10 @@ name: CI Workflow Contracts on: pull_request: branches: [main, dev] - paths: ['.github/workflows/**', '.github/tests/**'] + paths: ['.github/workflows/**', '.github/tests/**', '.conda/**', 'recipes/**', 'scripts/*release.py', 'setup.py', 'unilabos/__init__.py', 'unilabos/utils/requirements*.txt'] push: branches: [main, dev] - paths: ['.github/workflows/**', '.github/tests/**'] + paths: ['.github/workflows/**', '.github/tests/**', '.conda/**', 'recipes/**', 'scripts/*release.py', 'setup.py', 'unilabos/__init__.py', 'unilabos/utils/requirements*.txt'] workflow_dispatch: permissions: @@ -22,7 +22,7 @@ jobs: go-version: stable cache: false - name: Validate workflow syntax - run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 -shellcheck='' -pyflakes='' .github/workflows/multi-platform-build.yml .github/workflows/unilabos-conda-build.yml .github/workflows/conda-pack-build.yml .github/workflows/ci-workflows-check.yml + run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 -shellcheck='' -pyflakes='' .github/workflows/ci-check.yml .github/workflows/multi-platform-build.yml .github/workflows/unilabos-conda-build.yml .github/workflows/conda-pack-build.yml .github/workflows/wheel-release.yml .github/workflows/ci-workflows-check.yml .github/workflows/deploy-docs.yml - uses: actions/setup-python@v6 with: python-version: '3.12' diff --git a/.github/workflows/conda-pack-build.yml b/.github/workflows/conda-pack-build.yml index 174a5d1e6..2609ebf9f 100644 --- a/.github/workflows/conda-pack-build.yml +++ b/.github/workflows/conda-pack-build.yml @@ -1,27 +1,20 @@ name: Build Conda-Pack Environment on: - # 在 UniLabOS Conda Build 成功上传后自动构建非全量 conda-pack workflow_run: workflows: ["UniLabOS Conda Build"] types: [completed] workflow_dispatch: inputs: branch: - description: '选择要构建的分支' + description: '构建并打包此源码的无 ROS 默认发行版' required: true default: 'dev' type: string platforms: - description: '选择构建平台 (逗号分隔): linux-64, osx-64, osx-arm64, win-64' - required: false - default: 'win-64' + description: '逗号分隔的平台' + default: 'linux-64,osx-64,osx-arm64,win-64' type: string - build_full: - description: '是否构建完整版 unilabos-full (默认构建轻量版 unilabos)' - required: false - default: false - type: boolean permissions: contents: read @@ -33,12 +26,14 @@ jobs: outputs: should_continue: ${{ steps.source.outputs.should_continue }} source_sha: ${{ steps.source.outputs.source_sha }} + platforms: ${{ steps.source.outputs.platforms }} steps: - name: Resolve the source that was actually built and uploaded id: source uses: actions/github-script@v8 env: REQUESTED_REF: ${{ inputs.branch }} + PLATFORMS: ${{ inputs.platforms }} with: script: | core.setOutput("should_continue", "false"); @@ -47,6 +42,7 @@ jobs: ...context.repo, ref: process.env.REQUESTED_REF }); core.setOutput("source_sha", commit.sha); + core.setOutput("platforms", process.env.PLATFORMS || "linux-64,osx-64,osx-arm64,win-64"); core.setOutput("should_continue", "true"); return; } @@ -55,385 +51,97 @@ jobs: const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { ...context.repo, run_id: run.id, per_page: 100 }); - const shas = new Set(artifacts.filter(a => !a.expired) - .map(a => a.name.match(/^unilabos-source-published-([0-9a-f]{40})-(jazzy|humble)-(linux-64|osx-64|osx-arm64|win-64)$/)) - .filter(Boolean).map(match => match[1])); + const proofs = artifacts.filter(a => !a.expired) + .map(a => a.name.match(/^unilabos-source-published-([0-9a-f]{40})-hostlink-(linux-64|osx-64|osx-arm64|win-64)$/)) + .filter(Boolean); + const shas = new Set(proofs.map(match => match[1])); if (shas.size === 0) { - core.notice("上游未实际上传包(可能仅运行了等待 job),不启动 conda-pack。"); + core.notice("上游没有无 ROS 默认包的上传凭证,不打包。"); return; } if (shas.size !== 1) { - core.setFailed("上游源码凭证不一致,不能任取一个 SHA 打包。"); + core.setFailed("上游源码凭证不一致。"); return; } core.setOutput("source_sha", [...shas][0]); + core.setOutput("platforms", [...new Set(proofs.map(match => match[2]))].join(",")); core.setOutput("should_continue", "true"); build-conda-pack: needs: resolve-source - if: | - needs.resolve-source.outputs.should_continue == 'true' + if: needs.resolve-source.outputs.should_continue == 'true' env: - BUILD_FULL: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }} PACKAGE_REF: ${{ needs.resolve-source.outputs.source_sha }} + PYTHONUTF8: '1' strategy: fail-fast: false matrix: include: - os: ubuntu-latest platform: linux-64 - env_file: unilabos-linux-64.yaml - script_ext: sh - - os: macos-15-intel # Intel x86_64 + - os: macos-15-intel platform: osx-64 - env_file: unilabos-osx-64.yaml - script_ext: sh - - os: macos-latest # ARM64 + - os: macos-latest platform: osx-arm64 - env_file: unilabos-osx-arm64.yaml - script_ext: sh - os: windows-2022 platform: win-64 - env_file: unilabos-win64.yaml - script_ext: bat - runs-on: ${{ matrix.os }} - defaults: run: - # Windows uses cmd for better conda/mamba compatibility, Unix uses bash - shell: ${{ matrix.platform == 'win-64' && 'cmd' || 'bash' }} - + shell: bash -l {0} steps: - name: Check if platform should be built id: should_build - shell: bash + env: + PLATFORMS: ${{ needs.resolve-source.outputs.platforms }} + PLATFORM: ${{ matrix.platform }} run: | - if [[ "${{ github.event_name }}" != "workflow_dispatch" ]]; then - echo "should_build=true" >> $GITHUB_OUTPUT - elif [[ -z "${{ github.event.inputs.platforms }}" ]]; then - echo "should_build=true" >> $GITHUB_OUTPUT - elif [[ "${{ github.event.inputs.platforms }}" == *"${{ matrix.platform }}"* ]]; then - echo "should_build=true" >> $GITHUB_OUTPUT + if [[ ",$PLATFORMS," == *",$PLATFORM,"* ]]; then + echo "should_build=true" >> "$GITHUB_OUTPUT" else - echo "should_build=false" >> $GITHUB_OUTPUT + echo "should_build=false" >> "$GITHUB_OUTPUT" fi - - uses: actions/checkout@v6 if: steps.should_build.outputs.should_build == 'true' with: ref: ${{ needs.resolve-source.outputs.source_sha }} - fetch-depth: 0 - - - name: Create artifact label - if: steps.should_build.outputs.should_build == 'true' - id: artifact_label - shell: bash - env: - RAW_PACKAGE_REF: ${{ env.PACKAGE_REF }} - run: | - SAFE_REF="${RAW_PACKAGE_REF//\//-}" - echo "value=$SAFE_REF" >> "$GITHUB_OUTPUT" - - - name: Setup Miniforge (with mamba) + - uses: conda-incubator/setup-miniconda@v4 if: steps.should_build.outputs.should_build == 'true' - uses: conda-incubator/setup-miniconda@v4 with: miniforge-version: latest use-mamba: true python-version: '3.12.13' - channels: conda-forge,robostack-jazzy,uni-lab - channel-priority: flexible - activate-environment: unilab + channels: conda-forge + channel-priority: strict + activate-environment: build-env auto-update-conda: false - show-channel-urls: true - - - name: Install conda-pack, unilabos and dependencies (Windows) - if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64' - run: | - echo Installing unilabos and dependencies to unilab environment... - echo Using mamba for faster and more reliable dependency resolution... - echo Build full: ${{ env.BUILD_FULL }} - if "${{ env.BUILD_FULL }}"=="true" ( - echo Installing unilabos-full ^(complete package^)... - mamba install -n unilab --override-channels -c uni-lab -c conda-forge -c robostack-jazzy uni-lab::unilabos-full conda-pack zstandard -y - ) else ( - echo Installing unilabos ^(minimal package^)... - mamba install -n unilab --override-channels -c uni-lab -c conda-forge -c robostack-jazzy uni-lab::unilabos conda-pack zstandard -y - ) - - - name: Install conda-pack, unilabos and dependencies (Unix) - if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64' - shell: bash + - name: Install build and index tools + if: steps.should_build.outputs.should_build == 'true' run: | - echo "Installing unilabos and dependencies to unilab environment..." - echo "Using mamba for faster and more reliable dependency resolution..." - echo "Build full: ${{ env.BUILD_FULL }}" - if [[ "${{ env.BUILD_FULL }}" == "true" ]]; then - echo "Installing unilabos-full (complete package)..." - mamba install -n unilab --override-channels -c uni-lab -c conda-forge -c robostack-jazzy uni-lab::unilabos-full conda-pack zstandard -y + if [[ "$RUNNER_OS" == "Windows" ]]; then + cmd //D //S //C '%CONDA%\condabin\mamba.bat install -n build-env --override-channels -c conda-forge rattler-build conda-index -y' else - echo "Installing unilabos (minimal package)..." - mamba install -n unilab --override-channels -c uni-lab -c conda-forge -c robostack-jazzy uni-lab::unilabos conda-pack zstandard -y + mamba install -n build-env --override-channels -c conda-forge rattler-build conda-index -y fi - - - name: Get latest ros-jazzy-unilabos-msgs version (Windows) - if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64' - id: msgs_version_win - run: | - echo Checking installed ros-jazzy-unilabos-msgs version... - conda list -n unilab ros-jazzy-unilabos-msgs - for /f "tokens=2" %%i in ('conda list -n unilab ros-jazzy-unilabos-msgs --json ^| python -c "import sys, json; pkgs=json.load(sys.stdin); print(pkgs[0]['version'] if pkgs else 'not-found')"') do set VERSION=%%i - echo installed_version=%VERSION% >> %GITHUB_OUTPUT% - echo Installed ros-jazzy-unilabos-msgs version: %VERSION% - - - name: Get latest ros-jazzy-unilabos-msgs version (Unix) - if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64' - id: msgs_version_unix - shell: bash - run: | - echo "Checking installed ros-jazzy-unilabos-msgs version..." - VERSION=$(conda list -n unilab ros-jazzy-unilabos-msgs --json | python -c "import sys, json; pkgs=json.load(sys.stdin); print(pkgs[0]['version'] if pkgs else 'not-found')") - echo "installed_version=$VERSION" >> $GITHUB_OUTPUT - echo "Installed ros-jazzy-unilabos-msgs version: $VERSION" - - - name: Check for newer ros-jazzy-unilabos-msgs (Windows) - if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64' - run: | - echo Checking for available ros-jazzy-unilabos-msgs versions... - mamba search --override-channels -c uni-lab -c conda-forge -c robostack-jazzy ros-jazzy-unilabos-msgs || echo Search completed - echo. - echo Updating ros-jazzy-unilabos-msgs to latest version... - mamba update -n unilab --override-channels -c uni-lab -c conda-forge -c robostack-jazzy ros-jazzy-unilabos-msgs -y || echo Already at latest version - - - name: Check for newer ros-jazzy-unilabos-msgs (Unix) - if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64' - shell: bash - run: | - echo "Checking for available ros-jazzy-unilabos-msgs versions..." - mamba search --override-channels -c uni-lab -c conda-forge -c robostack-jazzy ros-jazzy-unilabos-msgs || echo "Search completed" - echo "" - echo "Updating ros-jazzy-unilabos-msgs to latest version..." - mamba update -n unilab --override-channels -c uni-lab -c conda-forge -c robostack-jazzy ros-jazzy-unilabos-msgs -y || echo "Already at latest version" - - - name: Install latest unilabos from source (Windows) - if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64' - run: | - echo Uninstalling existing unilabos... - mamba run -n unilab pip uninstall unilabos -y || echo unilabos not installed via pip - echo Installing unilabos from source (ref: ${{ env.PACKAGE_REF }})... - mamba run -n unilab pip install . - echo Verifying installation... - mamba run -n unilab pip show unilabos - - - name: Install latest unilabos from source (Unix) - if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64' - shell: bash - run: | - echo "Uninstalling existing unilabos..." - mamba run -n unilab pip uninstall unilabos -y || echo "unilabos not installed via pip" - echo "Installing unilabos from source (ref: ${{ env.PACKAGE_REF }})..." - mamba run -n unilab pip install . - echo "Verifying installation..." - mamba run -n unilab pip show unilabos - - - name: Display environment info (Windows) - if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64' - run: | - echo === Environment Information === - mamba env list - echo. - echo === Installed Packages === - mamba list -n unilab | findstr /C:"unilabos" /C:"ros-jazzy-unilabos-msgs" || mamba list -n unilab - echo. - echo === Python Packages === - mamba run -n unilab pip list | findstr unilabos || mamba run -n unilab pip list - - - name: Display environment info (Unix) - if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64' - shell: bash - run: | - echo "=== Environment Information ===" - mamba env list - echo "" - echo "=== Installed Packages ===" - mamba list -n unilab | grep -E "(unilabos|ros-jazzy-unilabos-msgs)" || mamba list -n unilab - echo "" - echo "=== Python Packages ===" - mamba run -n unilab pip list | grep unilabos || mamba run -n unilab pip list - - - name: Verify environment integrity (Windows) - if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64' - run: | - echo Verifying Python version... - mamba run -n unilab python -c "import sys; print(f'Python version: {sys.version}')" - echo Verifying unilabos import... - mamba run -n unilab python -c "import unilabos; print(f'UniLabOS version: {unilabos.__version__}')" || echo Warning: Could not import unilabos - echo Checking critical packages... - mamba run -n unilab python -c "import rclpy; print('ROS2 rclpy: OK')" - echo Running comprehensive verification script... - mamba run -n unilab python scripts\verify_installation.py --auto-install || echo Warning: Verification script reported issues - echo Environment verification complete! - - - name: Verify environment integrity (Unix) - if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64' - shell: bash - run: | - echo "Verifying Python version..." - mamba run -n unilab python -c "import sys; print(f'Python version: {sys.version}')" - echo "Verifying unilabos import..." - mamba run -n unilab python -c "import unilabos; print(f'UniLabOS version: {unilabos.__version__}')" || echo "Warning: Could not import unilabos" - echo "Checking critical packages..." - mamba run -n unilab python -c "import rclpy; print('ROS2 rclpy: OK')" - echo "Running comprehensive verification script..." - mamba run -n unilab python scripts/verify_installation.py --auto-install || echo "Warning: Verification script reported issues" - echo "Environment verification complete!" - - - name: Pack conda environment (Windows) - if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64' - run: | - echo Packing unilab environment with conda-pack... - for /f "delims=" %%i in ('mamba run -n unilab python -c "import os; print(os.environ['CONDA_PREFIX'])"') do set "UNILAB_PREFIX=%%i" - echo Packing environment at: %UNILAB_PREFIX% - mamba run -n unilab conda-pack -p "%UNILAB_PREFIX%" -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files - echo Pack file created: - dir unilab-env-${{ matrix.platform }}.tar.gz - - - name: Pack conda environment (Unix) - if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64' - shell: bash - run: | - echo "Packing unilab environment with conda-pack..." - UNILAB_PREFIX="$(mamba run -n unilab python -c 'import os; print(os.environ["CONDA_PREFIX"])')" - echo "Packing environment at: $UNILAB_PREFIX" - mamba run -n unilab conda-pack -p "$UNILAB_PREFIX" -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files - echo "Pack file created:" - ls -lh unilab-env-${{ matrix.platform }}.tar.gz - - - name: Prepare Windows distribution package - if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64' - run: | - echo ========================================== - echo Creating distribution package... - echo Platform: ${{ matrix.platform }} - echo ========================================== - mkdir dist-package 2>nul || cd . - - rem Copy packed environment - echo Adding: unilab-env-${{ matrix.platform }}.tar.gz - copy unilab-env-${{ matrix.platform }}.tar.gz dist-package\ - - rem Copy installation script - echo Adding: install_unilab.bat - copy scripts\install_unilab.bat dist-package\ - - rem Copy verification script - echo Adding: verify_installation.py - copy scripts\verify_installation.py dist-package\ - - rem Copy source code repository (including .git) - echo Adding: Uni-Lab-OS source repository - robocopy . dist-package\Uni-Lab-OS /E /XD dist-package /NFL /NDL /NJH /NJS /NC /NS || if %ERRORLEVEL% LSS 8 exit /b 0 - - rem Create README using Python script - echo Creating: README.txt - python scripts\create_readme.py ${{ matrix.platform }} ${{ env.PACKAGE_REF }} dist-package\README.txt - - echo. - echo Distribution package contents: - dir /b dist-package - echo. - - - name: Prepare Unix/Linux distribution package - if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64' - shell: bash + - name: Download the exact upstream packages + if: steps.should_build.outputs.should_build == 'true' && github.event_name == 'workflow_run' + uses: actions/download-artifact@v5 + with: + name: conda-package-unilabos-hostlink-${{ matrix.platform }} + path: conda-artifacts + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + - name: Build exact source for a manual bundle + if: steps.should_build.outputs.should_build == 'true' && github.event_name == 'workflow_dispatch' + run: python scripts/build_conda_release.py build --platform "${{ matrix.platform }}" + - name: Install, verify and pack the ROS-free environment + if: steps.should_build.outputs.should_build == 'true' run: | - echo "==========================================" - echo "Creating distribution package..." - echo "Platform: ${{ matrix.platform }}" - echo "==========================================" - mkdir -p dist-package - - # Copy packed environment - echo "Adding: unilab-env-${{ matrix.platform }}.tar.gz" - cp unilab-env-${{ matrix.platform }}.tar.gz dist-package/ - - # Copy installation script - echo "Adding: install_unilab.sh" - cp scripts/install_unilab.sh dist-package/ - chmod +x dist-package/install_unilab.sh - - # Copy verification script - echo "Adding: verify_installation.py" - cp scripts/verify_installation.py dist-package/ - - # Copy source code repository (including .git) - echo "Adding: Uni-Lab-OS source repository" - rsync -a --exclude='dist-package' . dist-package/Uni-Lab-OS - - # Create README using Python script - echo "Creating: README.txt" - python scripts/create_readme.py ${{ matrix.platform }} ${{ env.PACKAGE_REF }} dist-package/README.txt - - echo "" - echo "Distribution package contents:" - ls -lh dist-package/ - echo "" - - - name: Upload distribution package + python scripts/pack_conda_release.py --platform "${{ matrix.platform }}" --source-sha "$PACKAGE_REF" --prefix "${{ runner.temp }}/unilab-bundle" + - uses: actions/upload-artifact@v6 if: steps.should_build.outputs.should_build == 'true' - uses: actions/upload-artifact@v6 with: - name: unilab-pack-${{ matrix.platform }}-${{ steps.artifact_label.outputs.value }} + name: unilab-pack-hostlink-${{ matrix.platform }}-${{ env.PACKAGE_REF }} path: dist-package/ - retention-days: 90 if-no-files-found: error - - - name: Display package info (Windows) - if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64' - run: | - echo ========================================== - echo Build Summary - echo ========================================== - echo Platform: ${{ matrix.platform }} - echo Branch: ${{ env.PACKAGE_REF }} - echo Python version: 3.12.13 - if "${{ env.BUILD_FULL }}"=="true" ( - echo Package: unilabos-full ^(complete^) - ) else ( - echo Package: unilabos ^(minimal^) - ) - echo. - echo Distribution package contents: - dir dist-package - echo. - echo Artifact name: unilab-pack-${{ matrix.platform }}-${{ steps.artifact_label.outputs.value }} - echo. - echo After download, extract the ZIP and run: - echo install_unilab.bat - echo ========================================== - - - name: Display package info (Unix) - if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64' - shell: bash - run: | - echo "==========================================" - echo "Build Summary" - echo "==========================================" - echo "Platform: ${{ matrix.platform }}" - echo "Branch: ${{ env.PACKAGE_REF }}" - echo "Python version: 3.12.13" - if [[ "${{ env.BUILD_FULL }}" == "true" ]]; then - echo "Package: unilabos-full (complete)" - else - echo "Package: unilabos (minimal)" - fi - echo "" - echo "Distribution package contents:" - ls -lh dist-package/ - echo "" - echo "Artifact name: unilab-pack-${{ matrix.platform }}-${{ steps.artifact_label.outputs.value }}" - echo "" - echo "After download:" - echo " install_unilab.sh" - echo "==========================================" + retention-days: 90 diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index cf483cd0e..1658e43f8 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -42,12 +42,14 @@ jobs: github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') runs-on: ubuntu-latest + env: + UNILABOS_DOCS_REQUIRE_ROS: '1' steps: - name: Checkout code uses: actions/checkout@v6 with: # workflow_run 时使用触发工作流的分支,手动触发时使用输入的分支 - ref: ${{ github.event.workflow_run.head_branch || github.event.inputs.branch || github.ref }} + ref: ${{ github.event.workflow_run.head_sha || github.event.inputs.branch || github.ref }} fetch-depth: 0 - name: Setup Miniforge (with mamba) @@ -57,30 +59,20 @@ jobs: use-mamba: true python-version: '3.12.13' channels: conda-forge,robostack-jazzy,uni-lab - channel-priority: flexible + channel-priority: strict activate-environment: unilab auto-update-conda: false show-channel-urls: true - - name: Install unilabos and dependencies + - name: Install native ROS message definitions for complete documentation run: | - echo "Installing unilabos and dependencies to unilab environment..." - echo "Using mamba for faster and more reliable dependency resolution..." - mamba install -n unilab --override-channels -c uni-lab -c conda-forge -c robostack-jazzy uni-lab::unilabos -y + mamba install -n unilab --override-channels -c uni-lab -c conda-forge -c robostack-jazzy numpy=2 robostack-jazzy::ros-jazzy-ros-core robostack-jazzy::ros-jazzy-control-msgs robostack-jazzy::ros-jazzy-nav2-msgs uni-lab::ros-jazzy-unilabos-msgs=0.12.1 -y - name: Install latest unilabos from source run: | - echo "Uninstalling existing unilabos..." - mamba run -n unilab pip uninstall unilabos -y || echo "unilabos not installed via pip" - echo "Installing unilabos from source..." - mamba run -n unilab pip install . - echo "Verifying installation..." - mamba run -n unilab pip show unilabos - - - name: Install documentation dependencies - run: | - echo "Installing documentation build dependencies..." - mamba run -n unilab pip install -r docs/requirements.txt + mamba run -n unilab python scripts/build_wheel_release.py --dependencies-only + mamba run -n unilab pip install --find-links dist/wheel-release/wheelhouse ".[full]" + mamba run -n unilab pip check - name: Setup Pages id: pages @@ -92,17 +84,13 @@ jobs: - name: Build Sphinx documentation run: | cd docs - # Clean previous builds - rm -rf _build - # Build HTML documentation in conda environment - mamba run -n unilab python -m sphinx -b html . _build/html -v + mamba run -n unilab python -m sphinx -b html . "${{ runner.temp }}/unilab-docs/html" -d "${{ runner.temp }}/unilab-docs/doctrees" -v - name: Check build results run: | echo "Documentation build completed, checking output directory:" - ls -la docs/_build/html/ - echo "Checking for index.html:" - test -f docs/_build/html/index.html && echo "✓ index.html exists" || echo "✗ index.html missing" + test -f "${{ runner.temp }}/unilab-docs/html/index.html" + git diff --exit-code -- docs - name: Upload build artifacts uses: actions/upload-pages-artifact@v5 @@ -110,7 +98,7 @@ jobs: github.event.workflow_run.head_branch == 'main' || (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true') with: - path: docs/_build/html + path: ${{ runner.temp }}/unilab-docs/html # Deploy to GitHub Pages deploy: diff --git a/.github/workflows/multi-platform-build.yml b/.github/workflows/multi-platform-build.yml index 56365e2d0..16ead22c6 100644 --- a/.github/workflows/multi-platform-build.yml +++ b/.github/workflows/multi-platform-build.yml @@ -1,18 +1,9 @@ name: Multi-Platform Conda Build on: - # 日常构建只接 dev 的 CI;main 目前仅同步工作流定义,不能误打包旧业务源码。 - workflow_run: - workflows: ["CI Check"] - types: - - completed - branches: [dev] - # 支持 tag 推送(不依赖 CI Check) + # ROS 消息独立发布,不由默认 Python/HostLink 发布触发。 push: - tags: ['v*'] - # GitHub Release 发布时自动构建并上传 - release: - types: [published] + tags: ['msgs-v*'] # 手动触发 workflow_dispatch: inputs: @@ -34,37 +25,9 @@ on: required: false default: false type: boolean - skip_ci_check: - description: '跳过等待 CI Check (手动触发时可选)' - required: false - default: false - type: boolean jobs: - # 等待 CI Check 完成的 job (仅用于 workflow_run 触发) - wait-for-ci: - runs-on: ubuntu-latest - if: github.event_name == 'workflow_run' - outputs: - should_continue: ${{ steps.check.outputs.should_continue }} - steps: - - name: Check CI status - id: check - run: | - if [[ "${{ github.event.workflow_run.conclusion }}" == "success" ]]; then - echo "should_continue=true" >> $GITHUB_OUTPUT - echo "CI Check passed, proceeding with build" - else - echo "should_continue=false" >> $GITHUB_OUTPUT - echo "CI Check did not succeed (status: ${{ github.event.workflow_run.conclusion }}), skipping build" - fi - build: - needs: [wait-for-ci] - # 运行条件:workflow_run 触发且 CI 成功,或者其他触发方式 - if: | - always() && - (needs.wait-for-ci.result == 'skipped' || needs.wait-for-ci.outputs.should_continue == 'true') strategy: fail-fast: false matrix: @@ -123,8 +86,7 @@ jobs: steps: - uses: actions/checkout@v6 with: - # 如果是 workflow_run 触发,使用触发 CI Check 的 commit - ref: ${{ inputs.source_ref || github.event.workflow_run.head_sha || github.event.release.tag_name || github.ref }} + ref: ${{ inputs.source_ref || github.ref }} fetch-depth: 0 - name: Check if platform should be built @@ -215,7 +177,6 @@ jobs: if: | steps.should_build.outputs.should_build == 'true' && ( - github.event_name == 'release' || startsWith(github.ref, 'refs/tags/') || github.event.inputs.upload_to_anaconda == 'true' ) @@ -230,7 +191,7 @@ jobs: id: source if: steps.should_build.outputs.should_build == 'true' env: - PUBLISHED: ${{ github.event_name == 'release' || startsWith(github.ref, 'refs/tags/') || inputs.upload_to_anaconda == true }} + PUBLISHED: ${{ startsWith(github.ref, 'refs/tags/') || inputs.upload_to_anaconda == true }} run: | sha="$(git rev-parse HEAD)" state=tested diff --git a/.github/workflows/unilabos-conda-build.yml b/.github/workflows/unilabos-conda-build.yml index 4c8cf99a3..b0557eb89 100644 --- a/.github/workflows/unilabos-conda-build.yml +++ b/.github/workflows/unilabos-conda-build.yml @@ -1,39 +1,38 @@ name: UniLabOS Conda Build on: - # 在 Multi-Platform Conda Build 成功上传 msgs 后自动触发 + # 默认发行链与 ROS msgs 脱钩;dev CI 仅构建验证,正式 Release 才自动发布。 workflow_run: - workflows: ["Multi-Platform Conda Build"] + workflows: ["CI Check"] types: [completed] - # 手动触发 + branches: [dev] + release: + types: [published] workflow_dispatch: inputs: source_ref: - description: '构建源码分支或完整 SHA;工作流定义仍取所选运行分支' + description: '构建源码分支或完整 SHA' required: true default: 'dev' type: string platforms: - description: '选择构建平台 (逗号分隔): linux-64, osx-64, osx-arm64, win-64' - required: false - default: 'linux-64' + description: '逗号分隔的平台' + default: 'linux-64,osx-64,osx-arm64,win-64' + type: string ros_distros: - description: '选择 ROS 2 发行版 (逗号分隔): jazzy, humble' - required: false - default: 'jazzy,humble' + description: '显式选装 ROS2 (jazzy,humble);留空仅构建无 ROS 默认包' + default: '' + type: string build_full: - description: '是否构建 unilabos-full 完整包 (默认只构建 unilabos 基础包)' - required: false + description: '额外构建完整运行/文档/开发环境;必须显式选择 ros_distros' default: false type: boolean - upload_to_anaconda: - description: '是否上传到Anaconda.org' - required: false + extensions_only: + description: '默认包已发布后,仅构建/上传 ROS2 和 full,不重新上传默认包' default: false type: boolean - skip_ci_check: - description: '跳过等待 CI Check (手动触发时可选)' - required: false + upload_to_anaconda: + description: '上传新的包坐标,不覆盖已有业务包' default: false type: boolean @@ -42,143 +41,88 @@ permissions: actions: read jobs: - # 等待上游 msgs 构建完成的 job (仅用于 workflow_run 触发) - wait-for-upstream: + resolve-source: runs-on: ubuntu-latest - if: github.event_name == 'workflow_run' outputs: - should_continue: ${{ steps.check.outputs.should_continue }} - source_sha: ${{ steps.check.outputs.source_sha }} + should_continue: ${{ steps.source.outputs.should_continue }} + source_sha: ${{ steps.source.outputs.source_sha }} steps: - - name: Check upstream workflow status - id: check + - name: Resolve exact source without depending on ROS messages + id: source uses: actions/github-script@v8 + env: + REQUESTED_REF: ${{ inputs.source_ref || github.event.release.tag_name }} with: script: | core.setOutput("should_continue", "false"); - const run = context.payload.workflow_run; - if (run.conclusion !== "success" || !["release", "push"].includes(run.event)) { - core.notice("上游并非成功的 release/tag 构建,不启动发布链。"); - return; + if (context.eventName === "release" && (process.env.REQUESTED_REF || "").startsWith("msgs-v")) return; + if (context.eventName === "workflow_run") { + const run = context.payload.workflow_run; + if (run.conclusion !== "success" || run.head_branch !== "dev" || run.event !== "push") return; + if (!/^[0-9a-f]{40}$/.test(run.head_sha)) { + core.setFailed("CI 未提供有效源码 SHA"); + return; + } + core.setOutput("source_sha", run.head_sha); + } else { + const {data: commit} = await github.rest.repos.getCommit({ + ...context.repo, ref: process.env.REQUESTED_REF + }); + core.setOutput("source_sha", commit.sha); } - const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { - ...context.repo, run_id: run.id, per_page: 100 - }); - const shas = new Set(artifacts.filter(a => !a.expired) - .map(a => a.name.match(/^msgs-source-published-([0-9a-f]{40})-(jazzy|humble)-(linux-64|osx-64|osx-arm64|win-64)$/)) - .filter(Boolean).map(match => match[1])); - if (shas.size !== 1) { - core.setFailed("上游缺少唯一的已上传源码凭证;不能回退到默认分支 SHA。"); - return; - } - core.setOutput("source_sha", [...shas][0]); core.setOutput("should_continue", "true"); build: - needs: [wait-for-upstream] - # 运行条件:workflow_run 触发且上游成功,或者手动触发 - if: | - always() && - (needs.wait-for-upstream.result == 'skipped' || needs.wait-for-upstream.outputs.should_continue == 'true') + needs: resolve-source + if: needs.resolve-source.outputs.should_continue == 'true' strategy: fail-fast: false matrix: include: - os: ubuntu-latest platform: linux-64 - ros_distro: jazzy - ros_channel: robostack-jazzy - environment_recipe: .conda/environment/recipe.yaml - base_recipe: .conda/base/recipe.yaml - full_recipe: .conda/full/recipe.yaml - - os: macos-15-intel # Intel x86_64 - platform: osx-64 - ros_distro: jazzy - ros_channel: robostack-jazzy - environment_recipe: .conda/environment/recipe.yaml - base_recipe: .conda/base/recipe.yaml - full_recipe: .conda/full/recipe.yaml - - os: macos-latest # ARM64 - platform: osx-arm64 - ros_distro: jazzy - ros_channel: robostack-jazzy - environment_recipe: .conda/environment/recipe.yaml - base_recipe: .conda/base/recipe.yaml - full_recipe: .conda/full/recipe.yaml - - os: windows-2022 - platform: win-64 - ros_distro: jazzy - ros_channel: robostack-jazzy - environment_recipe: .conda/environment/recipe.yaml - base_recipe: .conda/base/recipe.yaml - full_recipe: .conda/full/recipe.yaml - - os: ubuntu-latest - platform: linux-64 - ros_distro: humble - ros_channel: robostack-humble - environment_recipe: .conda/environment-humble/recipe.yaml - base_recipe: .conda/base-humble/recipe.yaml - full_recipe: .conda/full-humble/recipe.yaml - os: macos-15-intel platform: osx-64 - ros_distro: humble - ros_channel: robostack-humble - environment_recipe: .conda/environment-humble/recipe.yaml - base_recipe: .conda/base-humble/recipe.yaml - full_recipe: .conda/full-humble/recipe.yaml - os: macos-latest platform: osx-arm64 - ros_distro: humble - ros_channel: robostack-humble - environment_recipe: .conda/environment-humble/recipe.yaml - base_recipe: .conda/base-humble/recipe.yaml - full_recipe: .conda/full-humble/recipe.yaml - os: windows-2022 platform: win-64 - ros_distro: humble - ros_channel: robostack-humble - environment_recipe: .conda/environment-humble/recipe.yaml - base_recipe: .conda/base-humble/recipe.yaml - full_recipe: .conda/full-humble/recipe.yaml - runs-on: ${{ matrix.os }} - defaults: run: shell: bash -l {0} - + env: + ROS_DISTROS: ${{ inputs.ros_distros }} + BUILD_FULL: ${{ inputs.build_full == true }} + EXTENSIONS_ONLY: ${{ inputs.extensions_only == true }} + PYTHONUTF8: '1' + ANACONDA_CLIENT_FORCE_STANDALONE: '1' steps: - uses: actions/checkout@v6 with: - # 如果是 workflow_run 触发,使用上游 conda 包构建的 commit - ref: ${{ needs.wait-for-upstream.outputs.source_sha || inputs.source_ref || github.ref }} - fetch-depth: 0 - + ref: ${{ needs.resolve-source.outputs.source_sha }} - name: Check if platform should be built id: should_build + env: + PLATFORMS: ${{ inputs.platforms }} + PLATFORM: ${{ matrix.platform }} run: | - if [[ "${{ github.event_name }}" != "workflow_dispatch" ]]; then - echo "should_build=true" >> $GITHUB_OUTPUT - elif [[ ( -z "${{ github.event.inputs.platforms }}" || "${{ github.event.inputs.platforms }}" == *"${{ matrix.platform }}"* ) && ( -z "${{ github.event.inputs.ros_distros }}" || "${{ github.event.inputs.ros_distros }}" == *"${{ matrix.ros_distro }}"* ) ]]; then - echo "should_build=true" >> $GITHUB_OUTPUT + if [[ -z "$PLATFORMS" || ",$PLATFORMS," == *",$PLATFORM,"* ]]; then + echo "should_build=true" >> "$GITHUB_OUTPUT" else - echo "should_build=false" >> $GITHUB_OUTPUT + echo "should_build=false" >> "$GITHUB_OUTPUT" fi - - - name: Setup Miniforge + - uses: conda-incubator/setup-miniconda@v4 if: steps.should_build.outputs.should_build == 'true' - uses: conda-incubator/setup-miniconda@v4 with: miniforge-version: latest use-mamba: true python-version: '3.12.13' - channels: conda-forge,${{ matrix.ros_channel }},uni-lab + channels: conda-forge channel-priority: strict activate-environment: build-env auto-update-conda: false - show-channel-urls: true - - - name: Install rattler-build and anaconda-client + - name: Install build tools if: steps.should_build.outputs.should_build == 'true' run: | if [[ "$RUNNER_OS" == "Windows" ]]; then @@ -187,123 +131,33 @@ jobs: mamba install -n build-env --override-channels -c conda-forge rattler-build anaconda-client -y fi echo "CONDA_NO_PLUGINS=true" >> "$GITHUB_ENV" - echo "PYTHONUTF8=1" >> "$GITHUB_ENV" - echo "PYTHONIOENCODING=utf-8" >> "$GITHUB_ENV" - # anaconda-client 1.13+ 的顶层 CLI 不再支持 --version,上传也需使用兼容入口。 - echo "ANACONDA_CLIENT_FORCE_STANDALONE=1" >> "$GITHUB_ENV" - - - name: Show environment info - if: steps.should_build.outputs.should_build == 'true' - run: | - conda info - conda list -n build-env | grep -E "(rattler-build|anaconda-client)" - conda run -n build-env rattler-build --version - conda run -n build-env python -m binstar_client.scripts.cli --version - echo "Platform: ${{ matrix.platform }}" - echo "OS: ${{ matrix.os }}" - echo "ROS distro: ${{ matrix.ros_distro }}" - echo "Build full package: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }}" - echo "Building packages:" - echo " - unilabos-env (environment dependencies)" - echo " - unilabos (with pip package)" - if [[ "${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }}" == "true" ]]; then - echo " - unilabos-full (complete package)" - fi - - - name: Build unilabos-env (conda environment only, noarch) - if: steps.should_build.outputs.should_build == 'true' - run: | - echo "Building unilabos-env for ${{ matrix.ros_distro }}..." - conda run -n build-env rattler-build build -r ${{ matrix.environment_recipe }} --target-platform ${{ matrix.platform }} -c uni-lab -c conda-forge -c ${{ matrix.ros_channel }} - - - name: Upload unilabos-env to Anaconda.org (if enabled) - if: | - steps.should_build.outputs.should_build == 'true' && - matrix.platform == 'linux-64' && - ( - github.event_name == 'workflow_run' || - github.event.inputs.upload_to_anaconda == 'true' - ) - run: | - echo "Uploading unilabos-env to uni-lab organization..." - for package in $(find ./output -name "unilabos-env*.conda"); do - conda run -n build-env anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" - done - - - name: Build unilabos (with pip package) - if: steps.should_build.outputs.should_build == 'true' - run: | - echo "Building unilabos package for ${{ matrix.ros_distro }}..." - # 如果已上传到 Anaconda,从 uni-lab channel 获取 unilabos-env;否则从本地 output 获取 - conda run -n build-env rattler-build build -r ${{ matrix.base_recipe }} --target-platform ${{ matrix.platform }} -c uni-lab -c conda-forge -c ${{ matrix.ros_channel }} --channel ./output - - - name: Upload unilabos to Anaconda.org (if enabled) - if: | - steps.should_build.outputs.should_build == 'true' && - ( - github.event_name == 'workflow_run' || - github.event.inputs.upload_to_anaconda == 'true' - ) - run: | - echo "Uploading unilabos to uni-lab organization..." - for package in $(find ./output -name "unilabos-0*.conda" -o -name "unilabos-[0-9]*.conda"); do - conda run -n build-env anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" - done - - - name: Build unilabos-full - Only when explicitly requested - if: | - steps.should_build.outputs.should_build == 'true' && - github.event_name == 'workflow_dispatch' && - github.event.inputs.build_full == 'true' - run: | - echo "Building unilabos-full (${{ matrix.ros_distro }}) on ${{ matrix.platform }}..." - conda run -n build-env rattler-build build -r ${{ matrix.full_recipe }} --target-platform ${{ matrix.platform }} -c uni-lab -c conda-forge -c ${{ matrix.ros_channel }} --channel ./output - - - name: Upload unilabos-full to Anaconda.org (if enabled) - if: | - steps.should_build.outputs.should_build == 'true' && - matrix.platform == 'linux-64' && - github.event_name == 'workflow_dispatch' && - github.event.inputs.build_full == 'true' && - github.event.inputs.upload_to_anaconda == 'true' - run: | - echo "Uploading unilabos-full to uni-lab organization..." - for package in $(find ./output -name "unilabos-full*.conda"); do - conda run -n build-env anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" - done - - - name: List built packages + - name: Build and test packages if: steps.should_build.outputs.should_build == 'true' run: | - echo "Built packages in output directory:" - find ./output -name "*.conda" | head -10 - ls -la ./output/${{ matrix.platform }}/ || echo "${{ matrix.platform }} directory not found" - ls -la ./output/noarch/ || echo "noarch directory not found" - echo "Output directory structure:" - find ./output -type f -name "*.conda" - - - name: Prepare artifacts for upload - if: steps.should_build.outputs.should_build == 'true' + extra=() + if [[ "$BUILD_FULL" == "true" ]]; then extra+=(--full); fi + if [[ "$EXTENSIONS_ONLY" == "true" ]]; then extra+=(--extensions-only); fi + python scripts/build_conda_release.py build --platform "${{ matrix.platform }}" --ros-distros "$ROS_DISTROS" "${extra[@]}" + - name: Upload packages after successful tests + if: steps.should_build.outputs.should_build == 'true' && (github.event_name == 'release' || inputs.upload_to_anaconda == true) + env: + ANACONDA_API_TOKEN: ${{ secrets.ANACONDA_API_TOKEN }} run: | - mkdir -p conda-packages-temp - find ./output -name "*.conda" -exec cp {} conda-packages-temp/ \; - echo "Copied files to temp directory:" - ls -la conda-packages-temp/ - - - name: Upload conda package artifacts + extra=() + if [[ "$BUILD_FULL" == "true" ]]; then extra+=(--full); fi + if [[ "$EXTENSIONS_ONLY" == "true" ]]; then extra+=(--extensions-only); fi + python scripts/build_conda_release.py upload --platform "${{ matrix.platform }}" --ros-distros "$ROS_DISTROS" "${extra[@]}" + - uses: actions/upload-artifact@v6 if: steps.should_build.outputs.should_build == 'true' - uses: actions/upload-artifact@v6 with: - name: conda-package-unilabos-${{ matrix.ros_distro }}-${{ matrix.platform }} - path: conda-packages-temp + name: conda-package-unilabos-hostlink-${{ matrix.platform }} + path: conda-artifacts/ if-no-files-found: error - retention-days: 30 - - name: Record successful source provenance id: source - if: steps.should_build.outputs.should_build == 'true' + if: steps.should_build.outputs.should_build == 'true' && env.EXTENSIONS_ONLY != 'true' env: - PUBLISHED: ${{ github.event_name == 'workflow_run' || inputs.upload_to_anaconda == true }} + PUBLISHED: ${{ github.event_name == 'release' || inputs.upload_to_anaconda == true }} run: | sha="$(git rev-parse HEAD)" state=tested @@ -311,13 +165,10 @@ jobs: printf '%s\n' "$sha" > source-sha.txt echo "sha=$sha" >> "$GITHUB_OUTPUT" echo "state=$state" >> "$GITHUB_OUTPUT" - echo "Built $sha (${{ matrix.ros_distro }}/${{ matrix.platform }}, $state)" >> "$GITHUB_STEP_SUMMARY" - - name: Upload successful source provenance - if: steps.should_build.outputs.should_build == 'true' + if: steps.should_build.outputs.should_build == 'true' && env.EXTENSIONS_ONLY != 'true' uses: actions/upload-artifact@v6 with: - name: unilabos-source-${{ steps.source.outputs.state }}-${{ steps.source.outputs.sha }}-${{ matrix.ros_distro }}-${{ matrix.platform }} + name: unilabos-source-${{ steps.source.outputs.state }}-${{ steps.source.outputs.sha }}-hostlink-${{ matrix.platform }} path: source-sha.txt if-no-files-found: error - retention-days: 30 diff --git a/.github/workflows/wheel-release.yml b/.github/workflows/wheel-release.yml new file mode 100644 index 000000000..b3ab0d776 --- /dev/null +++ b/.github/workflows/wheel-release.yml @@ -0,0 +1,80 @@ +name: UniLabOS Companion Wheels + +on: + push: + branches: [dev] + paths: ['recipes/**', '.conda/pylabrobot/**', 'scripts/*wheel_release.py', 'scripts/verify_installation.py', 'setup.py', 'pyproject.toml', 'MANIFEST.in', 'unilabos/utils/requirements*.txt', '.github/workflows/wheel-release.yml'] + pull_request: + branches: [dev] + paths: ['recipes/**', '.conda/pylabrobot/**', 'scripts/*wheel_release.py', 'scripts/verify_installation.py', 'setup.py', 'pyproject.toml', 'MANIFEST.in', 'unilabos/utils/requirements*.txt', '.github/workflows/wheel-release.yml'] + release: + types: [published] + workflow_dispatch: + inputs: + source_ref: + description: '构建源码分支 / SHA;手动构建仅生成附件,不发布' + type: string + default: dev + +permissions: + contents: read + +jobs: + build: + if: github.event_name != 'release' || startsWith(github.event.release.tag_name, 'v') + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + platform: linux-64 + - os: windows-2022 + platform: win-64 + - os: macos-15-intel + platform: osx-64 + - os: macos-latest + platform: osx-arm64 + runs-on: ${{ matrix.os }} + env: + PYTHONUTF8: '1' + PYTHONIOENCODING: utf-8 + RELEASE_TAG: ${{ github.event.release.tag_name }} + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.source_ref || github.event.release.tag_name || github.ref }} + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - name: Build and verify complete offline wheelhouse outside checkout + run: python scripts/build_wheel_release.py + - name: Check release tag against built metadata + if: github.event_name == 'release' + run: python -c "import json, os; from pathlib import Path; m=json.loads(Path('dist/wheel-release/manifest.json').read_text()); assert os.environ['RELEASE_TAG'] == 'v'+m['version']; assert m['verified'] and not m['local_test']" + - uses: actions/upload-artifact@v6 + with: + name: unilabos-wheelhouse-${{ matrix.platform }} + path: | + dist/unilabos-*-wheelhouse-*.zip + dist/unilabos-*-wheelhouse-*.zip.sha256 + if-no-files-found: error + retention-days: 90 + + attach-release: + needs: build + if: github.event_name == 'release' && startsWith(github.event.release.tag_name, 'v') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v5 + with: + pattern: unilabos-wheelhouse-* + merge-multiple: true + path: release-wheels + - name: Attach tested bundles to this release (never PyPI, never overwrite) + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: gh release upload "$RELEASE_TAG" release-wheels/*.zip release-wheels/*.sha256 diff --git a/AGENTS.md b/AGENTS.md index 958b6af18..88c1f3e1e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,13 +5,13 @@ This file provides guidance for coding agents working in this repository. ## Build & Development ```bash -# Install in editable mode (requires Python 3.12 and either ROS 2 Jazzy or Humble) -pip install -e . -uv pip install -r unilabos/utils/requirements.txt +# Default installation needs only Python 3.12; ROS 2 is explicitly optional +python scripts/dev_install.py # builds companion PLR / Opentrons wheels; no PyPI upload +python scripts/dev_install.py --extras full # docs, tests, driver SDKs and development tools # Run with a device graph -unilab --graph --config --backend ros2 -unilab --graph --config --backend hostlink # no ROS2 runtime +unilab --graph --config # HostLink, no ROS runtime or messages +unilab --graph --config --backend ros2 # requires unilabos-ros2 # Common CLI flags unilab --test_mode # simulate hardware, no real execution diff --git a/MANIFEST.in b/MANIFEST.in index 156ca52b5..8dcbcc5d1 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,7 +1,7 @@ recursive-include unilabos/test * recursive-include unilabos/utils * recursive-include unilabos/registry *.yaml -recursive-include unilabos/app/web/static * -recursive-include unilabos/app/web/templates * +recursive-exclude * __pycache__ *.pyc +prune unilabos/devices/cytomat recursive-include unilabos/device_mesh/devices * recursive-include unilabos/device_mesh/resources * diff --git a/README.md b/README.md index a632e1f2e..b4f9da945 100644 --- a/README.md +++ b/README.md @@ -31,52 +31,62 @@ Detailed documentation can be found at: ## Supported Runtime -The current binary and development baseline is **Python 3.12.13 (`cp312`) + NumPy -2**. ROS 2 Jazzy is the default (`robostack-jazzy`, mutex `0.15.*`), and ROS 2 -Humble is also supported (`robostack-humble`, mutex `0.9.*`). Use separate Conda -environments and never mix the two RoboStack channels. See the -[runtime and ABI baseline](docs/user_guide/runtime_baseline.md) for migration and -verification instructions. +The default installation and runtime are **Python 3.12 + NumPy 2 + HostLink, +without ROS**. ROS 2 Jazzy/Humble remain explicit optional backends. Ordinary +Python drivers, Host/Slave, Workstation/sub-devices, materials, workflows, +HTTP/WS APIs and MCP do not require ROS. MoveIt, RViz and ROS-native drivers or +image pipelines still require an appropriate ROS environment. -## Quick Start +See the [installation guide](docs/user_guide/installation.md) and +[runtime baseline](docs/user_guide/runtime_baseline.md) for the exact boundaries. -### 1. Setup Conda Environment +## Quick Start -Uni-Lab-OS recommends using `mamba` for environment management. Choose the package that fits your needs: +### 1. Install without ROS (default) -| Package | Use Case | Contents | -|---------|----------|----------| -| `unilabos` | **Recommended for most users** | Complete package, ready to use | -| `unilabos-env` | Developers (editable install) | Environment only, install unilabos via pip | -| `unilabos-full` | Simulation/Visualization | unilabos + ROS2 Desktop + Gazebo + MoveIt | +From source (works before the new Conda release is published): ```bash -# Create new environment -mamba create -n unilab python=3.12.13 -mamba activate unilab +git clone -b dev https://github.com/deepmodeling/Uni-Lab-OS.git +cd Uni-Lab-OS +python -m venv .venv +# Linux/macOS: source .venv/bin/activate +# Windows PowerShell: .venv/Scripts/Activate.ps1 +python scripts/dev_install.py +unilab --disable-browser +``` -# Option A: Standard installation (recommended for most users) -mamba install uni-lab::unilabos -c uni-lab -c conda-forge -c robostack-jazzy +Alternatively, extract the platform-specific companion wheel bundle and run +`python install_wheel_release.py` with Python 3.12. It creates a new environment, +installs all default dependencies offline and verifies an Opentrons 96-well plate. +The patched Opentrons and pinned PLR wheels are release assets, not PyPI uploads. +Bare `pip install unilabos` against PyPI cannot discover these companion wheels; +use the installer or `--find-links /wheelhouse`. Source development uses +the helper above to build them first. See the [installation guide](docs/user_guide/installation.md). -# Option B: For developers (editable mode development) -mamba install uni-lab::unilabos-env -c uni-lab -c conda-forge -c robostack-jazzy -# Then install unilabos and dependencies: -git clone https://github.com/deepmodeling/Uni-Lab-OS.git && cd Uni-Lab-OS -pip install -e . -uv pip install -r unilabos/utils/requirements.txt +For the **0.12.3+ default Conda package**, once published: -# Option C: Full installation (simulation/visualization) -mamba install uni-lab::unilabos-full -c uni-lab -c conda-forge -c robostack-jazzy +```bash +mamba create -n unilab --override-channels -c uni-lab -c conda-forge "unilabos>=0.12.3" +mamba activate unilab +unilab --disable-browser ``` -For ROS 2 Humble, create a separate environment and replace every -`-c robostack-jazzy` above with `-c robostack-humble`; Conda will select the -matching `humble_1` package build automatically. +Older Conda releases still include ROS. Do not add a RoboStack channel for the +default installation. + +| Package | Purpose | +| --- | --- | +| `unilabos` | Default application and ROS-free dependencies | +| `unilabos-ros2` | Explicit Jazzy/Humble runtime and message extension | +| `unilabos-full` | Complete runtime, ROS desktop, documentation, tests and development tools | -**When to use which?** -- **unilabos**: Standard installation for production deployment and general usage (recommended) -- **unilabos-env**: For developers who need `pip install -e .` editable mode, modify source code -- **unilabos-full**: For simulation (Gazebo), visualization (rviz2), and Jupyter notebooks +ROS users install the matching `unilabos-ros2` variant in a separate environment +and select `--backend ros2`; see the installation guide for commands. Individual +hardware SDKs belong to their device packages. For source development, use +`python scripts/dev_install.py --extras full`. The only pip extras are `ros2` +and `full`; native ROS is installed separately via Conda/RoboStack. +`unilabos-env` is retired; default dependencies are declared by `unilabos` itself. ### 2. Clone Repository (Optional, for developers) @@ -88,10 +98,9 @@ cd Uni-Lab-OS ### 3. Start Uni-Lab -An Edge process owns a device graph and exposes two separate local endpoints: -the management/HTTP API (default `8002`) and the HostLink TCP channel (default -`7302`). The default `hostlink` backend does not require DDS or a ROS 2 daemon; -the `ros2` backend runs device actions and topics through ROS 2. +The default launcher starts a microbackend authority and a supervised Host child. +The authority serves the management/HTTP API (default `8002`); the Host serves +HostLink TCP (default `7302`). Neither requires ROS with the default backend. ```bash # HostLink runtime (the default) diff --git a/README_zh.md b/README_zh.md index ba95c30c2..3ea85ea9e 100644 --- a/README_zh.md +++ b/README_zh.md @@ -31,50 +31,57 @@ Uni-Lab-OS 是一个用于实验室自动化的综合平台,旨在连接和控 ## 支持的运行时 -当前二进制包和开发环境统一使用 **Python 3.12.13(`cp312`)+ NumPy 2**。 -ROS 2 Jazzy 是默认发行版(`robostack-jazzy`,mutex `0.15.*`),同时支持 -ROS 2 Humble(`robostack-humble`,mutex `0.9.*`)。两个发行版必须使用独立 -Conda 环境,不能混用 RoboStack channel。迁移和版本核对方法见 +默认安装和运行统一为 **Python 3.12 + NumPy 2 + HostLink,不需要 ROS**。 +ROS 2 Jazzy/Humble 改为显式选装。普通 Python 驱动、Host/Slave、Workstation/ +sub-device、物料、工作流、HTTP/WS API 和 MCP 都可以在无 ROS 环境运行。 +MoveIt、RViz、ROS 原生驱动和图像管线仍需要相应 ROS 环境。 + +安装方式见[安装指南](docs/user_guide/installation.md),能力边界见 [运行时与 ABI 基线](docs/user_guide/runtime_baseline.md)。 ## 快速开始 -### 1. 配置 Conda 环境 - -Uni-Lab-OS 建议使用 `mamba` 管理环境。根据您的需求选择合适的安装包: +### 1. 默认无 ROS 安装 -| 安装包 | 适用场景 | 包含内容 | -|--------|----------|----------| -| `unilabos` | **推荐大多数用户** | 完整安装包,开箱即用 | -| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos | -| `unilabos-full` | 仿真/可视化 | unilabos + ROS2 桌面版 + Gazebo + MoveIt | +源码安装(新 Conda 版本发布前也可使用): ```bash -# 创建新环境 -mamba create -n unilab python=3.12.13 -mamba activate unilab +git clone -b dev https://github.com/deepmodeling/Uni-Lab-OS.git +cd Uni-Lab-OS +python -m venv .venv +# Linux/macOS: source .venv/bin/activate +# Windows PowerShell: .venv/Scripts/Activate.ps1 +python scripts/dev_install.py +unilab --disable-browser +``` -# 方案 A:标准安装(推荐大多数用户) -mamba install uni-lab::unilabos -c uni-lab -c conda-forge -c robostack-jazzy +也可解压对应平台的配套 wheel 包,用 Python 3.12 执行 `python install_wheel_release.py`: +自动新建环境、离线安装全部默认依赖,并实际验证 Opentrons 96 孔板创建。 +修正版 Opentrons 和固定 PLR wheel 随发行附件分发,不上传 PyPI;仅对公共源执行 +`pip install unilabos` 找不到它们,需要安装入口或 `--find-links <配套包>/wheelhouse`。 +上面的源码安装工具会先构建配套依赖。详见[安装指南](docs/user_guide/installation.md)。 -# 方案 B:开发者环境(可编辑模式开发) -mamba install uni-lab::unilabos-env -c uni-lab -c conda-forge -c robostack-jazzy -# 然后安装 unilabos 和依赖: -git clone https://github.com/deepmodeling/Uni-Lab-OS.git && cd Uni-Lab-OS -pip install -e . -uv pip install -r unilabos/utils/requirements.txt +**0.12.3+ 默认 Conda 包发布后**: -# 方案 C:完整安装(仿真/可视化) -mamba install uni-lab::unilabos-full -c uni-lab -c conda-forge -c robostack-jazzy +```bash +mamba create -n unilab --override-channels -c uni-lab -c conda-forge "unilabos>=0.12.3" +mamba activate unilab +unilab --disable-browser ``` -如需 ROS 2 Humble,请新建独立环境,并将上述所有 `-c robostack-jazzy` 替换为 -`-c robostack-humble`;Conda 会自动选择对应的 `humble_1` 构建。 +旧 Conda 版本仍带 ROS;默认安装不添加任何 RoboStack channel。 + +| 安装包 | 用途 | +| --- | --- | +| `unilabos` | 默认应用及无 ROS 依赖 | +| `unilabos-ros2` | 显式选装 Jazzy/Humble 运行时和消息扩展 | +| `unilabos-full` | 完整运行环境、ROS 桌面、文档、测试与开发工具 | -**如何选择?** -- **unilabos**:标准安装,适用于生产部署和日常使用(推荐) -- **unilabos-env**:开发者使用,支持 `pip install -e .` 可编辑模式,可修改源代码 -- **unilabos-full**:需要仿真(Gazebo)、可视化(rviz2)或 Jupyter Notebook +需要 ROS 时,在独立环境安装对应的 `unilabos-ros2` 变体,并指定 +`--backend ros2`,具体命令见安装指南。硬件专用 SDK 按设备包要求安装; +源码开发使用 `python scripts/dev_install.py --extras full`,一次安装文档、测试和开发依赖。 +pip 只保留 `ros2` / `full` 两个可选档位;原生 ROS 仍由 Conda/RoboStack 安装。 +`unilabos-env` 停止发布,默认依赖直接由 `unilabos` 声明。 ### 2. 克隆仓库(可选,供开发者使用) @@ -86,10 +93,9 @@ cd Uni-Lab-OS ### 3. 启动 Uni-Lab -Edge 进程负责设备图,并分别暴露两个本地端口:管理/HTTP API(默认 -`8002`)和 HostLink TCP 通道(默认 `7302`)。默认的 `hostlink` backend -不需要 DDS 或 ROS 2 守护进程;`ros2` backend 则通过 ROS 2 执行设备动作和 -Topic。 +默认启动微后端权威进程及受管 Host 子进程。权威进程提供管理/HTTP API +(默认 `8002`),Host 提供 HostLink TCP 通道(默认 `7302`)。默认 backend +下两个进程都不需要 ROS。 ```bash # HostLink 运行时(默认) diff --git a/docs/conf.py b/docs/conf.py index fb6264b69..37276f61a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -8,9 +8,10 @@ import os import sys +from pathlib import Path # 将项目的根目录添加到 sys.path 中,以便 Sphinx 能够找到 unilabos 包 -sys.path.insert(0, os.path.abspath("..")) +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) project = "Uni-Lab-OS" copyright = "2026, Uni-Lab-OS Community" @@ -53,22 +54,20 @@ # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output -# 设置 HTML 主题为 sphinx-book-theme +# Read the Docs 主题;不要混用其他主题的配置键。 html_theme = "sphinx_rtd_theme" -# sphinx-book-theme 主题选项 html_theme_options = { - "repository_url": "https://github.com/deepmodeling/Uni-Lab-OS", - "use_repository_button": True, - "use_issues_button": True, - "use_edit_page_button": True, - "use_download_button": True, - "path_to_docs": "docs", - "show_navbar_depth": 2, - "show_toc_level": 2, - "home_page_in_toc": True, + "navigation_depth": 3, "logo_only": False, } +html_context = { + "display_github": True, + "github_user": "deepmodeling", + "github_repo": "Uni-Lab-OS", + "github_version": "dev", + "conf_py_path": "/docs/", +} section_titles = { "Simple": "## 简单单变量动作函数", @@ -87,44 +86,38 @@ """, } -import os -from pathlib import Path - - def get_conda_share_dir(package_name=None): """获取 Conda 环境的 share 目录路径 :param package_name: 可选参数,指定具体包的 share 子目录 :return: Path 对象或 None """ - # 获取当前 Conda 环境根目录 - conda_prefix = os.getenv("CONDA_PREFIX") + # 可显式复用 ROS 的消息定义目录;读取文档数据,不把另一环境加入 Python 导入路径。 + conda_prefix = os.getenv("UNILABOS_DOCS_ROS_PREFIX") or os.getenv("CONDA_PREFIX") if not conda_prefix: - raise EnvironmentError("未检测到激活的 Conda 环境") - - # 构建基础 share 目录路径 - share_dir = Path(conda_prefix) / "share" + conda_prefix = sys.prefix - # 如果指定了包名,追加包子目录 + # Windows Conda 把 ROS 数据放在 Library/share;Unix 位于 share。 + candidates = [Path(conda_prefix) / "share", Path(conda_prefix) / "Library/share"] if package_name: - share_dir = share_dir / package_name - - # 验证路径是否存在 - if not share_dir.exists(): - print(f"警告: 路径 {share_dir} 不存在") - return None - - return share_dir + candidates = [path / package_name for path in candidates] + for share_dir in candidates: + if share_dir.is_dir(): + return share_dir + if os.getenv("UNILABOS_DOCS_REQUIRE_ROS") == "1": + raise EnvironmentError(f"完整文档缺少 ROS 消息定义目录:{candidates}") + print(f"警告: 未找到 ROS 消息定义目录:{candidates}") + return None def generate_action_includes(app): src_dir = Path(app.srcdir) print(f"Generating action includes for {src_dir}") action_dir = src_dir.parent / "unilabos_msgs" / "action" # 修改为你的实际路径 - output_file = src_dir / "developer_guide" / "action_includes.md" + output_file = Path(app.doctreedir) / "generated" / "action_includes.md" # 确保输出目录存在 - output_file.parent.mkdir(exist_ok=True) + output_file.parent.mkdir(parents=True, exist_ok=True) # 初始化各部分内容 sections = {} @@ -161,16 +154,19 @@ def generate_action_includes(app): print(f"警告: 动作消息目录 {action_dir} 不存在") ros_action_dirs = [] - control_msgs_dir = get_conda_share_dir("control_msgs") - nav2_msgs_dir = get_conda_share_dir("nav2_msgs") + control_msgs_dir = get_conda_share_dir("control_msgs/action") + nav2_msgs_dir = get_conda_share_dir("nav2_msgs/action") if control_msgs_dir is not None: - ros_action_dirs.append(control_msgs_dir / "action") + ros_action_dirs.append(control_msgs_dir) if nav2_msgs_dir is not None: - ros_action_dirs.append(nav2_msgs_dir / "action") + ros_action_dirs.append(nav2_msgs_dir) for action_dir in ros_action_dirs: - for action_file in sorted(action_dir.glob("*.action")): + action_files = sorted(action_dir.glob("*.action")) + if not action_files and os.getenv("UNILABOS_DOCS_REQUIRE_ROS") == "1": + raise EnvironmentError(f"完整文档缺少 ROS 动作定义:{action_dir}") + for action_file in action_files: # 获取相对路径 rel_path = f"{action_file.absolute()}" # 读取首行注释 @@ -191,17 +187,26 @@ def generate_action_includes(app): else: sections[section] += text - # 写入内容到输出文件 - with open(output_file, "w", encoding="utf-8") as f: - # 按 Section 生成总文档 - for section, title in section_titles.items(): - content = sections.get(section, "") - if content: # 只有有内容时才写入标题和内容 - f.write(f"{title}\n\n") - f.write(content) + # 生成物留在构建目录;不改写仓库里已跟踪的 Markdown。 + rendered = "# 动作接口参考\n\n" + "".join(f"{title}\n\n{sections[section]}" for section, title in section_titles.items() + if sections.get(section)) + output_file.write_text(rendered, encoding="utf-8") + app._unilab_action_reference = rendered + + +def inject_action_includes(app, docname, source): + if docname == "developer_guide/action_includes": + source[0] = app._unilab_action_reference + + +def refresh_action_reference(app, env, added, changed, removed): + # ROS 环境可能与上次构建不同;增量构建也必须更新这一页,不能复用缺项内容。 + return ["developer_guide/action_includes"] def setup(app): app.connect("builder-inited", generate_action_includes) + app.connect("source-read", inject_action_includes) + app.connect("env-get-outdated", refresh_action_reference) app.add_js_file("https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js") app.add_js_file(None, body="mermaid.initialize({startOnLoad:true});") diff --git a/docs/developer_guide/readme_demo_tests.md b/docs/developer_guide/readme_demo_tests.md index e34cc0e35..8cb17fe27 100644 --- a/docs/developer_guide/readme_demo_tests.md +++ b/docs/developer_guide/readme_demo_tests.md @@ -16,7 +16,9 @@ ## 运行方式 -激活项目环境(例如 `unilab-dev-jazzy`),安装测试依赖;ROS2 测试还需该发行版的原生 `rclpy`、DDS 与 `unilabos_msgs`,不能只安装消息占位包。 +默认使用独立 Python 3.12 环境,安装默认包和 `pytest` / `pytest-asyncio`,不安装 ROS。 +开发者可直接使用 `python scripts/dev_install.py --extras full`。ROS2 测试另用 +Jazzy/Humble 环境,需要该发行版的原生 `rclpy`、DDS 与 `unilabos_msgs`,不能只安装消息占位包。 PowerShell 示例: @@ -38,6 +40,16 @@ CI 在 Jazzy、Humble 环境各自执行四种组合;发行版矩阵本身不 所有实例使用临时数据库、独立端口及机器名,不读取用户的 `UNILABOS_*` 配置覆盖或 demo 控制变量。 注册表校验和图生成也在临时工作目录执行,避免污染 demo checkout。结束时清理本测试启动的进程树。 +## 无 ROS 安装验证 + +从 0.12.3 起,CI 增加独立的无 ROS Python 安装矩阵(Windows、Linux、macOS): +只安装默认包和测试工具,执行 `pip check` 和 `verify_installation.py --assert-no-ros`,再运行七个 demo 的 +完整 CLI/HTTP/HostLink 链路。不能用已安装 Jazzy/Humble 的环境代替此项。 +证据产物为 `no-ros-evidence-<系统>`;原有 ROS2 的两发行版回归继续保留。 + +Slave 在物料和服务初始化完成前仅登记通信归属,不进入调度能力快照。 +测试应等待真实就绪状态,不靠固定 sleep 掩盖 Site 尚未创建的竞态。 + ## Site 与业务断言 API 导入默认 `site_binding_mode=resolve`。标记为 Site 的 `T3` / `T4` 会被解析为权威 UUID;动作回报 UUID 是正常契约。 diff --git a/docs/developer_guide/wheel_release.md b/docs/developer_guide/wheel_release.md new file mode 100644 index 000000000..3e1940f81 --- /dev/null +++ b/docs/developer_guide/wheel_release.md @@ -0,0 +1,65 @@ +# 配套 wheel 发布约定 + +默认发行使用 Python 3.12 / NumPy 2 / HostLink,不需要 ROS。下游修正版不上传官方 PyPI, +而是与 UniLabOS 同批作为发行附件提供。只附带一个 wheel 不会改变 pip 的查找规则; +安装入口必须显式提供 `--find-links`,不能把解决依赖的责任推迟到启动设备时。 + +## 源码与版本 + +- `recipes/wheels.toml` 固定下载地址、SHA256、版本、补丁和 NOTICE。 +- Opentrons wheel 使用 `recipes/opentrons-shared-data/numpy2.patch`,与 Conda 完全共用; + 包版本为 `9.1.0+unilabos.np2.1`,保留原许可证、修改说明和物料 JSON 数据。 +- PLR 固定提交 `6285d662effa972be97a781ba7493553f2ba94ee`,与 Conda 共用版本和依赖补丁; + Python 分发版本 `0.2.2+unilabos.6285d662effa`,避免与上游 `0.2.2` 混淆。 +- PLR 自身必须无条件声明 `opentrons-shared-data==9.1.0+unilabos.np2.1`; + Conda 的 PLR 配方也声明同一前置包。不是调整 requirements 的行顺序,也不安装完整的 `opentrons` SDK。 +- UniLabOS 的 `Requires-Dist` 与包内 `unilabos/utils/requirements.txt` 一致。 + 不使用源码直链依赖:`--no-index` 不会阻止直接 URL 的下载。 + +## 构建入口 + +仅准备开发/CI 配套依赖: + +```bash +python scripts/build_wheel_release.py --dependencies-only --output-dir dist/companion +python -m pip install --find-links dist/companion/wheelhouse -e ".[full]" +``` + +在当前目标平台构建完整包: + +```bash +python scripts/build_wheel_release.py --output-dir dist/wheel-release +# 未提交工作区只能生成明确标记的本地测试包 +python scripts/build_wheel_release.py --local-test --output-dir dist/wheel-local-test +``` + +构建机需要 Python 3.12、pip、Git 和网络;目标机只需 Python 3.12。 +脚本要求新的或空的输出目录,不会覆盖旧发行包。源码缓存按 SHA256 命名,每次复核哈希。 +正式构建拒绝相关未提交修改,主包从当前 HEAD 的 Git 归档构建,避免夹带本地 build 缓存。 + +完整包包含主包、两个配套 wheel 和全部默认间接依赖,以及: + +- `install_wheel_release.py`:标准库安装入口,新建 venv,拒绝覆盖现有目录。 +- `requirements.lock`:实际 wheel 的精确版本和 SHA256,安装启用 `--require-hashes`。 +- `manifest.json`:源码 SHA、平台、Python 版本、校验结果及文件哈希。 +- `wheel-sources.toml`、`sources/`:固定源清单、实际补丁和修改说明。 +- `verify_installation.py`:只读检查依赖、无 ROS、微后端、CLI 和实际物料创建。 + +所有 JSON、锁文件、ZIP、wheel 都在本地输出或 CI artifact 中生成,不提交到 docs 或源码。 + +## 验收与发布 + +构建必须在源码目录外分别创建全新 venv:只安装 PLR、只安装 UniLabOS、使用完整锁定清单安装。 +前两项不得提前安装 Opentrons 或手动补依赖,以免全量清单掩盖缺失的依赖声明。 +三种方式均从完整 wheelhouse 离线解析,运行 `pip check`、 +无 ROS 导入、CLI 和 `corning_96_wellplate_360ul_flat` 实际创建;96 孔数量、NumPy 2 +积分接口、修正版依赖版本都必须正确。仅 PLR 环境使用验证脚本的 `--materials-only`,不检查 UniLabOS 的 CLI。 +只有全部通过才标记 `verified`、记录 `verified_install_roots` 并生成最终 ZIP 和外部 SHA256 文件。 + +`wheel-release.yml` 覆盖 Windows x64、Linux x64、macOS Intel/Apple Silicon。 +PR/dev/手动运行只生成 Actions artifacts;正式 `v<版本>` Release 校验标签后, +必须等四个平台都通过,才把 ZIP 和校验文件附到该 Release,不覆盖已有附件,不上传 PyPI。 +本地验证不等于四个平台都已通过;发布时仍须检查完整 CI 矩阵。 + +开发环境添加测试或硬件 SDK 时仍按 extras 安装。离线包只保证默认核心依赖, +不包含所有硬件 SDK、Python 解释器或 ROS。需要打包解释器时使用既有 Conda-Pack 发行入口。 diff --git a/docs/requirements.txt b/docs/requirements.txt index 591cc07ad..598399a9d 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,18 +1,2 @@ -# Sphinx文档构建依赖 -sphinx>=7.0.0 -sphinx-rtd-theme>=2.0.0 -myst-parser>=2.0.0 -sphinxcontrib-mermaid - -# 用于支持Jupyter notebook文档 -myst-nb>=1.0.0 - -# 用于代码复制按钮 -sphinx-copybutton>=0.5.0 - -# 用于自动摘要生成 -sphinx-autobuild>=2024.2.4 - -# 用于PDF导出 (rinohtype方案,纯Python无需LaTeX) -rinohtype>=0.5.4 -sphinx-simplepdf>=1.6.0 \ No newline at end of file +# 与 unilabos[full] 使用同一份依赖,不维护第二套版本。 +-r ../unilabos/utils/requirements-docs.txt diff --git a/docs/user_guide/best_practice.md b/docs/user_guide/best_practice.md index 496d2c21d..211ccab82 100644 --- a/docs/user_guide/best_practice.md +++ b/docs/user_guide/best_practice.md @@ -4,8 +4,8 @@ 本指南将引导您从零开始完成 Uni-Lab-OS 实验室系统的完整搭建,从环境安装到高级设备开发。无论您是初次接触 Uni-Lab-OS 的用户,还是希望深入定制开发的开发者,都能在本指南中找到清晰的步骤和实用建议。 -> 本指南统一以 Python 3.12.13(`cp312`)、ROS 2 Jazzy 和 NumPy 2 为基线。 -> 从 Python 3.11/ROS 2 Humble 环境迁移时,请先阅读[运行时与 ABI 基线](runtime_baseline.md)。 +> 默认环境为 Python 3.12、HostLink 和 NumPy 2,不需要 ROS;ROS2 仅显式选装。 +> 旧 ROS 环境迁移及 ROS 专用设备的安装见[运行时与 ABI 基线](runtime_baseline.md)。 ### 适用对象 @@ -38,9 +38,9 @@ | 安装包 | 适用场景 | 包含组件 | | --------------- | ---------------------------- | --------------------------------------------- | -| `unilabos` | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 | -| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos | -| `unilabos-full` | 仿真/可视化 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt | +| `unilabos` | **推荐大多数用户**,生产部署 | 无 ROS 的 HostLink / 微后端应用 | +| `unilabos-ros2` | ROS 专用驱动 | 显式选装 Jazzy/Humble 运行时及消息 | +| `unilabos-full` | 完整开发与仿真 | 运行、文档、测试、开发工具及完整 ROS2 桌面环境 | **关键步骤:** @@ -57,21 +57,22 @@ mamba activate unilab # 4. 安装 Uni-Lab-OS(选择其一) # 方案 A:标准安装(推荐大多数用户) -mamba install uni-lab::unilabos -c uni-lab -c conda-forge -c robostack-jazzy +mamba install --override-channels -c uni-lab -c conda-forge "unilabos>=0.12.3" # 方案 B:开发者环境(可编辑模式开发) -mamba install uni-lab::unilabos-env -c uni-lab -c conda-forge -c robostack-jazzy -pip install -e /path/to/Uni-Lab-OS # 可编辑安装 -uv pip install -r unilabos/utils/requirements.txt # 安装 pip 依赖 +cd /path/to/Uni-Lab-OS +python scripts/dev_install.py --extras full # 可编辑安装 # 方案 C:完整版(仿真/可视化) -mamba install uni-lab::unilabos-full -c uni-lab -c conda-forge -c robostack-jazzy +mamba install --override-channels -c uni-lab -c conda-forge -c robostack-jazzy "unilabos-full=0.12.3=jazzy_0" ``` +以上 Conda 命令需对应新版本已发布;发布前可按安装指南从源码安装。 + **选择建议:** - **日常使用/生产部署**:使用 `unilabos`(推荐),完整功能,开箱即用 -- **开发者**:使用 `unilabos-env` + `pip install -e .` + `uv pip install -r unilabos/utils/requirements.txt`,代码修改立即生效 +- **开发者**:使用 Python 3.12 虚拟环境 + `python scripts/dev_install.py --extras full`,一次安装全部 Python 开发依赖;需要原生 ROS 时选 Conda full - **仿真/可视化**:使用 `unilabos-full`,含 Gazebo、rviz2、MoveIt #### 1.2 验证安装 @@ -80,11 +81,12 @@ mamba install uni-lab::unilabos-full -c uni-lab -c conda-forge -c robostack-jazz # 检查 unilabos 是否安装成功 python -c "import unilabos; print(unilabos.__version__)" -# 验证 ROS 消息包 -python -c "from unilabos_msgs.msg import Resource; print('ROS msgs OK')" +# 验证默认运行时 +python -c "from unilabos.backend.hostlink.local_runtime import HostLinkLocalRuntime; print('HostLink OK')" +python -m pip check ``` -如果两条命令都正常输出,说明安装成功。 +进一步的安装验证与显式 ROS 消息检查见安装指南。 --- @@ -798,39 +800,34 @@ Waiting for host service... #### 9.1 开发环境准备 -**推荐使用 `unilabos-env` + `pip install -e .` + `uv pip install`** 进行设备开发: +**推荐使用 `full` 档位**进行设备开发;不再单独安装 `unilabos-env`: ```bash -# 1. 创建环境并安装 unilabos-env(ROS2 + conda 依赖 + uv) +# 1. 创建 Python 环境;需要原生 ROS 时改用安装指南中的 Conda full mamba create -n unilab python=3.12.13 conda activate unilab -mamba install uni-lab::unilabos-env -c uni-lab -c conda-forge -c robostack-jazzy # 2. 克隆代码 git clone https://github.com/deepmodeling/Uni-Lab-OS.git cd Uni-Lab-OS # 3. 以可编辑模式安装(推荐使用脚本,自动检测中文环境) -python scripts/dev_install.py - -# 或手动安装: -pip install -e . -uv pip install -r unilabos/utils/requirements.txt +python scripts/dev_install.py --extras full ``` **为什么使用这种方式?** -- `unilabos-env` 提供 ROS2 核心组件和 uv(通过 conda 安装,避免编译) -- `unilabos/utils/requirements.txt` 包含所有运行时需要的 pip 依赖 -- `dev_install.py` 自动检测中文环境,中文系统自动使用清华镜像 -- 使用 `uv` 替代 `pip`,安装速度更快 +- 默认安装仅包含运行依赖;`full` 同时包含内置驱动 SDK、文档、测试和开发工具 +- pip 不安装原生 ROS;完整 ROS 环境使用 `unilabos-full`,硬件系统驱动仍按设备包准备 +- `dev_install.py` 先构建固定 PLR / Opentrons 配套 wheel,再安装源码;普通包可使用清华镜像 +- 已有发行包时,可用 `--wheelhouse <解压目录>/wheelhouse` 跳过配套依赖构建;修正版不在公共 PyPI +- 已安装 `uv` 时脚本优先使用它,并明确指定当前 Python;否则使用 pip - 可编辑模式:代码修改**立即生效**,无需重新安装 **如果安装失败或速度太慢**,可以手动执行(使用清华镜像): ```bash -pip install -e . -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple -uv pip install -r unilabos/utils/requirements.txt -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple +python scripts/dev_install.py --extras full --china ``` #### 9.2 为什么需要自定义设备? diff --git a/docs/user_guide/installation.md b/docs/user_guide/installation.md index a034853be..d5ebd7ad1 100644 --- a/docs/user_guide/installation.md +++ b/docs/user_guide/installation.md @@ -1,656 +1,217 @@ # Uni-Lab-OS 安装指南 -本指南提供 Uni-Lab-OS 的完整安装说明,涵盖从快速一键安装到完整开发环境配置的所有方式。 +从 **0.12.3** 起,默认安装和运行均使用 **HostLink,不需要 ROS**。Python 要求为 +`>=3.12,<3.13`;Windows、Linux、macOS 可使用相同的 Python 入口。 -## 系统要求 +本文的 Conda 命令适用于 0.12.3 及之后的发布。如果 channel 尚未提供该版本,请先使用 +下方源码安装;旧的 0.12.2 Conda 包仍包含 ROS,升级源码不会自动卸载旧环境中的 ROS。 -- **操作系统**: Windows 10/11, Linux (Ubuntu 20.04+), macOS (10.15+) -- **内存**: 最小 4GB,推荐 8GB 以上 -- **磁盘空间**: 至少 10GB 可用空间 -- **网络**: 稳定的互联网连接(用于下载软件包) -- **其他**: - - 已安装 Conda/Miniconda/Miniforge/Mamba - - 开发者需要 Git 和基本的 Python 开发知识 - - 自定义 msgs 需要 GitHub 账号 +## 配套 wheel 发行包(推荐,无须 Conda) -### 当前运行时基线 - -| 组件 | 要求 | -|------|------| -| Python | 3.12.13(`cp312` ABI,包约束为 `>=3.12,<3.13`) | -| ROS 2 | Jazzy(默认)或 Humble(兼容);必须使用独立环境 | -| NumPy | `>=2,<3` | -| ROS 2 distro mutex | Jazzy `0.15.*`;Humble `0.9.*` | - -两个发行版都使用 Python 3.12 与 NumPy 2。不要原地混用 Humble 与 Jazzy -channel;完整的兼容矩阵、迁移步骤和验证命令见[运行时与 ABI 基线](runtime_baseline.md)。 - -## 安装包选择 - -Uni-Lab-OS 提供三个安装包版本,根据您的需求选择: - -| 安装包 | 适用场景 | 包含组件 | 磁盘占用 | -|--------|----------|----------|----------| -| **unilabos** | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 | ~2-3 GB | -| **unilabos-env** | 开发者环境(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos | ~2 GB | -| **unilabos-full** | 仿真可视化、完整功能体验 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt | ~8-10 GB | - -## 安装方式选择 - -根据您的使用场景,选择合适的安装方式: - -| 安装方式 | 适用人群 | 推荐安装包 | 特点 | 安装时间 | -| ---------------------- | -------------------- | ----------------- | ------------------------------ | ---------------------------- | -| **方式一:一键安装** | 快速体验、演示 | 预打包环境 | 离线可用,无需配置 | 5-10 分钟 (网络良好的情况下) | -| **方式二:手动安装** | **大多数用户** | `unilabos` | 完整功能,开箱即用 | 10-20 分钟 | -| **方式三:开发者安装** | 开发者、需要修改源码 | `unilabos-env` | 可编辑模式,支持自定义开发 | 20-30 分钟 | -| **仿真/可视化** | 仿真测试、可视化调试 | `unilabos-full` | 含 Gazebo、rviz2、MoveIt | 30-60 分钟 | - ---- - -## 方式一:一键安装(推荐新用户) - -使用预打包的 conda 环境,最快速的安装方法。 - -### 前置条件 - -确保已安装 Conda/Miniconda/Miniforge/Mamba。 - -### 安装步骤 - -#### 第一步:下载预打包环境 - -1. 访问 [GitHub Actions - Conda Pack Build](https://github.com/deepmodeling/Uni-Lab-OS/actions/workflows/conda-pack-build.yml) - -2. 选择最新的成功构建记录(绿色勾号 ✓) - -3. 在页面底部的 "Artifacts" 部分,下载对应你操作系统的压缩包: - - Windows: `unilab-pack-win-64-{branch}.zip` - - macOS (Intel): `unilab-pack-osx-64-{branch}.tar.gz` - - macOS (Apple Silicon): `unilab-pack-osx-arm64-{branch}.tar.gz` - - Linux: `unilab-pack-linux-64-{branch}.tar.gz` - -#### 第二步:解压并运行安装脚本 - -**Windows**: - -```batch -REM 使用 Windows 资源管理器解压下载的 zip 文件 -REM 或使用命令行: -tar -xzf unilab-pack-win-64-dev.zip - -REM 进入解压后的目录 -cd unilab-pack-win-64-dev - -REM 双击运行 install_unilab.bat -REM 或在命令行中执行: -install_unilab.bat -``` - -**macOS**: +下载对应平台的 `unilabos-<版本>-wheelhouse-<平台>.zip` 并解压。需要自行安装 Python 3.12; +Windows x64、Linux x64、macOS Intel/Apple Silicon 分别提供独立产物,不能混用。 +在解压目录执行: ```bash -# 解压下载的压缩包 -tar -xzf unilab-pack-osx-arm64-dev.tar.gz - -# 进入解压后的目录 -cd unilab-pack-osx-arm64-dev - -# 运行安装脚本 -bash install_unilab.sh +python install_wheel_release.py +# 可选:指定一个尚不存在的新环境目录 +python install_wheel_release.py --prefix /path/to/new-environment ``` -**Linux**: - -```bash -# 解压下载的压缩包 -tar -xzf unilab-pack-linux-64-dev.tar.gz +入口会校验 SHA256、创建 `.venv`、离线安装全部默认依赖并验证 CLI、微后端和 Opentrons 96 孔板。 +已有环境目录不会覆盖;安装不需要 ROS、Git、Conda、编译器或访问包源。执行结束会打印启动命令。 +这个包不包含 Python 解释器,需要连同解释器分发时使用下文 Conda-Pack 包。 -# 进入解压后的目录 -cd unilab-pack-linux-64-dev +PLR fork 和 Opentrons NumPy 2 修正版只作为配套文件提供,**不上传官方 PyPI**。 +发布到公开 GitHub Release 的附件仍然可公开下载;不等于私有源。 +未发布正式 Release 时,可从 [配套 wheel 构建](https://github.com/deepmodeling/Uni-Lab-OS/actions/workflows/wheel-release.yml) +的成功 artifact 获取;`local-test` 文件仅供本地验证,不是正式发布版本。 -# 添加执行权限(如果需要) -chmod +x install_unilab.sh - -# 运行安装脚本 -./install_unilab.sh -``` - -#### 第三步:激活环境 +已有独立 Python 3.12 环境时,也可由 pip 自动解析依赖: ```bash -conda activate unilab +# 完整离线、精确版本与文件哈希:推荐保留安装入口的默认行为 +python -m pip install --no-index --find-links ./wheelhouse --require-hashes -r requirements.lock +# 只指定主包,自动安装所需依赖(不锁定清单中全部间接依赖版本) +python -m pip install --no-index --find-links ./wheelhouse "unilabos==0.12.3" ``` -激活后,您的命令行提示符应该会显示 `(unilab)` 前缀。 - ---- - -## 方式二:手动安装(标准用户) - -适合生产环境和需要灵活配置的用户。 - -### 第一步:安装 Mamba 环境管理器 - -Mamba 是 Conda 的快速替代品,我们强烈推荐使用 Mamba 来管理 Uni-Lab 环境。 - -#### Windows +只复制 `unilabos.whl`、不提供配套目录,或直接对公共源执行 `pip install unilabos`, +都不能让 pip 发现未托管在公共源的修正版。不要用上游同名包、`--no-deps` 或降级 NumPy 来绕过它。 -下载并安装 Miniforge(包含 Mamba): +## Python 安装 -```powershell -# 访问 https://github.com/conda-forge/miniforge/releases -# 下载 Miniforge3-Windows-x86_64.exe -# 运行安装程序 - -# 也可以使用镜像站 https://mirrors.tuna.tsinghua.edu.cn/github-release/conda-forge/miniforge/LatestRelease/ -# 下载 Miniforge3-Windows-x86_64.exe -# 运行安装程序 -``` - -#### Linux/macOS - -```bash -# 下载 Miniforge 安装脚本 -curl -L -O "https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-$(uname)-$(uname -m).sh" - -# 运行安装 -bash Miniforge3-$(uname)-$(uname -m).sh - -# 按照提示完成安装,建议选择 yes 来初始化 -``` - -安装完成后,重新打开终端使 Mamba 生效。 - -### 第二步:创建 Uni-Lab 环境 - -使用以下命令创建 Uni-Lab 专用环境: +准备一个独立的 Python 3.12 环境,避免复用旧 ROS 环境: ```bash -mamba create -n unilab-jazzy python=3.12.13 -mamba activate unilab-jazzy - -# 选择安装包(三选一): +python -m venv .venv +# Linux / macOS +source .venv/bin/activate +# Windows PowerShell 改用:.venv\Scripts\Activate.ps1 -# 方案 A:标准安装(推荐大多数用户) -mamba install uni-lab::unilabos -c uni-lab -c conda-forge -c robostack-jazzy - -# 方案 B:开发者环境(可编辑模式开发) -mamba install uni-lab::unilabos-env -c uni-lab -c conda-forge -c robostack-jazzy -# 然后安装 unilabos 和 pip 依赖: -git clone https://github.com/deepmodeling/Uni-Lab-OS.git && cd Uni-Lab-OS -pip install -e . -uv pip install -r unilabos/utils/requirements.txt - -# 方案 C:完整版(含仿真和可视化工具) -mamba install uni-lab::unilabos-full -c uni-lab -c conda-forge -c robostack-jazzy -``` - -Humble 使用相同的 Python/NumPy ABI,但必须创建另一个环境并只启用 -`robostack-humble`: - -```bash -mamba create -n unilab-humble python=3.12.13 -mamba activate unilab-humble -mamba install uni-lab::unilabos -c uni-lab -c conda-forge -c robostack-humble -``` - -**参数说明**: - -- `-n unilab-jazzy` / `-n unilab-humble`: 为每个 ROS 发行版创建独立环境 -- `uni-lab::unilabos`: 安装 unilabos 完整包,开箱即用(推荐) -- `uni-lab::unilabos-env`: 仅安装环境依赖,适合开发者使用 `pip install -e .` -- `uni-lab::unilabos-full`: 安装完整包(含 ROS2 Desktop、Gazebo、MoveIt 等) -- `-c uni-lab -c conda-forge -c robostack-`: 添加 Uni-Lab-OS、通用依赖与对应 ROS 2 软件源 - -**包选择建议**: -- **日常使用/生产部署**:安装 `unilabos`(推荐,完整功能,开箱即用) -- **开发者**:安装 `unilabos-env`,然后使用 `uv pip install -r unilabos/utils/requirements.txt` 安装依赖,再 `pip install -e .` 进行可编辑安装 -- **仿真/可视化**:安装 `unilabos-full`(Gazebo、rviz2、MoveIt) - -**如果遇到网络问题**,可以使用清华镜像源加速下载: - -```bash -# 配置清华镜像源 -mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main/ -mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/ -mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/ - -# 然后重新执行安装命令(推荐标准安装) -mamba create -n unilab uni-lab::unilabos -c uni-lab -c conda-forge -c robostack-jazzy - -# 或完整版(仿真/可视化) -mamba create -n unilab uni-lab::unilabos-full -c uni-lab -c conda-forge -c robostack-jazzy - -# pip 安装时使用清华镜像(开发者安装时使用) -uv pip install -r unilabos/utils/requirements.txt -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple -``` - -### 第三步:激活环境 - -```bash -conda activate unilab -``` - ---- - -## 方式三:开发者安装 - -适用于需要修改 Uni-Lab 源代码或开发新设备驱动的开发者。 - -### 前置条件 - -- 已安装 Git -- 已安装 Mamba/Conda -- 有 GitHub 账号(如需自定义 msgs) -- 基本的 Python 开发知识 - -### 第一步:克隆仓库 - -```bash -git clone https://github.com/deepmodeling/Uni-Lab-OS.git +git clone --branch dev https://github.com/deepmodeling/Uni-Lab-OS.git cd Uni-Lab-OS -``` - -如果您需要贡献代码,建议先 Fork 仓库: - -1. 访问 https://github.com/deepmodeling/Uni-Lab-OS -2. 点击右上角的 "Fork" 按钮 -3. Clone 您的 Fork 版本: - ```bash - git clone https://github.com/YOUR_USERNAME/Uni-Lab-OS.git - cd Uni-Lab-OS - ``` - -### 第二步:安装开发环境(unilabos-env) - -**重要**:开发者请使用 `unilabos-env` 包,它专为开发者设计: -- 包含所选 ROS 2 发行版的核心组件和消息包(ros-core、std-msgs、geometry-msgs 等) -- 包含 transforms3d、cv-bridge、tf2 等 conda 依赖 -- 包含 `uv` 工具,用于快速安装 pip 依赖 -- **不包含** pip 依赖和 unilabos 包(由 `pip install -e .` 和 `uv pip install` 安装) - -```bash -# 创建并激活环境 -mamba create -n unilab python=3.12.13 -conda activate unilab - -# 安装开发者环境包(ROS2 + conda 依赖 + uv) -mamba install uni-lab::unilabos-env -c uni-lab -c conda-forge -c robostack-jazzy -``` - -Humble 开发环境使用独立环境和对应 channel: - -```bash -mamba create -n unilab-humble python=3.12.13 -conda activate unilab-humble -mamba install uni-lab::unilabos-env -c uni-lab -c conda-forge -c robostack-humble -``` - -### 第三步:安装 pip 依赖和可编辑模式安装 - -克隆代码并安装依赖: - -```bash -# 确保环境已激活 -conda activate unilab - -# 克隆仓库(如果还未克隆) -git clone https://github.com/deepmodeling/Uni-Lab-OS.git -cd Uni-Lab-OS - -# 切换到 dev 分支(可选) -git checkout dev -git pull -``` - -**推荐:使用安装脚本**(自动检测中文环境,使用 uv 加速): - -```bash -# 自动检测中文环境,如果是中文系统则使用清华镜像 python scripts/dev_install.py - -# 或者手动指定: -python scripts/dev_install.py --china # 强制使用清华镜像 -python scripts/dev_install.py --no-mirror # 强制使用 PyPI -python scripts/dev_install.py --skip-deps # 跳过 pip 依赖安装 -python scripts/dev_install.py --use-pip # 使用 pip 而非 uv -``` - -**手动安装**(如果脚本安装失败或速度太慢): - -```bash -# 1. 安装 unilabos(可编辑模式) -pip install -e . - -# 2. 使用 uv 安装 pip 依赖(推荐,速度更快) -uv pip install -r unilabos/utils/requirements.txt - -# 国内用户使用清华镜像: -pip install -e . -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple -uv pip install -r unilabos/utils/requirements.txt -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple -``` - -**注意**: -- `uv` 已包含在 `unilabos-env` 中,无需单独安装 -- `unilabos/utils/requirements.txt` 包含运行 unilabos 所需的所有 pip 依赖 -- 部分特殊包(如 pylabrobot)会在运行时由 unilabos 自动检测并安装 - -**为什么使用可编辑模式?** - -- `-e` (editable mode):代码修改**立即生效**,无需重新安装 -- 适合开发调试:修改代码后直接运行测试 -- 与 `unilabos-env` 配合:环境依赖由 conda 管理,unilabos 代码由 pip 管理 - -**验证安装**: - -```bash -# 检查 unilabos 版本 -python -c "import unilabos; print(unilabos.__version__)" - -# 检查安装位置(应该指向你的代码目录) -pip show unilabos | grep Location -``` - -### 第四步:安装或自定义 unilabos_msgs(可选) - -Uni-Lab 使用 ROS2 消息系统进行设备间通信。如果你使用方式一或方式二安装,msgs 包已经自动安装。 - -#### 使用已安装的 msgs(大多数用户) - -如果你不需要修改 msgs,可以跳过此步骤,直接使用已安装的 msgs 包。验证安装: - -```bash -# 列出所有 unilabos_msgs 接口 -ros2 interface list | grep unilabos_msgs - -# 查看特定 action 定义 -ros2 interface show unilabos_msgs/action/DeviceCmd +unilab --help +python -m unilabos.app.main --disable_browser --port 8002 ``` -#### 自定义 msgs(高级用户) +首次运行按提示选择工作目录和配置;不带 `-g` 时以空设备图启动,可从微前端安装设备包。 +安装时已声明微后端、调度、物料、HostLink 和 CLI 所需的 Python 依赖,不再需要额外安装 +`unilabos_msgs`、执行 `colcon build` 或手动补一个 requirements 清单。 -如果你需要: +源码安装入口会从固定源码构建配套 wheel,再可编辑安装 UniLabOS。此构建过程需要 Git、 +Python 包源和 GitHub;如果已有配套包,用 `python scripts/dev_install.py --wheelhouse <解压目录>/wheelhouse` +可以跳过这一步。构建产物不会上传 PyPI,源码下载以 SHA256 校验。 -- 添加新的 ROS2 action 定义 -- 修改现有 msg/srv/action 接口 -- 为特定设备定制通信协议 +用户安装 wheel 并提供 `--find-links` 时,也会自动安装其 `Requires-Dist` 依赖,不需要源码仓库。 +依赖清单随 wheel 安装在 `unilabos/utils/requirements.txt`,启动检查不读取仓库根目录。 +若使用了 `pip install --no-deps`,可显式运行 +`python -m unilabos.utils.environment_check --wheelhouse <解压目录>/wheelhouse` 补齐当前包声明的依赖; +仅检查使用 `--no-auto-install`。也可通过 `UNILABOS_WHEELHOUSE` 显式设置该目录。 +这只是误用 `--no-deps` 后的恢复入口,正常安装不依靠首次启动时再下载依赖; +恢复入口允许访问普通包源,严格离线安装应使用前面的安装入口/锁定清单。 -请参考 **[添加新动作指令(Action)指南](../developer_guide/add_action.md)**,该指南详细介绍了如何: +两个修正版是默认物料依赖:`pylabrobot==0.2.2+unilabos.6285d662effa` 固定到已验证的 fork 提交, +`opentrons-shared-data==9.1.0+unilabos.np2.1` 与 Conda 共用 NumPy 2 补丁。 +普通上游 `opentrons-shared-data` 仍限制 NumPy 1,不能替换修正版。 +PLR 自身也声明了这个 Opentrons 数据包前置依赖;从完整配套 `wheelhouse` 只安装 PLR 时, +pip 仍会自动带齐修正版和 NumPy 2,无须手动安排安装顺序,也无须安装完整的 `opentrons` SDK。 -- 编写新的 Action 定义 -- 在线构建 Action(通过 GitHub Actions) -- 下载并安装自定义的 msgs 包 -- 测试和验证新的 Action +安装只分三档,不再拆分独立的 docs/dev/test/drivers 选项: -```bash -# Jazzy:安装自定义构建的 msgs 包 -mamba remove --force ros-jazzy-unilabos-msgs -mamba config set safety_checks disabled # 关闭 md5 检查 -mamba install /path/to/ros-jazzy-unilabos-msgs-*.conda --offline +| 档位 | Python 安装 | 内容 | +| --- | --- | --- | +| 默认 | `python scripts/dev_install.py` | HostLink、微后端、工作流、物料,默认无 ROS | +| ros2 | `python scripts/dev_install.py --extras ros2` | 默认依赖及 ROS Python 辅助依赖;原生 ROS 另装 | +| full | `python scripts/dev_install.py --extras full` | 全部 Python 依赖:内置驱动 SDK、文档、测试、开发工具及 ROS Python 辅助依赖 | -# Humble 环境使用 ros-humble-unilabos-msgs-*.conda;不要交叉安装 -``` +已有配套 wheel 时追加 `--wheelhouse <解压目录>/wheelhouse`。非源码安装使用 +`python -m pip install --find-links <解压目录>/wheelhouse "unilabos[full]==0.12.3"`。 +默认离线 wheel 包只锁定默认依赖;扩展档位还需联网取得公共依赖,不能宣称整套 full 离线可用。 +pip 的 `[ros2]` / `[full]` **不安装原生 ROS、DDS、RViz 或消息 typesupport**, +需要这些组件时使用下方 Conda 对应档位。专有硬件 SDK、系统驱动和许可证仍按设备包要求准备。 +`--test_mode` 是硬件模拟开关,与“无 ROS”不是同一概念;普通 Python 驱动可通过 HostLink 操作真实硬件。 -### 第五步:验证开发环境 +## Conda 安装 -完成上述步骤后,验证开发环境是否正确配置: +默认只启用 Uni-Lab 和 conda-forge,不添加 RoboStack channel: ```bash -# 确保环境已激活 +mamba create -n unilab --override-channels -c uni-lab -c conda-forge "unilabos>=0.12.3" conda activate unilab - -# 检查 ROS2 环境 -ros2 --version - -# 检查 msgs 包 -ros2 interface list | grep unilabos_msgs - -# 检查 Python 可以导入 unilabos -python -c "import unilabos; print(f'Uni-Lab版本: {unilabos.__version__}')" - -# 检查 unilab 命令 unilab --help +unilab --disable_browser --port 8002 ``` -如果所有命令都正常输出,说明开发环境配置成功! - ---- - -## 验证安装 - -无论使用哪种安装方式,都应该验证安装是否成功。 - -### 基本验证 - -```bash -# 确保已激活环境 -conda activate unilab # 或 unilab-dev - -# 检查 unilab 命令 -unilab --help -``` - -您应该看到类似以下的输出: - -``` -usage: unilab [-h] [-g GRAPH] [-c CONTROLLERS] [--registry_path REGISTRY_PATH] - [--working_dir WORKING_DIR] [--backend {hostlink,ros2}] - ... -``` - -### 检查版本 +| 包 | 用途 | 是否安装 ROS | +| --- | --- | --- | +| `unilabos` | 默认完整的 HostLink / 微后端运行程序 | 否 | +| `unilabos-ros2` | 显式选装 Jazzy 或 Humble 的设备通信扩展 | 是 | +| `unilabos-full` | 完整运行、文档、测试、开发工具及 ROS Desktop / MoveIt | 是 | -```bash -python -c "import unilabos; print(f'Uni-Lab版本: {unilabos.__version__}')" -``` +默认包的 build 为 `py312_0`。不再发布 `unilabos-env` 中间包;这与用户自行创建的 +名为 `unilab` 的 Conda 环境不是同一概念,不会删除或更改已有环境。 +ROS 扩展使用另一个包名,避免 Conda 在同名默认包的 ROS / 非 ROS 变体之间自行选择。 -### 使用验证脚本(方式一) +只安装 `unilabos` 即会直接带上 PLR fork、`msgcenterpy` 及 NumPy 2 修复版 +`opentrons-shared-data=9.1.0+unilabos.np2.1=py312_np2_1`,不要求用户另装环境中间包 +或 ROS 扩展。发行自检会实际创建 Opentrons 96 孔板,并检查 NumPy 2 积分接口,不能只验证 import。 -如果使用一键安装,可以运行预打包的验证脚本: +## 运行 Host 与 Slave ```bash -# 确保已激活环境 -conda activate unilab - -# 运行验证脚本 -python verify_installation.py -``` - -如果看到 "✓ All checks passed!",说明安装成功! - ---- +# 默认 HostLink;省略 --backend 也一样 +unilab -g host.json --machine_name lab-host --port 8002 --hostlink_port 7302 -## 常见问题 - -### 问题 1: 找不到 unilab 命令 - -**原因**: 环境未正确激活或 PATH 未设置 - -**解决方案**: - -```bash -# 确保激活了正确的环境 -conda activate unilab +# 另一台机器(或同机另一个进程) +unilab --is_slave -g slave.json --machine_name lab-slave --host_node_ip 192.168.1.10 --hostlink_port 7302 -# 检查 unilab 是否在 PATH 中 -which unilab # Linux/macOS -where unilab # Windows +# 单独的调度/物料权威,不启动设备 +unilab --role backend --port 8081 ``` -### 问题 2: 包冲突或依赖错误 +默认启动会分为微后端权威和 Host 子进程;前端只连接管理端 HTTP 端口, +Slave 连接 HostLink TCP 端口。同机时 Host IP 可用 `127.0.0.1`。 +设备归属可以提前登记,但必须完成 `post_init/setup/initialize` 后才会进入可调度设备快照。 -**解决方案**: +无 ROS 安装支持普通设备驱动、Workstation/sub-device、动作与 Service、物料创建及 transfer、 +工作流、异常干预、逐步执行、实时日志和 MCP。原生 ROS graph/TF、MoveIt、RViz、 +依赖 ROS 消息或 DDS/QoS 的驱动、ROS 高频图像流需要下方的 ROS 扩展。 +HTTP/FFmpeg 等非 ROS 图像方案不因此被禁用,但仍需安装自己的依赖。 -```bash -# 删除旧环境重新创建 -conda deactivate -conda env remove -n unilab -mamba create -n unilab uni-lab::unilabos -c uni-lab -c conda-forge -c robostack-jazzy -``` - -### 问题 3: 下载速度慢 +## 显式选装 ROS2 -**解决方案**: 使用国内镜像源(清华、中科大等) +分别创建环境,不能混用 Jazzy 和 Humble 的 channel: ```bash -# 查看当前 channel 配置 -conda config --show channels - -# 添加清华镜像 -conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/ -``` - -### 问题 4: 权限错误 +# Jazzy +mamba create -n unilab-jazzy --override-channels -c uni-lab -c conda-forge -c robostack-jazzy "unilabos-ros2=0.12.3=jazzy_0" +conda activate unilab-jazzy +unilab --backend ros2 -g graph.json -**Windows 解决方案**: 以管理员身份运行命令提示符 - -**Linux/macOS 解决方案**: - -```bash -# 不要使用 sudo 安装 conda 包 -# 如果 conda 安装在需要权限的位置,考虑重新安装 conda 到用户目录 +# Humble:在另一个环境执行 +mamba create -n unilab-humble --override-channels -c uni-lab -c conda-forge -c robostack-humble "unilabos-ros2=0.12.3=humble_0" ``` -### 问题 5: 安装脚本找不到 conda(方式一) - -**解决方案**: 确保你已经安装了 conda/miniconda/miniforge,并且安装在标准位置: - -- **Windows**: - - - `%USERPROFILE%\miniforge3` - - `%USERPROFILE%\miniconda3` - - `%USERPROFILE%\anaconda3` - - `C:\ProgramData\miniforge3` +ROS 扩展保持 Python 3.12.13 / NumPy 2 与消息包 0.12.1 的 ABI 基线; +安装扩展后也必须使用 `--backend ros2`,不会偷偷切换默认 backend。 +需要 RViz/MoveIt 时再显式选择匹配的 `unilabos-full=0.12.3=jazzy_0` 或 `humble_0`, +这些大型组件的可用性还取决于平台和 RoboStack 包。 -- **macOS/Linux**: - - `~/miniforge3` - - `~/miniconda3` - - `~/anaconda3` - - `/opt/conda` +自定义 ROS Action/消息参见[添加动作](../developer_guide/add_action.md)。 +纯 Python/HostLink 驱动不需要为了增加动作而编译 ROS 消息。 -如果安装在其他位置,可以先激活 conda base 环境,然后手动运行安装脚本。 +## 完整开发与文档环境 -### 问题 6: 安装后激活环境提示找不到? - -**解决方案**: 尝试以下方法: +需要连同 ROS 运行与文档一起开发时,创建完整环境后可编辑安装源码: ```bash -# 方法 1: 使用 conda activate -conda activate unilab - -# 方法 2: 使用完整路径激活(Windows) -call C:\Users\{YourUsername}\miniforge3\envs\unilab\Scripts\activate.bat - -# 方法 2: 使用完整路径激活(Unix) -source ~/miniforge3/envs/unilab/bin/activate +mamba create -n unilab-full --override-channels -c uni-lab -c conda-forge -c robostack-jazzy "unilabos-full=0.12.3=jazzy_0" +conda activate unilab-full +python scripts/dev_install.py --extras full +python -m pytest tests/ +python -m sphinx -b html -d .local/docs/doctrees docs .local/docs/html ``` -### 问题 7: conda-unpack 失败怎么办?(方式一) - -**解决方案**: 尝试手动运行: - -```bash -# Windows -cd %CONDA_PREFIX%\envs\unilab -.\Scripts\conda-unpack.exe - -# macOS/Linux -cd $CONDA_PREFIX/envs/unilab -./bin/conda-unpack -``` - -### 问题 8: 环境很大,有办法减小吗? - -**解决方案**: - -1. **使用 `unilabos` 标准版**(推荐大多数用户): - ```bash - mamba install uni-lab::unilabos -c uni-lab -c conda-forge -c robostack-jazzy - ``` - 标准版包含完整功能,环境大小约 2-3GB(相比完整版的 8-10GB)。 - -2. **使用 `unilabos-env` 开发者版**(最小化): - ```bash - mamba install uni-lab::unilabos-env -c uni-lab -c conda-forge -c robostack-jazzy - # 然后手动安装依赖 - pip install -e . - uv pip install -r unilabos/utils/requirements.txt - ``` - 开发者版只包含环境依赖,体积最小约 2GB。 +`full` 统一包含文档、测试、开发工具和内置驱动的通用 Python SDK,不再拆分额外档位。 +Conda 发行同时提供 PDF 字体和各平台的 OPC-UA 依赖;Windows GUI 自动化依赖仅在 +Windows 安装,因为对应的 Agilent HPLC 驱动依赖 Windows 应用,并不支持其他操作系统。 -3. **按需安装额外组件**: - 如果后续需要特定功能,可以单独安装: - ```bash - # 需要 Jupyter - mamba install jupyter jupyros - - # 需要可视化 - mamba install matplotlib opencv - - # 需要仿真(注意:这会安装大量依赖) - mamba install ros-jazzy-gazebo-ros - ``` +正式文档构建设置 `UNILABOS_DOCS_REQUIRE_ROS=1`,缺少 `control_msgs` / `nav2_msgs` +动作定义时直接失败,不静默生成缺页文档。无 ROS 环境只做普通页面预览;若已有另一套 ROS +环境,可设置 `UNILABOS_DOCS_ROS_PREFIX` 指向它,仅复用 `share` 中的消息定义,不混用 Python 包。 +生成的动作参考和 HTML 均保留在构建输出目录,不改写 `docs/developer_guide` 或提交生成物。 -4. **预打包环境问题**: - 预打包环境(方式一)包含所有依赖,通常较大(压缩后 2-5GB)。这是为了确保离线安装和完整功能。 +## 离线包 -**包选择建议**: -| 需求 | 推荐包 | 预估大小 | -|------|--------|----------| -| 日常使用/生产部署 | `unilabos` | ~2-3 GB | -| 开发调试(可编辑模式) | `unilabos-env` | ~2 GB | -| 仿真/可视化 | `unilabos-full` | ~8-10 GB | +在 [Conda-Pack 构建](https://github.com/deepmodeling/Uni-Lab-OS/actions/workflows/conda-pack-build.yml) +选择成功的 `unilab-pack-hostlink-<平台>-<源码SHA>` 产物,解压 GitHub artifact ZIP 后, +Windows 运行 `install_unilab.bat`,Linux/macOS 运行 `bash install_unilab.sh`。 -### 问题 9: 如何更新到最新版本? +包内包含 Python 环境、安装/验证工具和对应源码归档;默认不包含 ROS。 +已存在的环境不会被覆盖;需要新名字时用 `install_unilab.bat unilab-hostlink` +或 `bash install_unilab.sh unilab-hostlink`。 +自动打包使用上游同一次构建的 Conda 文件,不覆盖安装另一个源码版本,不追踪最新 ROS 消息。 +手动构建会先按指定 SHA 构建默认 Conda 包,再安装验证和打包。 -**解决方案**: +## 验证与排错 -**方式一用户**: 重新下载最新的预打包环境,运行安装脚本时选择覆盖现有环境。 - -**方式二/三用户**: 在现有环境中更新: +在源码目录执行(离线包中省略 `scripts/` 前缀): ```bash -conda activate unilab - -# 更新 unilabos -cd /path/to/Uni-Lab-OS -git pull -pip install -e . --upgrade -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple - -# Jazzy 环境 -mamba update ros-jazzy-unilabos-msgs -c uni-lab -c conda-forge -c robostack-jazzy - -# Humble 环境 -mamba update ros-humble-unilabos-msgs -c uni-lab -c conda-forge -c robostack-humble +python -m pip check +python scripts/verify_installation.py --assert-no-ros +unilab --help ``` ---- - -## 下一步 - -安装完成后,请继续: - -- **快速启动**: 学习如何首次启动 Uni-Lab -- **配置指南**: 配置您的实验室环境和设备 -- **运行示例**: 查看启动示例和最佳实践 -- **开发指南**: - - 添加新设备驱动 - - 添加新物料资源 - - 了解工作站架构 - -## 需要帮助? - -- **故障排查**: 查看更详细的故障排查信息 -- **GitHub Issues**: [报告问题](https://github.com/deepmodeling/Uni-Lab-OS/issues) -- **开发者文档**: 查看开发者指南获取更多技术细节 -- **社区讨论**: [GitHub Discussions](https://github.com/deepmodeling/Uni-Lab-OS/discussions) - ---- +ROS 环境改为 `python scripts/verify_installation.py --backend ros2`,不加 `--assert-no-ros`。 +验证默认不会自动安装缺失依赖;缺失或版本不满足时返回非零退出码。 -**提示**: +- 找不到 `unilab`:先激活安装时使用的环境,或执行 `python -m unilabos.app.main`。 +- 提示缺少 `rclpy`:普通驱动使用默认 HostLink;确实需要 ROS 才安装匹配扩展,不用 pip 猜装 ROS 原生库。 +- 想从旧环境去掉 ROS:保留原环境,另建干净环境;不要删除数据库或复制旧的 site-packages。 +- 驱动缺少专用 SDK:按设备包的声明安装,不代表默认微后端需要 ROS。 +- 网络下载失败:目标机器优先使用配套离线包;源码构建仍需下载固定上游归档,不能改成任意分支。 -- **大多数用户**推荐使用方式二(手动安装)的 `unilabos` 标准版 -- **开发者**推荐使用方式三(开发者安装),安装 `unilabos-env` 后使用 `uv pip install -r unilabos/utils/requirements.txt` 安装依赖 -- **仿真/可视化**推荐安装 `unilabos-full` 完整版 -- **快速体验和演示**推荐使用方式一(一键安装) +运行参数见[启动指南](launch.md),接口与设计见[开发者指南](../developer_guide/interfaces/index.md)。 diff --git a/docs/user_guide/runtime_baseline.md b/docs/user_guide/runtime_baseline.md index 5f6778610..b53797f3b 100644 --- a/docs/user_guide/runtime_baseline.md +++ b/docs/user_guide/runtime_baseline.md @@ -1,102 +1,83 @@ # 运行时与 ABI 基线 -本页记录当前 `dev` 分支及 Conda 包支持的运行时基线。Python 与 NumPy ABI 在 -两个 ROS 2 发行版之间保持一致,但每个 Conda 环境只能选择一个 ROS 发行版。 +从 0.12.3 起,默认安装、运行、Conda 发布和离线包都采用无 ROS 的 HostLink。 +旧版 0.12.2 的已发布包不覆盖;新版本发布前可从当前源码安装。 -## 当前支持矩阵 +## 支持矩阵 -| 组件 | Jazzy | Humble | -| --- | --- | --- | -| Python | 3.12.13(`cp312`) | 3.12.13(`cp312`) | -| NumPy | `>=2,<3` | `>=2,<3` | -| RoboStack channel | `robostack-jazzy` | `robostack-humble` | -| ROS 2 distro mutex | `0.15.*` / `jazzy_*` | `0.9.*` / `humble_*` | -| Uni-Lab-OS messages | `ros-jazzy-unilabos-msgs=0.12.0` | `ros-humble-unilabos-msgs=0.12.0` | -| Conda build string | `jazzy_1` | `humble_1` | +| 组件 | 默认 HostLink | 显式 ROS2 Jazzy | 显式 ROS2 Humble | +| --- | --- | --- | --- | +| Python | >=3.12,<3.13(构建解释器 3.12.13) | 3.12.13(cp312) | 3.12.13(cp312) | +| NumPy | >=2,<3 | >=2,<3 | >=2,<3 | +| 入口包 | unilabos | unilabos-ros2,jazzy_0 | unilabos-ros2,humble_0 | +| 核心应用构建 | py312_0 | 同一核心包 | 同一核心包 | +| 环境依赖 | 默认包直接声明 | 默认依赖加 ROS 扩展 | 默认依赖加 ROS 扩展 | +| RoboStack channel | 不需要 | robostack-jazzy | robostack-humble | +| distro mutex | 不需要 | 0.15.* / jazzy_* | 0.9.* / humble_* | +| UniLabOS 消息包 | 不需要 | ros-jazzy-unilabos-msgs 0.12.1 | ros-humble-unilabos-msgs 0.12.1 | +| 启动选择 | 不传 backend 或 --backend hostlink | --backend ros2 | --backend ros2 | -Jazzy 是默认和推荐发行版;Humble 作为兼容发行版运行相同的 Python 3.12、NumPy 2 -与 microbackend v2 源码。公开通信 backend 只有 `hostlink` 和 `ros2`:HostLink -内部的本地 Python 执行引擎不依赖具体 ROS 发行版,`ros2` backend 会使用当前 -环境安装的 Humble 或 Jazzy。 +Python 核心应用不再按 ROS 发行版重复打包。ROS2 包只是显式选装扩展, +不会因为安装了 ROS 就自动改变默认 backend。不要在同一环境混合两个 RoboStack channel。 -## Backend 选择边界 +## 能力边界 -ROS 发行版兼容性与设备 backend 是两个不同维度。`hostlink` 不启动 rclpy/DDS, -适用于普通 Python 设备驱动,并支持 Action、Service、状态、低中频 JSON Topic、 -Workstation 和 sub-device 的初始化与通信。驱动仅借用 ROS message Python 类作为 -数据结构时,也可以使用 HostLink,但环境中仍需能导入相应的 message 包。 +HostLink 支持普通 Python 驱动的 Action、Service、状态、JSON Topic、 +Workstation/sub-device 初始化,以及 Host/Slave 跨进程和跨机器通信。 +微后端、CLI 建图、工作流调度、物料同步、HTTP/WS、实时日志和 MCP 不需要 ROS。 -HostLink 与 ROS2 的通用设备接口保持对齐,但并非 100% 等价。MoveIt、RViz、原生 -ROS graph/TF、ROS2 专用高频图像流,以及依赖 QoS、零拷贝或复杂消息图的设备应选择 -`ros2` backend。详细启动参数和选择示例见 {doc}`launch`,组网能力边界见 -{doc}`../developer_guide/networking_overview`。 +以下能力仍应使用 ROS2 环境: -不要在同一环境中同时配置 `robostack-humble` 与 `robostack-jazzy`。两个 channel -提供互斥的 `ros2-distro-mutex`,混装或原地切换会导致 ROS 原生扩展 ABI 不一致。 +- MoveIt、RViz、TF/原生 ROS graph。 +- 原生 ROS 摄像头、高频图像流、DDS QoS 等专用能力。 +- 在模块顶层直接导入 rclpy 或 ROS 消息类的驱动。此类驱动不会自动变成纯 Python; + 若希望双 backend 运行,应隔离 ROS 专用部分或使用 backend 无关的数据模型。 -## 推荐安装 +硬件 SDK 也不是默认核心依赖:按设备包声明安装;内置驱动可选依赖可用 +`python scripts/dev_install.py --extras full` 安装。这不等于所有硬件驱动在裸 Python 中都开箱即用。 -### ROS 2 Jazzy(默认) +## 安装与迁移 -```bash -mamba create -n unilab-jazzy python=3.12.13 -mamba activate unilab-jazzy -mamba install uni-lab::unilabos -c uni-lab -c conda-forge -c robostack-jazzy -``` - -### ROS 2 Humble(兼容) - -```bash -mamba create -n unilab-humble python=3.12.13 -mamba activate unilab-humble -mamba install uni-lab::unilabos -c uni-lab -c conda-forge -c robostack-humble -``` - -开发者可将 `unilabos` 换成 `unilabos-env`,再安装当前源码: +完整命令见[安装指南](installation.md)。默认不添加 RoboStack channel: ```bash -uv pip install -r unilabos/utils/requirements.txt -pip install -e . +mamba create -n unilab --override-channels -c uni-lab -c conda-forge "unilabos>=0.12.3" +mamba activate unilab +unilab --disable-browser ``` -包名在两个发行版中保持一致,Conda 根据启用的 RoboStack channel 选择 -`jazzy_1` 或 `humble_1` build。自定义消息包名称仍包含发行版,不能交叉安装。 - -## Windows DLL 加载兼容 +需要 ROS2 时另建 Jazzy 或 Humble 环境,安装对应 `unilabos-ros2` 构建, +并在运行时显式传 `--backend ros2`。`unilabos-full` 仍是可选桌面/仿真套件。 -Uni-Lab-OS 会优先从当前环境的 `ros2-distro-mutex` 元数据识别 ROS 发行版, -不依赖可能尚未由激活脚本设置的 `ROS_DISTRO`: +保留旧环境以便复现实验,不要直接删除 ROS 包、复制旧 site-packages 或在 +Humble/Jazzy 间原地切换。旧 Python 3.11、NumPy 1 消息扩展不能混入 cp312/NumPy 2 环境。 -- Humble 与 Jazzy 仅在实际出现 `DLL load failed` 时,对 rclpy/rpyutils 的加载 - 入口应用同一套兼容补丁,并提示重新启动进程。 -- 补丁使用原子文件替换,避免修改与环境文件硬链接的 Conda package cache。 +## Windows DLL 兼容 -## 从旧环境迁移 +仅 ROS2 后端需要处理 ROS 原生 DLL。发行版优先从当前环境的 +`ros2-distro-mutex` 元数据识别,不依赖激活脚本是否设置 `ROS_DISTRO`。 +现有 DLL 兼容修补只针对实际加载失败,使用原子替换以免修改 Conda 的硬链接缓存。 +默认 HostLink 不加载该 ROS 运行时。 -旧 Humble 环境常见组合是 Python 3.11、NumPy 1、mutex 0.7。它与当前 Humble -兼容线的 cp312/NumPy 2/mutex 0.9 也不兼容,必须新建环境: +## 构建与验证 -1. 保留旧环境用于复现实验,不要原地升级 Python、NumPy 或 distro mutex。 -2. 按上面的 Jazzy 或 Humble 命令创建新的 Python 3.12.13 环境。 -3. 重新安装设备驱动及其 Python 依赖,不要复制旧环境的 `site-packages`。 -4. 验证实际安装版本: +- `.conda/base/`、`.conda/environment/`:唯一一套无 ROS 核心和依赖。 +- `.conda/msgcenterpy/`、`.conda/pylabrobot/`:固定版本的消息转换和 PLR 分支依赖。 +- `.conda/mcp/`:补齐既有 MCP 1.30 最低版本,不通过降低版本绕过缺包。 +- `.conda/ros2*/`、`.conda/full*/`:显式 ROS2 扩展和桌面变体。 +- `recipes/msgs*/`:独立的 ROS 消息发布;不由普通 HostLink 发布触发。 +- `scripts/build_conda_release.py`:默认只构建无 ROS 包;ROS 需指定 `--ros-distros`。 +- `scripts/pack_conda_release.py`:离线包只消费同一源码 SHA 的 Conda 产物。 +- `recipes/wheels.toml`、`scripts/build_wheel_release.py`:复用 Conda 补丁,生成各平台完整 wheelhouse; + 在仓库外用配套安装入口验证后作为发行附件分发,不上传 PyPI。 +- 默认 CI 使用未安装 ROS 的 Python 环境,验证安装、CLI、接口、HostLink 和七个 + 完整 demo;保留独立 Jazzy/Humble 检查。 ```bash -python -c "import os, sys, numpy; print(os.environ.get('ROS_DISTRO')); print(sys.version); print(numpy.__version__)" -conda list | grep -E "ros2-distro-mutex|ros-(jazzy|humble)|unilabos" -``` - -Windows 可将最后一条命令改为: - -```powershell -conda list | findstr /I "ros2-distro-mutex ros-jazzy ros-humble unilabos" +python -m pip check +python scripts/verify_installation.py --assert-no-ros +python -m pytest -q tests/app/test_no_ros_installation.py tests/e2e/test_readme_demos.py ``` -## 构建与验证入口 - -- `recipes/msgs/`:Jazzy/cp312/NumPy 2 消息包。 -- `recipes/msgs-humble/`:Humble/cp312/NumPy 2 消息包。 -- `.conda/environment*/`、`.conda/base*/`、`.conda/full*/`:两个发行版的 - `unilabos-env`、`unilabos` 和 `unilabos-full` build 变体。 -- CI 在 Humble 与 Jazzy 中分别从源码构建 `unilabos_msgs` 并运行同一套 - backend/ROS 合同测试。 +ROS 环境验证用 `python scripts/verify_installation.py --backend ros2`。 +无 ROS 检查会确认 rclpy/rosidl/ament 不存在,不会用自动安装掩盖缺失依赖。 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..642ab3ceb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/recipes/unilabos/recipe.yaml b/recipes/unilabos/recipe.yaml deleted file mode 100644 index 1340eae0c..000000000 --- a/recipes/unilabos/recipe.yaml +++ /dev/null @@ -1,23 +0,0 @@ -package: - name: unilabos - version: "0.12.2" - -source: - path: ../.. - -build: - noarch: python - script: | - {{ PYTHON }} -m pip install . --no-deps --ignore-installed -vv -# {{ PYTHON }} clean_build_dir.py - -requirements: - host: - - python ==3.12.13 - - pip - run: - - python >=3.12,<3.13 - -test: - imports: - - unilabos diff --git a/recipes/wheels.toml b/recipes/wheels.toml new file mode 100644 index 000000000..2c95cc751 --- /dev/null +++ b/recipes/wheels.toml @@ -0,0 +1,15 @@ +# 下游 wheel 只随 UniLabOS 发行附件分发,不上传 PyPI。 +# 上游源码必须锁定 SHA256,Conda 与 wheel 共用补丁;构建不追踪分支。 +[packages.opentrons-shared-data] +version = "9.1.0+unilabos.np2.1" +url = "https://files.pythonhosted.org/packages/50/82/680fd30ad68bbb211a1407eb2b2e5e411321c089057ae7be80dd60a5a10a/opentrons_shared_data-9.1.0.tar.gz" +sha256 = "faf194a52ffa82b471fd0fa82a1b3e33e6e278b9835210c45644123869c05794" +patches = ["recipes/opentrons-shared-data/numpy2.patch"] +notice = "recipes/opentrons-shared-data/NOTICE" + +[packages.pylabrobot] +version = "0.2.2+unilabos.6285d662effa" +url = "https://github.com/Xuwznln/pylabrobot/archive/6285d662effa972be97a781ba7493553f2ba94ee.tar.gz" +sha256 = "3565c12bd28133e569695e37b78e5a1aba0bae57e297457fcc89c1f66a93840a" +patches = [".conda/pylabrobot/unilab-version.patch", ".conda/pylabrobot/opentrons-dependency.patch"] +requires = ["opentrons-shared-data==9.1.0+unilabos.np2.1"] diff --git a/scripts/build_conda_release.py b/scripts/build_conda_release.py new file mode 100644 index 000000000..1989d7339 --- /dev/null +++ b/scripts/build_conda_release.py @@ -0,0 +1,94 @@ +"""构建默认无 ROS 包及显式选装包;不在构建过程中从 pip 隐式联网补依赖。""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import shutil +import subprocess +import sys + + +# 公共 channel 缺失的纯 Python/字体包,仅 full 需要,不增加用户安装档位。 +FULL_SUPPORT_RECIPES = ( + "pprp", "opcua", "rinoh-typeface-dejavuserif", "rinoh-typeface-texgyrecursor", + "rinoh-typeface-texgyreheros", "rinoh-typeface-texgyrepagella", +) + + +def recipes(ros_distros: str = "", full: bool = False, extensions_only: bool = False) -> list[tuple[str, list[str]]]: + distros = list(dict.fromkeys(x.strip() for x in ros_distros.split(",") if x.strip())) + if any(x not in ("jazzy", "humble") for x in distros): + raise ValueError("ros_distros 只能为空、jazzy、humble 或 jazzy,humble") + if full and not distros: + raise ValueError("build_full 必须显式选择 ros_distros") + if extensions_only and not distros: + raise ValueError("仅构建扩展必须显式选择 ros_distros,并先发布同版本默认包") + result = [] if extensions_only else [(name, []) for name in ("msgcenterpy", "pylabrobot", "mcp", "base")] + if full: + result.extend((name, []) for name in FULL_SUPPORT_RECIPES) + for distro in distros: + suffix = "-humble" if distro == "humble" else "" + result.append((f"ros2{suffix}", [f"robostack-{distro}"])) + if full: + result.append((f"full{suffix}", [f"robostack-{distro}"])) + return result + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("operation", choices=("build", "upload")) + parser.add_argument("--platform", required=True, choices=("linux-64", "osx-64", "osx-arm64", "win-64")) + parser.add_argument("--ros-distros", default="") + parser.add_argument("--full", action="store_true") + parser.add_argument("--extensions-only", action="store_true", help="复用已发布的默认包,仅构建/上传 ROS2/full 扩展") + parser.add_argument("--output-dir", default="output") + parser.add_argument("--artifact-dir", default="conda-artifacts") + args = parser.parse_args() + selected = recipes(args.ros_distros, args.full, args.extensions_only) + output = Path(args.output_dir).resolve() + if args.operation == "build": + build_env = os.environ.copy() + if os.name == "nt": + # 仅为本次构建的 git 子进程开启长路径,不修改用户全局 git 配置。 + count = int(build_env.get("GIT_CONFIG_COUNT", "0")) + build_env.update({"GIT_CONFIG_COUNT": str(count + 1), + f"GIT_CONFIG_KEY_{count}": "core.longpaths", + f"GIT_CONFIG_VALUE_{count}": "true"}) + for name, extra_channels in selected: + command = ["rattler-build", "build", "-r", f".conda/{name}/recipe.yaml", + "--target-platform", args.platform, "--output-dir", str(output), + "-c", str(output), "-c", "uni-lab", "-c", "conda-forge"] + for channel in extra_channels: + command.extend(("-c", channel)) + subprocess.run(command, check=True, env=build_env) + for subdir in ("noarch", args.platform): + for package in (output / subdir).glob("*.conda"): + target = Path(args.artifact_dir) / subdir / package.name + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(package, target) + return + + token = os.environ["ANACONDA_API_TOKEN"] + upload_env = {**os.environ, "ANACONDA_CLIENT_FORCE_STANDALONE": "1"} + # noarch 在矩阵内重复构建;平台相关的 MCP 依赖也会被后续框架版本复用。 + # 这些已有坐标只跳过、不覆盖;框架平台包仍须发布新的版本/build。 + packages = sorted((output / "noarch").glob("*.conda")) + sorted((output / args.platform).glob("*.conda")) + if not packages: + raise RuntimeError("没有已构建的 .conda 产物,禁止写入 published 凭证") + for package in packages: + package_name = package.name.rsplit("-", 2)[0] + if args.extensions_only and package_name not in {"unilabos-ros2", "unilabos-full", *FULL_SUPPORT_RECIPES}: + continue + command = [sys.executable, "-m", "binstar_client.scripts.cli", "-t", token, + "upload", "--user", "uni-lab", "--register"] + if package.parent.name == "noarch" or package_name == "mcp": + command.append("--skip-existing") + command.append(str(package)) + if subprocess.run(command, env=upload_env).returncode: + raise RuntimeError(f"上传失败:{package.name}") + + +if __name__ == "__main__": + main() diff --git a/scripts/build_wheel_release.py b/scripts/build_wheel_release.py new file mode 100644 index 000000000..9ce36cd39 --- /dev/null +++ b/scripts/build_wheel_release.py @@ -0,0 +1,264 @@ +"""构建配套 wheel / 完整离线发行包;复用 Conda 补丁,不上传任何 Python 包源。""" + +from __future__ import annotations + +import argparse +import ast +from email.parser import BytesParser +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tarfile +import tempfile +import tomllib +from urllib.request import urlopen +from zipfile import ZipFile + +if __package__: + from .install_wheel_release import environment_python, platform_id, sha256 +else: + from install_wheel_release import environment_python, platform_id, sha256 + +ROOT = Path(__file__).resolve().parents[1] + + +def run(*command: str, cwd: Path | None = None) -> None: + subprocess.run(command, cwd=cwd, check=True, + env={**os.environ, "PYTHONUTF8": "1", "PYTHONIOENCODING": "utf-8", + "SOURCE_DATE_EPOCH": os.environ.get("SOURCE_DATE_EPOCH", "315532800")}) + + +def read_version(root: Path) -> str: + tree = ast.parse((root / "unilabos/__init__.py").read_text(encoding="utf-8")) + return next(ast.literal_eval(node.value) for node in tree.body if isinstance(node, ast.Assign) + and any(isinstance(target, ast.Name) and target.id == "__version__" for target in node.targets)) + + +def wheel_metadata(wheel: Path) -> tuple[str, str, list[str]]: + with ZipFile(wheel) as archive: + # setuptools 等包包含 vendored 依赖的 dist-info;只读 wheel 根目录的元数据。 + entries = [name for name in archive.namelist() + if name.count("/") == 1 and name.endswith(".dist-info/METADATA")] + if len(entries) != 1: + raise RuntimeError(f"wheel 元数据不唯一:{wheel.name}") + metadata = BytesParser().parsebytes(archive.read(entries[0])) + name = re.sub(r"[-_.]+", "-", metadata["Name"]).lower() + version = metadata["Version"] + if not re.fullmatch(r"[a-z0-9-]+", name) or not re.fullmatch(r"[A-Za-z0-9.+!-]+", version): + raise RuntimeError(f"非法的包名或版本:{wheel.name}") + return name, version, metadata.get_all("Requires-Dist", []) + + +def verify_companion_metadata(wheel: Path, name: str, spec: dict) -> None: + """前置依赖必须写进 wheel 本身,不能由主包或完整锁文件碰巧补齐。""" + actual_name, actual_version, dependencies = wheel_metadata(wheel) + if (actual_name, actual_version) != (name, spec["version"]): + raise RuntimeError(f"下游包版本不匹配:{wheel.name}") + declared = {re.sub(r"\s+", "", dependency).lower() for dependency in dependencies} + for requirement in spec.get("requires", []): + if re.sub(r"\s+", "", requirement).lower() not in declared: + raise RuntimeError(f"{wheel.name} 缺少必需的直接依赖:{requirement}") + + +def download_source(spec: dict, target: Path, cache: Path | None = None) -> None: + """只接受固定哈希的源码,镜像也必须与原包逐字节一致。""" + if not re.fullmatch(r"[0-9a-f]{64}", spec.get("sha256", "")): + raise RuntimeError("源码缺少固定 SHA256,拒绝构建") + cached = cache / f"{spec['sha256']}.tar.gz" if cache else None + if cached and cached.is_file(): + if sha256(cached) != spec["sha256"]: + raise RuntimeError(f"源码缓存 SHA256 不匹配:{cached}") + shutil.copy2(cached, target) + return + urls = [spec["url"], *spec.get("mirrors", [])] + failures = [] + for url in urls: + if not url.startswith("https://"): + raise RuntimeError("源码必须使用 HTTPS") + try: + with urlopen(url, timeout=60) as response, target.open("wb") as stream: + shutil.copyfileobj(response, stream) + if sha256(target) != spec["sha256"]: + raise RuntimeError(f"源码 SHA256 不匹配:{url}") + if cached: + cached.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(target, cached) + return + except OSError as exc: + failures.append(str(exc)) + raise RuntimeError("下载源码失败:" + "; ".join(failures)) + + +def extract_source(archive: Path, target: Path) -> Path: + target.mkdir() + with tarfile.open(archive) as stream: + # Python 3.12 的 data filter 拒绝路径穿越及目录外链接。 + stream.extractall(target, filter="data") + roots = list(target.iterdir()) + if len(roots) != 1 or not roots[0].is_dir(): + raise RuntimeError("源码归档必须恰好包含一个项目根目录") + return roots[0] + + +def build_dependencies(root: Path, work: Path, wheels: Path, cache: Path | None = None) -> dict: + sources = tomllib.loads((root / "recipes/wheels.toml").read_text(encoding="utf-8"))["packages"] + for name, spec in sources.items(): + archive = work / f"{name}.tar.gz" + download_source(spec, archive, cache) + source = extract_source(archive, work / name) + for index, relative in enumerate(spec["patches"]): + # Windows checkout 可能将补丁转成 CRLF;sdist 保持 LF,先规范临时副本。 + patch = work / f"{name}-{index}.patch" + patch.write_text((root / relative).read_text(encoding="utf-8"), encoding="utf-8", newline="\n") + run("git", "apply", "--check", str(patch), cwd=source) + run("git", "apply", str(patch), cwd=source) + if spec.get("notice"): + shutil.copy2(root / spec["notice"], source / "NOTICE") + before = set(wheels.glob("*.whl")) + run(sys.executable, "-m", "pip", "wheel", "--no-deps", "--wheel-dir", str(wheels), str(source), cwd=work) + built = set(wheels.glob("*.whl")) - before + if len(built) != 1: + raise RuntimeError(f"{name} 未生成唯一 wheel") + wheel = built.pop() + verify_companion_metadata(wheel, name, spec) + if spec.get("notice"): + with ZipFile(wheel) as archive: + notices = [item for item in archive.namelist() if item.endswith("/NOTICE")] + licenses = [item for item in archive.namelist() if item.endswith("/LICENSE")] + if not licenses or not any(archive.read(item) == (root / spec["notice"]).read_bytes() + for item in notices): + raise RuntimeError(f"wheel 未携带许可证或修改说明:{wheel.name}") + return sources + + +def verify_single_root(wheels: Path, prefix: Path, verifier: Path, requirement: str, + *, materials_only: bool = False) -> None: + """只指定一个根依赖,在全新环境证明 pip 能自动带齐所有前置包。""" + if prefix.exists(): + raise RuntimeError(f"验证环境必须是新目录:{prefix}") + work = prefix.parent + run(sys.executable, "-I", "-m", "venv", str(prefix), cwd=work) + python = str(environment_python(prefix)) + run(python, "-I", "-m", "pip", "--isolated", "install", "--disable-pip-version-check", + "--no-index", "--only-binary=:all:", "--find-links", str(wheels), requirement, cwd=work) + run(python, "-I", "-m", "pip", "check", cwd=work) + run(python, "-I", str(verifier), "--assert-no-ros", + *(["--materials-only"] if materials_only else []), cwd=work) + if not materials_only: + run(python, "-I", "-m", "unilabos.app.main", "--help", cwd=work) + + +def lock_wheels(wheels: Path) -> str: + """冻结实际产物而非重新求解,拒绝仍会绕过 --no-index 联网的直接 URL 依赖。""" + entries = {} + for wheel in sorted(wheels.glob("*.whl")): + name, version, dependencies = wheel_metadata(wheel) + if name in entries: + raise RuntimeError(f"wheelhouse 存在同名多份包:{name}") + if any(re.search(r"@|https?://|file:", requirement) for requirement in dependencies): + raise RuntimeError(f"离线包不允许直接 URL 依赖:{wheel.name}") + entries[name] = f"{name}=={version} --hash=sha256:{sha256(wheel)}" + if "unilabos" not in entries: + raise RuntimeError("离线包缺少 unilabos wheel") + return "\n".join(entries[name] for name in sorted(entries)) + "\n" + + +def snapshot_source(root: Path, work: Path, *, local_test: bool) -> tuple[Path, str]: + source_sha = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=root, text=True).strip() + source = work / "unilabos-source" + source.mkdir() + paths = ["unilabos", "setup.py", "pyproject.toml", "MANIFEST.in", "LICENSE"] + if local_test: + shutil.copytree(root / "unilabos", source / "unilabos", + ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.egg-info")) + for name in paths[1:]: + shutil.copy2(root / name, source / name) + else: + # release 不使用工作区缓存、未提交源码或另一个 HEAD。 + changed = subprocess.check_output(["git", "status", "--porcelain", "--untracked-files=all", "--", + *paths, "scripts", "recipes", ".conda", ".github/workflows"], + cwd=root, text=True) + if changed.strip(): + raise RuntimeError("发布源码有未提交修改;本地验证请显式使用 --local-test") + archive = work / "source.tar" + run("git", "archive", "--format=tar", "-o", str(archive), source_sha, *paths, cwd=root) + with tarfile.open(archive) as stream: + stream.extractall(source, filter="data") + return source, source_sha + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output-dir", type=Path, default=Path("dist/wheel-release"), help="必须是新的或空的目录") + parser.add_argument("--dependencies-only", action="store_true", help="只构建配套依赖,供源码开发/CI 的 --find-links 使用") + parser.add_argument("--local-test", action="store_true", help="允许未提交源码,产物标记 local-test,不能正式发布") + parser.add_argument("--source-cache", type=Path, default=Path("dist/wheel-source-cache"), help="按源码哈希缓存下载,不缓存校验结果") + parser.add_argument("--release-tag", default="", help="正式 Release 时校验 v<版本> 标签") + args = parser.parse_args() + if sys.version_info[:2] != (3, 12): + raise RuntimeError("必须用 Python 3.12 构建默认发行包") + version = read_version(ROOT) + if args.release_tag and (args.local_test or args.release_tag != f"v{version}"): + raise RuntimeError("Release 标签与源码版本不一致,或试图发布 local-test 产物") + output = args.output_dir.resolve() + if output.exists() and any(output.iterdir()): + raise RuntimeError(f"输出目录非空,不覆盖已有产物:{output}") + output.mkdir(parents=True, exist_ok=True) + wheels = output / "wheelhouse" + wheels.mkdir() + with tempfile.TemporaryDirectory(prefix="unilab-wheel-") as temporary: + work = Path(temporary) + source, source_sha = (None, None) if args.dependencies_only else snapshot_source(ROOT, work, local_test=args.local_test) + sources = build_dependencies(ROOT, work, wheels, args.source_cache.resolve()) + if args.dependencies_only: + print(f"配套依赖已生成:{wheels};安装源码时使用 --find-links 指向此目录") + return + run(sys.executable, "-m", "pip", "wheel", "--no-deps", "--wheel-dir", str(wheels), str(source), cwd=work) + main_wheels = [path for path in wheels.glob("*.whl") if wheel_metadata(path)[:2] == ("unilabos", version)] + if len(main_wheels) != 1: + raise RuntimeError("当前源码没有生成唯一的 UniLabOS wheel") + # 将所有间接依赖也变成 wheel,目标用户无需 Git / 编译器 / Conda / 网络。 + run(sys.executable, "-m", "pip", "wheel", "--wheel-dir", str(wheels), "--find-links", str(wheels), + str(main_wheels[0]), cwd=work) + (output / "requirements.lock").write_text(lock_wheels(wheels), encoding="utf-8") + for name in ("install_wheel_release.py", "verify_installation.py"): + shutil.copy2(ROOT / "scripts" / name, output / name) + shutil.copy2(ROOT / "recipes/wheels.toml", output / "wheel-sources.toml") + # 保留发行所用补丁与 NOTICE,避免只分发一个无法追溯的二进制文件。 + for spec in sources.values(): + for relative in [*spec["patches"], *([spec["notice"]] if spec.get("notice") else [])]: + target = output / "sources" / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(ROOT / relative, target) + manifest = {"schema_version": 1, "kind": "offline-wheelhouse", "version": version, + "source_sha": source_sha, "local_test": args.local_test, "python": "3.12", + "platform": platform_id(), "verified": False, + "files": {path.relative_to(output).as_posix(): sha256(path) + for path in sorted(output.rglob("*")) if path.is_file()}} + manifest_path = output / "manifest.json" + manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + # 全量锁文件会掩盖缺失的依赖边;PLR 和主包必须分别单独安装验收。 + roots = [f"pylabrobot=={sources['pylabrobot']['version']}", f"unilabos=={version}"] + for index, requirement in enumerate(roots): + verify_single_root(wheels, work / f"verify-root-{index}", output / "verify_installation.py", + requirement, materials_only=index == 0) + # 安装入口自身就是验收用例:在仓库外创建干净环境,运行实际物料工厂和 CLI。 + run(sys.executable, "-I", str(output / "install_wheel_release.py"), "--prefix", str(work / "verify-env"), cwd=work) + manifest["verified_install_roots"] = roots + manifest["verified"] = True + manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + label = "-local-test" if args.local_test else "" + archive_base = output.parent / f"unilabos-{version}-wheelhouse-{platform_id()}{label}" + if Path(str(archive_base) + ".zip").exists(): + raise RuntimeError(f"发行 ZIP 已存在,不会覆盖:{archive_base}.zip") + archive_path = Path(shutil.make_archive(str(archive_base), "zip", root_dir=output)) + Path(str(archive_path) + ".sha256").write_text(f"{sha256(archive_path)} {archive_path.name}\n", encoding="utf-8") + print(f"完整离线包已验证:{archive_path};未上传到 PyPI 或其他服务器。") + + +if __name__ == "__main__": + main() diff --git a/scripts/create_readme.py b/scripts/create_readme.py index 7fa2de6e2..cb34924ff 100644 --- a/scripts/create_readme.py +++ b/scripts/create_readme.py @@ -41,31 +41,23 @@ def get_readme_content(platform: str, branch: str) -> str: is_windows = platform == "win-64" if is_windows: - archive_ext = "zip" install_script = "install_unilab.bat" platform_instructions = """Windows: 1. Extract the downloaded ZIP file 2. Double-click install_unilab.bat (or run in cmd) 3. Follow the prompts""" else: - archive_ext = "tar.gz" install_script = "install_unilab.sh" - platform_name = {"linux-64": "linux-64", "osx-64": "osx-64", "osx-arm64": "osx-arm64"}.get(platform, platform) - platform_instructions = f"""macOS/Linux: - 1. Download and extract unilab-pack-{platform_name}.tar.gz + platform_instructions = """macOS/Linux: + 1. Extract the downloaded GitHub artifact ZIP 2. Run: bash install_unilab.sh - 3. Follow the prompts - - Alternative (if downloaded from GitHub Actions): - 1. Extract the artifact ZIP file - 2. Extract unilab-pack-{platform_name}.tar.gz inside - 3. Run: bash install_unilab.sh""" + 3. Follow the prompts""" # Generate README content readme = f"""Uni-Lab-OS Conda-Pack Environment ================================ -This package contains a pre-built Uni-Lab-OS environment. +This package contains a pre-built ROS-free Uni-Lab-OS HostLink environment. Installation Instructions: -------------------------- @@ -76,17 +68,22 @@ def get_readme_content(platform: str, branch: str) -> str: - Automatically find your conda installation - Extract the environment to conda's envs/unilab directory - Run conda-unpack to finalize setup + - Verify the entry point and dependencies without downloading packages + +Existing environments are never overwritten. To choose a new name: + {('install_unilab.bat' if is_windows else 'bash install_unilab.sh')} unilab-hostlink After installation: conda activate unilab - python verify_installation.py + python verify_installation.py --assert-no-ros + unilab --disable-browser Verification: ------------- The verify_installation.py script will check: - - Python version (3.12.13 / cp312) - - ROS2 rclpy installation + - Python version (3.12 / cp312) + - HostLink runtime and microbackend (no ROS installation required) - Uni-Lab-OS installation and dependencies If all checks pass, you're ready to use Uni-Lab-OS! @@ -97,14 +94,15 @@ def get_readme_content(platform: str, branch: str) -> str: - {install_script} (automatic installation script) - unilab-env-{platform}.tar.gz (packed conda environment) - verify_installation.py (environment verification tool) + - source.zip (source snapshot and Markdown documentation at the SHA below) - README.txt (this file) Build Information: ------------------ - Branch: {branch} + Source: {branch} Platform: {platform} - Python: 3.12.13 (cp312) + Python: 3.12 (cp312) Date: {build_date} Troubleshooting: @@ -116,7 +114,7 @@ def get_readme_content(platform: str, branch: str) -> str: Check: conda --version 2. Verify you have sufficient disk space - Required: ~5-10 GB after extraction + Leave sufficient space for the extracted Python environment and experiment data 3. Check installation permissions You need write access to conda's envs directory @@ -124,8 +122,7 @@ def get_readme_content(platform: str, branch: str) -> str: 4. For detailed logs, run the install script from terminal For more help: - - Documentation: docs/user_guide/installation.md - - Quick Start: QUICK_START_CONDA_PACK.md + - Documentation: docs/user_guide/installation.md in source.zip - Issues: https://github.com/deepmodeling/Uni-Lab-OS/issues License: diff --git a/scripts/dev_install.py b/scripts/dev_install.py index b707ae7a7..2fa1df597 100644 --- a/scripts/dev_install.py +++ b/scripts/dev_install.py @@ -1,214 +1,71 @@ #!/usr/bin/env python3 -""" -Development installation script for Uni-Lab-OS. -Auto-detects Chinese locale and uses appropriate mirror. +"""开发安装入口:安装当前解释器的可编辑包及声明依赖,默认不安装 ROS。""" -Usage: - python scripts/dev_install.py - python scripts/dev_install.py --no-mirror # Force no mirror - python scripts/dev_install.py --china # Force China mirror - python scripts/dev_install.py --skip-deps # Skip pip dependencies installation - -Flow: - 1. pip install -e . (install unilabos in editable mode) - 2. Detect Chinese locale - 3. Use uv to install pip dependencies from requirements.txt - 4. Special packages (like pylabrobot) are handled by environment_check.py at runtime -""" +from __future__ import annotations +import argparse import locale +from pathlib import Path +import shutil import subprocess import sys -import argparse -from pathlib import Path +import tempfile -# Tsinghua mirror URL TSINGHUA_MIRROR = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" -def is_chinese_locale() -> bool: - """ - Detect if system is in Chinese locale. - Same logic as EnvironmentChecker._is_chinese_locale() - """ - try: - lang = locale.getdefaultlocale()[0] - if lang and ("zh" in lang.lower() or "chinese" in lang.lower()): - return True - except Exception: - pass - return False - - -def run_command(cmd: list, description: str, retry: int = 2) -> bool: - """Run command with retry support.""" - print(f"[INFO] {description}") - print(f"[CMD] {' '.join(cmd)}") - - for attempt in range(retry + 1): - try: - result = subprocess.run(cmd, check=True, timeout=600) - print(f"[OK] {description}") - return True - except subprocess.CalledProcessError as e: - if attempt < retry: - print(f"[WARN] Attempt {attempt + 1} failed, retrying...") - else: - print(f"[ERROR] {description} failed: {e}") - return False - except subprocess.TimeoutExpired: - print(f"[ERROR] {description} timed out") - return False - return False - - -def install_editable(project_root: Path, use_mirror: bool) -> bool: - """Install unilabos in editable mode using pip.""" - cmd = [sys.executable, "-m", "pip", "install", "-e", str(project_root)] - if use_mirror: - cmd.extend(["-i", TSINGHUA_MIRROR]) - - return run_command(cmd, "Installing unilabos in editable mode") - - -def install_requirements_uv(requirements_file: Path, use_mirror: bool) -> bool: - """Install pip dependencies using uv (installed via conda-forge::uv).""" - cmd = ["uv", "pip", "install", "-r", str(requirements_file)] - if use_mirror: - cmd.extend(["-i", TSINGHUA_MIRROR]) - - return run_command(cmd, "Installing pip dependencies with uv", retry=2) - - -def install_requirements_pip(requirements_file: Path, use_mirror: bool) -> bool: - """Fallback: Install pip dependencies using pip.""" - cmd = [sys.executable, "-m", "pip", "install", "-r", str(requirements_file)] - if use_mirror: - cmd.extend(["-i", TSINGHUA_MIRROR]) - - return run_command(cmd, "Installing pip dependencies with pip", retry=2) - - -def check_uv_available() -> bool: - """Check if uv is available (installed via conda-forge::uv).""" - try: - subprocess.run(["uv", "--version"], capture_output=True, check=True) - return True - except (subprocess.CalledProcessError, FileNotFoundError): - return False - - -def main(): - parser = argparse.ArgumentParser(description="Development installation script for Uni-Lab-OS") - parser.add_argument("--china", action="store_true", help="Force use China mirror (Tsinghua)") - parser.add_argument("--no-mirror", action="store_true", help="Force use default PyPI (no mirror)") - parser.add_argument( - "--skip-deps", action="store_true", help="Skip pip dependencies installation (only install unilabos)" - ) - parser.add_argument("--use-pip", action="store_true", help="Use pip instead of uv for dependencies") - args = parser.parse_args() - - # Determine project root - script_dir = Path(__file__).parent - project_root = script_dir.parent - requirements_file = project_root / "unilabos" / "utils" / "requirements.txt" - - if not (project_root / "setup.py").exists(): - print(f"[ERROR] setup.py not found in {project_root}") - sys.exit(1) - - print("=" * 60) - print("Uni-Lab-OS Development Installation") - print("=" * 60) - print(f"Project root: {project_root}") - print() - - # Determine mirror usage based on locale - if args.no_mirror: - use_mirror = False - print("[INFO] Mirror disabled by --no-mirror flag") - elif args.china: - use_mirror = True - print("[INFO] China mirror enabled by --china flag") +def build_command(project_root: Path, *, use_pip: bool, skip_deps: bool, mirror: bool, + wheelhouse: Path | None = None, extras: str = "") -> list[str]: + if not use_pip and shutil.which("uv"): + command = ["uv", "pip", "install", "--python", sys.executable] else: - use_mirror = is_chinese_locale() - if use_mirror: - print("[INFO] Chinese locale detected, using Tsinghua mirror") - else: - print("[INFO] Non-Chinese locale detected, using default PyPI") - - print() - - # Step 1: Install unilabos in editable mode - print("[STEP 1] Installing unilabos in editable mode...") - if not install_editable(project_root, use_mirror): - print("[ERROR] Failed to install unilabos") - print() - print("Manual fallback:") - if use_mirror: - print(f" pip install -e {project_root} -i {TSINGHUA_MIRROR}") - else: - print(f" pip install -e {project_root}") - sys.exit(1) - - print() - - # Step 2: Install pip dependencies - if args.skip_deps: - print("[INFO] Skipping pip dependencies installation (--skip-deps)") - else: - print("[STEP 2] Installing pip dependencies...") - - if not requirements_file.exists(): - print(f"[WARN] Requirements file not found: {requirements_file}") - print("[INFO] Skipping dependencies installation") - else: - # Try uv first (faster), fallback to pip - if args.use_pip: - print("[INFO] Using pip (--use-pip flag)") - success = install_requirements_pip(requirements_file, use_mirror) - elif check_uv_available(): - print("[INFO] Using uv (installed via conda-forge::uv)") - success = install_requirements_uv(requirements_file, use_mirror) - if not success: - print("[WARN] uv failed, falling back to pip...") - success = install_requirements_pip(requirements_file, use_mirror) - else: - print("[WARN] uv not available (should be installed via: mamba install conda-forge::uv)") - print("[INFO] Falling back to pip...") - success = install_requirements_pip(requirements_file, use_mirror) - - if not success: - print() - print("[WARN] Failed to install some dependencies automatically.") - print("You can manually install them:") - if use_mirror: - print(f" uv pip install -r {requirements_file} -i {TSINGHUA_MIRROR}") - print(" or:") - print(f" pip install -r {requirements_file} -i {TSINGHUA_MIRROR}") - else: - print(f" uv pip install -r {requirements_file}") - print(" or:") - print(f" pip install -r {requirements_file}") - - print() - print("=" * 60) - print("Installation complete!") - print("=" * 60) - print() - print("Note: Some special packages (like pylabrobot) are installed") - print("automatically at runtime by unilabos if needed.") - print() - print("Verify installation:") - print(' python -c "import unilabos; print(unilabos.__version__)"') - print() - print("If you encounter issues, you can manually install dependencies:") - if use_mirror: - print(f" uv pip install -r unilabos/utils/requirements.txt -i {TSINGHUA_MIRROR}") - else: - print(" uv pip install -r unilabos/utils/requirements.txt") - print() + command = [sys.executable, "-m", "pip", "install"] + requested = list(dict.fromkeys(value.strip() for value in extras.split(",") if value.strip())) + if set(requested) - {"ros2", "full"}: + raise ValueError(f"未知的可选依赖组:{extras}") + target = str(project_root) + (f"[{','.join(requested)}]" if requested else "") + command.extend(["-e", target]) + if skip_deps: + command.append("--no-deps") + if wheelhouse: + command.extend(["--find-links", str(wheelhouse.resolve())]) + if mirror: + command.extend(["--index-url", TSINGHUA_MIRROR]) + return command + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + mirrors = parser.add_mutually_exclusive_group() + mirrors.add_argument("--china", action="store_true", help="使用清华 Python 包镜像") + mirrors.add_argument("--no-mirror", action="store_true", help="使用默认 Python 包源") + parser.add_argument("--skip-deps", action="store_true", help="只安装源码;依赖须已由 Conda 等方式安装") + parser.add_argument("--use-pip", action="store_true", help="不用 uv,使用当前解释器的 pip") + parser.add_argument("--wheelhouse", type=Path, help="已有配套 wheel 目录;省略时从固定源码构建,不上传 PyPI") + parser.add_argument("--extras", default="", choices=("ros2", "full"), + help="可选 ros2 或 full(含文档/测试/开发);原生 ROS 须另由 Conda 安装") + args = parser.parse_args() + project_root = Path(__file__).resolve().parent.parent + language = locale.getlocale()[0] or "" + mirror = not args.no_mirror and (args.china or language.lower().startswith(("zh", "chinese"))) + print("[INFO] 安装默认 HostLink 开发环境;PLR/Opentrons 使用配套 wheel,不在启动时切换分支。") + try: + with tempfile.TemporaryDirectory(prefix="unilab-dev-wheels-") as temporary: + wheelhouse = args.wheelhouse + if not args.skip_deps and wheelhouse is None: + subprocess.run([sys.executable, str(project_root / "scripts/build_wheel_release.py"), + "--dependencies-only", "--output-dir", temporary], check=True) + wheelhouse = Path(temporary) / "wheelhouse" + command = build_command(project_root, use_pip=args.use_pip, skip_deps=args.skip_deps, + mirror=mirror, wheelhouse=wheelhouse, extras=args.extras) + subprocess.run(command, check=True) + subprocess.run([sys.executable, "-m", "pip", "check"], check=True) + except subprocess.CalledProcessError as exc: + print(f"[ERROR] 安装或依赖验证失败,退出码 {exc.returncode}。请检查上述输出。") + return exc.returncode + print("[OK] 安装完成。可运行 python -m unilabos.app.main --disable-browser") + return 0 if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/scripts/install_unilab.bat b/scripts/install_unilab.bat index 2f180c9e2..70054365c 100644 --- a/scripts/install_unilab.bat +++ b/scripts/install_unilab.bat @@ -86,20 +86,14 @@ echo. REM Set target environment path set "ENV_NAME=unilab" +if not "%~1"=="" set "ENV_NAME=%~1" set "ENV_PATH=%CONDA_BASE%\envs\%ENV_NAME%" REM Check if environment already exists if exist "%ENV_PATH%" ( - echo WARNING: Environment '%ENV_NAME%' already exists at %ENV_PATH% - echo. - set /p "OVERWRITE=Do you want to overwrite it? (y/n): " - if /i not "!OVERWRITE!"=="y" ( - echo Installation cancelled. - pause - exit /b 0 - ) - echo Removing existing environment... - rmdir /s /q "%ENV_PATH%" + echo ERROR: Environment already exists at %ENV_PATH%. Nothing was removed. + echo Use a new name: install_unilab.bat unilab-hostlink + exit /b 1 ) REM Find the packed environment file @@ -163,26 +157,21 @@ if errorlevel 1 ( echo. echo Checking Uni-Lab-OS entry point... -REM Check if unilab-script.py exists -set "UNILAB_SCRIPT=%ENV_PATH%\Scripts\unilab-script.py" +set "UNILAB_SCRIPT=%ENV_PATH%\Scripts\unilab.exe" if not exist "%UNILAB_SCRIPT%" ( - echo WARNING: unilab-script.py not found, creating it... - ( - echo # -*- coding: utf-8 -*- - echo import re - echo import sys - echo. - echo from unilabos.app.main import main - echo. - echo if __name__ == '__main__': - echo sys.argv[0] = re.sub^(r'(-script\.pyw?^|\.exe^)?$', '', sys.argv[0]^) - echo sys.exit^(main^(^)^) - ) > "%UNILAB_SCRIPT%" - echo Created: %UNILAB_SCRIPT% + echo ERROR: Entry point is missing. The archive is incomplete. + exit /b 1 ) else ( echo Found: %UNILAB_SCRIPT% ) +call "%CONDA_BASE%\condabin\conda.bat" activate "%ENV_PATH%" +if errorlevel 1 exit /b 1 +"%ENV_PATH%\python.exe" "%SCRIPT_DIR%verify_installation.py" --assert-no-ros +if errorlevel 1 exit /b 1 +"%UNILAB_SCRIPT%" --help +if errorlevel 1 exit /b 1 + echo. echo ================================================ echo Installation completed successfully! @@ -197,7 +186,7 @@ echo call %ENV_PATH%\Scripts\activate.bat echo. echo You can verify the installation by running: echo cd /d "%SCRIPT_DIR%" -echo python verify_installation.py +echo python verify_installation.py --assert-no-ros echo. pause diff --git a/scripts/install_unilab.sh b/scripts/install_unilab.sh index dcf748883..2a76b2718 100755 --- a/scripts/install_unilab.sh +++ b/scripts/install_unilab.sh @@ -44,29 +44,29 @@ if [ -f "$CONDA_BASE/etc/profile.d/conda.sh" ]; then fi # Set target environment path -ENV_NAME="unilab" +ENV_NAME="${1:-unilab}" +if [[ ! "$ENV_NAME" =~ ^[a-zA-Z0-9_-]+$ ]]; then + echo "ERROR: Use only letters, numbers, '-' or '_' in the environment name." + exit 1 +fi ENV_PATH="$CONDA_BASE/envs/$ENV_NAME" # Check if environment already exists -if [ -d "$ENV_PATH" ]; then - echo "WARNING: Environment '$ENV_NAME' already exists at $ENV_PATH" - read -p "Do you want to overwrite it? (y/n): " OVERWRITE - if [ "$OVERWRITE" != "y" ] && [ "$OVERWRITE" != "Y" ]; then - echo "Installation cancelled." - exit 0 - fi - echo "Removing existing environment..." - rm -rf "$ENV_PATH" +if [ -e "$ENV_PATH" ]; then + echo "ERROR: Environment already exists at $ENV_PATH. Nothing was removed." + echo "Use a new name: bash install_unilab.sh unilab-hostlink" + exit 1 fi # Find the packed environment file -PACK_FILE=$(ls unilab-env*.tar.gz 2>/dev/null | head -n 1) - -if [ -z "$PACK_FILE" ]; then - echo "ERROR: Could not find unilab-env*.tar.gz file!" +shopt -s nullglob +PACK_FILES=(unilab-env*.tar.gz) +if [ "${#PACK_FILES[@]}" -ne 1 ]; then + echo "ERROR: Expected exactly one unilab-env*.tar.gz file!" echo "Please make sure the packed environment file is in the same directory as this script." exit 1 fi +PACK_FILE="${PACK_FILES[0]}" echo "Found packed environment: $PACK_FILE" echo "" @@ -101,25 +101,16 @@ echo "Checking Uni-Lab-OS entry point..." # Check if unilab script exists in bin directory UNILAB_SCRIPT="$ENV_PATH/bin/unilab" if [ ! -f "$UNILAB_SCRIPT" ]; then - echo "WARNING: unilab script not found, creating it..." - cat > "$UNILAB_SCRIPT" << 'EOF' -#!/usr/bin/env python -# -*- coding: utf-8 -*- -import re -import sys - -from unilabos.app.main import main - -if __name__ == '__main__': - sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0]) - sys.exit(main()) -EOF - chmod +x "$UNILAB_SCRIPT" - echo "Created: $UNILAB_SCRIPT" + echo "ERROR: Entry point is missing. The archive is incomplete." + exit 1 else echo "Found: $UNILAB_SCRIPT" fi +conda activate "$ENV_PATH" +"$ENV_PATH/bin/python" "$SCRIPT_DIR/verify_installation.py" --assert-no-ros +"$UNILAB_SCRIPT" --help + echo "" echo "================================================" echo "Installation completed successfully!" @@ -134,6 +125,6 @@ echo " source $ENV_PATH/bin/activate" echo "" echo "You can verify the installation by running:" echo " cd $SCRIPT_DIR" -echo " python verify_installation.py" +echo " python verify_installation.py --assert-no-ros" echo "" diff --git a/scripts/install_wheel_release.py b/scripts/install_wheel_release.py new file mode 100644 index 000000000..71ee36f1c --- /dev/null +++ b/scripts/install_wheel_release.py @@ -0,0 +1,94 @@ +"""安装配套 wheel 发行包:只用标准库,校验后创建新环境,全程不访问包源。""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +import platform +import subprocess +import sys +import venv + + +def platform_id() -> str: + machine = platform.machine().lower() + targets = { + ("win32", "amd64"): "win-64", ("win32", "x86_64"): "win-64", + ("linux", "x86_64"): "linux-64", + ("darwin", "x86_64"): "osx-64", ("darwin", "arm64"): "osx-arm64", + } + try: + return targets[sys.platform, machine] + except KeyError: + raise RuntimeError(f"此发行包暂不支持 {sys.platform}/{machine}") from None + + +def sha256(path: Path) -> str: + with path.open("rb") as stream: + return hashlib.file_digest(stream, "sha256").hexdigest() + + +def environment_python(prefix: Path) -> Path: + return prefix / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python") + + +def verify_bundle(bundle: Path) -> dict: + manifest = json.loads((bundle / "manifest.json").read_text(encoding="utf-8")) + if manifest.get("schema_version") != 1 or manifest.get("kind") != "offline-wheelhouse": + raise RuntimeError("不是完整的 wheel 发行包;不能用依赖构建目录代替") + if manifest["python"] != "3.12" or sys.version_info[:2] != (3, 12): + raise RuntimeError("请使用 Python 3.12 执行安装入口") + if manifest["platform"] != platform_id(): + raise RuntimeError(f"平台不匹配:此包用于 {manifest['platform']},当前为 {platform_id()}") + files = manifest["files"] + if not {"requirements.lock", "verify_installation.py"} <= files.keys(): + raise RuntimeError("发行包缺少安装清单或验证脚本的校验信息") + for relative, expected in files.items(): + path = (bundle / relative).resolve() + if not path.is_relative_to(bundle.resolve()) or not path.is_file(): + raise RuntimeError(f"发行包文件缺失或路径越界:{relative}") + if sha256(path) != expected: + raise RuntimeError(f"SHA256 校验失败:{relative}") + recorded = {name for name in files if name.startswith("wheelhouse/") and name.endswith(".whl")} + actual = {f"wheelhouse/{path.name}" for path in (bundle / "wheelhouse").iterdir()} + if not recorded or recorded != actual: + raise RuntimeError("wheelhouse 含缺失或清单外文件,拒绝安装") + return manifest + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--prefix", type=Path, help="新环境目录,默认是发行包内的 .venv;已有目录不会覆盖") + args = parser.parse_args() + bundle = Path(__file__).resolve().parent + manifest = verify_bundle(bundle) + prefix = (args.prefix or bundle / ".venv").resolve() + if prefix.exists(): + raise RuntimeError(f"环境目录已存在,不会覆盖:{prefix};请指定新的 --prefix") + venv.EnvBuilder(with_pip=True).create(prefix) + python = str(environment_python(prefix)) + # -I 与 --isolated 禁止用户 PYTHONPATH / pip 环境配置把安装导向其他环境。 + subprocess.run([python, "-I", "-m", "pip", "--isolated", "install", + "--disable-pip-version-check", "--no-index", "--only-binary=:all:", + "--find-links", str(bundle / "wheelhouse"), "--require-hashes", + "-r", str(bundle / "requirements.lock")], check=True, cwd=bundle) + subprocess.run([python, "-I", "-m", "pip", "check"], check=True, cwd=bundle) + subprocess.run([python, "-I", str(bundle / "verify_installation.py"), "--assert-no-ros"], + check=True, cwd=bundle) + subprocess.run([python, "-I", "-m", "unilabos.app.main", "--help"], check=True, cwd=bundle) + print(f"安装完成:UniLabOS {manifest['version']},无 ROS,环境 {prefix}") + print(f'启动:"{python}" -m unilabos.app.main --disable_browser') + return 0 + + +if __name__ == "__main__": + for stream in (sys.stdout, sys.stderr): + if hasattr(stream, "reconfigure"): + stream.reconfigure(encoding="utf-8", errors="replace") + try: + sys.exit(main()) + except (OSError, RuntimeError, ValueError, subprocess.CalledProcessError) as exc: + print(f"安装失败:{exc}", file=sys.stderr) + sys.exit(1) diff --git a/scripts/pack_conda_release.py b/scripts/pack_conda_release.py new file mode 100644 index 000000000..cd1dda112 --- /dev/null +++ b/scripts/pack_conda_release.py @@ -0,0 +1,64 @@ +"""从同一源码凭证下的本地 Conda 产物安装、验证并打包;禁止覆盖式 pip 重装。""" + +from __future__ import annotations + +import argparse +import ast +import os +from pathlib import Path +import shutil +import subprocess +import sys + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--platform", required=True) + parser.add_argument("--source-sha", required=True) + parser.add_argument("--channel", default="conda-artifacts") + parser.add_argument("--prefix", required=True) + parser.add_argument("--artifact-dir", default="dist-package") + parser.add_argument("--local-test", action="store_true", help="验证未提交源码;不生成发布说明和源码 SHA 归档") + args = parser.parse_args() + if not args.local_test: + checked_out = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip() + if checked_out != args.source_sha: + raise RuntimeError("发布源码 SHA 与 checkout 不一致") + subprocess.run(["git", "diff", "--exit-code", "HEAD", "--", "unilabos", "scripts", ".conda", + "setup.py", "pyproject.toml", "MANIFEST.in"], check=True) + tree = ast.parse(Path("unilabos/__init__.py").read_text(encoding="utf-8")) + version = next(ast.literal_eval(node.value) for node in tree.body + if isinstance(node, ast.Assign) + and any(isinstance(t, ast.Name) and t.id == "__version__" for t in node.targets)) + channel = Path(args.channel).resolve() + package = channel / args.platform / f"unilabos-{version}-py312_0.conda" + if not package.is_file(): + raise RuntimeError(f"缺少当前源码对应的默认包:{package};不回退到远端 latest") + subprocess.run([sys.executable, "-m", "conda_index", str(channel)], check=True) + conda = [os.environ["CONDA_PYTHON_EXE"], "-m", "conda"] + prefix = str(Path(args.prefix).resolve()) + subprocess.run([*conda, "create", "-y", "-p", prefix, "--override-channels", + "-c", str(channel), "-c", "conda-forge", "-c", "uni-lab", + f"unilabos=={version}=py312_0", "conda-pack", "zstandard"], check=True) + run = [*conda, "run", "--no-capture-output", "-p", prefix] + subprocess.run([*run, "python", "-m", "pip", "check"], check=True) + subprocess.run([*run, "python", "scripts/verify_installation.py", "--assert-no-ros"], check=True) + subprocess.run([*run, "unilab", "--help"], check=True) + target = Path(args.artifact_dir).resolve() + target.mkdir(parents=True, exist_ok=True) + subprocess.run([*run, "conda-pack", "-p", prefix, "-o", + str(target / f"unilab-env-{args.platform}.tar.gz")], check=True) + installer = "install_unilab.bat" if args.platform == "win-64" else "install_unilab.sh" + for name in (installer, "verify_installation.py"): + shutil.copy2(Path("scripts") / name, target / name) + if args.local_test: + print("本地测试包已验证;未生成发布说明或源码归档,不可作为发布产物。") + return + subprocess.run([sys.executable, "scripts/create_readme.py", args.platform, + args.source_sha, str(target / "README.txt")], check=True) + subprocess.run(["git", "archive", "--format=zip", "-o", str(target / "source.zip"), + args.source_sha], check=True) + + +if __name__ == "__main__": + main() diff --git a/scripts/verify_installation.py b/scripts/verify_installation.py index c06ea4273..29e51fd47 100644 --- a/scripts/verify_installation.py +++ b/scripts/verify_installation.py @@ -47,6 +47,7 @@ # Now import other modules import importlib +import importlib.util # Use ASCII-safe symbols that work across all platforms CHECK_MARK = "[OK]" @@ -89,6 +90,25 @@ def check_python_version() -> bool: return False +def check_material_catalog() -> bool: + """必须实际创建物料:PLR 的可选导入可能把缺失的 Opentrons 依赖隐藏起来。""" + try: + import numpy as np + from opentrons_shared_data.labware import labware_definition as ld + from pylabrobot.resources.opentrons.plates import corning_96_wellplate_360ul_flat + + if int(np.__version__.split(".")[0]) != 2 or ld.trapezoid([1, 2, 3]) != 4: + raise RuntimeError("NumPy 2 积分接口不正确") + plate = corning_96_wellplate_360ul_flat("installation_check") + if plate.num_items != 96: + raise RuntimeError("Opentrons 标准孔板定义不完整") + print(f" {CHECK_MARK} NumPy 2 / Opentrons 96 孔板创建成功") + return True + except Exception as exc: + print(f" {CROSS_MARK} 物料库验证失败:{exc}") + return False + + def main(): """Run all verification checks.""" # Parse command line arguments @@ -101,6 +121,9 @@ def main(): action="store_true", help="Automatically install missing packages", ) + parser.add_argument("--backend", choices=("hostlink", "ros2"), default="hostlink") + parser.add_argument("--assert-no-ros", action="store_true", help="验证默认发行包未安装 ROS 运行时") + parser.add_argument("--materials-only", action="store_true", help="只验证 PLR / Opentrons 物料依赖,不要求安装 UniLabOS") args = parser.parse_args() print("=" * 60) @@ -120,11 +143,17 @@ def main(): all_passed = False print() - # Check ROS2 rclpy - print("Checking ROS2 rclpy...") - if not check_package("rclpy", "ROS2 rclpy"): - all_passed = False - print() + if args.backend == "ros2": + for module in ("rclpy", "unilabos_msgs.msg", "unilabos_msgs.action"): + all_passed = check_package(module) and all_passed + elif args.assert_no_ros: + for module in ("rclpy", "rosidl_runtime_py", "ament_index_python"): + if importlib.util.find_spec(module) is not None: + print(f" {CROSS_MARK} 默认包不应包含 {module}") + all_passed = False + + if args.materials_only: + return 0 if check_material_catalog() and all_passed else 1 # Run environment checker from unilabos print("Checking Uni-Lab-OS and dependencies...") @@ -140,14 +169,19 @@ def main(): if env_check_passed: print(f" {CHECK_MARK} All required packages available") else: - print(f" {CROSS_MARK} Some optional packages are missing") + all_passed = False + print(f" {CROSS_MARK} Some required packages are missing") if not args.auto_install: print(" Hint: Run with --auto-install to automatically install missing packages") except ImportError: print(f" {CROSS_MARK} Uni-Lab-OS not installed") all_passed = False except Exception as e: + all_passed = False print(f" {CROSS_MARK} Environment check failed: {str(e)}") + for module in ("unilabos.server.api.app", "unilabos.backend.hostlink.local_runtime"): + all_passed = check_package(module) and all_passed + all_passed = check_material_catalog() and all_passed print() # Summary diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 5197889b4..000000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[install] -install_scripts=$base/lib/unilabos diff --git a/setup.py b/setup.py index 5f60c184e..e2983d63f 100644 --- a/setup.py +++ b/setup.py @@ -1,19 +1,52 @@ -from setuptools import setup, find_packages +from pathlib import Path + +from setuptools import setup, find_namespace_packages package_name = 'unilabos' + +def read_requirements(name='requirements.txt'): + return [line.strip() for line in (Path(__file__).parent / 'unilabos/utils' / name) + .read_text(encoding='utf-8').splitlines() + if line.strip() and not line.lstrip().startswith('#')] + + +dependency_groups = { + # ROS 原生运行时及消息由 Conda/RoboStack 提供;不要用 pip 的 OpenCV 覆盖 cv_bridge 所需 ABI。 + 'ros2': ['transforms3d>=0.4'], + 'docs': read_requirements('requirements-docs.txt'), + 'drivers': [ + 'opcua>=0.98.13', 'pandas>=2.2', 'matplotlib>=3.8', + 'pylibftdi>=0.22', 'pprp>=0.2.7', + # Agilent HPLC 的 Windows GUI 驱动;其他平台没有对应运行入口。 + 'pyautogui>=0.9.54; sys_platform == "win32"', + 'pywinauto>=0.6.8; sys_platform == "win32"', + ], + 'test': ['pytest>=8', 'pytest-asyncio>=0.23'], +} +extras = { + 'ros2': dependency_groups['ros2'], + 'full': list(dict.fromkeys([ + *(requirement for group in dependency_groups.values() for requirement in group), + 'build>=1.2', 'ipython>=8', 'jupyterlab>=4', 'ruff>=0.9', + ])), +} + setup( name=package_name, python_requires='>=3.12,<3.13', - version='0.12.2', - packages=find_packages(), + version='0.12.3', + packages=[ + name for name in find_namespace_packages( + include=['unilabos', 'unilabos.*'], + # cytomat 目录是未注册的串口调试草稿(语法未完成),不作为运行包发布。 + exclude=['unilabos.test', 'unilabos.test.*', 'unilabos.devices.cytomat*'], + ) if all(part.isidentifier() for part in name.split('.')) + ], include_package_data=True, - install_requires=['setuptools'], - extras_require={ - # Dora 的 Python 包导入名为 ``dora``,并包含 PyArrow;CLI 需单独安装。 - 'dora': ['dora-rs'], - }, - zip_safe=True, + install_requires=read_requirements(), + extras_require=extras, + zip_safe=False, author="The unilabos developers", maintainer='Junhan Chang, Xuwznln', maintainer_email='Junhan Chang , Xuwznln <18435084+Xuwznln@users.noreply.github.com>', diff --git a/tests/app/test_docs_build.py b/tests/app/test_docs_build.py new file mode 100644 index 000000000..c54098347 --- /dev/null +++ b/tests/app/test_docs_build.py @@ -0,0 +1,65 @@ +"""完整文档不能缺少 ROS 动作定义,也不能把生成物写进源码。""" + +from pathlib import Path +import runpy +import sys +from types import SimpleNamespace + +import pytest + + +@pytest.fixture +def docs_config(monkeypatch): + monkeypatch.setattr(sys, "path", list(sys.path)) + return runpy.run_path(str(Path(__file__).resolve().parents[2] / "docs/conf.py")) + + +def test_missing_ros_actions_fail_in_complete_mode(docs_config, monkeypatch, tmp_path): + monkeypatch.setenv("UNILABOS_DOCS_ROS_PREFIX", str(tmp_path)) + monkeypatch.setenv("UNILABOS_DOCS_REQUIRE_ROS", "1") + with pytest.raises(EnvironmentError, match="完整文档缺少"): + docs_config["get_conda_share_dir"]("control_msgs/action") + + +def test_windows_conda_share_directory(docs_config, monkeypatch, tmp_path): + target = tmp_path / "Library/share/control_msgs/action" + target.mkdir(parents=True) + monkeypatch.setenv("UNILABOS_DOCS_ROS_PREFIX", str(tmp_path)) + monkeypatch.setenv("UNILABOS_DOCS_REQUIRE_ROS", "1") + assert docs_config["get_conda_share_dir"]("control_msgs/action") == target + + +def test_action_reference_is_generated_only_in_build_directory(docs_config, monkeypatch, tmp_path): + src = tmp_path / "docs" + reference = src / "developer_guide/action_includes.md" + reference.parent.mkdir(parents=True) + reference.write_text("源码占位页,不允许改写", encoding="utf-8") + local_actions = tmp_path / "unilabos_msgs/action" + local_actions.mkdir(parents=True) + (local_actions / "Simple.action").write_text("# Simple\nstring data\n---\nbool ok\n---\n", encoding="utf-8") + for package in ("control_msgs", "nav2_msgs"): + ros_actions = tmp_path / "ros/share" / package / "action" + ros_actions.mkdir(parents=True) + (ros_actions / f"{package}.action").write_text("string target\n---\nbool ok\n---\n", encoding="utf-8") + monkeypatch.setenv("UNILABOS_DOCS_ROS_PREFIX", str(tmp_path / "ros")) + monkeypatch.setenv("UNILABOS_DOCS_REQUIRE_ROS", "1") + app = SimpleNamespace(srcdir=str(src), doctreedir=str(tmp_path / "build/doctrees")) + docs_config["generate_action_includes"](app) + rendered = (Path(app.doctreedir) / "generated/action_includes.md").read_text(encoding="utf-8") + assert "`Simple`" in rendered + assert "`control_msgs`" in rendered and "`nav2_msgs`" in rendered + assert reference.read_text(encoding="utf-8") == "源码占位页,不允许改写" + source = [""] + docs_config["inject_action_includes"](app, "developer_guide/action_includes", source) + assert source == [rendered] + assert docs_config["refresh_action_reference"](app, None, set(), set(), set()) == ["developer_guide/action_includes"] + + +def test_empty_ros_action_directory_is_not_a_complete_reference(docs_config, monkeypatch, tmp_path): + for name in ("control_msgs", "nav2_msgs"): + (tmp_path / "share" / name / "action").mkdir(parents=True) + monkeypatch.setenv("UNILABOS_DOCS_ROS_PREFIX", str(tmp_path)) + monkeypatch.setenv("UNILABOS_DOCS_REQUIRE_ROS", "1") + app = SimpleNamespace(srcdir=str(tmp_path / "docs"), doctreedir=str(tmp_path / "build")) + with pytest.raises(EnvironmentError, match="完整文档缺少 ROS 动作定义"): + docs_config["generate_action_includes"](app) diff --git a/tests/app/test_no_ros_installation.py b/tests/app/test_no_ros_installation.py new file mode 100644 index 000000000..0e6365a49 --- /dev/null +++ b/tests/app/test_no_ros_installation.py @@ -0,0 +1,391 @@ +"""无 ROS 环境的公开安装/CLI 契约;即使开发机有 ROS,也禁止隐式导入。""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import sys + +import pytest + + +ROS_IMPORT_GUARD = ''' +import importlib.abc +import sys +class NoROS(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + root = fullname.split('.')[0] + if root in {'rclpy', 'rosidl_runtime_py', 'rosidl_parser', 'unilabos_msgs', + 'std_msgs', 'std_srvs', 'geometry_msgs', 'sensor_msgs', + 'builtin_interfaces', 'action_msgs', 'control_msgs', + 'tf2_ros', 'cv_bridge', 'ament_index_python'}: + raise ModuleNotFoundError('当前环境未安装 ROS: ' + fullname, name=fullname) +sys.meta_path.insert(0, NoROS()) +''' + + +def run_python(source: str, *args: str) -> subprocess.CompletedProcess: + result = subprocess.run( + [sys.executable, "-c", ROS_IMPORT_GUARD + source, *args], + text=True, encoding="utf-8", capture_output=True, timeout=90, + env={**os.environ, "PYTHONUTF8": "1"}, + ) + assert result.returncode == 0, result.stdout + result.stderr + return result + + +def test_default_runtime_imports_do_not_need_ros(): + run_python(''' +from unilabos.config.config import BasicConfig +from unilabos.app.cli.parser import build_parser +assert BasicConfig.backend == build_parser().parse_args([]).backend == 'hostlink' +import unilabos.resources.graphio +import unilabos.registry.registry +import unilabos.server.api.app +import unilabos.backend.hostlink.local_runtime +''') + + +@pytest.mark.parametrize("module", ["unilabos", "unilabos.app.main"]) +def test_module_entrypoint_help_without_ros(module): + run_python("import runpy\nsys.argv = ['unilab', '--help']\nrunpy.run_module(" + + repr(module) + ", run_name='__main__')\n") + + +@pytest.mark.parametrize("backend_args", [[], ["--backend", "hostlink"], ["--backend", "ros2"]]) +def test_graph_create_is_backend_neutral_without_ros(tmp_path, backend_args): + package = tmp_path / "no_ros_device" + package.mkdir() + (package / "__init__.py").write_text("", encoding="utf-8") + (package / "device.py").write_text(''' +from unilabos.registry.decorators import device, action +@device(id="no_ros_counter") +class Counter: + @action() + def add(self, amount: int = 1) -> int: + return amount +''', encoding="utf-8") + target = tmp_path / "graph.json" + argv = ["unilab", *backend_args, "graph", "create", "--devices", str(package), "-o", str(target)] + run_python("import runpy\nsys.argv = " + repr(argv) + "\nrunpy.run_module('unilabos', run_name='__main__')\n") + assert [node["id"] for node in json.loads(target.read_text(encoding="utf-8"))["nodes"]] == ["no_ros_counter"] + + +def test_conda_build_default_has_no_ros_channels(): + from scripts.build_conda_release import FULL_SUPPORT_RECIPES, recipes + + assert recipes() == [(name, []) for name in ("msgcenterpy", "pylabrobot", "mcp", "base")] + assert recipes("jazzy")[-1] == ("ros2", ["robostack-jazzy"]) + with pytest.raises(ValueError, match="显式"): + recipes(full=True) + with pytest.raises(ValueError, match="显式"): + recipes(extensions_only=True) + assert recipes("jazzy", full=True, extensions_only=True) == [ + *((name, []) for name in FULL_SUPPORT_RECIPES), + ("ros2", ["robostack-jazzy"]), ("full", ["robostack-jazzy"]), + ] + + +def test_extension_upload_does_not_republish_default_packages(monkeypatch, tmp_path): + from scripts import build_conda_release + + for name in ("unilabos-0.12.3-py312_0.conda", "unilabos-full-0.12.3-jazzy_0.conda"): + package = tmp_path / "win-64" / name + package.parent.mkdir(exist_ok=True) + package.touch() + support = tmp_path / "noarch" / "rinoh-typeface-dejavuserif-0.1.3-py_0.conda" + support.parent.mkdir() + support.touch() + monkeypatch.setenv("ANACONDA_API_TOKEN", "test-token-not-a-secret") + monkeypatch.setattr(sys, "argv", ["build_conda_release.py", "upload", "--platform", "win-64", + "--ros-distros", "jazzy", "--full", "--extensions-only", + "--output-dir", str(tmp_path)]) + uploads = [] + monkeypatch.setattr(build_conda_release.subprocess, "run", + lambda command, **kw: uploads.append(Path(command[-1]).name) + or subprocess.CompletedProcess(command, 0)) + build_conda_release.main() + assert uploads == [support.name, "unilabos-full-0.12.3-jazzy_0.conda"] + + +def test_conda_upload_reuses_dependencies_without_overwriting_framework(monkeypatch, tmp_path): + from scripts import build_conda_release + + packages = { + "noarch/msgcenterpy-0.1.8-py_0.conda": True, + "win-64/mcp-1.30.0-py312_0.conda": True, + "win-64/unilabos-0.12.3-py312_0.conda": False, + } + for name in packages: + package = tmp_path / name + package.parent.mkdir(parents=True, exist_ok=True) + package.touch() + monkeypatch.setenv("ANACONDA_API_TOKEN", "test-token-not-a-secret") + monkeypatch.setattr(sys, "argv", ["build_conda_release.py", "upload", "--platform", "win-64", + "--output-dir", str(tmp_path)]) + commands = [] + + def record_upload(command, **kwargs): + commands.append(command) + assert kwargs["env"]["ANACONDA_CLIENT_FORCE_STANDALONE"] == "1" + return subprocess.CompletedProcess(command, 0) + + monkeypatch.setattr(build_conda_release.subprocess, "run", record_upload) + build_conda_release.main() + assert len(commands) == len(packages) + for command in commands: + relative = Path(command[-1]).relative_to(tmp_path).as_posix() + assert ("--skip-existing" in command) is packages[relative] + assert "--force" not in command + + +def test_verification_failure_is_not_reported_as_success(monkeypatch): + import scripts.verify_installation as verifier + import unilabos.utils.environment_check as checker + + monkeypatch.setattr(sys, "argv", ["verify_installation.py"]) + monkeypatch.setattr(verifier, "check_package", lambda *args: True) + monkeypatch.setattr(checker, "check_environment", lambda **kwargs: False) + assert verifier.main() == 1 + + +@pytest.mark.parametrize("installed, expected", [("0.1.0", False), ("0.0.39", True), ("0.0.38", False)]) +def test_environment_verifies_all_version_bounds_after_install(monkeypatch, installed, expected): + import importlib.metadata + from unilabos.utils.environment_check import EnvironmentChecker + + checker = EnvironmentChecker() + checker.required_packages = {"sqlmodel": checker.required_packages["sqlmodel"]} + monkeypatch.setattr(checker, "check_package_installed", lambda name: True) + monkeypatch.setattr(importlib.metadata, "version", lambda name: installed) + assert checker.verify_installation() is expected + assert bool(checker.packages_need_upgrade) is (not expected) + + +def test_environment_uses_serial_import_for_pyserial(): + from unilabos.utils.environment_check import EnvironmentChecker + + checker = EnvironmentChecker() + assert "serial" in checker.required_packages + assert "pyserial" not in checker.required_packages + assert checker.requirements["serial"].name == "pyserial" + + +def test_environment_does_not_import_modules_before_dependencies_are_complete(monkeypatch): + from importlib import metadata + from unilabos.utils.environment_check import EnvironmentChecker + + checker = EnvironmentChecker() + checker.required_packages = {name: checker.required_packages[name] for name in ("numpy", "yaml")} + + def version(name): + if name == "PyYAML": + raise metadata.PackageNotFoundError(name) + return "2.5.3" + + monkeypatch.setattr(metadata, "version", version) + monkeypatch.setattr(checker, "check_package_installed", lambda name: pytest.fail("依赖未齐不应导入模块")) + assert not checker.check_all_packages() + assert checker.missing_packages == [("yaml", "PyYAML>=6")] + + +def test_plr_chinese_mirror_preserves_commit_and_falls_back(monkeypatch): + from unilabos.utils import environment_check as checker + + # 配套发行版已用本地版本号;保留外部设备包显式源码依赖的同提交镜像转换。 + requirement = "pylabrobot @ https://github.com/Xuwznln/pylabrobot/archive/6285d662effa972be97a781ba7493553f2ba94ee.tar.gz" + candidates = checker._requirement_candidates(requirement, True) + assert len(candidates) == 2 + assert "gitee.com/xuwznln/pylabrobot/repository/archive/" in candidates[0] + assert candidates[0].rsplit("/", 1)[1] == candidates[1].rsplit("/", 1)[1] + assert candidates[1] == requirement + assert checker._requirement_candidates(requirement, False) == [requirement] + assert checker._requirement_candidates("msgcenterpy>=0.1.8", True) == ["msgcenterpy>=0.1.8"] + monkeypatch.setattr(checker, "_is_chinese_locale", lambda: True) + monkeypatch.setattr(checker, "_installer_candidates", lambda: ["pip"]) + calls = [] + + def install(command, **kwargs): + calls.append(command) + is_mirror = any("gitee.com" in part for part in command) + return subprocess.CompletedProcess(command, 1 if is_mirror else 0, "", "mirror 404" if is_mirror else "") + + monkeypatch.setattr(checker.subprocess, "run", install) + assert checker._install_packages([requirement]) + assert len(calls) == 2 + assert candidates[0] in calls[0] + assert candidates[1] in calls[1] + + +def test_bootstrap_does_not_install_when_readonly(monkeypatch): + import builtins + from unilabos.utils import environment_check as checker + + original = builtins.__import__ + + def without_packaging(name, *args, **kwargs): + if name == "packaging.requirements": + raise ModuleNotFoundError(name) + return original(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", without_packaging) + monkeypatch.setattr(checker, "_install_packages", lambda *a, **k: pytest.fail("只读检查不应补装")) + assert not checker.check_environment(auto_install=False) + + +def test_bootstrap_installs_parser_before_reading_packaged_requirements(monkeypatch): + import builtins + from types import SimpleNamespace + from unilabos.utils import environment_check as checker + + original = builtins.__import__ + installed = False + + def without_packaging(name, *args, **kwargs): + if name == "packaging.requirements" and not installed: + raise ModuleNotFoundError(name) + return original(name, *args, **kwargs) + + def install(packages, **kwargs): + nonlocal installed + assert packages == ["packaging>=24"] + installed = True + return True + + def environment(): + assert installed + return SimpleNamespace(check_all_packages=lambda: True) + + monkeypatch.setattr(builtins, "__import__", without_packaging) + monkeypatch.setattr(checker, "_install_packages", install) + monkeypatch.setattr(checker, "EnvironmentChecker", environment) + assert checker.check_environment() + + +def test_post_install_verification_refreshes_pth_paths(monkeypatch): + from unilabos.utils import environment_check as checker + + environment = checker.EnvironmentChecker() + calls = [] + monkeypatch.setattr(checker.site, "getsitepackages", lambda: ["/new-site-packages"]) + monkeypatch.setattr(checker.site, "addsitedir", calls.append) + + def check_all(): + assert calls == ["/new-site-packages"] + return True + + monkeypatch.setattr(environment, "check_all_packages", check_all) + assert environment.verify_installation() + + +def test_developer_install_targets_current_interpreter_and_respects_no_deps(monkeypatch, tmp_path): + from scripts import dev_install + + monkeypatch.setattr(dev_install.shutil, "which", lambda executable: "/tools/uv") + command = dev_install.build_command(tmp_path, use_pip=False, skip_deps=False, mirror=False) + assert command[:5] == ["uv", "pip", "install", "--python", sys.executable] + assert "--no-deps" not in command + command = dev_install.build_command(tmp_path, use_pip=True, skip_deps=True, mirror=True) + assert command[:4] == [sys.executable, "-m", "pip", "install"] + assert "--no-deps" in command + assert command[-2:] == ["--index-url", dev_install.TSINGHUA_MIRROR] + + +@pytest.mark.parametrize("extra", ["ros2", "full"]) +def test_developer_install_supports_only_public_profiles(tmp_path, extra): + from scripts import dev_install + + command = dev_install.build_command(tmp_path, use_pip=True, skip_deps=False, mirror=False, extras=extra) + assert str(tmp_path) + f"[{extra}]" in command + for obsolete in ("dev", "docs", "test", "drivers", "dora"): + with pytest.raises(ValueError, match="未知"): + dev_install.build_command(tmp_path, use_pip=True, skip_deps=False, mirror=False, extras=obsolete) + + +def test_full_profile_contains_all_python_development_dependencies(monkeypatch): + import runpy + import setuptools + from packaging.requirements import Requirement + + root = Path(__file__).resolve().parents[2] + captured = {} + monkeypatch.setattr(setuptools, "setup", lambda **kwargs: captured.update(kwargs)) + runpy.run_path(str(root / "setup.py")) + extras = captured["extras_require"] + assert set(extras) == {"ros2", "full"} + assert set(extras["ros2"]) <= set(extras["full"]) + names = {Requirement(raw).name for raw in extras["full"]} + assert {"pytest", "pytest-asyncio", "build", "ruff", "ipython", "jupyterlab", "pandas", "opcua"} <= names + docs = (root / "unilabos/utils/requirements-docs.txt").read_text(encoding="utf-8") + assert {line.strip() for line in docs.splitlines() if line.strip() and not line.startswith("#")} <= set(extras["full"]) + assert "-r ../unilabos/utils/requirements-docs.txt" in (root / "docs/requirements.txt").read_text() + assert not any("ros-" in raw or "rclpy" in raw for raw in captured["install_requires"]) + for raw in extras["full"]: + requirement = Requirement(raw) + if requirement.name in {"pyautogui", "pywinauto"}: + assert requirement.marker.evaluate({"sys_platform": "win32"}) + assert not requirement.marker.evaluate({"sys_platform": "linux"}) + assert not requirement.marker.evaluate({"sys_platform": "darwin"}) + + +def test_full_conda_profiles_cover_python_extras(monkeypatch): + import runpy + import setuptools + import yaml + from packaging.requirements import Requirement + from packaging.utils import canonicalize_name + from scripts.build_conda_release import recipes + + root = Path(__file__).resolve().parents[2] + captured = {} + monkeypatch.setattr(setuptools, "setup", lambda **kwargs: captured.update(kwargs)) + runpy.run_path(str(root / "setup.py")) + conda_names = {"build": "python-build", "matplotlib": "matplotlib-base"} + for distro, suffix in (("jazzy", ""), ("humble", "-humble")): + assert ("pprp", []) in recipes(distro, full=True) + full = yaml.safe_load((root / f".conda/full{suffix}/recipe.yaml").read_text()) + ros2 = yaml.safe_load((root / f".conda/ros2{suffix}/recipe.yaml").read_text()) + # Windows SDK 的选择使 full 成为平台包,不能把它发布成同名不同内容的 noarch。 + assert "noarch" not in full["build"] + dependencies = full["requirements"]["run"] + ros2["requirements"]["run"] + names = {canonicalize_name((dep["then"] if isinstance(dep, dict) else dep).split("::")[-1].split()[0]) + for dep in dependencies} + for raw in captured["extras_require"]["full"]: + requirement = Requirement(raw) + assert canonicalize_name(conda_names.get(requirement.name, requirement.name)) in names + + +def test_full_conda_support_packages_and_closed_dependency_validation(): + import yaml + from scripts.build_conda_release import FULL_SUPPORT_RECIPES + + root = Path(__file__).resolve().parents[2] + for name in FULL_SUPPORT_RECIPES: + recipe = yaml.safe_load((root / f".conda/{name}/recipe.yaml").read_text(encoding="utf-8")) + assert recipe["package"]["name"] == name + assert recipe["build"]["noarch"] == "python" + sources = recipe["source"] if isinstance(recipe["source"], list) else [recipe["source"]] + assert all(len(source["sha256"]) == 64 for source in sources) + for suffix in ("", "-humble"): + full = yaml.safe_load((root / f".conda/full{suffix}/recipe.yaml").read_text(encoding="utf-8")) + dependencies = full["requirements"]["run"] + assert "colcon-notification" in dependencies + assert set(FULL_SUPPORT_RECIPES) <= {dep.split()[0] for dep in dependencies if isinstance(dep, str)} + assert {"if": "win", "then": "pyautogui >=0.9.54"} in dependencies + assert any(test.get("python", {}).get("pip_check") is True for test in full["tests"]) + commands = next(test["script"] for test in full["tests"] if "script" in test) + assert commands[0]["if"] == "win" + assert all("%USERPROFILE%\\AppData\\" in command for command in commands[0]["then"]) + assert "import rinoh_typeface_dejavuserif" in commands[1] + + +def test_offline_release_rejects_wrong_source_before_installing(monkeypatch, tmp_path): + from scripts import pack_conda_release + + monkeypatch.setattr(sys, "argv", ["pack_conda_release.py", "--platform", "win-64", + "--source-sha", "1" * 40, "--prefix", str(tmp_path / "unused")]) + monkeypatch.setattr(pack_conda_release.subprocess, "check_output", lambda *a, **kw: "2" * 40) + with pytest.raises(RuntimeError, match="SHA"): + pack_conda_release.main() diff --git a/tests/app/test_wheel_release.py b/tests/app/test_wheel_release.py new file mode 100644 index 000000000..d36fb1338 --- /dev/null +++ b/tests/app/test_wheel_release.py @@ -0,0 +1,264 @@ +"""配套 wheel 的来源、离线解析、安装安全及 Conda 一致性。""" + +from __future__ import annotations + +import io +import json +from pathlib import Path +import subprocess +import sys +import tarfile +import tomllib +from zipfile import ZipFile + +import pytest +import yaml + +from scripts import build_wheel_release as builder +from scripts import install_wheel_release as installer + +ROOT = Path(__file__).resolve().parents[2] + + +def fake_wheel(directory: Path, name="unilabos", version="0.12.3", dependencies=()) -> Path: + path = directory / f"{name}-{version}-py3-none-any.whl" + with ZipFile(path, "w") as wheel: + metadata = f"Metadata-Version: 2.1\nName: {name}\nVersion: {version}\n" + metadata += "".join(f"Requires-Dist: {dependency}\n" for dependency in dependencies) + wheel.writestr(f"{name}-{version}.dist-info/METADATA", metadata) + return path + + +def test_downstream_versions_sources_and_patches_match_conda(): + specs = tomllib.loads((ROOT / "recipes/wheels.toml").read_text(encoding="utf-8"))["packages"] + requirements = (ROOT / "unilabos/utils/requirements.txt").read_text(encoding="utf-8") + for name, spec in specs.items(): + assert f"{name}=={spec['version']}" in requirements + assert len(spec["sha256"]) == 64 + assert all((ROOT / patch).is_file() for patch in spec["patches"]) + ot = yaml.safe_load((ROOT / "recipes/opentrons-shared-data/recipe.yaml").read_text(encoding="utf-8")) + assert specs["opentrons-shared-data"]["version"] == ot["package"]["version"] + for key in ("url", "sha256"): + assert specs["opentrons-shared-data"][key] == ot["source"][key] + assert specs["opentrons-shared-data"]["patches"] == [ + f"recipes/opentrons-shared-data/{patch}" for patch in ot["source"]["patches"]] + plr = yaml.safe_load((ROOT / ".conda/pylabrobot/recipe.yaml").read_text(encoding="utf-8")) + assert specs["pylabrobot"]["version"] == plr["package"]["version"] + assert plr["source"]["rev"] in specs["pylabrobot"]["url"] + assert specs["pylabrobot"]["patches"] == [f".conda/pylabrobot/{p}" for p in plr["source"]["patches"]] + assert specs["pylabrobot"]["requires"] == [f"opentrons-shared-data=={ot['package']['version']}"] + assert (f"opentrons-shared-data =={ot['package']['version']} {ot['build']['string']}" + in plr["requirements"]["run"]) + assert " @ " not in requirements + + +@pytest.mark.parametrize("dependencies", [ + [], + ["opentrons-shared-data>=9.1"], + ['opentrons-shared-data==9.1.0+unilabos.np2.1; extra == "opentrons"'], + ["opentrons-shared-data==9.1.0"], +]) +def test_plr_wheel_requires_numpy2_opentrons_unconditionally(tmp_path, dependencies): + spec = {"version": "0.2.2", "requires": ["opentrons-shared-data==9.1.0+unilabos.np2.1"]} + wheel = fake_wheel(tmp_path, "pylabrobot", spec["version"], dependencies) + with pytest.raises(RuntimeError, match="直接依赖"): + builder.verify_companion_metadata(wheel, "pylabrobot", spec) + + +def test_companion_dependency_metadata_accepts_exact_pin(tmp_path): + spec = {"version": "0.2.2", "requires": ["opentrons-shared-data==9.1.0+unilabos.np2.1"]} + wheel = fake_wheel(tmp_path, "pylabrobot", spec["version"], spec["requires"]) + builder.verify_companion_metadata(wheel, "pylabrobot", spec) + + +@pytest.mark.parametrize("materials_only", [True, False]) +def test_single_root_install_does_not_preinstall_dependencies(monkeypatch, tmp_path, materials_only): + calls = [] + + def record(*command, **kwargs): + assert kwargs["cwd"] == tmp_path + assert command[1] == "-I" + calls.append(command) + + monkeypatch.setattr(builder, "run", record) + root = "pylabrobot==0.2.2" if materials_only else "unilabos==0.12.3" + builder.verify_single_root(tmp_path / "wheels", tmp_path / "env", tmp_path / "verify.py", + root, materials_only=materials_only) + assert "venv" in calls[0] + assert calls[1][-1] == root + assert {"--no-index", "--only-binary=:all:", "--isolated"} <= set(calls[1]) + assert "--no-deps" not in calls[1] and "-r" not in calls[1] + assert calls[2][-1] == "check" + assert "--assert-no-ros" in calls[3] + assert ("--materials-only" in calls[3]) is materials_only + assert len(calls) == (4 if materials_only else 5) + if not materials_only: + assert calls[4][-1] == "--help" + + +def test_single_root_preserves_existing_environment(monkeypatch, tmp_path): + monkeypatch.setattr(builder, "run", lambda *a, **k: pytest.fail("不能复用已有验证环境")) + with pytest.raises(RuntimeError, match="新目录"): + builder.verify_single_root(tmp_path / "wheels", tmp_path, tmp_path / "verify.py", "pylabrobot") + + +@pytest.mark.parametrize("catalog_ok", [True, False]) +def test_materials_only_verification_does_not_require_unilabos(monkeypatch, catalog_ok): + from scripts import verify_installation as verifier + + monkeypatch.setattr(sys, "argv", ["verify_installation.py", "--materials-only", "--assert-no-ros"]) + monkeypatch.setattr(verifier, "check_python_version", lambda: True) + monkeypatch.setattr(verifier.importlib.util, "find_spec", lambda name: None) + monkeypatch.setattr(verifier, "check_package", lambda *a: pytest.fail("只装 PLR 不应检查微后端")) + monkeypatch.setattr(verifier, "check_material_catalog", lambda: catalog_ok) + assert verifier.main() == (0 if catalog_ok else 1) + + +def test_lock_uses_exact_downstream_version_and_wheel_hash(tmp_path): + fake_wheel(tmp_path) + patch = fake_wheel(tmp_path, "opentrons_shared_data", "9.1.0+unilabos.np2.1") + lock = builder.lock_wheels(tmp_path) + assert f"opentrons-shared-data==9.1.0+unilabos.np2.1 --hash=sha256:{installer.sha256(patch)}" in lock + assert len(lock.splitlines()) == 2 + + +def test_lock_rejects_network_references_even_with_no_index(tmp_path): + fake_wheel(tmp_path, dependencies=["pylabrobot @ https://example.invalid/archive.tar.gz"]) + with pytest.raises(RuntimeError, match="URL"): + builder.lock_wheels(tmp_path) + + +def test_lock_rejects_multiple_versions(tmp_path): + fake_wheel(tmp_path) + fake_wheel(tmp_path, version="0.12.2") + with pytest.raises(RuntimeError, match="多份"): + builder.lock_wheels(tmp_path) + + +def test_metadata_ignores_vendored_dist_info(tmp_path): + wheel = fake_wheel(tmp_path) + with ZipFile(wheel, "a") as archive: + archive.writestr("vendor/dependency.dist-info/METADATA", "Name: vendored\nVersion: 1.0\n") + assert builder.wheel_metadata(wheel)[:2] == ("unilabos", "0.12.3") + + +def test_source_cache_is_verified_before_reuse(monkeypatch, tmp_path): + archive = tmp_path / "source.tar.gz" + archive.write_bytes(b"source") + digest = installer.sha256(archive) + archive.rename(tmp_path / f"{digest}.tar.gz") + monkeypatch.setattr(builder, "urlopen", lambda *a, **k: pytest.fail("缓存命中不应联网")) + spec = {"sha256": digest, "url": "https://example.invalid/source.tar.gz"} + builder.download_source(spec, archive, tmp_path) + assert archive.read_bytes() == b"source" + (tmp_path / f"{digest}.tar.gz").write_bytes(b"tampered") + with pytest.raises(RuntimeError, match="SHA256"): + builder.download_source(spec, archive, tmp_path) + + +def test_download_rejects_hash_mismatch_without_building(monkeypatch, tmp_path): + monkeypatch.setattr(builder, "urlopen", lambda *a, **k: io.BytesIO(b"wrong-source")) + with pytest.raises(RuntimeError, match="SHA256"): + builder.download_source({"url": "https://example.invalid/source", "sha256": "0" * 64}, tmp_path / "s") + + +def test_extract_rejects_path_traversal(tmp_path): + source = tmp_path / "source.tar.gz" + with tarfile.open(source, "w:gz") as archive: + item = tarfile.TarInfo("../escape") + item.size = 4 + archive.addfile(item, io.BytesIO(b"oops")) + with pytest.raises(tarfile.FilterError): + builder.extract_source(source, tmp_path / "extract") + assert not (tmp_path / "escape").exists() + + +@pytest.fixture +def bundle(tmp_path): + wheels = tmp_path / "wheelhouse" + wheels.mkdir() + fake_wheel(wheels) + (tmp_path / "requirements.lock").write_text(builder.lock_wheels(wheels), encoding="utf-8") + (tmp_path / "verify_installation.py").write_text("pass", encoding="utf-8") + manifest = {"schema_version": 1, "kind": "offline-wheelhouse", "version": "0.12.3", + "python": "3.12", "platform": installer.platform_id(), + "files": {p.relative_to(tmp_path).as_posix(): installer.sha256(p) + for p in tmp_path.rglob("*") if p.is_file()}} + (tmp_path / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + return tmp_path + + +def test_bundle_rejects_tampering_before_creating_environment(bundle, monkeypatch): + monkeypatch.setattr(installer, "__file__", str(bundle / "install_wheel_release.py")) + monkeypatch.setattr(sys, "argv", ["install_wheel_release.py"]) + monkeypatch.setattr(installer.venv, "EnvBuilder", lambda **k: pytest.fail("校验失败不能创建环境")) + (bundle / "requirements.lock").write_text("tampered", encoding="utf-8") + with pytest.raises(RuntimeError, match="SHA256"): + installer.main() + + +def test_bundle_rejects_unlisted_wheels(bundle): + fake_wheel(bundle / "wheelhouse", "unexpected") + with pytest.raises(RuntimeError, match="清单外"): + installer.verify_bundle(bundle) + + +def test_bundle_rejects_wrong_platform(bundle): + path = bundle / "manifest.json" + manifest = json.loads(path.read_text()) + manifest["platform"] = "wrong-platform" + path.write_text(json.dumps(manifest), encoding="utf-8") + with pytest.raises(RuntimeError, match="平台不匹配"): + installer.verify_bundle(bundle) + + +def test_installer_preserves_existing_environment(bundle, monkeypatch): + monkeypatch.setattr(installer, "__file__", str(bundle / "install_wheel_release.py")) + monkeypatch.setattr(sys, "argv", ["install_wheel_release.py"]) + prefix = bundle / ".venv" + prefix.mkdir() + sentinel = prefix / "keep.txt" + sentinel.write_text("user data") + with pytest.raises(RuntimeError, match="不会覆盖"): + installer.main() + assert sentinel.read_text() == "user data" + + +def test_installer_is_offline_hash_locked_and_outside_checkout(bundle, monkeypatch): + monkeypatch.setattr(installer, "__file__", str(bundle / "install_wheel_release.py")) + monkeypatch.setattr(sys, "argv", ["install_wheel_release.py"]) + monkeypatch.setattr(installer.venv.EnvBuilder, "create", lambda *a: None) + commands = [] + + def record(command, **kwargs): + commands.append(command) + assert kwargs["cwd"] == bundle + assert command[1] == "-I" + + monkeypatch.setattr(installer.subprocess, "run", record) + assert installer.main() == 0 + assert {"--no-index", "--require-hashes", "--only-binary=:all:", "--isolated"} <= set(commands[0]) + assert "check" in commands[1] + assert "--assert-no-ros" in commands[2] + assert "--help" in commands[3] + + +def test_environment_bootstrap_accepts_companion_wheelhouse(monkeypatch, tmp_path): + from unilabos.utils import environment_check + + monkeypatch.setenv("UNILABOS_WHEELHOUSE", str(tmp_path)) + for tool in ("pip", "uv"): + command = environment_check._install_command(tool, "opentrons-shared-data==9.1.0+unilabos.np2.1", False, False) + assert command[-2:] == ["--find-links", str(tmp_path)] + + +def test_source_release_refuses_dirty_worktree(monkeypatch, tmp_path): + monkeypatch.setattr(builder.subprocess, "check_output", lambda cmd, **k: " M setup.py\n" if "status" in cmd else "a" * 40) + with pytest.raises(RuntimeError, match="未提交"): + builder.snapshot_source(ROOT, tmp_path, local_test=False) + + +def test_opentrons_material_catalog_is_actually_constructed(): + from scripts.verify_installation import check_material_catalog + + assert check_material_catalog() diff --git a/tests/backend/hostlink/test_downlink.py b/tests/backend/hostlink/test_downlink.py index 8ede94ef1..a00fb1285 100644 --- a/tests/backend/hostlink/test_downlink.py +++ b/tests/backend/hostlink/test_downlink.py @@ -24,7 +24,7 @@ run_node_coroutine, sync_resource_tree_to_device, ) -from unilabos.backend.ros2.base_device_node import registered_devices +from unilabos.config.config import BasicConfig class _FakeTask: @@ -91,9 +91,23 @@ async def append_resource(self, payload: Dict[str, Any]) -> Dict[str, Any]: return {"created_resource_tree": [[{"id": "r1"}]], "substance_resource_tree": []} -@pytest.fixture() -def fake_device(): +@pytest.fixture(params=["hostlink", "ros2"]) +def fake_device(request, monkeypatch): node = _FakeNode() + monkeypatch.setattr(BasicConfig, "backend", request.param) + if request.param == "hostlink": + from types import SimpleNamespace + from unilabos.backend.hostlink import main_hostlink_run + + runtime = SimpleNamespace(local=SimpleNamespace( + get_device=lambda identity: node if identity == node.device_id else None, + )) + monkeypatch.setattr(main_hostlink_run, "get_runtime", lambda: runtime) + yield node + return + pytest.importorskip("rclpy", reason="仅 ROS2 设备表分支需要选装 ROS") + from unilabos.backend.ros2.base_device_node import registered_devices + registered_devices[node.device_id] = {"base_node_instance": node} # type: ignore[typeddict-item] try: yield node diff --git a/tests/backend/hostlink/test_host_node.py b/tests/backend/hostlink/test_host_node.py index 5dfb98965..732e2b931 100644 --- a/tests/backend/hostlink/test_host_node.py +++ b/tests/backend/hostlink/test_host_node.py @@ -29,6 +29,25 @@ class CommunicationError(RuntimeError): severity = "recoverable" +def test_initializing_device_is_owned_but_not_schedulable(): + """初始化期间的服务路由仍可用,调度能力快照须等 post_init 完成。""" + from types import SimpleNamespace + + pending = {"device": {"id": "bench", "ready": False}, "online": True} + ready = {"device": {"id": "rack", "ready": True}, "online": True} + runtime = SimpleNamespace( + devices=lambda **kwargs: {"bench": pending, "rack": ready}, + local=SimpleNamespace(descriptors=lambda: []), + server=SimpleNamespace(devices=lambda online: {"bench": pending, "rack": ready}), + ) + host = object.__new__(HostNode) + host.runtime = runtime + assert set(host._device_snapshot(initial=True)) == {"rack"} + assert set(host._device_snapshot(initial=False)) == {"rack"} + pending["device"]["ready"] = True + assert set(host._device_snapshot(initial=False)) == {"bench", "rack"} + + class AdapterDriver: def __init__(self) -> None: self.calls = 0 diff --git a/tests/backend/hostlink/test_local_runtime.py b/tests/backend/hostlink/test_local_runtime.py index 75ecab5d6..509d69fb2 100644 --- a/tests/backend/hostlink/test_local_runtime.py +++ b/tests/backend/hostlink/test_local_runtime.py @@ -3,6 +3,7 @@ import asyncio from pathlib import Path import time +import threading import pytest import yaml @@ -244,6 +245,33 @@ def test_hostlink_runtime_constructs_and_sets_up_pylabrobot_style_driver() -> No node.stop() +def test_hostlink_readiness_waits_for_post_init() -> None: + entered, release = threading.Event(), threading.Event() + + class InitializingDriver: + def post_init(self, node): + entered.set() + assert release.wait(10), "测试没有释放初始化屏障" + + from concurrent.futures import ThreadPoolExecutor + + node = HostLinkDeviceNode(driver=InitializingDriver(), device_id="initializing") + assert node.describe()["ready"] is False + with ThreadPoolExecutor(max_workers=1) as executor: + started = executor.submit(node.start) + try: + assert entered.wait(5) + assert node.describe()["ready"] is False + finally: + release.set() + try: + started.result(timeout=10) + assert node.describe()["ready"] is True + finally: + node.stop() + assert node.describe()["ready"] is False + + def test_hostlink_device_lifecycle_and_direct_action() -> None: driver = AsyncDriver("dev-1", {}) node = HostLinkDeviceNode(driver, "dev-1") @@ -326,6 +354,7 @@ def test_hostlink_runtime_exposes_registered_actions_and_status() -> None: assert runtime.descriptors() == [ { "id": "dev-1", + "ready": True, "registry_name": "async_driver", "display_name": "Async Driver", "actions": ["auto-add"], diff --git a/tests/backend/hostlink/test_workstation_protocols.py b/tests/backend/hostlink/test_workstation_protocols.py index 8fb95759d..7658de2fa 100644 --- a/tests/backend/hostlink/test_workstation_protocols.py +++ b/tests/backend/hostlink/test_workstation_protocols.py @@ -9,6 +9,7 @@ import asyncio import inspect +from pathlib import Path import pytest from pydantic import BaseModel @@ -123,10 +124,11 @@ def test_workstation_shared_logic_is_single_source_across_backends() -> None: """ import unilabos.backend.hostlink.local_runtime as local_runtime_module import unilabos.backend.hostlink.workstation as hostlink_workstation_module - import unilabos.backend.ros2.base_device_node as base_device_node_module from unilabos.backend.runtime import workstation_protocol - base_source = inspect.getsource(base_device_node_module.ROS2DeviceNode.__init__) + # 验证装配关系时不导入 ROS 运行时,无 ROS 的 HostLink CI 也覆盖此契约。 + ros2_directory = Path(local_runtime_module.__file__).parents[1] / "ros2" + base_source = (ros2_directory / "base_device_node.py").read_text(encoding="utf-8") assert "unilabos.backend.ros2.presets.workstation" in base_source runtime_source = inspect.getsource(local_runtime_module.HostLinkLocalRuntime.add_driver) @@ -149,11 +151,7 @@ def test_workstation_shared_logic_is_single_source_across_backends() -> None: assert hasattr(workstation_protocol, name), name hostlink_source = inspect.getsource(hostlink_workstation_module) assert "unilabos.backend.runtime.workstation_protocol" in hostlink_source - import pathlib - - ros2_workstation_path = ( - pathlib.Path(base_device_node_module.__file__).parent / "presets" / "workstation.py" - ) + ros2_workstation_path = ros2_directory / "presets" / "workstation.py" assert ros2_workstation_path.is_file() assert "unilabos.backend.runtime.workstation_protocol" in ros2_workstation_path.read_text( encoding="utf-8" diff --git a/unilabos/__init__.py b/unilabos/__init__.py index 76da4a988..8e1395bd3 100644 --- a/unilabos/__init__.py +++ b/unilabos/__init__.py @@ -1 +1 @@ -__version__ = "0.12.2" +__version__ = "0.12.3" diff --git a/unilabos/app/main.py b/unilabos/app/main.py index a9f7a14f7..bb9a8a82f 100644 --- a/unilabos/app/main.py +++ b/unilabos/app/main.py @@ -345,7 +345,14 @@ def main(): except BackendConfigurationError as exc: parser.error(str(exc)) args_dict["backend"] = backend_selection.name - if backend_selection.name == "ros2": + if backend_selection.name == "ros2" and args_dict.get("role") != "backend": + import importlib.util + + if importlib.util.find_spec("rclpy") is None: + parser.error( + "当前为无 ROS 安装。默认请使用 --backend hostlink;" + "仅需 ROS2 时安装 unilabos-ros2(jazzy/humble)及对应消息包。" + ) # HostLink direct backend must not probe/import rclpy as a side effect. from unilabos.app.utils import patch_rclpy_dll_windows diff --git a/unilabos/backend/hostlink/host_node.py b/unilabos/backend/hostlink/host_node.py index 3492929c1..34d3d7e9a 100644 --- a/unilabos/backend/hostlink/host_node.py +++ b/unilabos/backend/hostlink/host_node.py @@ -152,9 +152,14 @@ def _monitor_devices(self) -> None: def _device_snapshot(self, *, initial: bool) -> Dict[str, Dict[str, Any]]: if initial: - return self.runtime.devices(online_only=True) + return { + key: value for key, value in self.runtime.devices(online_only=True).items() + if value.get("device", {}).get("ready", True) + } result: Dict[str, Dict[str, Any]] = {} for descriptor in self.runtime.local.descriptors(): + if not descriptor.get("ready", True): + continue device_id = str(descriptor["id"]) node = self.runtime.local.devices[device_id] result[device_id] = { @@ -165,6 +170,8 @@ def _device_snapshot(self, *, initial: bool) -> Dict[str, Dict[str, Any]]: } if self.runtime.server is not None: for device_id, peer in self.runtime.server.devices(True).items(): + if not peer.get("device", {}).get("ready", True): + continue remote = dict(peer) remote["location"] = "remote" result.setdefault(device_id, remote) diff --git a/unilabos/backend/hostlink/local_runtime.py b/unilabos/backend/hostlink/local_runtime.py index c905bbfea..09af0f6fa 100644 --- a/unilabos/backend/hostlink/local_runtime.py +++ b/unilabos/backend/hostlink/local_runtime.py @@ -276,6 +276,7 @@ def __init__( ) self._loop_ready = threading.Event() self._started = False + self._ready = False self._status_lock = threading.Lock() self._decorated_subscriptions: list[Any] = [] self._status_bindings = self._build_status_bindings() @@ -615,6 +616,7 @@ def start(self) -> None: except Exception: self.stop() raise + self._ready = True self._logger.info("HostLink 设备已就绪:%s", self.device_id) def _setup_decorated_subscriptions(self) -> None: @@ -1069,6 +1071,8 @@ def snapshot_status(self) -> Dict[str, Any]: def describe(self) -> Dict[str, Any]: descriptor = { "id": self.device_id, + # 归属须在 post_init 前登记,但调度只能在物料/服务初始化完成后看到设备。 + "ready": self._ready, "registry_name": self.registry_name, "display_name": self.display_name, "actions": list(self.action_names), @@ -1099,6 +1103,7 @@ def describe(self) -> Dict[str, Any]: return descriptor def stop(self) -> None: + self._ready = False if not self._started: return for subscription in self._decorated_subscriptions: diff --git a/unilabos/config/config.py b/unilabos/config/config.py index 6715d431e..d40414ff3 100644 --- a/unilabos/config/config.py +++ b/unilabos/config/config.py @@ -26,7 +26,7 @@ class BasicConfig: # 运行时 backend 名称由 unilabos.backend 统一规范化。 - backend: Literal["hostlink", "ros2"] = "ros2" + backend: Literal["hostlink", "ros2"] = "hostlink" ak = "" sk = "" working_dir = "" diff --git a/unilabos/resources/graphio.py b/unilabos/resources/graphio.py index 00befad74..51b34254b 100644 --- a/unilabos/resources/graphio.py +++ b/unilabos/resources/graphio.py @@ -10,7 +10,6 @@ from pylabrobot.resources import ResourceHolder from unilabos.config.config import BasicConfig -from unilabos.resources.presets.container import RegularContainer from unilabos.resources.presets.itemized_carrier import BottleCarrier, ItemizedCarrier from unilabos.resources.objects.joint_state import ResourceJointState from unilabos.resources.objects.pose import ( @@ -42,13 +41,6 @@ from unilabos.utils import logger from unilabos.utils.banner_print import print_status -if BasicConfig.backend == "ros2": - from unilabos_msgs.msg import Resource - from unilabos.backend.ros2.msgs.message_converter import convert_to_ros_msg -else: - Resource = None - convert_to_ros_msg = None - try: from pylabrobot.resources.resource import Resource as ResourcePLR except ImportError: @@ -1531,13 +1523,6 @@ def initialize_resource(resource_config: dict, resource_type: Any = None) -> Uni r = resource_plr elif resource_class_config["type"] == "unilabos": raise ValueError(f"No more support for unilabos Resource class {resource_class_config}") - if Resource is None or convert_to_ros_msg is None: - raise RuntimeError("HostLink JSON 模式不支持旧 unilabos ROS Resource") - res_instance: RegularContainer = RESOURCE(id=resource_config["name"]) - res_instance.ulr_resource = convert_to_ros_msg( - Resource, {k: v for k, v in resource_config.items() if k != "class"} - ) - r = [res_instance.get_ulr_resource_as_dict()] elif isinstance(RESOURCE, dict): r = [RESOURCE.copy()] diff --git a/unilabos/server/services/runtime/workflow/common.py b/unilabos/server/services/runtime/workflow/common.py index 7efde54bc..3dfe7d27d 100644 --- a/unilabos/server/services/runtime/workflow/common.py +++ b/unilabos/server/services/runtime/workflow/common.py @@ -104,8 +104,6 @@ import uuid import networkx as nx -from networkx.drawing.nx_agraph import to_agraph -import matplotlib.pyplot as plt from typing import Dict, List, Any, Optional Json = Dict[str, Any] @@ -633,6 +631,8 @@ def draw_protocol_graph(protocol_graph: WorkflowGraph, output_path: str): """ (辅助功能) 使用 networkx 和 matplotlib 绘制协议工作流图,用于可视化。 """ + import matplotlib.pyplot as plt + if not protocol_graph: print("Cannot draw graph: Graph object is empty.") return @@ -686,6 +686,8 @@ def draw_protocol_graph_with_ports(protocol_graph, output_path: str, rankdir: st - 否则自动为节点创建 record 形状并定义命名端口 。 最终由 PyGraphviz 渲染并输出到 output_path(后缀决定格式,如 .png/.svg/.pdf)。 """ + from networkx.drawing.nx_agraph import to_agraph + if not protocol_graph: print("Cannot draw graph: Graph object is empty.") return diff --git a/unilabos/utils/environment_check.py b/unilabos/utils/environment_check.py index 95a5992b0..7e05d1b9a 100644 --- a/unilabos/utils/environment_check.py +++ b/unilabos/utils/environment_check.py @@ -6,8 +6,10 @@ import argparse import importlib import locale +import os import re import shutil +import site import subprocess import sys from pathlib import Path @@ -48,6 +50,8 @@ def _has_uv() -> bool: def _install_command(installer: str, package: str, upgrade: bool, is_chinese: bool) -> List[str]: + # 配套 wheel 路径只由用户显式指定,不隐式访问未发布的 URL 或更换依赖版本。 + wheelhouse = os.environ.get("UNILABOS_WHEELHOUSE") if installer == "uv": # uv >= 0.5 默认要求虚拟环境,对 conda env 会报 "No virtual environment found"。 # 显式 --python sys.executable 让 uv 把当前解释器(conda/venv/system 都行) @@ -58,6 +62,8 @@ def _install_command(installer: str, package: str, upgrade: bool, is_chinese: bo cmd.append(package) if is_chinese: cmd.extend(["--index-url", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"]) + if wheelhouse: + cmd.extend(["--find-links", wheelhouse]) return cmd cmd = [sys.executable, "-m", "pip", "install", "--disable-pip-version-check"] @@ -66,6 +72,8 @@ def _install_command(installer: str, package: str, upgrade: bool, is_chinese: bo cmd.append(package) if is_chinese: cmd.extend(["-i", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"]) + if wheelhouse: + cmd.extend(["--find-links", wheelhouse]) return cmd @@ -77,6 +85,20 @@ def _installer_candidates() -> List[str]: return installers +def _requirement_candidates(requirement: str, is_chinese: bool) -> List[str]: + """PLR 中文镜像只替换下载站点;镜像缺少固定提交时回退原地址,不换分支。""" + match = re.fullmatch( + r"pylabrobot\s*@\s*https://github\.com/Xuwznln/pylabrobot/archive/([0-9a-f]{40})\.tar\.gz", + requirement.strip(), + ) + if not is_chinese or not match: + return [requirement] + return [ + f"pylabrobot @ https://gitee.com/xuwznln/pylabrobot/repository/archive/{match[1]}.tar.gz", + requirement, + ] + + def _git_url_from_requirement(requirement: str) -> Optional[str]: if not requirement.startswith("git+"): return None @@ -141,13 +163,16 @@ def _install_packages( pkg_installed = False last_error = "unknown error" - for installer in installers: - cmd = _install_command(installer, pkg, upgrade, is_chinese) + attempts = [(source, installer) for source in _requirement_candidates(pkg, is_chinese) + for installer in installers] + for source, installer in attempts: + cmd = _install_command(installer, source, upgrade, is_chinese) logger.trace(f"[install]{(' [' + label + ']') if label else ''} exec: {' '.join(cmd)}") try: - result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", + errors="replace", timeout=300) if result.returncode == 0: - print_status(f"✓ {pkg} {action_word}成功 (via {installer})", "success") + print_status(f"✓ {source} {action_word}成功 (via {installer})", "success") logger.trace( f"[install] ok: {pkg} via {installer} (rc=0)\n" f"{(result.stdout or '').strip()[-2000:]}" @@ -156,7 +181,7 @@ def _install_packages( break last_error = result.stderr.strip().split("\n")[-1] if result.stderr else "unknown error" - print_status(f"× {pkg} {action_word}失败 (via {installer}): {last_error}", "warning") + print_status(f"× {source} {action_word}失败 (via {installer}): {last_error}", "warning") logger.trace( f"[install] fail: {pkg} via {installer} (rc={result.returncode})\n" f"STDOUT:\n{(result.stdout or '').strip()[-2000:]}\n" @@ -173,6 +198,8 @@ def _install_packages( if not pkg_installed: print_status(f"× {pkg} {action_word}失败: {last_error}", "error") + if "+unilabos." in pkg: + print_status("此修正版不在 PyPI:请下载配套发行包,或用 --wheelhouse 指定包内 wheelhouse 目录。", "warning") _print_manual_git_install_hint(pkg) failed.append(pkg) @@ -297,26 +324,22 @@ class EnvironmentChecker: """环境检查器""" def __init__(self): - self.required_packages = { - "websockets": "websockets", - "msgcenterpy": "msgcenterpy", - "orjson": "orjson", - "typing_extensions": "typing_extensions", - "crcmod": "crcmod-plus", - } - - # 中文 locale 下走 Gitee 镜像,规避 GitHub 拉取失败 - pylabrobot_url = ( - "git+https://gitee.com/xuwznln/pylabrobot.git" - if _is_chinese_locale() - else "git+https://github.com/Xuwznln/pylabrobot.git" - ) - self.special_packages = {"pylabrobot": pylabrobot_url} - - self.version_requirements = { - "msgcenterpy": "0.1.8", - } - + self.required_packages = {} + + # 与 wheel 安装共用依赖清单,不能在首次启动时换成另一个可变 PLR 分支。 + from packaging.requirements import Requirement + + import_names = {"PyYAML": "yaml", "crcmod-plus": "crcmod", "pyserial": "serial"} + self.requirements = {} + for line in Path(__file__).with_name("requirements.txt").read_text(encoding="utf-8").splitlines(): + if not line.strip() or line.lstrip().startswith("#"): + continue + requirement = Requirement(line) + if requirement.marker and not requirement.marker.evaluate(): + continue + name = import_names.get(requirement.name, requirement.name.replace("-", "_")) + self.required_packages[name] = line.strip() + self.requirements[name] = requirement self.missing_packages: List[tuple] = [] self.failed_installs: List[tuple] = [] self.packages_need_upgrade: List[tuple] = [] @@ -328,43 +351,33 @@ def check_package_installed(self, package_name: str) -> bool: except ImportError: return False - def get_package_version(self, package_name: str) -> str | None: - try: - module = importlib.import_module(package_name) - return getattr(module, "__version__", None) - except (ImportError, AttributeError): - return None - - def compare_version(self, current: str, required: str) -> bool: - try: - current_parts = [int(x) for x in current.split(".")] - required_parts = [int(x) for x in required.split(".")] - max_len = max(len(current_parts), len(required_parts)) - current_parts.extend([0] * (max_len - len(current_parts))) - required_parts.extend([0] * (max_len - len(required_parts))) - return current_parts >= required_parts - except Exception: - return True - def check_all_packages(self) -> bool: print_status("开始检查环境依赖...", "info") + from importlib.metadata import PackageNotFoundError, version + self.missing_packages.clear() + self.packages_need_upgrade.clear() + + # 先只查分发元数据:依赖尚未齐全时不能 import 留下半初始化的模块, + # 否则第一次补装完成后仍可能把同一进程内的旧状态误判成缺包。 for import_name, pip_name in self.required_packages.items(): - if not self.check_package_installed(import_name): + requirement = self.requirements[import_name] + try: + current_version = version(requirement.name) + except PackageNotFoundError: self.missing_packages.append((import_name, pip_name)) - elif import_name in self.version_requirements: - current_version = self.get_package_version(import_name) - required_version = self.version_requirements[import_name] - if current_version and not self.compare_version(current_version, required_version): - print_status( - f"{import_name} 版本过低 (当前: {current_version}, 需要: >={required_version})", - "warning", - ) - self.packages_need_upgrade.append((import_name, pip_name)) + continue + if not requirement.specifier.contains(current_version, prereleases=True): + print_status( + f"{import_name} 版本不匹配 (当前: {current_version}, 需要: {requirement.specifier})", + "warning", + ) + self.packages_need_upgrade.append((import_name, pip_name)) - for package_name, install_url in self.special_packages.items(): - if not self.check_package_installed(package_name): - self.missing_packages.append((package_name, install_url)) + if not self.missing_packages and not self.packages_need_upgrade: + for import_name, pip_name in self.required_packages.items(): + if not self.check_package_installed(import_name): + self.missing_packages.append((import_name, pip_name)) all_ok = not self.missing_packages and not self.packages_need_upgrade @@ -374,12 +387,15 @@ def check_all_packages(self) -> bool: if self.missing_packages: print_status(f"发现 {len(self.missing_packages)} 个缺失的包", "warning") + for import_name, pip_name in self.missing_packages: + print_status(f" - {import_name}: {pip_name}", "warning") if self.packages_need_upgrade: print_status(f"发现 {len(self.packages_need_upgrade)} 个需要升级的包", "warning") return False def install_missing_packages(self, auto_install: bool = True) -> bool: + self.failed_installs.clear() if not self.missing_packages and not self.packages_need_upgrade: return True @@ -407,37 +423,14 @@ def install_missing_packages(self, auto_install: bool = True) -> bool: return not self.failed_installs def verify_installation(self) -> bool: - if not self.missing_packages and not self.packages_need_upgrade: - return True - print_status("验证安装结果...", "info") - failed_verification = [] - - for import_name, pip_name in self.missing_packages: - if not self.check_package_installed(import_name): - failed_verification.append((import_name, pip_name)) - - for import_name, pip_name in self.packages_need_upgrade: - if not self.check_package_installed(import_name): - failed_verification.append((import_name, pip_name)) - elif import_name in self.version_requirements: - current_version = self.get_package_version(import_name) - required_version = self.version_requirements[import_name] - if current_version and not self.compare_version(current_version, required_version): - failed_verification.append((import_name, pip_name)) - print_status( - f" {import_name} 版本仍然过低 (当前: {current_version}, 需要: >={required_version})", - "error", - ) - - if failed_verification: - print_status(f"有 {len(failed_verification)} 个包验证失败:", "error") - for import_name, pip_name in failed_verification: - print_status(f" - {import_name}", "error") - return False - - print_status("✓ 所有包验证通过", "success") - return True + # pip 子进程安装的 pywin32 等依赖通过 .pth 注册额外模块路径。 + # 仅刷新 FileFinder 缓存不会加载它们,需补一次 site 路径初始化。 + for directory in site.getsitepackages(): + site.addsitedir(directory) + importlib.invalidate_caches() + # 同时重查缺失、版本上下限;不能只判断 import 成功就放过不兼容版本。 + return self.check_all_packages() def check_environment(auto_install: bool = True, show_details: bool = True) -> bool: @@ -451,6 +444,18 @@ def check_environment(auto_install: bool = True, show_details: bool = True) -> b Returns: bool: 环境检查是否通过 """ + # 支持只安装 wheel(--no-deps)的显式补装入口:解析清单前先补齐解析器。 + # 本模块以及 banner/log 模块仅依赖标准库,不要求源码仓库或 scripts 目录。 + try: + import packaging.requirements # noqa: F401 + except ImportError: + if not auto_install: + print_status("缺少 packaging>=24,无法检查依赖清单", "error") + return False + if not _install_packages(["packaging>=24"], label="bootstrap"): + return False + importlib.invalidate_caches() + checker = EnvironmentChecker() if checker.check_all_packages(): @@ -470,11 +475,21 @@ def check_environment(auto_install: bool = True, show_details: bool = True) -> b if __name__ == "__main__": + # 此入口也可从只有 wheel 的环境独立运行,不经过 app.main 的终端编码配置。 + if sys.platform == "win32": + for stream in (sys.stdout, sys.stderr): + if hasattr(stream, "reconfigure"): + stream.reconfigure(encoding="utf-8", errors="replace") parser = argparse.ArgumentParser(description="Uni-Lab-OS 环境依赖检查工具") parser.add_argument("--no-auto-install", action="store_true", help="仅检查环境,不自动安装缺失的包") parser.add_argument("--silent", action="store_true", help="静默模式,不显示详细信息") + parser.add_argument("--wheelhouse", type=Path, help="配套发行包中的 wheelhouse 目录,用于显式补装依赖") args = parser.parse_args() + if args.wheelhouse: + if not args.wheelhouse.is_dir(): + parser.error("--wheelhouse 必须指向已存在的目录") + os.environ["UNILABOS_WHEELHOUSE"] = str(args.wheelhouse.resolve()) auto_install = not args.no_auto_install show_details = not args.silent diff --git a/unilabos/utils/requirements-docs.txt b/unilabos/utils/requirements-docs.txt new file mode 100644 index 000000000..e0d0c051c --- /dev/null +++ b/unilabos/utils/requirements-docs.txt @@ -0,0 +1,10 @@ +# 文档依赖的唯一来源;setup.py 的 [full] 与 docs/requirements.txt 共用。 +sphinx>=7.0.0 +sphinx-rtd-theme>=2.0.0 +myst-parser>=2.0.0 +sphinxcontrib-mermaid +myst-nb>=1.0.0 +sphinx-copybutton>=0.5.0 +sphinx-autobuild>=2024.2.4 +rinohtype>=0.5.4 +sphinx-simplepdf>=1.6.0 diff --git a/unilabos/utils/requirements.txt b/unilabos/utils/requirements.txt index ecba53c85..ab7ec36dd 100644 --- a/unilabos/utils/requirements.txt +++ b/unilabos/utils/requirements.txt @@ -1,3 +1,10 @@ +# 默认 HostLink / 微后端依赖,与 wheel 的 Requires-Dist 共用此清单。 +# 硬件专用 SDK 见 unilabos[drivers] 或设备包自己的依赖声明。 +setuptools>=68 +packaging>=24 +PyYAML>=6 +pydantic>=2.11,<3 +numpy>=2,<3 networkx>=3.2 typing_extensions>=4.12 websockets>=12 @@ -11,12 +18,10 @@ requests>=2.32 httpx>=0.27.0 mcp>=1.30,<2 uvicorn>=0.30 -pyautogui>=0.9.54 -opcua>=0.98.13 pyserial>=3.5 -pandas>=2.2 crcmod-plus>=2.3.1 pymodbus>=3.6.9,<4 -matplotlib>=3.8 -pylibftdi>=0.22 -pprp>=0.2.7 +# 修正版随发行包的 wheelhouse 分发,不上传 PyPI;安装时须提供 --find-links。 +# 本地版本号区分 UniLabOS fork 与同版本上游,且不包含离线时仍会联网的源码 URL。 +pylabrobot==0.2.2+unilabos.6285d662effa +opentrons-shared-data==9.1.0+unilabos.np2.1